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:
parent
4678a3f358
commit
2675b8633c
32 changed files with 491 additions and 71 deletions
7
.changeset/cool-crabs-trade.md
Normal file
7
.changeset/cool-crabs-trade.md
Normal 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
|
|
@ -1110,3 +1110,5 @@ export interface SSRResult {
|
|||
response: ResponseInit;
|
||||
_metadata: SSRMetadata;
|
||||
}
|
||||
|
||||
export type MarkdownAstroData = { frontmatter: object };
|
||||
|
|
|
@ -7,7 +7,7 @@ import { collectErrorMetadata } from '../core/errors.js';
|
|||
import type { LogOptions } from '../core/logger/core.js';
|
||||
import { warn } from '../core/logger/core.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 {
|
||||
config: AstroConfig;
|
||||
|
@ -44,7 +44,14 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
|
|||
|
||||
const html = renderResult.code;
|
||||
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;
|
||||
|
||||
if (frontmatter.setup) {
|
||||
|
@ -94,6 +101,7 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
|
|||
}
|
||||
export default Content;
|
||||
`);
|
||||
|
||||
return {
|
||||
code,
|
||||
meta: {
|
||||
|
|
|
@ -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';
|
||||
|
||||
export function getFileInfo(id: string, config: AstroConfig) {
|
||||
|
@ -15,3 +16,30 @@ export function getFileInfo(id: string, config: AstroConfig) {
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
11
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs
vendored
Normal file
11
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs
vendored
Normal 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],
|
||||
}
|
||||
});
|
11
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/package.json
vendored
Normal file
11
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
20
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/markdown-plugins.mjs
vendored
Normal file
20
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/markdown-plugins.mjs
vendored
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
6
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/glob.json.js
vendored
Normal file
6
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/glob.json.js
vendored
Normal 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)),
|
||||
}
|
||||
}
|
3
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-1.md
vendored
Normal file
3
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-1.md
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Page 1
|
||||
|
||||
Look at that!
|
19
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-2.md
vendored
Normal file
19
packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-2.md
vendored
Normal 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
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: 'Overridden title'
|
||||
injectedReadingTime:
|
||||
text: '1000 min read'
|
||||
---
|
||||
|
||||
# Working!
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@test/astro-markdown-md-mode",
|
||||
"name": "@test/astro-markdown",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
|
|
@ -43,7 +43,8 @@
|
|||
"remark-shiki-twoslash": "^3.1.0",
|
||||
"remark-smartypants": "^2.0.0",
|
||||
"shiki": "^0.10.1",
|
||||
"unist-util-visit": "^4.1.0"
|
||||
"unist-util-visit": "^4.1.0",
|
||||
"vfile": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.1",
|
||||
|
|
82
packages/integrations/mdx/src/astro-data-utils.ts
Normal file
82
packages/integrations/mdx/src/astro-data-utils.ts
Normal 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;
|
||||
}
|
|
@ -1,19 +1,18 @@
|
|||
import { compile as mdxCompile, nodeTypes } from '@mdx-js/mdx';
|
||||
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 rehypeRaw from 'rehype-raw';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter';
|
||||
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
|
||||
import remarkShikiTwoslash from 'remark-shiki-twoslash';
|
||||
import remarkSmartypants from 'remark-smartypants';
|
||||
import { VFile } from 'vfile';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import rehypeCollectHeadings from './rehype-collect-headings.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 };
|
||||
|
||||
|
@ -37,44 +36,52 @@ function handleExtends<T>(config: WithExtends<T[] | undefined>, defaults: T[] =
|
|||
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 {
|
||||
return {
|
||||
name: '@astrojs/mdx',
|
||||
hooks: {
|
||||
'astro:config:setup': ({ updateConfig, config, addPageExtension, command }: any) => {
|
||||
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 = {
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
remarkPlugins: getRemarkPlugins(mdxOptions, config),
|
||||
rehypePlugins: getRehypePlugins(mdxOptions, config),
|
||||
jsx: true,
|
||||
jsxImportSource: 'astro',
|
||||
// Note: disable `.md` support
|
||||
|
@ -93,24 +100,27 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
|
|||
async transform(code, id) {
|
||||
if (!id.endsWith('mdx')) return;
|
||||
|
||||
// If user overrides our default YAML parser,
|
||||
// do not attempt to parse the `layout` via gray-matter
|
||||
if (!mdxOptions.frontmatterOptions?.parsers) {
|
||||
const frontmatter = getFrontmatter(code, id);
|
||||
let { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
|
||||
if (frontmatter.layout) {
|
||||
const { layout, ...content } = frontmatter;
|
||||
code += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify(
|
||||
const { layout, ...contentProp } = frontmatter;
|
||||
pageContent += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify(
|
||||
frontmatter.layout
|
||||
)})).default;\nconst frontmatter=${JSON.stringify(
|
||||
content
|
||||
contentProp
|
||||
)};\nreturn <Layout frontmatter={frontmatter} content={frontmatter} headings={getHeadings()}>{children}</Layout> }`;
|
||||
}
|
||||
}
|
||||
|
||||
const compiled = await mdxCompile(
|
||||
new VFile({ value: code, path: id }),
|
||||
mdxPluginOpts
|
||||
);
|
||||
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
|
||||
...mdxPluginOpts,
|
||||
rehypePlugins: [
|
||||
...(mdxPluginOpts.rehypePlugins ?? []),
|
||||
() =>
|
||||
rehypeApplyFrontmatterExport(
|
||||
frontmatter,
|
||||
mdxOptions.frontmatterOptions?.name
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
code: String(compiled.value),
|
||||
|
|
|
@ -47,9 +47,9 @@ export function getFileInfo(id: string, config: AstroConfig): FileInfo {
|
|||
* Match YAML exception handling from Astro core errors
|
||||
* @see 'astro/src/core/errors.ts'
|
||||
*/
|
||||
export function getFrontmatter(code: string, id: string) {
|
||||
export function parseFrontmatter(code: string, id: string) {
|
||||
try {
|
||||
return matter(code).data;
|
||||
return matter(code);
|
||||
} catch (e: any) {
|
||||
if (e.name === 'YAMLException') {
|
||||
const err: SSRError = e;
|
||||
|
|
12
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs
vendored
Normal file
12
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs
vendored
Normal 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],
|
||||
})],
|
||||
});
|
12
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json
vendored
Normal file
12
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
20
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/markdown-plugins.mjs
vendored
Normal file
20
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/markdown-plugins.mjs
vendored
Normal 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
6
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/glob.json.js
vendored
Normal file
6
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/glob.json.js
vendored
Normal 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)),
|
||||
}
|
||||
}
|
3
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-1.mdx
vendored
Normal file
3
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-1.mdx
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Page 1
|
||||
|
||||
Look at that!
|
19
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-2.mdx
vendored
Normal file
19
packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-2.mdx
vendored
Normal 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
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: 'Overridden title'
|
||||
injectedReadingTime:
|
||||
text: '1000 min read'
|
||||
---
|
||||
|
||||
# Working!
|
|
@ -1,7 +1,7 @@
|
|||
import { readingTime } from './space-ipsum.mdx';
|
||||
import * as exps from './space-ipsum.mdx';
|
||||
|
||||
export function get() {
|
||||
return {
|
||||
body: JSON.stringify(readingTime),
|
||||
body: JSON.stringify(exps),
|
||||
}
|
||||
}
|
||||
|
|
6
packages/integrations/mdx/test/fixtures/mdx-remark-plugins/src/pages/headings-glob.json.js
vendored
Normal file
6
packages/integrations/mdx/test/fixtures/mdx-remark-plugins/src/pages/headings-glob.json.js
vendored
Normal 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)),
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -1,15 +1,15 @@
|
|||
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 { 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';
|
||||
|
||||
export function rehypeReadingTime() {
|
||||
return function (tree) {
|
||||
function rehypeReadingTime() {
|
||||
return function (tree, { data }) {
|
||||
const readingTime = getReadingTime(toString(tree));
|
||||
tree.children.unshift(
|
||||
jsToTreeNode(`export const readingTime = ${JSON.stringify(readingTime)}`)
|
||||
|
@ -46,7 +46,7 @@ describe('MDX rehype plugins', () => {
|
|||
});
|
||||
|
||||
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.text).to.match(/^\d+ min read/);
|
||||
|
|
|
@ -13,6 +13,7 @@ import remarkPrism from './remark-prism.js';
|
|||
import scopedStyles from './remark-scoped-styles.js';
|
||||
import remarkShiki from './remark-shiki.js';
|
||||
import remarkUnwrap from './remark-unwrap.js';
|
||||
import { remarkInitializeAstroData } from './remark-initialize-astro-data.js';
|
||||
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
|
@ -45,6 +46,7 @@ export async function renderMarkdown(
|
|||
|
||||
let parser = unified()
|
||||
.use(markdown)
|
||||
.use(remarkInitializeAstroData)
|
||||
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
|
||||
|
||||
if (remarkPlugins.length === 0 && rehypePlugins.length === 0) {
|
||||
|
@ -99,10 +101,9 @@ export async function renderMarkdown(
|
|||
)
|
||||
.use(rehypeStringify, { allowDangerousHtml: true });
|
||||
|
||||
let result: string;
|
||||
let vfile: VFile;
|
||||
try {
|
||||
const vfile = await parser.process(input);
|
||||
result = vfile.toString();
|
||||
vfile = await parser.process(input);
|
||||
} catch (err) {
|
||||
// Ensure that the error message contains the input filename
|
||||
// to make it easier for the user to fix the issue
|
||||
|
@ -113,8 +114,9 @@ export async function renderMarkdown(
|
|||
}
|
||||
|
||||
return {
|
||||
metadata: { headings, source: content, html: result.toString() },
|
||||
code: result.toString(),
|
||||
metadata: { headings, source: content, html: String(vfile.value) },
|
||||
code: String(vfile.value),
|
||||
vfile,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {} };
|
||||
}
|
||||
};
|
||||
}
|
|
@ -2,6 +2,7 @@ import type * as hast from 'hast';
|
|||
import type * as mdast from 'mdast';
|
||||
import type { ILanguageRegistration, IThemeRegistration, Theme } from 'shiki';
|
||||
import type * as unified from 'unified';
|
||||
import type { VFile } from 'vfile';
|
||||
|
||||
export type { Node } from 'unist';
|
||||
|
||||
|
@ -58,5 +59,6 @@ export interface MarkdownMetadata {
|
|||
|
||||
export interface MarkdownRenderingResult {
|
||||
metadata: MarkdownMetadata;
|
||||
vfile: VFile;
|
||||
code: string;
|
||||
}
|
||||
|
|
|
@ -1231,6 +1231,18 @@ importers:
|
|||
dependencies:
|
||||
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:
|
||||
specifiers:
|
||||
'@astrojs/preact': workspace:*
|
||||
|
@ -2183,6 +2195,7 @@ importers:
|
|||
remark-toc: ^8.0.1
|
||||
shiki: ^0.10.1
|
||||
unist-util-visit: ^4.1.0
|
||||
vfile: ^5.3.2
|
||||
dependencies:
|
||||
'@astrojs/prism': link:../../astro-prism
|
||||
'@mdx-js/mdx': 2.1.2
|
||||
|
@ -2199,6 +2212,7 @@ importers:
|
|||
remark-smartypants: 2.0.0
|
||||
shiki: 0.10.1
|
||||
unist-util-visit: 4.1.0
|
||||
vfile: 5.3.4
|
||||
devDependencies:
|
||||
'@types/chai': 4.3.1
|
||||
'@types/mocha': 9.1.1
|
||||
|
@ -2212,6 +2226,20 @@ importers:
|
|||
reading-time: 1.5.0
|
||||
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:
|
||||
specifiers:
|
||||
'@astrojs/mdx': workspace:*
|
||||
|
@ -14710,7 +14738,6 @@ packages:
|
|||
|
||||
/reading-time/1.5.0:
|
||||
resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==}
|
||||
dev: true
|
||||
|
||||
/recast/0.20.5:
|
||||
resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==}
|
||||
|
|
Loading…
Reference in a new issue