Frontmatter injection for MD and MDX (#4176)

* feat: inject vfile data as exports

* feat: add vfile to renderMarkdown output

* feat: add safe astroExports parser to utils

* refactor: expose vite-plugin-utils on astro package

* feat: handle astroExports in mdx

* deps: vfile

* chore: lockfile

* test: astroExports in mdx

* refactor: merge plugin exports into forntmatter

* refactor: astroExports -> astro.frontmatter

* refactor: md astroExports -> astro.frontmatter

* feat: astro.frontmatter vite-plugin-markdown

* chore: remove unused import

* fix: inline safelyGetAstroData in MDX integration

* chore: check that frontmatter export is valid export name

* chore: error log naming

* test: mdx remark frontmatter injection

* fix: inconsistent shiki mod resolution

* fix: add new frontmatter and heading props

* test: remark vdata

* fix: spread astro.data.frontmatter

* test deps: mdast-util-to-string, reading-time

* fix: astro-md test package name

* test: md frontmatter injection

* fix: layouts

* deps: remove vite-plugin-utils export

* fix: package lock

* chore: remove dup import

* chore: changeset

* chore: add comment on safelyGetAstroData source

* deps: move mdast-util-to-string + reading-time to test fixture

* chore: move remark plugins to test fixture

* fix: override plugin frontmatter with user frontmatter

* test: md injected frontmatter overrides

* test: frontmatter injection overrides mdx
This commit is contained in:
Ben Holmes 2022-08-05 18:55:38 -05:00 committed by GitHub
parent 4678a3f358
commit 2675b8633c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 491 additions and 71 deletions

View file

@ -0,0 +1,7 @@
---
'astro': minor
'@astrojs/mdx': minor
'@astrojs/markdown-remark': patch
---
Support frontmatter injection for MD and MDX using remark and rehype plugins

View file

@ -1110,3 +1110,5 @@ export interface SSRResult {
response: ResponseInit; response: ResponseInit;
_metadata: SSRMetadata; _metadata: SSRMetadata;
} }
export type MarkdownAstroData = { frontmatter: object };

View file

@ -7,7 +7,7 @@ import { collectErrorMetadata } from '../core/errors.js';
import type { LogOptions } from '../core/logger/core.js'; import type { LogOptions } from '../core/logger/core.js';
import { warn } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import { getFileInfo } from '../vite-plugin-utils/index.js'; import { getFileInfo, safelyGetAstroData } from '../vite-plugin-utils/index.js';
interface AstroPluginOptions { interface AstroPluginOptions {
config: AstroConfig; config: AstroConfig;
@ -44,7 +44,14 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
const html = renderResult.code; const html = renderResult.code;
const { headings } = renderResult.metadata; const { headings } = renderResult.metadata;
const frontmatter = { ...raw.data, url: fileUrl, file: fileId } as any; const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data);
const frontmatter = {
...injectedFrontmatter,
...raw.data,
url: fileUrl,
file: fileId,
} as any;
const { layout } = frontmatter; const { layout } = frontmatter;
if (frontmatter.setup) { if (frontmatter.setup) {
@ -94,6 +101,7 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
} }
export default Content; export default Content;
`); `);
return { return {
code, code,
meta: { meta: {

View file

@ -1,4 +1,5 @@
import type { AstroConfig } from '../@types/astro'; import { Data } from 'vfile';
import type { AstroConfig, MarkdownAstroData } from '../@types/astro';
import { appendForwardSlash } from '../core/path.js'; import { appendForwardSlash } from '../core/path.js';
export function getFileInfo(id: string, config: AstroConfig) { export function getFileInfo(id: string, config: AstroConfig) {
@ -15,3 +16,30 @@ export function getFileInfo(id: string, config: AstroConfig) {
} }
return { fileId, fileUrl }; return { fileId, fileUrl };
} }
function isValidAstroData(obj: unknown): obj is MarkdownAstroData {
if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty('frontmatter')) {
const { frontmatter } = obj as any;
try {
// ensure frontmatter is JSON-serializable
JSON.stringify(frontmatter);
} catch {
return false;
}
return typeof frontmatter === 'object' && frontmatter !== null;
}
return false;
}
export function safelyGetAstroData(vfileData: Data): MarkdownAstroData {
const { astro } = vfileData;
if (!astro) return { frontmatter: {} };
if (!isValidAstroData(astro)) {
throw Error(
`[Markdown] A remark or rehype plugin tried to add invalid frontmatter. Ensure "astro.frontmatter" is a JSON object!`
);
}
return astro;
}

View file

@ -0,0 +1,40 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
const FIXTURE_ROOT = './fixtures/astro-markdown-frontmatter-injection/';
describe('Astro Markdown - frontmatter injection', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: FIXTURE_ROOT,
});
await fixture.build();
});
it('remark supports custom vfile data - get title', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Page 1');
expect(titles).to.contain('Page 2');
});
it('rehype supports custom vfile data - reading time', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime);
expect(readingTimes.length).to.be.greaterThan(0);
for (let readingTime of readingTimes) {
expect(readingTime).to.not.be.null;
expect(readingTime.text).match(/^\d+ min read/);
}
});
it('overrides injected frontmatter with user frontmatter', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime?.text);
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Overridden title');
expect(readingTimes).to.contain('1000 min read');
});
});

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs'
// https://astro.build/config
export default defineConfig({
site: 'https://astro.build/',
markdown: {
remarkPlugins: [remarkTitle],
rehypePlugins: [rehypeReadingTime],
}
});

View file

@ -0,0 +1,11 @@
{
"name": "@test/astro-markdown-frontmatter-injection",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"mdast-util-to-string": "^3.1.0",
"reading-time": "^1.5.0",
"unist-util-visit": "^4.1.0"
}
}

View file

@ -0,0 +1,20 @@
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
import { visit } from 'unist-util-visit';
export function rehypeReadingTime() {
return function (tree, { data }) {
const readingTime = getReadingTime(toString(tree));
data.astro.frontmatter.injectedReadingTime = readingTime;
};
}
export function remarkTitle() {
return function (tree, { data }) {
visit(tree, ['heading'], (node) => {
if (node.depth === 1) {
data.astro.frontmatter.title = toString(node.children);
}
});
};
}

View file

@ -0,0 +1,6 @@
export async function get() {
const docs = await import.meta.glob('./*.md', { eager: true });
return {
body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)),
}
}

View file

@ -0,0 +1,3 @@
# Page 1
Look at that!

View file

@ -0,0 +1,19 @@
# Page 2
## Table of contents
## Section 1
Some text!
### Subsection 1
Some subsection test!
### Subsection 2
Oh cool, more text!
## Section 2
More content

View file

@ -0,0 +1,7 @@
---
title: 'Overridden title'
injectedReadingTime:
text: '1000 min read'
---
# Working!

View file

@ -1,5 +1,5 @@
{ {
"name": "@test/astro-markdown-md-mode", "name": "@test/astro-markdown",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {

View file

@ -43,7 +43,8 @@
"remark-shiki-twoslash": "^3.1.0", "remark-shiki-twoslash": "^3.1.0",
"remark-smartypants": "^2.0.0", "remark-smartypants": "^2.0.0",
"shiki": "^0.10.1", "shiki": "^0.10.1",
"unist-util-visit": "^4.1.0" "unist-util-visit": "^4.1.0",
"vfile": "^5.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.3.1", "@types/chai": "^4.3.1",

View file

@ -0,0 +1,82 @@
import { name as isValidIdentifierName } from 'estree-util-is-identifier-name';
import type { VFile } from 'vfile';
import type { MdxjsEsm } from 'mdast-util-mdx';
import type { MarkdownAstroData } from 'astro';
import type { Data } from 'vfile';
import { jsToTreeNode } from './utils.js';
export function remarkInitializeAstroData() {
return function (tree: any, vfile: VFile) {
if (!vfile.data.astro) {
vfile.data.astro = { frontmatter: {} };
}
};
}
export function rehypeApplyFrontmatterExport(
pageFrontmatter: Record<string, any>,
exportName = 'frontmatter'
) {
return function (tree: any, vfile: VFile) {
if (!isValidIdentifierName(exportName)) {
throw new Error(
`[MDX] ${JSON.stringify(
exportName
)} is not a valid frontmatter export name! Make sure "frontmatterOptions.name" could be used as a JS export (i.e. "export const frontmatterName = ...")`
);
}
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data);
const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter };
let exportNodes: MdxjsEsm[] = [];
if (!exportName) {
exportNodes = Object.entries(frontmatter).map(([k, v]) => {
if (!isValidIdentifierName(k)) {
throw new Error(
`[MDX] A remark or rehype plugin tried to inject ${JSON.stringify(
k
)} as a top-level export, which is not a valid export name.`
);
}
return jsToTreeNode(`export const ${k} = ${JSON.stringify(v)};`);
});
} else {
exportNodes = [jsToTreeNode(`export const ${exportName} = ${JSON.stringify(frontmatter)};`)];
}
tree.children = exportNodes.concat(tree.children);
};
}
/**
* Copied from markdown utils
* @see "vite-plugin-utils"
*/
function isValidAstroData(obj: unknown): obj is MarkdownAstroData {
if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty('frontmatter')) {
const { frontmatter } = obj as any;
try {
// ensure frontmatter is JSON-serializable
JSON.stringify(frontmatter);
} catch {
return false;
}
return typeof frontmatter === 'object' && frontmatter !== null;
}
return false;
}
/**
* Copied from markdown utils
* @see "vite-plugin-utils"
*/
export function safelyGetAstroData(vfileData: Data): MarkdownAstroData {
const { astro } = vfileData;
if (!astro) return { frontmatter: {} };
if (!isValidAstroData(astro)) {
throw Error(
`[MDX] A remark or rehype plugin tried to add invalid frontmatter. Ensure "astro.frontmatter" is a JSON object!`
);
}
return astro;
}

View file

@ -1,19 +1,18 @@
import { compile as mdxCompile, nodeTypes } from '@mdx-js/mdx'; import { compile as mdxCompile, nodeTypes } from '@mdx-js/mdx';
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
import type { AstroIntegration } from 'astro'; import type { AstroIntegration, AstroConfig } from 'astro';
import { remarkInitializeAstroData, rehypeApplyFrontmatterExport } from './astro-data-utils.js';
import { parse as parseESM } from 'es-module-lexer'; import { parse as parseESM } from 'es-module-lexer';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter'; import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
import remarkShikiTwoslash from 'remark-shiki-twoslash'; import remarkShikiTwoslash from 'remark-shiki-twoslash';
import remarkSmartypants from 'remark-smartypants'; import remarkSmartypants from 'remark-smartypants';
import { VFile } from 'vfile'; import { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite'; import type { Plugin as VitePlugin } from 'vite';
import rehypeCollectHeadings from './rehype-collect-headings.js'; import rehypeCollectHeadings from './rehype-collect-headings.js';
import remarkPrism from './remark-prism.js'; import remarkPrism from './remark-prism.js';
import { getFileInfo, getFrontmatter } from './utils.js'; import { getFileInfo, parseFrontmatter } from './utils.js';
type WithExtends<T> = T | { extends: T }; type WithExtends<T> = T | { extends: T };
@ -37,44 +36,52 @@ function handleExtends<T>(config: WithExtends<T[] | undefined>, defaults: T[] =
return [...defaults, ...(config?.extends ?? [])]; return [...defaults, ...(config?.extends ?? [])];
} }
function getRemarkPlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): MdxRollupPluginOptions['remarkPlugins'] {
let remarkPlugins = [
// Initialize vfile.data.astroExports before all plugins are run
remarkInitializeAstroData,
...handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS),
];
if (config.markdown.syntaxHighlight === 'shiki') {
// Default export still requires ".default" chaining for some reason
// Workarounds tried:
// - "import * as remarkShikiTwoslash"
// - "import { default as remarkShikiTwoslash }"
const shikiTwoslash = (remarkShikiTwoslash as any).default ?? remarkShikiTwoslash;
remarkPlugins.push([shikiTwoslash, config.markdown.shikiConfig]);
}
if (config.markdown.syntaxHighlight === 'prism') {
remarkPlugins.push(remarkPrism);
}
return remarkPlugins;
}
function getRehypePlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): MdxRollupPluginOptions['rehypePlugins'] {
let rehypePlugins = handleExtends(mdxOptions.rehypePlugins, DEFAULT_REHYPE_PLUGINS);
if (config.markdown.syntaxHighlight === 'shiki' || config.markdown.syntaxHighlight === 'prism') {
rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]);
}
return rehypePlugins;
}
export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
return { return {
name: '@astrojs/mdx', name: '@astrojs/mdx',
hooks: { hooks: {
'astro:config:setup': ({ updateConfig, config, addPageExtension, command }: any) => { 'astro:config:setup': ({ updateConfig, config, addPageExtension, command }: any) => {
addPageExtension('.mdx'); addPageExtension('.mdx');
let remarkPlugins = handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS);
let rehypePlugins = handleExtends(mdxOptions.rehypePlugins, DEFAULT_REHYPE_PLUGINS);
if (config.markdown.syntaxHighlight === 'shiki') {
remarkPlugins.push([
// Default export still requires ".default" chaining for some reason
// Workarounds tried:
// - "import * as remarkShikiTwoslash"
// - "import { default as remarkShikiTwoslash }"
(remarkShikiTwoslash as any).default ?? remarkShikiTwoslash,
config.markdown.shikiConfig,
]);
rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]);
}
if (config.markdown.syntaxHighlight === 'prism') {
remarkPlugins.push(remarkPrism);
rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]);
}
remarkPlugins.push(remarkFrontmatter);
remarkPlugins.push([
remarkMdxFrontmatter,
{
name: 'frontmatter',
...mdxOptions.frontmatterOptions,
},
]);
const mdxPluginOpts: MdxRollupPluginOptions = { const mdxPluginOpts: MdxRollupPluginOptions = {
remarkPlugins, remarkPlugins: getRemarkPlugins(mdxOptions, config),
rehypePlugins, rehypePlugins: getRehypePlugins(mdxOptions, config),
jsx: true, jsx: true,
jsxImportSource: 'astro', jsxImportSource: 'astro',
// Note: disable `.md` support // Note: disable `.md` support
@ -93,24 +100,27 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
async transform(code, id) { async transform(code, id) {
if (!id.endsWith('mdx')) return; if (!id.endsWith('mdx')) return;
// If user overrides our default YAML parser, let { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
// do not attempt to parse the `layout` via gray-matter if (frontmatter.layout) {
if (!mdxOptions.frontmatterOptions?.parsers) { const { layout, ...contentProp } = frontmatter;
const frontmatter = getFrontmatter(code, id); pageContent += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify(
if (frontmatter.layout) { frontmatter.layout
const { layout, ...content } = frontmatter; )})).default;\nconst frontmatter=${JSON.stringify(
code += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify( contentProp
frontmatter.layout )};\nreturn <Layout frontmatter={frontmatter} content={frontmatter} headings={getHeadings()}>{children}</Layout> }`;
)})).default;\nconst frontmatter=${JSON.stringify(
content
)};\nreturn <Layout frontmatter={frontmatter} content={frontmatter} headings={getHeadings()}>{children}</Layout> }`;
}
} }
const compiled = await mdxCompile( const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
new VFile({ value: code, path: id }), ...mdxPluginOpts,
mdxPluginOpts rehypePlugins: [
); ...(mdxPluginOpts.rehypePlugins ?? []),
() =>
rehypeApplyFrontmatterExport(
frontmatter,
mdxOptions.frontmatterOptions?.name
),
],
});
return { return {
code: String(compiled.value), code: String(compiled.value),

View file

@ -47,9 +47,9 @@ export function getFileInfo(id: string, config: AstroConfig): FileInfo {
* Match YAML exception handling from Astro core errors * Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts' * @see 'astro/src/core/errors.ts'
*/ */
export function getFrontmatter(code: string, id: string) { export function parseFrontmatter(code: string, id: string) {
try { try {
return matter(code).data; return matter(code);
} catch (e: any) { } catch (e: any) {
if (e.name === 'YAMLException') { if (e.name === 'YAMLException') {
const err: SSRError = e; const err: SSRError = e;

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs';
// https://astro.build/config
export default defineConfig({
site: 'https://astro.build/',
integrations: [mdx({
remarkPlugins: [remarkTitle],
rehypePlugins: [rehypeReadingTime],
})],
});

View file

@ -0,0 +1,12 @@
{
"name": "@test/mdx-frontmatter-injection",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*",
"mdast-util-to-string": "^3.1.0",
"reading-time": "^1.5.0",
"unist-util-visit": "^4.1.0"
}
}

View file

@ -0,0 +1,20 @@
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
import { visit } from 'unist-util-visit';
export function rehypeReadingTime() {
return function (tree, { data }) {
const readingTime = getReadingTime(toString(tree));
data.astro.frontmatter.injectedReadingTime = readingTime;
};
}
export function remarkTitle() {
return function (tree, { data }) {
visit(tree, ['heading'], (node) => {
if (node.depth === 1) {
data.astro.frontmatter.title = toString(node.children);
}
});
};
}

View file

@ -0,0 +1,6 @@
export async function get() {
const docs = await import.meta.glob('./*.mdx', { eager: true });
return {
body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)),
}
}

View file

@ -0,0 +1,3 @@
# Page 1
Look at that!

View file

@ -0,0 +1,19 @@
# Page 2
## Table of contents
## Section 1
Some text!
### Subsection 1
Some subsection test!
### Subsection 2
Oh cool, more text!
## Section 2
More content

View file

@ -0,0 +1,7 @@
---
title: 'Overridden title'
injectedReadingTime:
text: '1000 min read'
---
# Working!

View file

@ -1,7 +1,7 @@
import { readingTime } from './space-ipsum.mdx'; import * as exps from './space-ipsum.mdx';
export function get() { export function get() {
return { return {
body: JSON.stringify(readingTime), body: JSON.stringify(exps),
} }
} }

View file

@ -0,0 +1,6 @@
export async function get() {
const docs = await import.meta.glob('./*.mdx', { eager: true });
return {
body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)),
}
}

View file

@ -0,0 +1,40 @@
import { expect } from 'chai';
import { loadFixture } from '../../../astro/test/test-utils.js';
const FIXTURE_ROOT = new URL('./fixtures/mdx-frontmatter-injection/', import.meta.url);
describe('MDX frontmatter injection', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: FIXTURE_ROOT,
});
await fixture.build();
});
it('remark supports custom vfile data - get title', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Page 1');
expect(titles).to.contain('Page 2');
});
it('rehype supports custom vfile data - reading time', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime);
expect(readingTimes.length).to.be.greaterThan(0);
for (let readingTime of readingTimes) {
expect(readingTime).to.not.be.null;
expect(readingTime.text).match(/^\d+ min read/);
}
});
it('overrides injected frontmatter with user frontmatter', async () => {
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime?.text);
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Overridden title');
expect(readingTimes).to.contain('1000 min read');
});
});

View file

@ -1,15 +1,15 @@
import mdx from '@astrojs/mdx'; import mdx from '@astrojs/mdx';
import { jsToTreeNode } from '../dist/utils.js';
import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import getReadingTime from 'reading-time'; import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string'; import { toString } from 'mdast-util-to-string';
import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import { jsToTreeNode } from '../dist/utils.js';
import { loadFixture } from '../../../astro/test/test-utils.js'; import { loadFixture } from '../../../astro/test/test-utils.js';
export function rehypeReadingTime() { function rehypeReadingTime() {
return function (tree) { return function (tree, { data }) {
const readingTime = getReadingTime(toString(tree)); const readingTime = getReadingTime(toString(tree));
tree.children.unshift( tree.children.unshift(
jsToTreeNode(`export const readingTime = ${JSON.stringify(readingTime)}`) jsToTreeNode(`export const readingTime = ${JSON.stringify(readingTime)}`)
@ -46,7 +46,7 @@ describe('MDX rehype plugins', () => {
}); });
it('supports custom rehype plugins - reading time', async () => { it('supports custom rehype plugins - reading time', async () => {
const readingTime = JSON.parse(await fixture.readFile('/reading-time.json')); const { readingTime } = JSON.parse(await fixture.readFile('/reading-time.json'));
expect(readingTime).to.not.be.null; expect(readingTime).to.not.be.null;
expect(readingTime.text).to.match(/^\d+ min read/); expect(readingTime.text).to.match(/^\d+ min read/);

View file

@ -13,6 +13,7 @@ import remarkPrism from './remark-prism.js';
import scopedStyles from './remark-scoped-styles.js'; import scopedStyles from './remark-scoped-styles.js';
import remarkShiki from './remark-shiki.js'; import remarkShiki from './remark-shiki.js';
import remarkUnwrap from './remark-unwrap.js'; import remarkUnwrap from './remark-unwrap.js';
import { remarkInitializeAstroData } from './remark-initialize-astro-data.js';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify'; import rehypeStringify from 'rehype-stringify';
@ -45,6 +46,7 @@ export async function renderMarkdown(
let parser = unified() let parser = unified()
.use(markdown) .use(markdown)
.use(remarkInitializeAstroData)
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []); .use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
if (remarkPlugins.length === 0 && rehypePlugins.length === 0) { if (remarkPlugins.length === 0 && rehypePlugins.length === 0) {
@ -99,10 +101,9 @@ export async function renderMarkdown(
) )
.use(rehypeStringify, { allowDangerousHtml: true }); .use(rehypeStringify, { allowDangerousHtml: true });
let result: string; let vfile: VFile;
try { try {
const vfile = await parser.process(input); vfile = await parser.process(input);
result = vfile.toString();
} catch (err) { } 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
@ -113,8 +114,9 @@ export async function renderMarkdown(
} }
return { return {
metadata: { headings, source: content, html: result.toString() }, metadata: { headings, source: content, html: String(vfile.value) },
code: result.toString(), code: String(vfile.value),
vfile,
}; };
} }

View file

@ -0,0 +1,9 @@
import type { VFile } from 'vfile';
export function remarkInitializeAstroData() {
return function (tree: any, vfile: VFile) {
if (!vfile.data.astro) {
vfile.data.astro = { frontmatter: {} };
}
};
}

View file

@ -2,6 +2,7 @@ import type * as hast from 'hast';
import type * as mdast from 'mdast'; import type * as mdast from 'mdast';
import type { ILanguageRegistration, IThemeRegistration, Theme } from 'shiki'; import type { ILanguageRegistration, IThemeRegistration, Theme } from 'shiki';
import type * as unified from 'unified'; import type * as unified from 'unified';
import type { VFile } from 'vfile';
export type { Node } from 'unist'; export type { Node } from 'unist';
@ -58,5 +59,6 @@ export interface MarkdownMetadata {
export interface MarkdownRenderingResult { export interface MarkdownRenderingResult {
metadata: MarkdownMetadata; metadata: MarkdownMetadata;
vfile: VFile;
code: string; code: string;
} }

View file

@ -1231,6 +1231,18 @@ importers:
dependencies: dependencies:
astro: link:../../.. astro: link:../../..
packages/astro/test/fixtures/astro-markdown-frontmatter-injection:
specifiers:
astro: workspace:*
mdast-util-to-string: ^3.1.0
reading-time: ^1.5.0
unist-util-visit: ^4.1.0
dependencies:
astro: link:../../..
mdast-util-to-string: 3.1.0
reading-time: 1.5.0
unist-util-visit: 4.1.0
packages/astro/test/fixtures/astro-markdown-plugins: packages/astro/test/fixtures/astro-markdown-plugins:
specifiers: specifiers:
'@astrojs/preact': workspace:* '@astrojs/preact': workspace:*
@ -2183,6 +2195,7 @@ importers:
remark-toc: ^8.0.1 remark-toc: ^8.0.1
shiki: ^0.10.1 shiki: ^0.10.1
unist-util-visit: ^4.1.0 unist-util-visit: ^4.1.0
vfile: ^5.3.2
dependencies: dependencies:
'@astrojs/prism': link:../../astro-prism '@astrojs/prism': link:../../astro-prism
'@mdx-js/mdx': 2.1.2 '@mdx-js/mdx': 2.1.2
@ -2199,6 +2212,7 @@ importers:
remark-smartypants: 2.0.0 remark-smartypants: 2.0.0
shiki: 0.10.1 shiki: 0.10.1
unist-util-visit: 4.1.0 unist-util-visit: 4.1.0
vfile: 5.3.4
devDependencies: devDependencies:
'@types/chai': 4.3.1 '@types/chai': 4.3.1
'@types/mocha': 9.1.1 '@types/mocha': 9.1.1
@ -2212,6 +2226,20 @@ importers:
reading-time: 1.5.0 reading-time: 1.5.0
remark-toc: 8.0.1 remark-toc: 8.0.1
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection:
specifiers:
'@astrojs/mdx': workspace:*
astro: workspace:*
mdast-util-to-string: ^3.1.0
reading-time: ^1.5.0
unist-util-visit: ^4.1.0
dependencies:
'@astrojs/mdx': link:../../..
astro: link:../../../../../astro
mdast-util-to-string: 3.1.0
reading-time: 1.5.0
unist-util-visit: 4.1.0
packages/integrations/mdx/test/fixtures/mdx-page: packages/integrations/mdx/test/fixtures/mdx-page:
specifiers: specifiers:
'@astrojs/mdx': workspace:* '@astrojs/mdx': workspace:*
@ -14710,7 +14738,6 @@ packages:
/reading-time/1.5.0: /reading-time/1.5.0:
resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==}
dev: true
/recast/0.20.5: /recast/0.20.5:
resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==} resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==}