Improve markdown rendering performance (#8532)

This commit is contained in:
Bjorn Lu 2023-09-14 18:22:16 +08:00 committed by GitHub
parent 61ac5c9eaa
commit 7522bb4914
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 209 additions and 125 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improve markdown rendering performance by sharing processor instance

View file

@ -0,0 +1,5 @@
---
'@astrojs/markdown-remark': minor
---
Export `createMarkdownProcessor` and deprecate `renderMarkdown` API

View file

@ -1,8 +1,8 @@
import { renderMarkdown } from '@astrojs/markdown-remark';
import { import {
createMarkdownProcessor,
InvalidAstroDataError, InvalidAstroDataError,
safelyGetAstroData, type MarkdownProcessor,
} from '@astrojs/markdown-remark/dist/internal.js'; } from '@astrojs/markdown-remark';
import matter from 'gray-matter'; import matter from 'gray-matter';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -57,9 +57,14 @@ const astroErrorModulePath = normalizePath(
); );
export default function markdown({ settings, logger }: AstroPluginOptions): Plugin { export default function markdown({ settings, logger }: AstroPluginOptions): Plugin {
let processor: MarkdownProcessor;
return { return {
enforce: 'pre', enforce: 'pre',
name: 'astro:markdown', name: 'astro:markdown',
async buildStart() {
processor = await createMarkdownProcessor(settings.config.markdown);
},
// Why not the "transform" hook instead of "load" + readFile? // Why not the "transform" hook instead of "load" + readFile?
// A: Vite transforms all "import.meta.env" references to their values before // 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 // passing to the transform hook. This lets us get the truly raw value
@ -70,33 +75,32 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
const rawFile = await fs.promises.readFile(fileId, 'utf-8'); const rawFile = await fs.promises.readFile(fileId, 'utf-8');
const raw = safeMatter(rawFile, id); const raw = safeMatter(rawFile, id);
const renderResult = await renderMarkdown(raw.content, { const renderResult = await processor
...settings.config.markdown, .render(raw.content, {
fileURL: new URL(`file://${fileId}`), fileURL: new URL(`file://${fileId}`),
frontmatter: raw.data, frontmatter: raw.data,
})
.catch((err) => {
// Improve error message for invalid astro data
if (err instanceof InvalidAstroDataError) {
throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
}
throw err;
}); });
let html = renderResult.code; let html = renderResult.code;
const { headings } = renderResult.metadata; const { headings, imagePaths: rawImagePaths, frontmatter } = renderResult.metadata;
// Resolve all the extracted images from the content // Resolve all the extracted images from the content
let imagePaths: { raw: string; resolved: string }[] = []; const imagePaths: { raw: string; resolved: string }[] = [];
if (renderResult.vfile.data.imagePaths) { for (const imagePath of rawImagePaths.values()) {
for (let imagePath of renderResult.vfile.data.imagePaths.values()) {
imagePaths.push({ imagePaths.push({
raw: imagePath, raw: imagePath,
resolved: resolved:
(await this.resolve(imagePath, id))?.id ?? path.join(path.dirname(id), imagePath), (await this.resolve(imagePath, id))?.id ?? path.join(path.dirname(id), imagePath),
}); });
} }
}
const astroData = safelyGetAstroData(renderResult.vfile.data);
if (astroData instanceof InvalidAstroDataError) {
throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
}
const { frontmatter } = astroData;
const { layout } = frontmatter; const { layout } = frontmatter;
if (frontmatter.setup) { if (frontmatter.setup) {

View file

@ -27,6 +27,13 @@ export function safelyGetAstroData(vfileData: Data): MarkdownAstroData | Invalid
return astro; return astro;
} }
export function setAstroData(vfileData: Data, astroData: MarkdownAstroData) {
vfileData.astro = astroData;
}
/**
* @deprecated Use `setAstroData` instead
*/
export function toRemarkInitializeAstroData({ export function toRemarkInitializeAstroData({
userFrontmatter, userFrontmatter,
}: { }: {

View file

@ -1,11 +1,16 @@
import type { import type {
AstroMarkdownOptions, AstroMarkdownOptions,
MarkdownProcessor,
MarkdownRenderingOptions, MarkdownRenderingOptions,
MarkdownRenderingResult, MarkdownRenderingResult,
MarkdownVFile, MarkdownVFile,
} from './types.js'; } from './types.js';
import { toRemarkInitializeAstroData } from './frontmatter-injection.js'; import {
InvalidAstroDataError,
safelyGetAstroData,
setAstroData,
} from './frontmatter-injection.js';
import { loadPlugins } from './load-plugins.js'; import { loadPlugins } from './load-plugins.js';
import { rehypeHeadingIds } from './rehype-collect-headings.js'; import { rehypeHeadingIds } from './rehype-collect-headings.js';
import { remarkCollectImages } from './remark-collect-images.js'; import { remarkCollectImages } from './remark-collect-images.js';
@ -15,13 +20,14 @@ import { remarkShiki } from './remark-shiki.js';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify'; import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import markdown from 'remark-parse'; import remarkParse from 'remark-parse';
import markdownToHtml from 'remark-rehype'; import remarkRehype from 'remark-rehype';
import remarkSmartypants from 'remark-smartypants'; import remarkSmartypants from 'remark-smartypants';
import { unified } from 'unified'; import { unified } from 'unified';
import { VFile } from 'vfile'; import { VFile } from 'vfile';
import { rehypeImages } from './rehype-images.js'; import { rehypeImages } from './rehype-images.js';
export { InvalidAstroDataError } from './frontmatter-injection.js';
export { rehypeHeadingIds } from './rehype-collect-headings.js'; export { rehypeHeadingIds } from './rehype-collect-headings.js';
export { remarkCollectImages } from './remark-collect-images.js'; export { remarkCollectImages } from './remark-collect-images.js';
export { remarkPrism } from './remark-prism.js'; export { remarkPrism } from './remark-prism.js';
@ -45,30 +51,29 @@ export const markdownConfigDefaults: Omit<Required<AstroMarkdownOptions>, 'draft
// Skip nonessential plugins during performance benchmark runs // Skip nonessential plugins during performance benchmark runs
const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK); const isPerformanceBenchmark = Boolean(process.env.ASTRO_PERFORMANCE_BENCHMARK);
/** Shared utility for rendering markdown */ /**
export async function renderMarkdown( * Create a markdown preprocessor to render multiple markdown files
content: string, */
opts: MarkdownRenderingOptions export async function createMarkdownProcessor(
): Promise<MarkdownRenderingResult> { opts?: AstroMarkdownOptions
let { ): Promise<MarkdownProcessor> {
fileURL, const {
syntaxHighlight = markdownConfigDefaults.syntaxHighlight, syntaxHighlight = markdownConfigDefaults.syntaxHighlight,
shikiConfig = markdownConfigDefaults.shikiConfig, shikiConfig = markdownConfigDefaults.shikiConfig,
remarkPlugins = markdownConfigDefaults.remarkPlugins, remarkPlugins = markdownConfigDefaults.remarkPlugins,
rehypePlugins = markdownConfigDefaults.rehypePlugins, rehypePlugins = markdownConfigDefaults.rehypePlugins,
remarkRehype = markdownConfigDefaults.remarkRehype, remarkRehype: remarkRehypeOptions = markdownConfigDefaults.remarkRehype,
gfm = markdownConfigDefaults.gfm, gfm = markdownConfigDefaults.gfm,
smartypants = markdownConfigDefaults.smartypants, smartypants = markdownConfigDefaults.smartypants,
frontmatter: userFrontmatter = {}, } = opts ?? {};
} = opts;
const input = new VFile({ value: content, path: fileURL });
let parser = unified() const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins));
.use(markdown) const loadedRehypePlugins = await Promise.all(loadPlugins(rehypePlugins));
.use(toRemarkInitializeAstroData({ userFrontmatter }))
.use([]);
if (!isPerformanceBenchmark && gfm) { const parser = unified().use(remarkParse);
// gfm and smartypants
if (!isPerformanceBenchmark) {
if (gfm) { if (gfm) {
parser.use(remarkGfm); parser.use(remarkGfm);
} }
@ -77,14 +82,13 @@ export async function renderMarkdown(
} }
} }
const loadedRemarkPlugins = await Promise.all(loadPlugins(remarkPlugins)); // User remark plugins
const loadedRehypePlugins = await Promise.all(loadPlugins(rehypePlugins)); for (const [plugin, pluginOpts] of loadedRemarkPlugins) {
parser.use(plugin, pluginOpts);
loadedRemarkPlugins.forEach(([plugin, pluginOpts]) => { }
parser.use([[plugin, pluginOpts]]);
});
if (!isPerformanceBenchmark) { if (!isPerformanceBenchmark) {
// Syntax highlighting
if (syntaxHighlight === 'shiki') { if (syntaxHighlight === 'shiki') {
parser.use(remarkShiki, shikiConfig); parser.use(remarkShiki, shikiConfig);
} else if (syntaxHighlight === 'prism') { } else if (syntaxHighlight === 'prism') {
@ -95,45 +99,88 @@ export async function renderMarkdown(
parser.use(remarkCollectImages); parser.use(remarkCollectImages);
} }
parser.use([ // Remark -> Rehype
[ parser.use(remarkRehype as any, {
markdownToHtml as any,
{
allowDangerousHtml: true, allowDangerousHtml: true,
passThrough: [], passThrough: [],
...remarkRehype, ...remarkRehypeOptions,
},
],
]);
loadedRehypePlugins.forEach(([plugin, pluginOpts]) => {
parser.use([[plugin, pluginOpts]]);
}); });
parser.use(rehypeImages()); // User rehype plugins
if (!isPerformanceBenchmark) { for (const [plugin, pluginOpts] of loadedRehypePlugins) {
parser.use([rehypeHeadingIds]); parser.use(plugin, pluginOpts);
} }
parser.use([rehypeRaw]).use(rehypeStringify, { allowDangerousHtml: true }); // Images / Assets support
parser.use(rehypeImages());
let vfile: MarkdownVFile; // Headings
try { if (!isPerformanceBenchmark) {
vfile = await parser.process(input); parser.use(rehypeHeadingIds);
} catch (err) { }
// Stringify to HTML
parser.use(rehypeRaw).use(rehypeStringify, { allowDangerousHtml: true });
return {
async render(content, renderOpts) {
const vfile = new VFile({ value: content, path: renderOpts?.fileURL });
setAstroData(vfile.data, { frontmatter: renderOpts?.frontmatter ?? {} });
const result: MarkdownVFile = await parser.process(vfile).catch((err) => {
// Ensure that the error message contains the input filename // Ensure that the error message contains the input filename
// to make it easier for the user to fix the issue // to make it easier for the user to fix the issue
err = prefixError(err, `Failed to parse Markdown file "${input.path}"`); err = prefixError(err, `Failed to parse Markdown file "${vfile.path}"`);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err);
throw err; throw err;
});
const astroData = safelyGetAstroData(result.data);
if (astroData instanceof InvalidAstroDataError) {
throw astroData;
} }
const headings = vfile?.data.__astroHeadings || [];
return { return {
metadata: { headings, source: content, html: String(vfile.value) }, code: String(result.value),
code: String(vfile.value), metadata: {
vfile, headings: result.data.__astroHeadings ?? [],
imagePaths: result.data.imagePaths ?? new Set(),
frontmatter: astroData.frontmatter ?? {},
},
// Compat for `renderMarkdown` only. Do not use!
__renderMarkdownCompat: {
result,
},
};
},
};
}
/**
* Shared utility for rendering markdown
*
* @deprecated Use `createMarkdownProcessor` instead for better performance
*/
export async function renderMarkdown(
content: string,
opts: MarkdownRenderingOptions
): Promise<MarkdownRenderingResult> {
const processor = await createMarkdownProcessor(opts);
const result = await processor.render(content, {
fileURL: opts.fileURL,
frontmatter: opts.frontmatter,
});
return {
code: result.code,
metadata: {
headings: result.metadata.headings,
source: content,
html: result.code,
},
vfile: (result as any).__renderMarkdownCompat.result,
}; };
} }

View file

@ -1,5 +1,6 @@
export { export {
InvalidAstroDataError, InvalidAstroDataError,
safelyGetAstroData, safelyGetAstroData,
setAstroData,
toRemarkInitializeAstroData, toRemarkInitializeAstroData,
} from './frontmatter-injection.js'; } from './frontmatter-injection.js';

View file

@ -14,7 +14,7 @@ async function importPlugin(p: string | unified.Plugin): Promise<unified.Plugin>
} catch {} } catch {}
// Try import from user project // Try import from user project
const resolved = await importMetaResolve(p, cwdUrlStr); const resolved = importMetaResolve(p, cwdUrlStr);
const importResult = await import(resolved); const importResult = await import(resolved);
return importResult.default; return importResult.default;
} }

View file

@ -58,13 +58,33 @@ export interface ImageMetadata {
type: string; type: string;
} }
export interface MarkdownRenderingOptions extends AstroMarkdownOptions { export interface MarkdownProcessor {
render: (
content: string,
opts?: MarkdownProcessorRenderOptions
) => Promise<MarkdownProcessorRenderResult>;
}
export interface MarkdownProcessorRenderOptions {
/** @internal */ /** @internal */
fileURL?: URL; fileURL?: URL;
/** Used for frontmatter injection plugins */ /** Used for frontmatter injection plugins */
frontmatter?: Record<string, any>; frontmatter?: Record<string, any>;
} }
export interface MarkdownProcessorRenderResult {
code: string;
metadata: {
headings: MarkdownHeading[];
imagePaths: Set<string>;
frontmatter: Record<string, any>;
};
}
export interface MarkdownRenderingOptions
extends AstroMarkdownOptions,
MarkdownProcessorRenderOptions {}
export interface MarkdownHeading { export interface MarkdownHeading {
depth: number; depth: number;
slug: string; slug: string;

View file

@ -1,14 +1,12 @@
import { renderMarkdown } from '../dist/index.js'; import { createMarkdownProcessor } from '../dist/index.js';
import chai from 'chai'; import chai from 'chai';
import { mockRenderMarkdownParams } from './test-utils.js';
describe('autolinking', () => { describe('autolinking', () => {
describe('plain md', () => { describe('plain md', async () => {
const processor = await createMarkdownProcessor();
it('autolinks URLs starting with a protocol in plain text', async () => { it('autolinks URLs starting with a protocol in plain text', async () => {
const { code } = await renderMarkdown( const { code } = await processor.render(`See https://example.com for more.`);
`See https://example.com for more.`,
mockRenderMarkdownParams
);
chai chai
.expect(code.replace(/\n/g, '')) .expect(code.replace(/\n/g, ''))
@ -16,10 +14,7 @@ describe('autolinking', () => {
}); });
it('autolinks URLs starting with "www." in plain text', async () => { it('autolinks URLs starting with "www." in plain text', async () => {
const { code } = await renderMarkdown( const { code } = await processor.render(`See www.example.com for more.`);
`See www.example.com for more.`,
mockRenderMarkdownParams
);
chai chai
.expect(code.trim()) .expect(code.trim())
@ -27,9 +22,8 @@ describe('autolinking', () => {
}); });
it('does not autolink URLs in code blocks', async () => { it('does not autolink URLs in code blocks', async () => {
const { code } = await renderMarkdown( const { code } = await processor.render(
'See `https://example.com` or `www.example.com` for more.', 'See `https://example.com` or `www.example.com` for more.'
mockRenderMarkdownParams
); );
chai chai

View file

@ -1,13 +1,11 @@
import { renderMarkdown } from '../dist/index.js'; import { createMarkdownProcessor } from '../dist/index.js';
import { expect } from 'chai'; import { expect } from 'chai';
import { mockRenderMarkdownParams } from './test-utils.js';
describe('entities', () => { describe('entities', async () => {
const processor = await createMarkdownProcessor();
it('should not unescape entities in regular Markdown', async () => { it('should not unescape entities in regular Markdown', async () => {
const { code } = await renderMarkdown( const { code } = await processor.render(`&lt;i&gt;This should NOT be italic&lt;/i&gt;`);
`&lt;i&gt;This should NOT be italic&lt;/i&gt;`,
mockRenderMarkdownParams
);
expect(code).to.equal(`<p>&#x3C;i>This should NOT be italic&#x3C;/i></p>`); expect(code).to.equal(`<p>&#x3C;i>This should NOT be italic&#x3C;/i></p>`);
}); });

View file

@ -1,5 +1,4 @@
import { renderMarkdown } from '../dist/index.js'; import { createMarkdownProcessor } from '../dist/index.js';
import { mockRenderMarkdownParams } from './test-utils.js';
import chai from 'chai'; import chai from 'chai';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -8,9 +7,8 @@ describe('plugins', () => {
// https://github.com/withastro/astro/issues/3264 // https://github.com/withastro/astro/issues/3264
it('should be able to get file path when passing fileURL', async () => { it('should be able to get file path when passing fileURL', async () => {
let context; let context;
await renderMarkdown(`test`, {
...mockRenderMarkdownParams, const processor = await createMarkdownProcessor({
fileURL: new URL('virtual.md', import.meta.url),
remarkPlugins: [ remarkPlugins: [
function () { function () {
const transformer = (tree, file) => { const transformer = (tree, file) => {
@ -22,6 +20,10 @@ describe('plugins', () => {
], ],
}); });
await processor.render(`test`, {
fileURL: new URL('virtual.md', import.meta.url),
});
chai.expect(typeof context).to.equal('object'); chai.expect(typeof context).to.equal('object');
chai.expect(context.path).to.equal(fileURLToPath(new URL('virtual.md', import.meta.url))); chai.expect(context.path).to.equal(fileURLToPath(new URL('virtual.md', import.meta.url)));
}); });

View file

@ -1,28 +1,33 @@
import { renderMarkdown } from '../dist/index.js'; import { createMarkdownProcessor } from '../dist/index.js';
import { mockRenderMarkdownParams } from './test-utils.js';
import chai from 'chai'; import chai from 'chai';
describe('collect images', () => { describe('collect images', async () => {
const processor = await createMarkdownProcessor();
it('should collect inline image paths', async () => { it('should collect inline image paths', async () => {
const { code, vfile } = await renderMarkdown( const {
`Hello ![inline image url](./img.png)`, code,
mockRenderMarkdownParams metadata: { imagePaths },
); } = await processor.render(`Hello ![inline image url](./img.png)`, {
fileURL: 'file.md',
});
chai chai
.expect(code) .expect(code)
.to.equal('<p>Hello <img alt="inline image url" __ASTRO_IMAGE_="./img.png"></p>'); .to.equal('<p>Hello <img alt="inline image url" __ASTRO_IMAGE_="./img.png"></p>');
chai.expect(Array.from(vfile.data.imagePaths)).to.deep.equal(['./img.png']); chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.png']);
}); });
it('should add image paths from definition', async () => { it('should add image paths from definition', async () => {
const { code, vfile } = await renderMarkdown( const {
`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, code,
mockRenderMarkdownParams metadata: { imagePaths },
); } = await processor.render(`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, {
fileURL: 'file.md',
});
chai.expect(code).to.equal('<p>Hello <img alt="image ref" __ASTRO_IMAGE_="./img.webp"></p>'); chai.expect(code).to.equal('<p>Hello <img alt="image ref" __ASTRO_IMAGE_="./img.webp"></p>');
chai.expect(Array.from(vfile.data.imagePaths)).to.deep.equal(['./img.webp']); chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.webp']);
}); });
}); });

View file

@ -1,4 +0,0 @@
export const mockRenderMarkdownParams = {
fileURL: 'file.md',
contentDir: new URL('file:///src/content/'),
};