Skip to content

Commit

Permalink
Lazy loaded shiki languages during syntax highlighting (#10618)
Browse files Browse the repository at this point in the history
  • Loading branch information
43081j committed Apr 1, 2024
1 parent 31590d4 commit 374efcd
Show file tree
Hide file tree
Showing 16 changed files with 169 additions and 110 deletions.
5 changes: 5 additions & 0 deletions .changeset/real-rabbits-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/markdown-remark": major
---

Updates Shiki syntax highlighting to lazily load shiki languages by default (only preloading `plaintext`). Additionally, the `createShikiHighlighter()` API now returns an asynchronous `highlight()` function due to this.
2 changes: 1 addition & 1 deletion packages/astro/components/Code.astro
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const highlighter = await getCachedHighlighter({
wrap,
});
const html = highlighter.highlight(code, typeof lang === 'string' ? lang : lang.name, {
const html = await highlighter.highlight(code, typeof lang === 'string' ? lang : lang.name, {
inline,
attributes: rest as any,
});
Expand Down
135 changes: 67 additions & 68 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { extname } from 'node:path';
import MagicString from 'magic-string';
import type * as vite from 'vite';
import { normalizePath } from 'vite';
import type { AstroPluginOptions, ImageTransform } from '../@types/astro.js';
import type { AstroPluginOptions, AstroSettings, ImageTransform } from '../@types/astro.js';
import { extendManualChunks } from '../core/build/plugins/util.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import {
Expand All @@ -24,6 +24,71 @@ const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;

const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})`, 'i');
const assetRegexEnds = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})$`, 'i');
const addStaticImageFactory = (
settings: AstroSettings
): typeof globalThis.astroAsset.addStaticImage => {
return (options, hashProperties, originalFSPath) => {
if (!globalThis.astroAsset.staticImages) {
globalThis.astroAsset.staticImages = new Map<
string,
{
originalSrcPath: string;
transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
}
>();
}

// Rollup will copy the file to the output directory, as such this is the path in the output directory, including the asset prefix / base
const ESMImportedImageSrc = isESMImportedImage(options.src) ? options.src.src : options.src;
const fileExtension = extname(ESMImportedImageSrc);
const assetPrefix = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);

// This is the path to the original image, from the dist root, without the base or the asset prefix (e.g. /_astro/image.hash.png)
const finalOriginalPath = removeBase(
removeBase(ESMImportedImageSrc, settings.config.base),
assetPrefix
);

const hash = hashTransform(options, settings.config.image.service.entrypoint, hashProperties);

let finalFilePath: string;
let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath);
let transformForHash = transformsForPath?.transforms.get(hash);

// If the same image has already been transformed with the same options, we'll reuse the final path
if (transformsForPath && transformForHash) {
finalFilePath = transformForHash.finalPath;
} else {
finalFilePath = prependForwardSlash(
joinPaths(
isESMImportedImage(options.src) ? '' : settings.config.build.assets,
prependForwardSlash(propsToFilename(finalOriginalPath, options, hash))
)
);

if (!transformsForPath) {
globalThis.astroAsset.staticImages.set(finalOriginalPath, {
originalSrcPath: originalFSPath,
transforms: new Map(),
});
transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath)!;
}

transformsForPath.transforms.set(hash, {
finalPath: finalFilePath,
transform: options,
});
}

// The paths here are used for URLs, so we need to make sure they have the proper format for an URL
// (leading slash, prefixed with the base / assets prefix, encoded, etc)
if (settings.config.build.assetsPrefix) {
return encodeURI(joinPaths(assetPrefix, finalFilePath));
} else {
return encodeURI(prependForwardSlash(joinPaths(settings.config.base, finalFilePath)));
}
};
};

export default function assets({
settings,
Expand Down Expand Up @@ -92,73 +157,7 @@ export default function assets({
return;
}

globalThis.astroAsset.addStaticImage = (options, hashProperties, originalFSPath) => {
if (!globalThis.astroAsset.staticImages) {
globalThis.astroAsset.staticImages = new Map<
string,
{
originalSrcPath: string;
transforms: Map<string, { finalPath: string; transform: ImageTransform }>;
}
>();
}

// Rollup will copy the file to the output directory, as such this is the path in the output directory, including the asset prefix / base
const ESMImportedImageSrc = isESMImportedImage(options.src)
? options.src.src
: options.src;
const fileExtension = extname(ESMImportedImageSrc);
const assetPrefix = getAssetsPrefix(fileExtension, settings.config.build.assetsPrefix);

// This is the path to the original image, from the dist root, without the base or the asset prefix (e.g. /_astro/image.hash.png)
const finalOriginalPath = removeBase(
removeBase(ESMImportedImageSrc, settings.config.base),
assetPrefix
);

const hash = hashTransform(
options,
settings.config.image.service.entrypoint,
hashProperties
);

let finalFilePath: string;
let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath);
let transformForHash = transformsForPath?.transforms.get(hash);

// If the same image has already been transformed with the same options, we'll reuse the final path
if (transformsForPath && transformForHash) {
finalFilePath = transformForHash.finalPath;
} else {
finalFilePath = prependForwardSlash(
joinPaths(
isESMImportedImage(options.src) ? '' : settings.config.build.assets,
prependForwardSlash(propsToFilename(finalOriginalPath, options, hash))
)
);

if (!transformsForPath) {
globalThis.astroAsset.staticImages.set(finalOriginalPath, {
originalSrcPath: originalFSPath,
transforms: new Map(),
});
transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath)!;
}

transformsForPath.transforms.set(hash, {
finalPath: finalFilePath,
transform: options,
});
}

// The paths here are used for URLs, so we need to make sure they have the proper format for an URL
// (leading slash, prefixed with the base / assets prefix, encoded, etc)
if (settings.config.build.assetsPrefix) {
return encodeURI(joinPaths(assetPrefix, finalFilePath));
} else {
return encodeURI(prependForwardSlash(joinPaths(settings.config.base, finalFilePath)));
}
};
globalThis.astroAsset.addStaticImage = addStaticImageFactory(settings);
},
// In build, rewrite paths to ESM imported images in code to their final location
async renderChunk(code) {
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/content/vite-plugin-content-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ export function astroConfigBuildPlugin(
mutate(chunk, ['server'], newCode);
}
}

ssrPluginContext = undefined;
},
},
};
Expand Down
7 changes: 5 additions & 2 deletions packages/astro/src/vite-plugin-markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,17 @@ const astroErrorModulePath = normalizePath(
);

export default function markdown({ settings, logger }: AstroPluginOptions): Plugin {
let processor: MarkdownProcessor;
let processor: MarkdownProcessor | undefined;

return {
enforce: 'pre',
name: 'astro:markdown',
async buildStart() {
processor = await createMarkdownProcessor(settings.config.markdown);
},
buildEnd() {
processor = undefined;
},
// Why not the "transform" hook instead of "load" + readFile?
// A: Vite transforms all "import.meta.env" references to their values before
// passing to the transform hook. This lets us get the truly raw value
Expand All @@ -52,7 +55,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug

const fileURL = pathToFileURL(fileId);

const renderResult = await processor
const renderResult = await processor!
.render(raw.content, {
// @ts-expect-error passing internal prop
fileURL,
Expand Down
28 changes: 22 additions & 6 deletions packages/astro/test/astro-markdown-shiki.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,26 +80,42 @@ describe('Astro Markdown Shiki', () => {
});
});

describe('Custom langs', () => {
describe('Languages', () => {
let fixture;
let $;

before(async () => {
fixture = await loadFixture({ root: './fixtures/astro-markdown-shiki/langs/' });
await fixture.build();
});

it('Markdown file', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
$ = cheerio.load(html);
});

const segments = $('.line').get(6).children;
it('custom language', async () => {
const lang = $('.astro-code').get(0);
const segments = $('.line', lang).get(6).children;
assert.equal(segments.length, 2);
assert.equal(segments[0].attribs.style, 'color:#79B8FF');
assert.equal(segments[1].attribs.style, 'color:#E1E4E8');
});

it('handles unknown languages', () => {
const unknownLang = $('.astro-code').get(1);
assert.ok(unknownLang.attribs.style.includes('background-color:#24292e;color:#e1e4e8;'));
});

it('handles lazy loaded languages', () => {
const lang = $('.astro-code').get(2);
const segments = $('.line', lang).get(0).children;
assert.equal(segments.length, 7);
assert.equal(segments[0].attribs.style, 'color:#F97583');
assert.equal(segments[1].attribs.style, 'color:#79B8FF');
assert.equal(segments[2].attribs.style, 'color:#F97583');
assert.equal(segments[3].attribs.style, 'color:#79B8FF');
assert.equal(segments[4].attribs.style, 'color:#F97583');
assert.equal(segments[5].attribs.style, 'color:#79B8FF');
assert.equal(segments[6].attribs.style, 'color:#E1E4E8');
});
});

describe('Wrapping behaviours', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ fin
```unknown
This language does not exist
```

```ts
const someTypeScript: number = 5;
```
2 changes: 1 addition & 1 deletion packages/create-astro/test/fixtures/not-empty/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"build": "astro build",
"preview": "astro preview"
}
}
}
2 changes: 1 addition & 1 deletion packages/integrations/markdoc/components/Renderer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type Props = {
const { stringifiedAst, config } = Astro.props as Props;
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast, config);
const content = await Markdoc.transform(ast, config);
---

{
Expand Down
4 changes: 2 additions & 2 deletions packages/integrations/markdoc/src/extensions/shiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ export default async function shiki(config?: ShikiConfig): Promise<AstroMarkdocC
nodes: {
fence: {
attributes: Markdoc.nodes.fence.attributes!,
transform({ attributes }) {
async transform({ attributes }) {
// NOTE: The `meta` from fence code, e.g. ```js {1,3-4}, isn't quite supported by Markdoc.
// Only the `js` part is parsed as `attributes.language` and the rest is ignored. This means
// some Shiki transformers may not work correctly as it relies on the `meta`.
const lang = typeof attributes.language === 'string' ? attributes.language : 'plaintext';
const html = highlighter.highlight(attributes.content, lang);
const html = await highlighter.highlight(attributes.content, lang);

// Use `unescapeHTML` to return `HTMLString` for Astro renderer to inline as HTML
return unescapeHTML(html) as any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('Markdoc - syntax highlighting', () => {
describe('shiki', () => {
it('transforms with defaults', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(ast, await getConfigExtendingShiki());
const content = await Markdoc.transform(ast, await getConfigExtendingShiki());

assert.equal(content.children.length, 2);
for (const codeBlock of content.children) {
Expand All @@ -36,7 +36,7 @@ describe('Markdoc - syntax highlighting', () => {
});
it('transforms with `theme` property', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(
const content = await Markdoc.transform(
ast,
await getConfigExtendingShiki({
theme: 'dracula',
Expand All @@ -53,7 +53,7 @@ describe('Markdoc - syntax highlighting', () => {
});
it('transforms with `wrap` property', async () => {
const ast = Markdoc.parse(entry);
const content = Markdoc.transform(
const content = await Markdoc.transform(
ast,
await getConfigExtendingShiki({
wrap: true,
Expand All @@ -76,7 +76,7 @@ describe('Markdoc - syntax highlighting', () => {
const config = await setupConfig({
extends: [prism()],
});
const content = Markdoc.transform(ast, config);
const content = await Markdoc.transform(ast, config);

assert.equal(content.children.length, 2);
const [tsBlock, cssBlock] = content.children;
Expand Down
27 changes: 21 additions & 6 deletions packages/markdown/remark/src/highlight.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Element, Root } from 'hast';
import type { Element, Parent, Root } from 'hast';
import { fromHtml } from 'hast-util-from-html';
import { toText } from 'hast-util-to-text';
import { removePosition } from 'unist-util-remove-position';
import { visitParents } from 'unist-util-visit-parents';

type Highlighter = (code: string, language: string, options?: { meta?: string }) => string;
type Highlighter = (code: string, language: string, options?: { meta?: string }) => Promise<string>;

const languagePattern = /\blanguage-(\S+)\b/;

Expand All @@ -17,7 +17,14 @@ const languagePattern = /\blanguage-(\S+)\b/;
* A fnction which receives the code and language, and returns the HTML of a syntax
* highlighted `<pre>` element.
*/
export function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
export async function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
const nodes: Array<{
node: Element;
language: string;
parent: Element;
grandParent: Parent;
}> = [];

// We’re looking for `<code>` elements
visitParents(tree, { type: 'element', tagName: 'code' }, (node, ancestors) => {
const parent = ancestors.at(-1);
Expand Down Expand Up @@ -55,17 +62,25 @@ export function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
return;
}

nodes.push({
node,
language: languageMatch?.[1] || 'plaintext',
parent,
grandParent: ancestors.at(-2)!,
});
});

for (const { node, language, grandParent, parent } of nodes) {
const meta = (node.data as any)?.meta ?? node.properties.metastring ?? undefined;
const code = toText(node, { whitespace: 'pre' });
const html = highlighter(code, languageMatch?.[1] || 'plaintext', { meta });
const html = await highlighter(code, language, { meta });
// The replacement returns a root node with 1 child, the `<pr>` element replacement.
const replacement = fromHtml(html, { fragment: true }).children[0] as Element;
// We just generated this node, so any positional information is invalid.
removePosition(replacement);

// We replace the parent in its parent with the new `<pre>` element.
const grandParent = ancestors.at(-2)!;
const index = grandParent.children.indexOf(parent);
grandParent.children[index] = replacement;
});
}
}
Loading

0 comments on commit 374efcd

Please sign in to comment.