Files
openmaic/OpenMAIC/lib/export/inline-assets.ts
2026-08-16 14:58:47 +08:00

728 lines
26 KiB
TypeScript

import {
toDataUri,
type InlineReport,
type InlineOptions,
type FetchAsset,
} from './inline-assets-shared';
import {
buildInlinedImportmap,
resolveSpecifier,
rewriteModuleSpecifiers,
} from './inline-assets-importmap';
import {
analyzeHtmlAssetInventory,
applySourcePatches,
collectAssetRefs,
replaceAttributePatch,
replaceAttributeRangePatch,
type AssetRefKind,
type SourcePatch,
} from './html-asset-inventory';
import parseSrcset, { type SrcsetCandidate } from 'parse-srcset';
import type { AtRule, Root } from 'postcss';
import {
cssImportReference,
cssUrlReferences,
parseCss,
rewriteCssValue,
} from './css-asset-parser';
export { toDataUri } from './inline-assets-shared';
export type { InlineReport, InlineOptions, FetchAsset } from './inline-assets-shared';
export { collectAssetRefs } from './html-asset-inventory';
export type { AssetRef, AssetRefKind } from './html-asset-inventory';
const HTTP_URL = /^https?:\/\//i;
interface CssImportConditions {
layer?: string | null;
supports?: string;
media?: string;
}
function readParenthesized(value: string, start: number): { inner: string; rest: string } | null {
if (value[start] !== '(') return null;
let depth = 0;
for (let index = start; index < value.length; index++) {
if (value[index] === '(') depth++;
if (value[index] === ')') depth--;
if (depth === 0) {
return { inner: value.slice(start + 1, index), rest: value.slice(index + 1).trim() };
}
}
return null;
}
function parseCssImportConditions(value: string): CssImportConditions {
let rest = value.trim();
const conditions: CssImportConditions = {};
if (/^layer\b/i.test(rest)) {
rest = rest.slice(5).trimStart();
if (rest.startsWith('(')) {
const parsed = readParenthesized(rest, 0);
conditions.layer = parsed?.inner.trim() ?? null;
rest = parsed?.rest ?? '';
} else {
conditions.layer = null;
}
}
if (/^supports\s*\(/i.test(rest)) {
const open = rest.indexOf('(');
const parsed = readParenthesized(rest, open);
conditions.supports = parsed?.inner.trim() ?? '';
rest = parsed?.rest ?? '';
}
conditions.media = rest || undefined;
return conditions;
}
function wrapImportedCss(css: string, conditions: CssImportConditions): string {
let wrapped = css;
if (conditions.media) wrapped = `@media ${conditions.media}{${wrapped}}`;
if (conditions.supports) {
const supports = /^(?:\(|not\b|selector\(|font-(?:format|tech)\()/i.test(conditions.supports)
? conditions.supports
: `(${conditions.supports})`;
wrapped = `@supports ${supports}{${wrapped}}`;
}
if (conditions.layer !== undefined) {
wrapped = conditions.layer ? `@layer ${conditions.layer}{${wrapped}}` : `@layer{${wrapped}}`;
}
return wrapped;
}
function serializeSrcset(candidates: SrcsetCandidate[]): string {
return candidates
.map((candidate) => {
const descriptor =
candidate.w !== undefined
? `${candidate.w}w`
: candidate.d !== undefined
? `${candidate.d}x`
: candidate.h !== undefined
? `${candidate.h}h`
: '';
return descriptor ? `${candidate.url} ${descriptor}` : candidate.url;
})
.join(', ');
}
const DEFAULT_MAX_ASSET_BYTES = 8 * 1024 * 1024;
export function createAssetFetcher(options?: InlineOptions): FetchAsset {
const fetchImpl = options?.fetchImpl ?? fetch;
const maxBytes = options?.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES;
const cache = new Map<string, Promise<{ bytes: Uint8Array; contentType: string } | null>>();
return function fetchAsset(url: string) {
const cached = cache.get(url);
if (cached) return cached;
const promise = (async () => {
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await fetchImpl(url);
if (!res.ok) {
// permanent client errors (e.g. 404, 403): don't retry
if (res.status !== 429 && res.status < 500) return null;
// transient server/rate-limit error: fall through to retry
if (attempt === MAX_ATTEMPTS) return null;
} else {
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.byteLength > maxBytes) return null;
const contentType =
res.headers.get('content-type')?.split(';')[0]?.trim() || guessMime(url);
return { bytes: buf, contentType };
}
} catch {
// network error (connection reset, ECONNRESET, etc.)
if (attempt === MAX_ATTEMPTS) return null;
}
// backoff before next attempt (150ms, 300ms)
await new Promise((r) => setTimeout(r, 150 * attempt));
}
return null;
})();
cache.set(url, promise);
return promise;
};
}
/** Run `fn` over `items` with at most `limit` concurrent calls. */
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
/** Fallback MIME by extension when the server omits content-type. */
function guessMime(url: string): string {
const ext = url.split('?')[0].split('#')[0].split('.').pop()?.toLowerCase() ?? '';
const table: Record<string, string> = {
js: 'text/javascript',
mjs: 'text/javascript',
css: 'text/css',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
woff2: 'font/woff2',
woff: 'font/woff',
ttf: 'font/ttf',
otf: 'font/otf',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wav: 'audio/wav',
};
return table[ext] ?? 'application/octet-stream';
}
/** Extension pattern matching non-woff2 font files (.woff, .ttf, .otf, .eot).
* The `(\?|#|$)` boundary prevents `.woff` from matching inside `.woff2`. */
const NON_WOFF2_FONT_EXT = /\.(woff|ttf|otf|eot)(\?|#|$)/i;
const WOFF2_EXT = /\.woff2(\?|#|$)/i;
async function inlineParsedCssUrls(
root: Root,
cssUrl: string,
fetchAsset: FetchAsset,
dropRefs: ReadonlySet<string>,
): Promise<{ failed: { url: string; reason: string }[]; inlined: string[] }> {
const failed: { url: string; reason: string }[] = [];
const inlined: string[] = [];
const refsByUrl = new Map<string, Map<string, string>>();
root.walkDecls((declaration) => {
for (const ref of cssUrlReferences(declaration.value)) {
const raw = ref.raw.trim();
if (/^(?:data:|blob:|about:|#)/i.test(raw) || dropRefs.has(raw)) continue;
try {
const resolved = new URL(raw, cssUrl);
const fragment = resolved.hash;
resolved.hash = '';
const fetchUrl = resolved.href;
const raws = refsByUrl.get(fetchUrl) ?? new Map<string, string>();
raws.set(raw, fragment);
refsByUrl.set(fetchUrl, raws);
} catch {
// Leave unresolvable values untouched for strict residual validation.
}
}
});
const replacements = new Map<string, string>();
await mapWithConcurrency([...refsByUrl.entries()], 8, async ([fetchUrl, raws]) => {
const got = await fetchAsset(fetchUrl);
if (got) {
const dataUri = toDataUri(got.bytes, got.contentType);
for (const [raw, fragment] of raws) replacements.set(raw, dataUri + fragment);
inlined.push(fetchUrl);
} else {
failed.push({ url: fetchUrl, reason: 'fetch failed' });
}
});
root.walkDecls((declaration) => {
declaration.value = rewriteCssValue(declaration.value, (raw) => {
const key = raw.trim();
if (replacements.has(key)) return replacements.get(key);
if (dropRefs.has(key)) return 'about:invalid';
return undefined;
});
});
return { failed, inlined };
}
/** Inline every url(...) inside a CSS text, resolving relative URLs against cssUrl.
*
* Woff2-preference optimisation: within any @font-face block that contains a
* woff2 url(), only the woff2 is inlined; sibling woff/ttf/otf/eot urls are
* rewritten to `url(about:invalid)` so browsers never fetch them (they use the
* first matching format — woff2 — and never reach the fallbacks). @font-face
* blocks with NO woff2 fall back to the normal inline-everything behaviour.
*/
export async function inlineCssUrls(
css: string,
cssUrl: string,
fetchAsset: FetchAsset,
activeCssUrls: ReadonlySet<string> = new Set(),
): Promise<{ css: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const failed: { url: string; reason: string }[] = [];
const inlined: string[] = [];
const root = parseCss(css, cssUrl);
const imports: AtRule[] = [];
root.walkAtRules((rule) => {
if (rule.name.toLowerCase() === 'import') imports.push(rule);
});
for (const rule of imports) {
const reference = cssImportReference(rule);
if (!reference) continue;
const raw = reference.url.trim();
if (/^(?:data:|blob:|about:|#)/i.test(raw)) continue;
let abs: string;
try {
abs = new URL(raw, cssUrl).href;
} catch {
continue;
}
if (activeCssUrls.has(abs)) {
// Only imports on the current recursion path are cycles. Sibling imports
// of the same stylesheet may carry distinct media/layer/supports guards.
rule.remove();
continue;
}
const got = await fetchAsset(abs);
if (!got) {
failed.push({ url: abs, reason: 'fetch failed' });
continue;
}
const nested = await inlineCssUrls(
new TextDecoder().decode(got.bytes),
abs,
fetchAsset,
new Set(activeCssUrls).add(abs),
);
inlined.push(abs, ...nested.inlined);
failed.push(...nested.failed);
const wrapped = wrapImportedCss(nested.css, parseCssImportConditions(reference.conditions));
rule.replaceWith(...parseCss(wrapped, abs).nodes);
}
// 1. Find @font-face blocks; build dropRefs (non-woff2 fonts in blocks that have a woff2).
const dropRefs = new Set<string>();
root.walkAtRules((rule) => {
if (rule.name.toLowerCase() !== 'font-face') return;
const blockUrls: string[] = [];
rule.walkDecls((declaration) => {
blockUrls.push(...cssUrlReferences(declaration.value).map((ref) => ref.raw.trim()));
});
const hasWoff2 = blockUrls.some((u) => WOFF2_EXT.test(u) || /^data:font\/woff2/i.test(u));
if (!hasWoff2) return;
for (const u of blockUrls) {
if (!/^data:/i.test(u) && NON_WOFF2_FONT_EXT.test(u)) dropRefs.add(u);
}
});
const rewritten = await inlineParsedCssUrls(root, cssUrl, fetchAsset, dropRefs);
failed.push(...rewritten.failed);
inlined.push(...rewritten.inlined);
return { css: root.toString(), failed, inlined };
}
async function inlineStyleAttributeUrls(
css: string,
fetchAsset: FetchAsset,
): Promise<{ css: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const root = parseCss(`.openmaic-style{${css}}`, 'style-attribute');
const result = await inlineParsedCssUrls(root, 'about:blank', fetchAsset, new Set());
const serialized = root.toString();
return {
css: serialized.slice(serialized.indexOf('{') + 1, serialized.lastIndexOf('}')),
...result,
};
}
async function inlineSvgPresentationAttributeUrls(
attributeName: string,
cssValue: string,
fetchAsset: FetchAsset,
): Promise<{ cssValue: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const root = parseCss(
`.openmaic-svg{${attributeName}:${cssValue}}`,
'svg-presentation-attribute',
);
let declarationValue = cssValue;
const result = await inlineParsedCssUrls(root, 'about:blank', fetchAsset, new Set());
root.walkDecls((declaration) => {
if (declaration.prop === attributeName) declarationValue = declaration.value;
});
return { cssValue: declarationValue, ...result };
}
// ---------------------------------------------------------------------------
// inlineHtmlAssets — Task 4
// ---------------------------------------------------------------------------
async function inlineImportmaps(
html: string,
fetchAsset: FetchAsset,
report: InlineReport,
keepFallbacks: boolean,
): Promise<string> {
// Collect both inline module bodies and external module sources. The latter
// must be inspected before the script is converted to a data URI, otherwise
// bare imports in an external module are invisible to importmap analysis.
const moduleScripts: Array<{ code: string; baseUrl?: string }> = [];
const inventory = analyzeHtmlAssetInventory(html);
for (const script of inventory.moduleScripts) {
const src = script.attributes.src;
if (src && /^https?:\/\//i.test(src)) {
const got = await fetchAsset(src);
if (!got) {
if (!report.failed.some((failure) => failure.url === src))
report.failed.push({ url: src, reason: 'fetch failed' });
continue;
}
moduleScripts.push({ code: new TextDecoder().decode(got.bytes), baseUrl: src });
} else if (script.content.trim()) {
moduleScripts.push({ code: script.content, baseUrl: undefined });
}
}
const patches: SourcePatch[] = [];
for (const importmap of inventory.importmaps) {
if (!importmap.contentRange) continue;
let parsed: { imports?: Record<string, string>; [key: string]: unknown };
try {
parsed = JSON.parse(importmap.content);
} catch {
continue;
}
const orig = parsed.imports ?? {};
const { imports: inlined, report: r } = await buildInlinedImportmap(
orig,
moduleScripts,
fetchAsset,
);
for (const u of r.inlined) if (!report.inlined.includes(u)) report.inlined.push(u);
for (const f of r.failed)
if (!report.failed.some((g) => g.url === f.url)) report.failed.push(f);
// Merge: start from originals, overlay inlined data: entries.
// Merge: original prefix entries are retained as online fallback; inlined explicit
// data: entries take precedence for the modules we inlined. Keeping both is safe per
// the importmap spec (explicit specifier shadows prefix key) and strictly more correct:
// a sub-path not seen during static analysis can still resolve via the prefix online.
const merged: Record<string, string> = keepFallbacks ? { ...orig, ...inlined } : inlined;
patches.push({
range: importmap.contentRange,
replacement: JSON.stringify({ ...parsed, imports: merged }),
});
}
return applySourcePatches(html, patches);
}
function readImportmapImports(html: string): Record<string, string> {
const imports: Record<string, string> = {};
for (const importmap of analyzeHtmlAssetInventory(html).importmaps) {
try {
Object.assign(
imports,
(JSON.parse(importmap.content) as { imports?: Record<string, string> }).imports ?? {},
);
} catch {
// Strict preparation reports malformed import maps separately.
}
}
return imports;
}
function unresolvedAssetUrls(html: string): string[] {
const inventory = analyzeHtmlAssetInventory(html);
const imports = readImportmapImports(html);
const unresolved = new Set(
collectAssetRefs(html, { includeRelative: true })
.filter((ref) => ref.kind !== 'module-import' || !resolveSpecifier(ref.url, imports))
.map((ref) => ref.url),
);
for (const importmap of inventory.importmaps) {
try {
JSON.parse(importmap.content);
} catch {
unresolved.add('malformed importmap');
}
}
return [...unresolved];
}
/**
* Scoped maps are intentionally rejected until module referrer URLs can be
* preserved through data-URI packaging; silently flattening them changes which
* dependency wins for modules under each scope.
*/
function unsupportedImportmapFeatures(html: string): string[] {
for (const importmap of analyzeHtmlAssetInventory(html).importmaps) {
try {
const parsed = JSON.parse(importmap.content) as { scopes?: unknown };
if (
parsed.scopes !== undefined &&
(typeof parsed.scopes !== 'object' ||
parsed.scopes === null ||
Array.isArray(parsed.scopes) ||
Object.keys(parsed.scopes).length > 0)
) {
return ['unsupported-importmap-scopes'];
}
} catch {
// Malformed import maps are reported by residual validation.
}
}
return [];
}
/** Rewrite direct URL/relative module dependencies to data URIs for offline use. */
async function inlineModuleSource(
code: string,
baseUrl: string | undefined,
imports: Record<string, string>,
fetchAsset: FetchAsset,
report: InlineReport,
): Promise<string> {
const built = await buildInlinedImportmap(imports, [{ code, baseUrl }], fetchAsset);
for (const url of built.report.inlined)
if (!report.inlined.includes(url)) report.inlined.push(url);
for (const failure of built.report.failed) {
if (!report.failed.some((existing) => existing.url === failure.url))
report.failed.push(failure);
}
return rewriteModuleSpecifiers(code, (specifier) => {
return !resolveSpecifier(specifier, imports) ? built.imports[specifier] : undefined;
});
}
export async function inlineHtmlAssets(
html: string,
options?: InlineOptions,
): Promise<{ html: string; report: InlineReport; unresolved: string[] }> {
const fetchAsset = options?.fetcher ?? createAssetFetcher(options);
const report: InlineReport = { inlined: [], failed: [] };
const unsupported = unsupportedImportmapFeatures(html);
if (unsupported.length > 0) return { html, report, unresolved: unsupported };
// Pre-warm non-importmap asset fetches in parallel so the sequential
// structured rewrite phases hit a warm cache (fonts are parallelized inside
// inlineCssUrls; importmap modules are handled in buildInlinedImportmap).
await Promise.all(
collectAssetRefs(html)
.filter(
(ref) =>
ref.kind !== 'importmap' &&
ref.kind !== 'iframe-src' &&
ref.kind !== 'iframe-srcdoc' &&
ref.kind !== 'object-data' &&
ref.kind !== 'embed-src',
)
.map((ref) => {
const url =
ref.kind === 'svg-image' || ref.kind === 'svg-use' || ref.kind === 'css-url'
? ref.url.split('#')[0]
: ref.url;
return fetchAsset(url).catch(() => null);
}),
);
let out = html;
const markInlined = (url: string) => {
if (!report.inlined.includes(url)) report.inlined.push(url);
};
const markFailed = (url: string, reason: string) => {
if (!report.failed.some((f) => f.url === url)) report.failed.push({ url, reason });
};
const inlineAttributes = async (kinds: ReadonlySet<AssetRefKind>) => {
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (!kinds.has(asset.kind) || !HTTP_URL.test(asset.url)) continue;
const isSvgReference = asset.kind === 'svg-image' || asset.kind === 'svg-use';
const hashIndex = isSvgReference ? asset.url.indexOf('#') : -1;
const fetchUrl = hashIndex === -1 ? asset.url : asset.url.slice(0, hashIndex);
const fragment = hashIndex === -1 ? '' : asset.url.slice(hashIndex);
const got = await fetchAsset(fetchUrl);
if (!got) {
markFailed(fetchUrl, 'fetch failed');
continue;
}
const patch = replaceAttributePatch(asset, toDataUri(got.bytes, got.contentType) + fragment);
if (patch) patches.push(patch);
markInlined(fetchUrl);
}
out = applySourcePatches(out, patches);
};
// Resolve importmap entries before converting external module scripts. This
// lets the importmap walker inspect their source bodies and nested imports.
out = await inlineImportmaps(out, fetchAsset, report, options?.keepImportmapFallbacks !== false);
// 1) Real <link rel=stylesheet href> → <style> with nested url() inlined.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (
asset.kind !== 'link' ||
!HTTP_URL.test(asset.url) ||
!asset.attributes.rel?.toLowerCase().split(/\s+/).includes('stylesheet') ||
!asset.elementRange
) {
continue;
}
const got = await fetchAsset(asset.url);
if (!got) {
markFailed(asset.url, 'fetch failed');
continue;
}
let cssText = new TextDecoder().decode(got.bytes);
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineCssUrls(cssText, asset.url, fetchAsset);
cssText = rewritten;
for (const f of cssFailed) markFailed(f.url, f.reason);
for (const inlined of cssInlined) markInlined(inlined);
const mediaAttr = asset.attributes.media
? ` media="${asset.attributes.media.replace(/"/g, '&quot;')}"`
: '';
markInlined(asset.url);
patches.push({
range: asset.elementRange,
replacement: `<style data-inlined-from=""${mediaAttr}>${cssText}</style>`,
});
}
out = applySourcePatches(out, patches);
}
// 2) Real <script src> (non-importmap) → data: URI src.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (asset.kind !== 'script' || !HTTP_URL.test(asset.url)) continue;
const got = await fetchAsset(asset.url);
if (!got) {
markFailed(asset.url, 'fetch failed');
continue;
}
const type = asset.attributes.type?.trim().toLowerCase() === 'module';
const source = new TextDecoder().decode(got.bytes);
const rewritten = type
? await inlineModuleSource(source, asset.url, readImportmapImports(out), fetchAsset, report)
: source;
const patch = replaceAttributePatch(
asset,
toDataUri(new TextEncoder().encode(rewritten), got.contentType),
);
if (patch) patches.push(patch);
markInlined(asset.url);
}
out = applySourcePatches(out, patches);
}
// Inline module bodies can contain direct absolute imports or relative
// imports. Rewrite those too; bare specifiers remain governed by importmap.
{
const patches: SourcePatch[] = [];
for (const script of analyzeHtmlAssetInventory(out).moduleScripts) {
if (!script.contentRange || !script.content.trim()) continue;
const rewritten = await inlineModuleSource(
script.content,
undefined,
readImportmapImports(out),
fetchAsset,
report,
);
patches.push({ range: script.contentRange, replacement: rewritten });
}
out = applySourcePatches(out, patches);
}
// 3) Real <img>/<source>/<video>/<audio> src attributes.
await inlineAttributes(new Set<AssetRefKind>(['img', 'source', 'video', 'audio']));
// 4) Responsive image candidates use the same offline guarantee as src.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (asset.kind !== 'srcset') continue;
const rewritten = await Promise.all(
parseSrcset(asset.url).map(async (candidate) => {
if (!HTTP_URL.test(candidate.url)) return candidate;
const got = await fetchAsset(candidate.url);
if (!got) {
markFailed(candidate.url, 'fetch failed');
return candidate;
}
markInlined(candidate.url);
return { ...candidate, url: toDataUri(got.bytes, got.contentType) };
}),
);
const patch = replaceAttributePatch(asset, serializeSrcset(rewritten));
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
// 5) <video poster> is an external image resource too.
await inlineAttributes(new Set<AssetRefKind>(['poster', 'svg-image', 'svg-use']));
// 6) url() inside authored <style> blocks (skip ones we created in step 1).
{
const patches: SourcePatch[] = [];
for (const style of analyzeHtmlAssetInventory(out).styles) {
if (style.attributes['data-inlined-from'] !== undefined || !style.contentRange) continue;
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineCssUrls(style.content, 'about:blank', fetchAsset);
for (const f of cssFailed) markFailed(f.url, f.reason);
for (const inlined of cssInlined) markInlined(inlined);
patches.push({ range: style.contentRange, replacement: rewritten });
}
out = applySourcePatches(out, patches);
}
// 7) url() inside authored style attributes.
{
const patches: SourcePatch[] = [];
for (const style of analyzeHtmlAssetInventory(out).styleAttributes) {
if (!style.range) continue;
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineStyleAttributeUrls(style.css, fetchAsset);
for (const f of cssFailed) markFailed(f.url, f.reason);
for (const inlined of cssInlined) markInlined(inlined);
const patch = replaceAttributeRangePatch('style', style.range, rewritten);
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
// 8) url() inside SVG presentation attributes.
{
const patches: SourcePatch[] = [];
for (const attribute of analyzeHtmlAssetInventory(out).svgPresentationAttributes) {
if (!attribute.range) continue;
const {
cssValue,
failed: cssFailed,
inlined: cssInlined,
} = await inlineSvgPresentationAttributeUrls(
attribute.attributeName,
attribute.cssValue,
fetchAsset,
);
for (const failure of cssFailed) markFailed(failure.url, failure.reason);
for (const inlined of cssInlined) markInlined(inlined);
if (cssValue === attribute.cssValue) continue;
const patch = replaceAttributeRangePatch(attribute.attributeName, attribute.range, cssValue);
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
return { html: out, report, unresolved: unresolvedAssetUrls(out) };
}