From 1a323e5c64c7c23212ed80546f593d930115d55c Mon Sep 17 00:00:00 2001 From: Julien Cayzac Date: Fri, 27 Feb 2026 20:45:59 +0900 Subject: [PATCH 1/2] fix: restore SVG renderability for content collection image() fields (#15685) --- .../fix-svg-content-collection-component.md | 5 ++++ packages/astro/src/assets/utils/svg.ts | 19 ++++++++++++++ .../astro/src/assets/vite-plugin-assets.ts | 26 ++++++++++++++++--- packages/astro/src/content/runtime.ts | 16 ++++++++++-- .../test/content-collection-tla-svg.test.js | 9 +++++++ .../src/pages/index.astro | 16 +++++++----- 6 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-svg-content-collection-component.md diff --git a/.changeset/fix-svg-content-collection-component.md b/.changeset/fix-svg-content-collection-component.md new file mode 100644 index 000000000000..6186509d6b95 --- /dev/null +++ b/.changeset/fix-svg-content-collection-component.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix regression where SVG images in content collection `image()` fields could not be rendered as inline components. This behavior is now restored while preserving the TLA deadlock fix. diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts index 0be4f9e2296e..623f30c7669c 100644 --- a/packages/astro/src/assets/utils/svg.ts +++ b/packages/astro/src/assets/utils/svg.ts @@ -64,3 +64,22 @@ export function makeSvgComponent( return `import { createSvgComponent } from 'astro/assets/runtime'; export default createSvgComponent(${JSON.stringify(props)})`; } + +/** + * Parse an SVG file and return the serialisable component data + * (attributes + inner HTML body) without generating any module code. + * @internal Used by the asset pipeline for content-collection SVG images. + */ +export function parseSvgComponentData( + meta: ImageMetadata, + contents: Buffer | string, + svgoConfig: AstroConfig['experimental']['svgo'], +): { attributes: Record; children: string } { + const file = typeof contents === 'string' ? contents : contents.toString('utf-8'); + const { attributes, body: children } = parseSvg({ + path: meta.fsPath, + contents: file, + svgoConfig, + }); + return { attributes: dropAttributes(attributes), children }; +} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 10d4a30ffa95..006d493f55a9 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -34,7 +34,7 @@ import { hashTransform, propsToFilename } from './utils/hash.js'; import { emitImageMetadata } from './utils/node.js'; import { CONTENT_IMAGE_FLAG } from '../content/consts.js'; import { getProxyCode } from './utils/proxy.js'; -import { makeSvgComponent } from './utils/svg.js'; +import { makeSvgComponent, parseSvgComponentData } from './utils/svg.js'; import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js'; const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})`, 'i'); @@ -310,9 +310,10 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl // an image on the client, it should be present in the final build. if (isAstroServerEnvironment(this.environment)) { // For SVGs imported directly (not via content collections), create a full - // component that can be rendered inline. For content collection SVGs, return - // plain metadata to avoid importing createComponent from the server runtime, - // which would create a circular dependency when combined with TLA. + // component that can be rendered inline. For content collection SVGs, the + // component is reconstructed later in content/runtime.ts from __svgData + // embedded in the metadata, avoiding a server-runtime import here that + // would create a circular dependency when combined with TLA. if (id.endsWith('.svg') && !isContentImage) { const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8', @@ -330,6 +331,23 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl if (isSSROnlyEnvironment) { globalThis.astroAsset.referencedImages.add(imageMetadata.fsPath); } + // Content-collection SVG: embed parsed SVG data so content/runtime.ts can + // reconstruct a renderable component without importing from the server runtime + // (which would recreate the TLA circular-dependency deadlock, see #15575). + if (id.endsWith('.svg') && isContentImage) { + const contents = await fs.promises.readFile(imageMetadata.fsPath, { + encoding: 'utf8', + }); + const svgData = parseSvgComponentData( + imageMetadata, + contents, + settings.config.experimental.svgo, + ); + const metadataWithSvg = { ...imageMetadata, __svgData: svgData }; + return { + code: `export default ${getProxyCode(metadataWithSvg as typeof imageMetadata, isSSROnlyEnvironment)}`, + }; + } return { code: `export default ${getProxyCode(imageMetadata, isSSROnlyEnvironment)}`, }; diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index b148b8e64458..611f43c037ff 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -4,6 +4,7 @@ import { Traverse } from 'neotraverse/modern'; import * as z from 'zod/v4'; import type * as zCore from 'zod/v4/core'; import type { GetImageResult, ImageMetadata } from '../assets/types.js'; +import { createSvgComponent } from '../assets/runtime.js'; import { imageSrcToImportId } from '../assets/utils/resolveImports.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { isRemotePath, prependForwardSlash } from '../core/path.js'; @@ -519,9 +520,20 @@ function updateImageReferencesInData>( ctx.update(src); return; } - const imported = imageAssetMap?.get(id); + const imported = imageAssetMap?.get(id) as + | (ImageMetadata & { __svgData?: { attributes: Record; children: string } }) + | undefined; if (imported) { - ctx.update(imported); + if (imported.__svgData) { + // Reconstruct the renderable SVG component from the data embedded at build + // time. We cannot call createSvgComponent inside the SVG Vite module itself + // because that would import the server runtime across a dynamic-import + // boundary, recreating the TLA circular-dependency deadlock (see #15575). + const { __svgData: svgData, ...meta } = imported; + ctx.update(createSvgComponent({ meta: meta as ImageMetadata, ...svgData })); + } else { + ctx.update(imported); + } } else { ctx.update(src); } diff --git a/packages/astro/test/content-collection-tla-svg.test.js b/packages/astro/test/content-collection-tla-svg.test.js index 000f7363e71e..34c01137da0d 100644 --- a/packages/astro/test/content-collection-tla-svg.test.js +++ b/packages/astro/test/content-collection-tla-svg.test.js @@ -36,5 +36,14 @@ describe('Content collection with SVG image and TLA', () => { assert.equal($img.attr('width'), '100'); assert.equal($img.attr('height'), '100'); }); + + it('renders SVG as an inline component from content collection', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + const $svg = $('.inline-svg').first(); + assert.ok($svg.length, 'Expected inline SVG element to be rendered'); + assert.equal($svg.prop('tagName').toLowerCase(), 'svg'); + }); }); }); diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro b/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro index f7bf4ff97aef..35c796e3464d 100644 --- a/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro +++ b/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro @@ -11,11 +11,15 @@ const articles = await getCollection('articles'); Articles - {articles.map((article) => ( -
-

{article.data.title}

- -
- ))} + {articles.map((article) => { + const Cover = article.data.cover; + return ( +
+

{article.data.title}

+ + +
+ ); + })} From f94d3c5313e5a7576cf2cb316a85d68d335a188f Mon Sep 17 00:00:00 2001 From: "Ocavue (Jiajin Wen)" Date: Sat, 28 Feb 2026 00:53:39 +1100 Subject: [PATCH 2/2] fix(markdown-remark): reuse shiki highlighter instance (#15651) --- .changeset/slow-coins-write.md | 5 + packages/markdown/remark/src/shiki.ts | 113 ++++++++++++++++---- packages/markdown/remark/test/shiki.test.js | 85 +++++++++++++++ 3 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 .changeset/slow-coins-write.md diff --git a/.changeset/slow-coins-write.md b/.changeset/slow-coins-write.md new file mode 100644 index 000000000000..28728e77132a --- /dev/null +++ b/.changeset/slow-coins-write.md @@ -0,0 +1,5 @@ +--- +'@astrojs/markdown-remark': patch +--- + +Reuses cached Shiki highlighter instances across languages. diff --git a/packages/markdown/remark/src/shiki.ts b/packages/markdown/remark/src/shiki.ts index 5502b0e13b70..f997cf457166 100644 --- a/packages/markdown/remark/src/shiki.ts +++ b/packages/markdown/remark/src/shiki.ts @@ -1,15 +1,16 @@ import type { Properties, Root } from 'hast'; import { + type BuiltinLanguage, type BundledLanguage, - type BundledTheme, createCssVariablesTheme, createHighlighter, type HighlighterCoreOptions, - type HighlighterGeneric, isSpecialLang, + type LanguageInput, type LanguageRegistration, type RegexEngine, type ShikiTransformer, + type SpecialLanguage, type ThemeRegistration, type ThemeRegistrationRaw, } from 'shiki'; @@ -29,6 +30,13 @@ export interface ShikiHighlighter { ): Promise; } +type ShikiLanguage = LanguageInput | BuiltinLanguage | SpecialLanguage; + +interface ShikiHighlighterInternal extends ShikiHighlighter { + loadLanguage(...langs: ShikiLanguage[]): Promise; + getLoadedLanguages(): string[]; +} + export interface CreateShikiHighlighterOptions { langs?: LanguageRegistration[]; theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw; @@ -73,41 +81,100 @@ const cssVariablesTheme = () => variablePrefix: '--astro-code-', })); -// Caches Promise for reuse when the same theme and langs are provided -const cachedHighlighters = new Map(); +// Caches Promise for reuse when the same `themes` and `langAlias`. +const cachedHighlighters = new Map>(); + +/** + * Only used for testing. + * + * @internal + */ +export function clearShikiHighlighterCache(): void { + cachedHighlighters.clear(); +} + +export function createShikiHighlighter( + options?: CreateShikiHighlighterOptions, +): Promise { + // Although this function returns a promise, its body runs synchronously so + // that the cache lookup happens immediately. Without this, calling + // `Promise.all([createShikiHighlighter(), createShikiHighlighter()])` would + // bypass the cache and create duplicate highlighters. + + const key: string = getCacheKey(options); + let highlighterPromise = cachedHighlighters.get(key); + if (!highlighterPromise) { + highlighterPromise = createShikiHighlighterInternal(options); + cachedHighlighters.set(key, highlighterPromise); + } + return ensureLanguagesLoaded(highlighterPromise, options?.langs); +} + +/** + * Gets the cache key for the highlighter. + * + * Notice that we don't use `langs` in the cache key because we can dynamically + * load languages. This allows us to reuse the same highlighter instance for + * different languages. + */ +function getCacheKey(options?: CreateShikiHighlighterOptions): string { + const keyCache: unknown[] = []; + const { theme, themes, langAlias } = options ?? {}; + if (theme) { + keyCache.push(theme); + } + if (themes) { + keyCache.push(Object.entries(themes).sort()); + } + if (langAlias) { + keyCache.push(Object.entries(langAlias).sort()); + } + return keyCache.length > 0 ? JSON.stringify(keyCache) : ''; +} + +/** + * Ensures that the languages are loaded into the highlighter. This is + * especially important when the languages are objects representing custom + * user-defined languages. + */ +async function ensureLanguagesLoaded( + promise: Promise, + langs?: ShikiLanguage[], +): Promise { + const highlighter = await promise; + if (!langs) { + return highlighter; + } + const loadedLanguages = highlighter.getLoadedLanguages(); + for (const lang of langs) { + if (typeof lang === 'string' && (isSpecialLang(lang) || loadedLanguages.includes(lang))) { + continue; + } + await highlighter.loadLanguage(lang); + } + return highlighter; +} let shikiEngine: RegexEngine | undefined = undefined; -export async function createShikiHighlighter({ +async function createShikiHighlighterInternal({ langs = [], theme = 'github-dark', themes = {}, langAlias = {}, -}: CreateShikiHighlighterOptions = {}): Promise { +}: CreateShikiHighlighterOptions = {}): Promise { theme = theme === 'css-variables' ? cssVariablesTheme() : theme; if (shikiEngine === undefined) { shikiEngine = await loadShikiEngine(); } - const highlighterOptions = { + const highlighter = await createHighlighter({ langs: ['plaintext', ...langs], langAlias, themes: Object.values(themes).length ? Object.values(themes) : [theme], engine: shikiEngine, - }; - - const key = JSON.stringify(highlighterOptions, Object.keys(highlighterOptions).sort()); - - let highlighter: HighlighterGeneric; - - // Highlighter has already been requested, reuse the same instance - if (cachedHighlighters.has(key)) { - highlighter = cachedHighlighters.get(key); - } else { - highlighter = await createHighlighter(highlighterOptions); - cachedHighlighters.set(key, highlighter); - } + }); async function highlight( code: string, @@ -220,6 +287,12 @@ export async function createShikiHighlighter({ codeToHtml(code, lang, options = {}) { return highlight(code, lang, options, 'html') as Promise; }, + loadLanguage(...newLangs) { + return highlighter.loadLanguage(...newLangs); + }, + getLoadedLanguages() { + return highlighter.getLoadedLanguages(); + }, }; } diff --git a/packages/markdown/remark/test/shiki.test.js b/packages/markdown/remark/test/shiki.test.js index e230b298239c..a4ed059e5130 100644 --- a/packages/markdown/remark/test/shiki.test.js +++ b/packages/markdown/remark/test/shiki.test.js @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createMarkdownProcessor, createShikiHighlighter } from '../dist/index.js'; +import { clearShikiHighlighterCache } from '../dist/shiki.js'; describe('shiki syntax highlighting', () => { it('does not add is:raw to the output', async () => { @@ -48,6 +49,51 @@ describe('shiki syntax highlighting', () => { assert.match(hast.children[0].properties.style, /background-color:#24292e;color:#e1e4e8;/); }); + it('createShikiHighlighter can reuse the same instance for different languages', async () => { + const langs = [ + 'abap', + 'ada', + 'adoc', + 'angular-html', + 'angular-ts', + 'apache', + 'apex', + 'apl', + 'applescript', + 'ara', + 'asciidoc', + 'asm', + 'astro', + 'awk', + 'ballerina', + 'bash', + 'bat', + 'batch', + 'be', + 'beancount', + 'berry', + 'bibtex', + 'bicep', + 'blade', + 'bsl', + ]; + + const highlighters = new Set(); + for (const lang of langs) { + highlighters.add(await createShikiHighlighter({ langs: [lang] })); + } + + // Ensure that we only have one highlighter instance. + assert.strictEqual(highlighters.size, 1); + + // Ensure that this highlighter instance can highlight different languages. + const highlighter = Array.from(highlighters)[0]; + const html1 = await highlighter.codeToHtml('const foo = "bar";', 'js'); + const html2 = await highlighter.codeToHtml('const foo = "bar";', 'ts'); + assert.match(html1, /color:#F97583/); + assert.match(html2, /color:#F97583/); + }); + it('diff +/- text has user-select: none', async () => { const highlighter = await createShikiHighlighter(); @@ -137,4 +183,43 @@ describe('shiki syntax highlighting', () => { assert.match(code, /data-language="cjs"/); }); + + it("the cached highlighter won't load the same language twice", async () => { + clearShikiHighlighterCache(); + + const theme = 'github-light'; + const highlighter = await createShikiHighlighter({ theme }); + + // loadLanguage is an internal method + const loadLanguageArgs = []; + const originalLoadLanguage = highlighter['loadLanguage']; + highlighter['loadLanguage'] = async (...args) => { + loadLanguageArgs.push(...args); + return await originalLoadLanguage(...args); + }; + + // No languages loaded yet + assert.equal(loadLanguageArgs.length, 0); + + // Load a new language + const h1 = await createShikiHighlighter({ theme, langs: ['js'] }); + assert.equal(loadLanguageArgs.length, 1); + + // Load the same language again + const h2 = await createShikiHighlighter({ theme, langs: ['js'] }); + assert.equal(loadLanguageArgs.length, 1); + + // Load another language + const h3 = await createShikiHighlighter({ theme, langs: ['ts'] }); + assert.equal(loadLanguageArgs.length, 2); + + // Load the same language again + const h4 = await createShikiHighlighter({ theme, langs: ['ts'] }); + assert.equal(loadLanguageArgs.length, 2); + + // All highlighters should be the same instance + assert.equal(new Set([highlighter, h1, h2, h3, h4]).size, 1); + + clearShikiHighlighterCache(); + }); });