Change frontmatter injection ordering (#5687)
* feat: make user frontmatter accessible in md * test: new frontmatter injection * refactor: move injection utils to remark pkg * fix: add dist/internal to remark exports * feat: update frontmater injection in mdx * tests: new mdx injection * chore: changeset * chore: simplify frontmatter destructuring * fix: remove old _internal references * refactor: injectedFrontmatter -> remarkPluginFrontmatter * docs: add content collections change * chore: changeset heading levels
This commit is contained in:
parent
16c7d0bfd4
commit
e2019be6ff
29 changed files with 234 additions and 204 deletions
47
.changeset/beige-pumpkins-pump.md
Normal file
47
.changeset/beige-pumpkins-pump.md
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
'astro': major
|
||||||
|
'@astrojs/markdown-remark': major
|
||||||
|
'@astrojs/mdx': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Give remark and rehype plugins access to user frontmatter via frontmatter injection. This means `data.astro.frontmatter` is now the _complete_ Markdown or MDX document's frontmatter, rather than an empty object.
|
||||||
|
|
||||||
|
This allows plugin authors to modify existing frontmatter, or compute new properties based on other properties. For example, say you want to compute a full image URL based on an `imageSrc` slug in your document frontmatter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function remarkInjectSocialImagePlugin() {
|
||||||
|
return function (tree, file) {
|
||||||
|
const { frontmatter } = file.data.astro;
|
||||||
|
frontmatter.socialImageSrc = new URL(
|
||||||
|
frontmatter.imageSrc,
|
||||||
|
'https://my-blog.com/',
|
||||||
|
).pathname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Content Collections - new `remarkPluginFrontmatter` property
|
||||||
|
|
||||||
|
We have changed _inject_ frontmatter to _modify_ frontmatter in our docs to improve discoverability. This is based on support forum feedback, where "injection" is rarely the term used.
|
||||||
|
|
||||||
|
To reflect this, the `injectedFrontmatter` property has been renamed to `remarkPluginFrontmatter`. This should clarify this plugin is still separate from the `data` export Content Collections expose today.
|
||||||
|
|
||||||
|
|
||||||
|
#### Migration instructions
|
||||||
|
|
||||||
|
Plugin authors should now **check for user frontmatter when applying defaults.**
|
||||||
|
|
||||||
|
For example, say a remark plugin wants to apply a default `title` if none is present. Add a conditional to check if the property is present, and update if none exists:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
export function remarkInjectTitlePlugin() {
|
||||||
|
return function (tree, file) {
|
||||||
|
const { frontmatter } = file.data.astro;
|
||||||
|
+ if (!frontmatter.title) {
|
||||||
|
frontmatter.title = 'Default title';
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or reype.
|
|
@ -37,49 +37,50 @@ declare module 'astro:content' {
|
||||||
render(): Promise<{
|
render(): Promise<{
|
||||||
Content: import('astro').MarkdownInstance<{}>['Content'];
|
Content: import('astro').MarkdownInstance<{}>['Content'];
|
||||||
headings: import('astro').MarkdownHeading[];
|
headings: import('astro').MarkdownHeading[];
|
||||||
injectedFrontmatter: Record<string, any>;
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const entryMap: {
|
const entryMap: {
|
||||||
blog: {
|
"blog": {
|
||||||
'first-post.md': {
|
"first-post.md": {
|
||||||
id: 'first-post.md';
|
id: "first-post.md",
|
||||||
slug: 'first-post';
|
slug: "first-post",
|
||||||
body: string;
|
body: string,
|
||||||
collection: 'blog';
|
collection: "blog",
|
||||||
data: InferEntrySchema<'blog'>;
|
data: InferEntrySchema<"blog">
|
||||||
};
|
},
|
||||||
'markdown-style-guide.md': {
|
"markdown-style-guide.md": {
|
||||||
id: 'markdown-style-guide.md';
|
id: "markdown-style-guide.md",
|
||||||
slug: 'markdown-style-guide';
|
slug: "markdown-style-guide",
|
||||||
body: string;
|
body: string,
|
||||||
collection: 'blog';
|
collection: "blog",
|
||||||
data: InferEntrySchema<'blog'>;
|
data: InferEntrySchema<"blog">
|
||||||
};
|
},
|
||||||
'second-post.md': {
|
"second-post.md": {
|
||||||
id: 'second-post.md';
|
id: "second-post.md",
|
||||||
slug: 'second-post';
|
slug: "second-post",
|
||||||
body: string;
|
body: string,
|
||||||
collection: 'blog';
|
collection: "blog",
|
||||||
data: InferEntrySchema<'blog'>;
|
data: InferEntrySchema<"blog">
|
||||||
};
|
},
|
||||||
'third-post.md': {
|
"third-post.md": {
|
||||||
id: 'third-post.md';
|
id: "third-post.md",
|
||||||
slug: 'third-post';
|
slug: "third-post",
|
||||||
body: string;
|
body: string,
|
||||||
collection: 'blog';
|
collection: "blog",
|
||||||
data: InferEntrySchema<'blog'>;
|
data: InferEntrySchema<"blog">
|
||||||
};
|
},
|
||||||
'using-mdx.mdx': {
|
"using-mdx.mdx": {
|
||||||
id: 'using-mdx.mdx';
|
id: "using-mdx.mdx",
|
||||||
slug: 'using-mdx';
|
slug: "using-mdx",
|
||||||
body: string;
|
body: string,
|
||||||
collection: 'blog';
|
collection: "blog",
|
||||||
data: InferEntrySchema<'blog'>;
|
data: InferEntrySchema<"blog">
|
||||||
};
|
},
|
||||||
};
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContentConfig = typeof import('./config');
|
type ContentConfig = typeof import("./config");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1464,10 +1464,6 @@ export interface SSRResult {
|
||||||
_metadata: SSRMetadata;
|
_metadata: SSRMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkdownAstroData = {
|
|
||||||
frontmatter: MD['frontmatter'];
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Preview server stuff */
|
/* Preview server stuff */
|
||||||
export interface PreviewServer {
|
export interface PreviewServer {
|
||||||
host?: string;
|
host?: string;
|
||||||
|
|
|
@ -137,12 +137,9 @@ async function render({
|
||||||
propagation: 'self',
|
propagation: 'self',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mod._internal && id.endsWith('.mdx')) {
|
|
||||||
throw new Error(`[Content] Failed to render MDX entry. Try installing @astrojs/mdx@latest`);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
Content,
|
Content,
|
||||||
headings: mod.getHeadings(),
|
headings: mod.getHeadings(),
|
||||||
injectedFrontmatter: mod._internal.injectedFrontmatter,
|
remarkPluginFrontmatter: mod.frontmatter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ declare module 'astro:content' {
|
||||||
render(): Promise<{
|
render(): Promise<{
|
||||||
Content: import('astro').MarkdownInstance<{}>['Content'];
|
Content: import('astro').MarkdownInstance<{}>['Content'];
|
||||||
headings: import('astro').MarkdownHeading[];
|
headings: import('astro').MarkdownHeading[];
|
||||||
injectedFrontmatter: Record<string, any>;
|
remarkPluginFrontmatter: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
|
||||||
if (isDelayedAsset(id)) {
|
if (isDelayedAsset(id)) {
|
||||||
const basePath = id.split('?')[0];
|
const basePath = id.split('?')[0];
|
||||||
const code = `
|
const code = `
|
||||||
export { Content, getHeadings, _internal } from ${JSON.stringify(basePath)};
|
export { Content, getHeadings } from ${JSON.stringify(basePath)};
|
||||||
export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)};
|
export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)};
|
||||||
export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
|
export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -520,6 +520,20 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
|
||||||
},
|
},
|
||||||
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
|
hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.',
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* @docs
|
||||||
|
* @see
|
||||||
|
* - [Frontmatter injection](https://docs.astro.build/en/guides/markdown-content/#example-injecting-frontmatter)
|
||||||
|
* @description
|
||||||
|
* A remark or rehype plugin attempted to inject invalid frontmatter. This occurs when "astro.frontmatter" is set to `null`, `undefined`, or an invalid JSON object.
|
||||||
|
*/
|
||||||
|
InvalidFrontmatterInjectionError: {
|
||||||
|
title: 'Invalid frontmatter injection.',
|
||||||
|
code: 6003,
|
||||||
|
message:
|
||||||
|
'A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.',
|
||||||
|
hint: 'See the frontmatter injection docs https://docs.astro.build/en/guides/markdown-content/#example-injecting-frontmatter for more information.',
|
||||||
|
},
|
||||||
// Config Errors - 7xxx
|
// Config Errors - 7xxx
|
||||||
UnknownConfigError: {
|
UnknownConfigError: {
|
||||||
title: 'Unknown configuration error.',
|
title: 'Unknown configuration error.',
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import { renderMarkdown } from '@astrojs/markdown-remark';
|
import { renderMarkdown } from '@astrojs/markdown-remark';
|
||||||
|
import {
|
||||||
|
safelyGetAstroData,
|
||||||
|
InvalidAstroDataError,
|
||||||
|
} from '@astrojs/markdown-remark/dist/internal.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
@ -6,16 +10,12 @@ import type { Plugin } from 'vite';
|
||||||
import { normalizePath } from 'vite';
|
import { normalizePath } from 'vite';
|
||||||
import type { AstroSettings } from '../@types/astro';
|
import type { AstroSettings } from '../@types/astro';
|
||||||
import { getContentPaths } from '../content/index.js';
|
import { getContentPaths } from '../content/index.js';
|
||||||
import { AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.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 { isMarkdownFile } from '../core/util.js';
|
import { isMarkdownFile } from '../core/util.js';
|
||||||
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
|
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
|
||||||
import {
|
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
|
||||||
escapeViteEnvReferences,
|
|
||||||
getFileInfo,
|
|
||||||
safelyGetAstroData,
|
|
||||||
} from '../vite-plugin-utils/index.js';
|
|
||||||
|
|
||||||
interface AstroPluginOptions {
|
interface AstroPluginOptions {
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
|
@ -74,16 +74,17 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
||||||
isAstroFlavoredMd: false,
|
isAstroFlavoredMd: false,
|
||||||
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
isExperimentalContentCollections: settings.config.experimental.contentCollections,
|
||||||
contentDir: getContentPaths(settings.config).contentDir,
|
contentDir: getContentPaths(settings.config).contentDir,
|
||||||
} as any);
|
frontmatter: raw.data,
|
||||||
|
});
|
||||||
|
|
||||||
const html = renderResult.code;
|
const html = renderResult.code;
|
||||||
const { headings } = renderResult.metadata;
|
const { headings } = renderResult.metadata;
|
||||||
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data);
|
const astroData = safelyGetAstroData(renderResult.vfile.data);
|
||||||
const frontmatter = {
|
if (astroData instanceof InvalidAstroDataError) {
|
||||||
...injectedFrontmatter,
|
throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
|
||||||
...raw.data,
|
}
|
||||||
} as any;
|
|
||||||
|
|
||||||
|
const { frontmatter } = astroData;
|
||||||
const { layout } = frontmatter;
|
const { layout } = frontmatter;
|
||||||
|
|
||||||
if (frontmatter.setup) {
|
if (frontmatter.setup) {
|
||||||
|
@ -100,9 +101,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
||||||
|
|
||||||
const html = ${JSON.stringify(html)};
|
const html = ${JSON.stringify(html)};
|
||||||
|
|
||||||
export const _internal = {
|
|
||||||
injectedFrontmatter: ${JSON.stringify(injectedFrontmatter)},
|
|
||||||
}
|
|
||||||
export const frontmatter = ${JSON.stringify(frontmatter)};
|
export const frontmatter = ${JSON.stringify(frontmatter)};
|
||||||
export const file = ${JSON.stringify(fileId)};
|
export const file = ${JSON.stringify(fileId)};
|
||||||
export const url = ${JSON.stringify(fileUrl)};
|
export const url = ${JSON.stringify(fileUrl)};
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import ancestor from 'common-ancestor-path';
|
import ancestor from 'common-ancestor-path';
|
||||||
import type { Data } from 'vfile';
|
import type { AstroConfig } from '../@types/astro';
|
||||||
import type { AstroConfig, MarkdownAstroData } from '../@types/astro';
|
|
||||||
import {
|
import {
|
||||||
appendExtension,
|
appendExtension,
|
||||||
appendForwardSlash,
|
appendForwardSlash,
|
||||||
|
@ -36,33 +35,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes different file names like:
|
* Normalizes different file names like:
|
||||||
*
|
*
|
||||||
|
|
|
@ -32,13 +32,10 @@ describe('Astro Markdown - frontmatter injection', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overrides injected frontmatter with user frontmatter', async () => {
|
it('allow user frontmatter mutation', async () => {
|
||||||
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
|
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
|
||||||
const readingTimes = frontmatterByPage.map(
|
const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
|
||||||
(frontmatter = {}) => frontmatter.injectedReadingTime?.text
|
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
|
||||||
);
|
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
|
||||||
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
|
|
||||||
expect(titles).to.contain('Overridden title');
|
|
||||||
expect(readingTimes).to.contain('1000 min read');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs'
|
import { rehypeReadingTime, remarkTitle, remarkDescription } from './src/markdown-plugins.mjs'
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://astro.build/',
|
site: 'https://astro.build/',
|
||||||
markdown: {
|
markdown: {
|
||||||
remarkPlugins: [remarkTitle],
|
remarkPlugins: [remarkTitle, remarkDescription],
|
||||||
rehypePlugins: [rehypeReadingTime],
|
rehypePlugins: [rehypeReadingTime],
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,3 +18,9 @@ export function remarkTitle() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function remarkDescription() {
|
||||||
|
return function (tree, { data }) {
|
||||||
|
data.astro.frontmatter.description = `Processed by remarkDescription plugin: ${data.astro.frontmatter.description}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
---
|
||||||
|
description: 'Page 1 description'
|
||||||
|
---
|
||||||
|
|
||||||
# Page 1
|
# Page 1
|
||||||
|
|
||||||
Look at that!
|
Look at that!
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
---
|
||||||
|
description: 'Page 2 description'
|
||||||
|
---
|
||||||
|
|
||||||
# Page 2
|
# Page 2
|
||||||
|
|
||||||
## Table of contents
|
## Table of contents
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
---
|
|
||||||
title: 'Overridden title'
|
|
||||||
injectedReadingTime:
|
|
||||||
text: '1000 min read'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Working!
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { toRemarkInitializeAstroData } from '@astrojs/markdown-remark/dist/internal.js';
|
||||||
import { compile as mdxCompile } from '@mdx-js/mdx';
|
import { compile as mdxCompile } from '@mdx-js/mdx';
|
||||||
import { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
import { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
||||||
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
||||||
|
@ -7,12 +8,7 @@ import fs from 'node:fs/promises';
|
||||||
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
|
import type { Options as RemarkRehypeOptions } from 'remark-rehype';
|
||||||
import { VFile } from 'vfile';
|
import { VFile } from 'vfile';
|
||||||
import type { Plugin as VitePlugin } from 'vite';
|
import type { Plugin as VitePlugin } from 'vite';
|
||||||
import {
|
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
|
||||||
getRehypePlugins,
|
|
||||||
getRemarkPlugins,
|
|
||||||
recmaInjectImportMetaEnvPlugin,
|
|
||||||
rehypeApplyFrontmatterExport,
|
|
||||||
} from './plugins.js';
|
|
||||||
import { getFileInfo, parseFrontmatter } from './utils.js';
|
import { getFileInfo, parseFrontmatter } from './utils.js';
|
||||||
|
|
||||||
const RAW_CONTENT_ERROR =
|
const RAW_CONTENT_ERROR =
|
||||||
|
@ -86,9 +82,10 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
|
||||||
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
|
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
|
||||||
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
|
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
|
||||||
...mdxPluginOpts,
|
...mdxPluginOpts,
|
||||||
rehypePlugins: [
|
remarkPlugins: [
|
||||||
...(mdxPluginOpts.rehypePlugins ?? []),
|
// Ensure `data.astro` is available to all remark plugins
|
||||||
() => rehypeApplyFrontmatterExport(frontmatter),
|
toRemarkInitializeAstroData({ userFrontmatter: frontmatter }),
|
||||||
|
...(mdxPluginOpts.remarkPlugins ?? []),
|
||||||
],
|
],
|
||||||
recmaPlugins: [
|
recmaPlugins: [
|
||||||
...(mdxPluginOpts.recmaPlugins ?? []),
|
...(mdxPluginOpts.recmaPlugins ?? []),
|
||||||
|
|
|
@ -2,7 +2,11 @@ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
|
||||||
import { nodeTypes } from '@mdx-js/mdx';
|
import { nodeTypes } from '@mdx-js/mdx';
|
||||||
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
|
||||||
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
|
||||||
import type { AstroConfig, MarkdownAstroData } from 'astro';
|
import type { AstroConfig } from 'astro';
|
||||||
|
import {
|
||||||
|
safelyGetAstroData,
|
||||||
|
InvalidAstroDataError,
|
||||||
|
} from '@astrojs/markdown-remark/dist/internal.js';
|
||||||
import type { Literal, MemberExpression } from 'estree';
|
import type { Literal, MemberExpression } from 'estree';
|
||||||
import { visit as estreeVisit } from 'estree-util-visit';
|
import { visit as estreeVisit } from 'estree-util-visit';
|
||||||
import { bold, yellow } from 'kleur/colors';
|
import { bold, yellow } from 'kleur/colors';
|
||||||
|
@ -47,26 +51,18 @@ export function recmaInjectImportMetaEnvPlugin({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remarkInitializeAstroData() {
|
export function rehypeApplyFrontmatterExport() {
|
||||||
return function (tree: any, vfile: VFile) {
|
return function (tree: any, vfile: VFile) {
|
||||||
if (!vfile.data.astro) {
|
const astroData = safelyGetAstroData(vfile.data);
|
||||||
vfile.data.astro = { frontmatter: {} };
|
if (astroData instanceof InvalidAstroDataError)
|
||||||
}
|
throw new Error(
|
||||||
};
|
// Copied from Astro core `errors-data`
|
||||||
}
|
// TODO: find way to import error data from core
|
||||||
|
'[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.'
|
||||||
export function rehypeApplyFrontmatterExport(pageFrontmatter: Record<string, any>) {
|
);
|
||||||
return function (tree: any, vfile: VFile) {
|
const { frontmatter } = astroData;
|
||||||
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data);
|
|
||||||
const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter };
|
|
||||||
const exportNodes = [
|
const exportNodes = [
|
||||||
jsToTreeNode(
|
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
|
||||||
`export const frontmatter = ${JSON.stringify(
|
|
||||||
frontmatter
|
|
||||||
)};\nexport const _internal = { injectedFrontmatter: ${JSON.stringify(
|
|
||||||
injectedFrontmatter
|
|
||||||
)} };`
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
if (frontmatter.layout) {
|
if (frontmatter.layout) {
|
||||||
// NOTE(bholmesdev) 08-22-2022
|
// NOTE(bholmesdev) 08-22-2022
|
||||||
|
@ -151,10 +147,7 @@ export async function getRemarkPlugins(
|
||||||
mdxOptions: MdxOptions,
|
mdxOptions: MdxOptions,
|
||||||
config: AstroConfig
|
config: AstroConfig
|
||||||
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
|
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
|
||||||
let remarkPlugins: PluggableList = [
|
let remarkPlugins: PluggableList = [];
|
||||||
// Set "vfile.data.astro" for plugins to inject frontmatter
|
|
||||||
remarkInitializeAstroData,
|
|
||||||
];
|
|
||||||
switch (mdxOptions.extendPlugins) {
|
switch (mdxOptions.extendPlugins) {
|
||||||
case false:
|
case false:
|
||||||
break;
|
break;
|
||||||
|
@ -217,6 +210,8 @@ export function getRehypePlugins(
|
||||||
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
|
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
|
||||||
rehypeHeadingIds,
|
rehypeHeadingIds,
|
||||||
rehypeInjectHeadingsExport,
|
rehypeInjectHeadingsExport,
|
||||||
|
// computed from `astro.data.frontmatter` in VFile data
|
||||||
|
rehypeApplyFrontmatterExport,
|
||||||
];
|
];
|
||||||
return rehypePlugins;
|
return rehypePlugins;
|
||||||
}
|
}
|
||||||
|
@ -250,41 +245,6 @@ function ignoreStringPlugins(plugins: any[]) {
|
||||||
return validPlugins;
|
return validPlugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if estree entry is "import.meta.env.VARIABLE"
|
* Check if estree entry is "import.meta.env.VARIABLE"
|
||||||
* If it is, return the variable name (i.e. "VARIABLE")
|
* If it is, return the variable name (i.e. "VARIABLE")
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import mdx from '@astrojs/mdx';
|
import mdx from '@astrojs/mdx';
|
||||||
import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs';
|
import { rehypeReadingTime, remarkDescription, remarkTitle } from './src/markdown-plugins.mjs';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://astro.build/',
|
site: 'https://astro.build/',
|
||||||
integrations: [mdx({
|
integrations: [mdx({
|
||||||
remarkPlugins: [remarkTitle],
|
remarkPlugins: [remarkTitle, remarkDescription],
|
||||||
rehypePlugins: [rehypeReadingTime],
|
rehypePlugins: [rehypeReadingTime],
|
||||||
})],
|
})],
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,3 +18,10 @@ export function remarkTitle() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function remarkDescription() {
|
||||||
|
return function (tree, vfile) {
|
||||||
|
const { frontmatter } = vfile.data.astro;
|
||||||
|
frontmatter.description = `Processed by remarkDescription plugin: ${frontmatter.description}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
layout: '../layouts/Base.astro'
|
layout: '../layouts/Base.astro'
|
||||||
|
description: Page 1 description
|
||||||
---
|
---
|
||||||
|
|
||||||
# Page 1
|
# Page 1
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
layout: '../layouts/Base.astro'
|
layout: '../layouts/Base.astro'
|
||||||
|
description: Page 2 description
|
||||||
---
|
---
|
||||||
|
|
||||||
# Page 2
|
# Page 2
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
---
|
|
||||||
title: 'Overridden title'
|
|
||||||
injectedReadingTime:
|
|
||||||
text: '1000 min read'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Working!
|
|
|
@ -33,14 +33,11 @@ describe('MDX frontmatter injection', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overrides injected frontmatter with user frontmatter', async () => {
|
it('allow user frontmatter mutation', async () => {
|
||||||
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
|
const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
|
||||||
const readingTimes = frontmatterByPage.map(
|
const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
|
||||||
(frontmatter = {}) => frontmatter.injectedReadingTime?.text
|
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
|
||||||
);
|
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
|
||||||
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
|
|
||||||
expect(titles).to.contain('Overridden title');
|
|
||||||
expect(readingTimes).to.contain('1000 min read');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes injected frontmatter to layouts', async () => {
|
it('passes injected frontmatter to layouts', async () => {
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
"homepage": "https://astro.build",
|
"homepage": "https://astro.build",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js"
|
".": "./dist/index.js",
|
||||||
|
"./dist/internal.js": "./dist/internal.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepublish": "pnpm build",
|
"prepublish": "pnpm build",
|
||||||
|
|
41
packages/markdown/remark/src/frontmatter-injection.ts
Normal file
41
packages/markdown/remark/src/frontmatter-injection.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Data, VFile } from 'vfile';
|
||||||
|
import type { MarkdownAstroData } from './types.js';
|
||||||
|
|
||||||
|
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 class InvalidAstroDataError extends TypeError {}
|
||||||
|
|
||||||
|
export function safelyGetAstroData(vfileData: Data): MarkdownAstroData | InvalidAstroDataError {
|
||||||
|
const { astro } = vfileData;
|
||||||
|
|
||||||
|
if (!astro || !isValidAstroData(astro)) {
|
||||||
|
return new InvalidAstroDataError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return astro;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRemarkInitializeAstroData({
|
||||||
|
userFrontmatter,
|
||||||
|
}: {
|
||||||
|
userFrontmatter: Record<string, any>;
|
||||||
|
}) {
|
||||||
|
return () =>
|
||||||
|
function (tree: any, vfile: VFile) {
|
||||||
|
if (!vfile.data.astro) {
|
||||||
|
vfile.data.astro = { frontmatter: userFrontmatter };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ import rehypeIslands from './rehype-islands.js';
|
||||||
import rehypeJsx from './rehype-jsx.js';
|
import rehypeJsx from './rehype-jsx.js';
|
||||||
import toRemarkContentRelImageError from './remark-content-rel-image-error.js';
|
import toRemarkContentRelImageError from './remark-content-rel-image-error.js';
|
||||||
import remarkEscape from './remark-escape.js';
|
import remarkEscape from './remark-escape.js';
|
||||||
import { remarkInitializeAstroData } from './remark-initialize-astro-data.js';
|
import { toRemarkInitializeAstroData } from './frontmatter-injection.js';
|
||||||
import remarkMarkAndUnravel from './remark-mark-and-unravel.js';
|
import remarkMarkAndUnravel from './remark-mark-and-unravel.js';
|
||||||
import remarkMdxish from './remark-mdxish.js';
|
import remarkMdxish from './remark-mdxish.js';
|
||||||
import remarkPrism from './remark-prism.js';
|
import remarkPrism from './remark-prism.js';
|
||||||
|
@ -45,13 +45,14 @@ export async function renderMarkdown(
|
||||||
isAstroFlavoredMd = false,
|
isAstroFlavoredMd = false,
|
||||||
isExperimentalContentCollections = false,
|
isExperimentalContentCollections = false,
|
||||||
contentDir,
|
contentDir,
|
||||||
|
frontmatter: userFrontmatter = {},
|
||||||
} = opts;
|
} = opts;
|
||||||
const input = new VFile({ value: content, path: fileURL });
|
const input = new VFile({ value: content, path: fileURL });
|
||||||
const scopedClassName = opts.$?.scopedClassName;
|
const scopedClassName = opts.$?.scopedClassName;
|
||||||
|
|
||||||
let parser = unified()
|
let parser = unified()
|
||||||
.use(markdown)
|
.use(markdown)
|
||||||
.use(remarkInitializeAstroData)
|
.use(toRemarkInitializeAstroData({ userFrontmatter }))
|
||||||
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
|
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
|
||||||
|
|
||||||
if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) {
|
if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) {
|
||||||
|
|
5
packages/markdown/remark/src/internal.ts
Normal file
5
packages/markdown/remark/src/internal.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export {
|
||||||
|
InvalidAstroDataError,
|
||||||
|
safelyGetAstroData,
|
||||||
|
toRemarkInitializeAstroData,
|
||||||
|
} from './frontmatter-injection.js';
|
|
@ -1,9 +0,0 @@
|
||||||
import type { VFile } from 'vfile';
|
|
||||||
|
|
||||||
export function remarkInitializeAstroData() {
|
|
||||||
return function (tree: any, vfile: VFile) {
|
|
||||||
if (!vfile.data.astro) {
|
|
||||||
vfile.data.astro = { frontmatter: {} };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -11,6 +11,10 @@ import type { VFile } from 'vfile';
|
||||||
|
|
||||||
export type { Node } from 'unist';
|
export type { Node } from 'unist';
|
||||||
|
|
||||||
|
export type MarkdownAstroData = {
|
||||||
|
frontmatter: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin<
|
export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin<
|
||||||
PluginParameters,
|
PluginParameters,
|
||||||
mdast.Root
|
mdast.Root
|
||||||
|
@ -58,6 +62,8 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions {
|
||||||
isExperimentalContentCollections?: boolean;
|
isExperimentalContentCollections?: boolean;
|
||||||
/** Used to prevent relative image imports from `src/content/` */
|
/** Used to prevent relative image imports from `src/content/` */
|
||||||
contentDir: URL;
|
contentDir: URL;
|
||||||
|
/** Used for frontmatter injection plugins */
|
||||||
|
frontmatter?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarkdownHeading {
|
export interface MarkdownHeading {
|
||||||
|
|
Loading…
Reference in a new issue