Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-svg-content-collection-component.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/slow-coins-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/markdown-remark': patch
---

Reuses cached Shiki highlighter instances across languages.
19 changes: 19 additions & 0 deletions packages/astro/src/assets/utils/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; 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 };
}
26 changes: 22 additions & 4 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand All @@ -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)}`,
};
Expand Down
16 changes: 14 additions & 2 deletions packages/astro/src/content/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -519,9 +520,20 @@ function updateImageReferencesInData<T extends Record<string, unknown>>(
ctx.update(src);
return;
}
const imported = imageAssetMap?.get(id);
const imported = imageAssetMap?.get(id) as
| (ImageMetadata & { __svgData?: { attributes: Record<string, string>; 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);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/test/content-collection-tla-svg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ const articles = await getCollection('articles');
<title>Articles</title>
</head>
<body>
{articles.map((article) => (
<div class="article">
<h2 class="title">{article.data.title}</h2>
<img class="cover" src={article.data.cover.src} width={article.data.cover.width} height={article.data.cover.height} />
</div>
))}
{articles.map((article) => {
const Cover = article.data.cover;
return (
<div class="article">
<h2 class="title">{article.data.title}</h2>
<img class="cover" src={article.data.cover.src} width={article.data.cover.width} height={article.data.cover.height} />
<Cover class="inline-svg" />
</div>
);
})}
</body>
</html>
113 changes: 93 additions & 20 deletions packages/markdown/remark/src/shiki.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -29,6 +30,13 @@ export interface ShikiHighlighter {
): Promise<string>;
}

type ShikiLanguage = LanguageInput | BuiltinLanguage | SpecialLanguage;

interface ShikiHighlighterInternal extends ShikiHighlighter {
loadLanguage(...langs: ShikiLanguage[]): Promise<void>;
getLoadedLanguages(): string[];
}

export interface CreateShikiHighlighterOptions {
langs?: LanguageRegistration[];
theme?: ThemePresets | ThemeRegistration | ThemeRegistrationRaw;
Expand Down Expand Up @@ -73,41 +81,100 @@ const cssVariablesTheme = () =>
variablePrefix: '--astro-code-',
}));

// Caches Promise<ShikiHighlighter> for reuse when the same theme and langs are provided
const cachedHighlighters = new Map();
// Caches Promise<ShikiHighlighterInternal> for reuse when the same `themes` and `langAlias`.
const cachedHighlighters = new Map<string, Promise<ShikiHighlighterInternal>>();

/**
* Only used for testing.
*
* @internal
*/
export function clearShikiHighlighterCache(): void {
cachedHighlighters.clear();
}

export function createShikiHighlighter(
options?: CreateShikiHighlighterOptions,
): Promise<ShikiHighlighter> {
// 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<ShikiHighlighterInternal>,
langs?: ShikiLanguage[],
): Promise<ShikiHighlighterInternal> {
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<ShikiHighlighter> {
}: CreateShikiHighlighterOptions = {}): Promise<ShikiHighlighterInternal> {
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<BundledLanguage, BundledTheme>;

// 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,
Expand Down Expand Up @@ -220,6 +287,12 @@ export async function createShikiHighlighter({
codeToHtml(code, lang, options = {}) {
return highlight(code, lang, options, 'html') as Promise<string>;
},
loadLanguage(...newLangs) {
return highlighter.loadLanguage(...newLangs);
},
getLoadedLanguages() {
return highlighter.getLoadedLanguages();
},
};
}

Expand Down
Loading
Loading