/
githubmirror
/
angular-cli
Обзор
Документация
Войти
/
githubmirror
/
angular-cli
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/angular/build/src/utils/index-file/augment-index-html.ts
428 строк
13 KB
Alan Agius
fix(@angular/build): ensure import map integrity keys are valid URL-like specifiers
21 июл 2026, 19:19
21 июл 2026, 19:19
21b8fc0
Код
Авторство
О чём код?
/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { createHash } from 'node:crypto'; import { extname } from 'node:path'; import { htmlRewritingStream } from './html-rewriting-stream'; import { VALID_SELF_CLOSING_TAGS } from './valid-self-closing-tags'; /** * RegExp to check if a URL is resolvable. * A URL is resolvable if it is absolute (starting with http/https) or relative (starting with `./`, `../`, or `/`). */ const RESOLVABLE_URL_REGEXP = /^(?:\.{0,2}\/|https?:\/\/)/i; export type LoadOutputFileFunctionType = (file: string) => Promise<string>; export type CrossOriginValue = 'none' | 'anonymous' | 'use-credentials'; export type Entrypoint = [name: string, isModule: boolean]; export interface AugmentIndexHtmlOptions { /* Input contents */ html: string; baseHref?: string; deployUrl?: string; sri: boolean; /** crossorigin attribute setting of elements that provide CORS support */ crossOrigin?: CrossOriginValue; /* * Files emitted by the build. */ files: FileInfo[]; /* * Function that loads a file used. * This allows us to use different routines within the IndexHtmlWebpackPlugin and * when used without this plugin. */ loadOutputFile: LoadOutputFileFunctionType; /** Used to sort the inseration of files in the HTML file */ entrypoints: Entrypoint[]; /** Used to set the document default locale */ lang?: string; hints?: { url: string; mode: string; as?: string }[]; imageDomains?: string[]; /** * Integrity metadata for module script URLs that are not directly referenced * from `index.html` (e.g. lazy-loaded chunks resolved via `import()`). * * Keys are URLs relative to the deployment base (matching how the browser * will request the module) and values are the corresponding * Subresource Integrity values (e.g. 'sha384-...'). * * Emitted as a `<script type="importmap">` block whose `integrity` map the * browser consults when fetching modules without an inline `integrity` * attribute. See: * https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap#integrity_metadata_map */ chunksIntegrity?: ReadonlyMap<string, string>; } export interface FileInfo { file: string; name?: string; extension: string; } /* * Helper function used by the IndexHtmlWebpackPlugin. * Can also be directly used by builder, e. g. in order to generate an index.html * after processing several configurations in order to build different sets of * bundles for differential serving. */ // eslint-disable-next-line max-lines-per-function export async function augmentIndexHtml( params: AugmentIndexHtmlOptions, ): Promise<{ content: string; warnings: string[]; errors: string[] }> { const { loadOutputFile, files, entrypoints, sri, deployUrl, lang, baseHref, html, imageDomains, chunksIntegrity, } = params; const warnings: string[] = []; const errors: string[] = []; let { crossOrigin = 'none' } = params; if (sri && crossOrigin === 'none') { crossOrigin = 'anonymous'; } const stylesheets = new Set<string>(); const scripts = new Map</** file name */ string, /** isModule */ boolean>(); // Sort files in the order we want to insert them by entrypoint for (const [entrypoint, isModule] of entrypoints) { for (const { extension, file, name } of files) { if (name !== entrypoint || scripts.has(file) || stylesheets.has(file)) { continue; } switch (extension) { case '.js': // Also, non entrypoints need to be loaded as no module as they can contain problematic code. scripts.set(file, isModule); break; case '.mjs': if (!isModule) { // It would be very confusing to link an `*.mjs` file in a non-module script context, // so we disallow it entirely. throw new Error('`.mjs` files *must* set `isModule` to `true`.'); } scripts.set(file, true /* isModule */); break; case '.css': stylesheets.add(file); break; } } } let scriptTags: string[] = []; for (const [src, isModule] of scripts) { const attrs = [`src="${generateUrl(src, deployUrl)}"`]; // This is also need for non entry-points as they may contain problematic code. if (isModule) { attrs.push('type="module"'); } else { attrs.push('defer'); } if (crossOrigin !== 'none') { attrs.push(`crossorigin="${crossOrigin}"`); } if (sri) { const content = await loadOutputFile(src); attrs.push(generateSriAttributes(content)); } scriptTags.push(`<script ${attrs.join(' ')}></script>`); } let subResourceIntegrityTag: string | undefined; let headerLinkTags: string[] = []; let bodyLinkTags: string[] = []; // Emit an integrity-only import map so the browser can validate lazy chunks // resolved via dynamic `import()` (which otherwise carry no SRI metadata). // The block is placed first inside `<head>` so it precedes any module // script, as required by the import-map spec. if (sri && chunksIntegrity?.size) { const integrity: Record<string, string> = {}; // Stable iteration order for reproducible builds. const sortedEntries = [...chunksIntegrity.entries()].sort(([keyA], [keyB]) => keyA.localeCompare(keyB), ); for (const [url, integrityHash] of sortedEntries) { const resolvedUrl = generateUrl(url, deployUrl); const key = RESOLVABLE_URL_REGEXP.test(resolvedUrl) ? resolvedUrl : `./${resolvedUrl}`; integrity[key] = integrityHash; } const importMapJson = JSON.stringify({ integrity }).replace(/</g, '\\u003c'); subResourceIntegrityTag = `<script type="importmap">${importMapJson}</script>`; } for (const src of stylesheets) { const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`]; if (crossOrigin !== 'none') { attrs.push(`crossorigin="${crossOrigin}"`); } if (sri) { const content = await loadOutputFile(src); attrs.push(generateSriAttributes(content)); } headerLinkTags.push(`<link ${attrs.join(' ')}>`); } if (params.hints?.length) { for (const hint of params.hints) { const attrs = [`rel="${hint.mode}"`, `href="${generateUrl(hint.url, deployUrl)}"`]; if (hint.mode !== 'modulepreload' && crossOrigin !== 'none') { // Value is considered anonymous by the browser when not present or empty attrs.push(crossOrigin === 'anonymous' ? 'crossorigin' : `crossorigin="${crossOrigin}"`); } if (hint.mode === 'preload' || hint.mode === 'prefetch') { switch (extname(hint.url)) { case '.js': attrs.push('as="script"'); break; case '.css': attrs.push('as="style"'); break; default: if (hint.as) { attrs.push(`as="${hint.as}"`); } break; } } if ( sri && (hint.mode === 'preload' || hint.mode === 'prefetch' || hint.mode === 'modulepreload') ) { const content = await loadOutputFile(hint.url); attrs.push(generateSriAttributes(content)); } const tag = `<link ${attrs.join(' ')}>`; if (hint.mode === 'modulepreload') { // Module preloads should be placed by the inserted script elements in the body since // they are only useful in combination with the scripts. bodyLinkTags.push(tag); } else { headerLinkTags.push(tag); } } } const dir = lang ? await getLanguageDirection(lang, warnings) : undefined; const { rewriter, transformedContent } = await htmlRewritingStream(html); const baseTagExists = html.includes('<base'); const foundPreconnects = new Set<string>(); rewriter .on('startTag', (tag, rawTagHtml) => { switch (tag.tagName) { case 'html': // Adjust document locale if specified if (isString(lang)) { updateAttribute(tag, 'lang', lang); } if (dir) { updateAttribute(tag, 'dir', dir); } break; case 'head': // Base href should be added before any link, meta tags if (!baseTagExists && isString(baseHref)) { rewriter.emitStartTag(tag); rewriter.emitRaw(`<base href="${baseHref}">`); if (subResourceIntegrityTag) { rewriter.emitRaw(subResourceIntegrityTag); } return; } break; case 'base': // Adjust base href if specified if (isString(baseHref)) { updateAttribute(tag, 'href', baseHref); } if (subResourceIntegrityTag) { rewriter.emitRaw(subResourceIntegrityTag); } break; case 'link': if (readAttribute(tag, 'rel') === 'preconnect') { const href = readAttribute(tag, 'href'); if (href) { foundPreconnects.add(href); } } break; default: if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) { errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`); return; } } rewriter.emitStartTag(tag); }) .on('endTag', (tag) => { switch (tag.tagName) { case 'head': for (const linkTag of headerLinkTags) { rewriter.emitRaw(linkTag); } if (imageDomains) { for (const imageDomain of imageDomains) { if (!foundPreconnects.has(imageDomain)) { rewriter.emitRaw(`<link rel="preconnect" href="${imageDomain}" data-ngimg>`); } } } headerLinkTags = []; break; case 'body': for (const linkTag of bodyLinkTags) { rewriter.emitRaw(linkTag); } bodyLinkTags = []; // Add script tags for (const scriptTag of scriptTags) { rewriter.emitRaw(scriptTag); } scriptTags = []; break; } rewriter.emitEndTag(tag); }); const content = await transformedContent(); return { content: headerLinkTags.length || scriptTags.length ? // In case no body/head tags are not present (dotnet partial templates) headerLinkTags.join('') + scriptTags.join('') + content : content, warnings, errors, }; } function generateSriAttributes(content: string): string { const algo = 'sha384'; const hash = createHash(algo).update(content, 'utf8').digest('base64'); return `integrity="${algo}-${hash}"`; } function generateUrl(value: string, deployUrl: string | undefined): string { if (!deployUrl) { return value; } // Skip if root-relative, absolute or protocol relative url if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { return value; } return `${deployUrl}${value}`; } function updateAttribute( tag: { attrs: { name: string; value: string }[] }, name: string, value: string, ): void { const index = tag.attrs.findIndex((a) => a.name === name); const newValue = { name, value }; if (index === -1) { tag.attrs.push(newValue); } else { tag.attrs[index] = newValue; } } function readAttribute( tag: { attrs: { name: string; value: string }[] }, name: string, ): string | undefined { const targetAttr = tag.attrs.find((attr) => attr.name === name); return targetAttr ? targetAttr.value : undefined; } function isString(value: unknown): value is string { return typeof value === 'string'; } async function getLanguageDirection( locale: string, warnings: string[], ): Promise<string | undefined> { const dir = await getLanguageDirectionFromLocales(locale); if (!dir) { warnings.push( `Locale data for '${locale}' cannot be found. 'dir' attribute will not be set for this locale.`, ); } return dir; } async function getLanguageDirectionFromLocales(locale: string): Promise<string | undefined> { try { const localeData = (await import(`@angular/common/locales/${locale}`)).default; const dir = localeData[localeData.length - 2]; return isString(dir) ? dir : undefined; } catch { // In some cases certain locales might map to files which are named only with language id. // Example: `en-US` -> `en`. const [languageId] = locale.split('-', 1); if (languageId !== locale) { return getLanguageDirectionFromLocales(languageId); } } return undefined; }