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:
Ben Holmes 2023-01-03 16:31:19 -05:00 committed by GitHub
parent 16c7d0bfd4
commit e2019be6ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 234 additions and 204 deletions

View 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.

View file

@ -37,49 +37,50 @@ declare module 'astro:content' {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
remarkPluginFrontmatter: Record<string, any>;
}>;
};
const entryMap: {
blog: {
'first-post.md': {
id: 'first-post.md';
slug: 'first-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'markdown-style-guide.md': {
id: 'markdown-style-guide.md';
slug: 'markdown-style-guide';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'second-post.md': {
id: 'second-post.md';
slug: 'second-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'third-post.md': {
id: 'third-post.md';
slug: 'third-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'using-mdx.mdx': {
id: 'using-mdx.mdx';
slug: 'using-mdx';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
};
"blog": {
"first-post.md": {
id: "first-post.md",
slug: "first-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"markdown-style-guide.md": {
id: "markdown-style-guide.md",
slug: "markdown-style-guide",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"second-post.md": {
id: "second-post.md",
slug: "second-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"third-post.md": {
id: "third-post.md",
slug: "third-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"using-mdx.mdx": {
id: "using-mdx.mdx",
slug: "using-mdx",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
},
};
type ContentConfig = typeof import('./config');
type ContentConfig = typeof import("./config");
}

View file

@ -1464,10 +1464,6 @@ export interface SSRResult {
_metadata: SSRMetadata;
}
export type MarkdownAstroData = {
frontmatter: MD['frontmatter'];
};
/* Preview server stuff */
export interface PreviewServer {
host?: string;

View file

@ -137,12 +137,9 @@ async function render({
propagation: 'self',
});
if (!mod._internal && id.endsWith('.mdx')) {
throw new Error(`[Content] Failed to render MDX entry. Try installing @astrojs/mdx@latest`);
}
return {
Content,
headings: mod.getHeadings(),
injectedFrontmatter: mod._internal.injectedFrontmatter,
remarkPluginFrontmatter: mod.frontmatter,
};
}

View file

@ -37,7 +37,7 @@ declare module 'astro:content' {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
remarkPluginFrontmatter: Record<string, any>;
}>;
};

View file

@ -34,7 +34,7 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
if (isDelayedAsset(id)) {
const basePath = id.split('?')[0];
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 collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
`;

View file

@ -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.',
},
/**
* @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
UnknownConfigError: {
title: 'Unknown configuration error.',

View file

@ -1,4 +1,8 @@
import { renderMarkdown } from '@astrojs/markdown-remark';
import {
safelyGetAstroData,
InvalidAstroDataError,
} from '@astrojs/markdown-remark/dist/internal.js';
import fs from 'fs';
import matter from 'gray-matter';
import { fileURLToPath } from 'node:url';
@ -6,16 +10,12 @@ import type { Plugin } from 'vite';
import { normalizePath } from 'vite';
import type { AstroSettings } from '../@types/astro';
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 { warn } from '../core/logger/core.js';
import { isMarkdownFile } from '../core/util.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import {
escapeViteEnvReferences,
getFileInfo,
safelyGetAstroData,
} from '../vite-plugin-utils/index.js';
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
interface AstroPluginOptions {
settings: AstroSettings;
@ -74,16 +74,17 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
isAstroFlavoredMd: false,
isExperimentalContentCollections: settings.config.experimental.contentCollections,
contentDir: getContentPaths(settings.config).contentDir,
} as any);
frontmatter: raw.data,
});
const html = renderResult.code;
const { headings } = renderResult.metadata;
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data);
const frontmatter = {
...injectedFrontmatter,
...raw.data,
} as any;
const astroData = safelyGetAstroData(renderResult.vfile.data);
if (astroData instanceof InvalidAstroDataError) {
throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
}
const { frontmatter } = astroData;
const { layout } = frontmatter;
if (frontmatter.setup) {
@ -100,9 +101,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
const html = ${JSON.stringify(html)};
export const _internal = {
injectedFrontmatter: ${JSON.stringify(injectedFrontmatter)},
}
export const frontmatter = ${JSON.stringify(frontmatter)};
export const file = ${JSON.stringify(fileId)};
export const url = ${JSON.stringify(fileUrl)};

View file

@ -1,6 +1,5 @@
import ancestor from 'common-ancestor-path';
import type { Data } from 'vfile';
import type { AstroConfig, MarkdownAstroData } from '../@types/astro';
import type { AstroConfig } from '../@types/astro';
import {
appendExtension,
appendForwardSlash,
@ -36,33 +35,6 @@ 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;
}
/**
* Normalizes different file names like:
*

View file

@ -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 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');
const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
});
});

View file

@ -1,11 +1,11 @@
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
export default defineConfig({
site: 'https://astro.build/',
markdown: {
remarkPlugins: [remarkTitle],
remarkPlugins: [remarkTitle, remarkDescription],
rehypePlugins: [rehypeReadingTime],
}
});

View file

@ -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}`
};
}

View file

@ -1,3 +1,7 @@
---
description: 'Page 1 description'
---
# Page 1
Look at that!

View file

@ -1,3 +1,7 @@
---
description: 'Page 2 description'
---
# Page 2
## Table of contents

View file

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

View file

@ -1,3 +1,4 @@
import { toRemarkInitializeAstroData } from '@astrojs/markdown-remark/dist/internal.js';
import { compile as mdxCompile } from '@mdx-js/mdx';
import { PluggableList } from '@mdx-js/mdx/lib/core.js';
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 { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite';
import {
getRehypePlugins,
getRemarkPlugins,
recmaInjectImportMetaEnvPlugin,
rehypeApplyFrontmatterExport,
} from './plugins.js';
import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
import { getFileInfo, parseFrontmatter } from './utils.js';
const RAW_CONTENT_ERROR =
@ -86,9 +82,10 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
...mdxPluginOpts,
rehypePlugins: [
...(mdxPluginOpts.rehypePlugins ?? []),
() => rehypeApplyFrontmatterExport(frontmatter),
remarkPlugins: [
// Ensure `data.astro` is available to all remark plugins
toRemarkInitializeAstroData({ userFrontmatter: frontmatter }),
...(mdxPluginOpts.remarkPlugins ?? []),
],
recmaPlugins: [
...(mdxPluginOpts.recmaPlugins ?? []),

View file

@ -2,7 +2,11 @@ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
import { nodeTypes } from '@mdx-js/mdx';
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
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 { visit as estreeVisit } from 'estree-util-visit';
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) {
if (!vfile.data.astro) {
vfile.data.astro = { frontmatter: {} };
}
};
}
export function rehypeApplyFrontmatterExport(pageFrontmatter: Record<string, any>) {
return function (tree: any, vfile: VFile) {
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data);
const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter };
const astroData = safelyGetAstroData(vfile.data);
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`.'
);
const { frontmatter } = astroData;
const exportNodes = [
jsToTreeNode(
`export const frontmatter = ${JSON.stringify(
frontmatter
)};\nexport const _internal = { injectedFrontmatter: ${JSON.stringify(
injectedFrontmatter
)} };`
),
jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
];
if (frontmatter.layout) {
// NOTE(bholmesdev) 08-22-2022
@ -151,10 +147,7 @@ export async function getRemarkPlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
let remarkPlugins: PluggableList = [
// Set "vfile.data.astro" for plugins to inject frontmatter
remarkInitializeAstroData,
];
let remarkPlugins: PluggableList = [];
switch (mdxOptions.extendPlugins) {
case false:
break;
@ -217,6 +210,8 @@ export function getRehypePlugins(
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
rehypeHeadingIds,
rehypeInjectHeadingsExport,
// computed from `astro.data.frontmatter` in VFile data
rehypeApplyFrontmatterExport,
];
return rehypePlugins;
}
@ -250,41 +245,6 @@ function ignoreStringPlugins(plugins: any[]) {
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"
* If it is, return the variable name (i.e. "VARIABLE")

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'astro/config';
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
export default defineConfig({
site: 'https://astro.build/',
integrations: [mdx({
remarkPlugins: [remarkTitle],
remarkPlugins: [remarkTitle, remarkDescription],
rehypePlugins: [rehypeReadingTime],
})],
});

View file

@ -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}`
};
}

View file

@ -1,5 +1,6 @@
---
layout: '../layouts/Base.astro'
description: Page 1 description
---
# Page 1

View file

@ -1,5 +1,6 @@
---
layout: '../layouts/Base.astro'
description: Page 2 description
---
# Page 2

View file

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

View file

@ -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 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');
const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
});
it('passes injected frontmatter to layouts', async () => {

View file

@ -13,7 +13,8 @@
"homepage": "https://astro.build",
"main": "./dist/index.js",
"exports": {
".": "./dist/index.js"
".": "./dist/index.js",
"./dist/internal.js": "./dist/internal.js"
},
"scripts": {
"prepublish": "pnpm build",

View 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 };
}
};
}

View file

@ -8,7 +8,7 @@ import rehypeIslands from './rehype-islands.js';
import rehypeJsx from './rehype-jsx.js';
import toRemarkContentRelImageError from './remark-content-rel-image-error.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 remarkMdxish from './remark-mdxish.js';
import remarkPrism from './remark-prism.js';
@ -45,13 +45,14 @@ export async function renderMarkdown(
isAstroFlavoredMd = false,
isExperimentalContentCollections = false,
contentDir,
frontmatter: userFrontmatter = {},
} = opts;
const input = new VFile({ value: content, path: fileURL });
const scopedClassName = opts.$?.scopedClassName;
let parser = unified()
.use(markdown)
.use(remarkInitializeAstroData)
.use(toRemarkInitializeAstroData({ userFrontmatter }))
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) {

View file

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

View file

@ -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: {} };
}
};
}

View file

@ -11,6 +11,10 @@ import type { VFile } from 'vfile';
export type { Node } from 'unist';
export type MarkdownAstroData = {
frontmatter: Record<string, any>;
};
export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin<
PluginParameters,
mdast.Root
@ -58,6 +62,8 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions {
isExperimentalContentCollections?: boolean;
/** Used to prevent relative image imports from `src/content/` */
contentDir: URL;
/** Used for frontmatter injection plugins */
frontmatter?: Record<string, any>;
}
export interface MarkdownHeading {