Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/satteri-ctx-data-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astrojs/markdown-satteri': patch
'@astrojs/mdx': minor
---

Adds support for [modifying frontmatter programmatically](https://docs.astro.build/en/guides/markdown-content/#modifying-frontmatter-programmatically) with the default Markdown processor.

A Sätteri plugin can now read and mutate `ctx.data.astro.frontmatter`, and Astro uses the result as the page's frontmatter, in both Markdown and MDX.
2 changes: 1 addition & 1 deletion packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"remark-math": "^6.0.0",
"remark-rehype": "^11.1.2",
"remark-toc": "^9.0.0",
"satteri": "^0.9.0",
"satteri": "^0.9.1",
"shiki": "^4.0.2",
"unified": "^11.0.5",
"vite": "^8.0.13"
Expand Down
19 changes: 11 additions & 8 deletions packages/integrations/mdx/src/satteri/hast-astro-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AstroMetadata } from '@astrojs/internal-helpers/markdown';
import { resolvePath } from 'astro/markdown';
import { createDefaultAstroMetadata, resolvePath } from 'astro/markdown';
import type { Identifier, Literal } from 'estree';
import {
defineHastPlugin,
Expand All @@ -11,6 +11,12 @@ import { findAttrValue, hasDirective, isComponent, type MdxJsxHastNode } from '.

export type { AstroMetadata };

declare module 'satteri' {
interface DataMap {
__astroMetadata: AstroMetadata;
}
}

type ImportSpecifier = { local: string; imported: string };
type MatchedImport = { name: string; path: string };

Expand Down Expand Up @@ -83,7 +89,6 @@ function findMatchingImport(
function processJsxNode(
node: MdxJsxHastNode,
ctx: HastVisitorContext,
metadata: AstroMetadata,
imports: Map<string, Set<ImportSpecifier>>,
filePath: string,
) {
Expand Down Expand Up @@ -118,6 +123,7 @@ function processJsxNode(
const exportName =
matchedImport.name === '*' ? tagName.split('.').slice(1).join('.') : matchedImport.name;

const metadata = (ctx.data.__astroMetadata ??= createDefaultAstroMetadata());
if (hasClient && findAttrValue(node, 'client:only') !== null) {
metadata.clientOnlyComponents.push({
exportName: matchedImport.name,
Expand Down Expand Up @@ -152,10 +158,7 @@ function processJsxNode(
}
}

export function createAstroMetadataPlugin(
metadata: AstroMetadata,
filePath: string,
): HastPluginDefinition {
export function createAstroMetadataPlugin(filePath: string): HastPluginDefinition {
const imports = new Map<string, Set<ImportSpecifier>>();
return defineHastPlugin({
name: 'astro-metadata',
Expand All @@ -169,14 +172,14 @@ export function createAstroMetadataPlugin(
mdxJsxFlowElement: {
filter: [],
visit(node, ctx) {
processJsxNode(node, ctx, metadata, imports, filePath);
processJsxNode(node, ctx, imports, filePath);
},
},

mdxJsxTextElement: {
filter: [],
visit(node, ctx) {
processJsxNode(node, ctx, metadata, imports, filePath);
processJsxNode(node, ctx, imports, filePath);
},
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,20 @@ export interface ImageImportInfo {
}

export function createImageToComponentPlugin(
localImagePaths: Set<string>,
remoteImagePaths: Set<string>,
imageImportInfo: ImageImportInfo,
): HastPluginDefinition {
return defineHastPlugin({
name: 'image-to-component',
element: {
filter: ['img'],
visit(node) {
visit(node, ctx) {
const src = node.properties?.src;
if (typeof src !== 'string') return;

const decodedSrc = decodeURI(src);
const isLocal = localImagePaths.has(decodedSrc);
const isRemote = remoteImagePaths.has(decodedSrc);
const astro = ctx.data.astro;
const isLocal = astro?.localImagePaths.has(decodedSrc) ?? false;
const isRemote = astro?.remoteImagePaths.has(decodedSrc) ?? false;
if (!isLocal && !isRemote) return;

const attrs: MdxJsxAttributeNode[] = [];
Expand Down
51 changes: 31 additions & 20 deletions packages/integrations/mdx/src/satteri/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { pathToFileURL } from 'node:url';
import { isFrontmatterValid } from '@astrojs/internal-helpers/frontmatter';
import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown';
import type { SatteriResolvedOptions } from '@astrojs/markdown-satteri';
import type { SatteriAstroData, SatteriResolvedOptions } from '@astrojs/markdown-satteri';
import {
satteriCollectImagesPlugin,
satteriCreateHighlightFn,
Expand Down Expand Up @@ -67,32 +68,28 @@ export function createMdxProcessor(
}
if (initPromise) await initPromise;

const headings: MarkdownHeading[] = [];
const localImagePaths = new Set<string>();
const remoteImagePaths = new Set<string>();

const astroMetadata = createDefaultAstroMetadata();
const astroData: SatteriAstroData = {
frontmatter,
headings: [],
localImagePaths: new Set(),
remoteImagePaths: new Set(),
};

const collectImages = satteriCollectImagesPlugin(localImagePaths, remoteImagePaths);
const headingIds = satteriHeadingIdsPlugin(headings, frontmatter);
const astroMeta = createAstroMetadataPlugin(astroMetadata, filePath);
const collectImages = satteriCollectImagesPlugin();
const headingIds = satteriHeadingIdsPlugin();
const astroMeta = createAstroMetadataPlugin(filePath);
const imageImportInfo: ImageImportInfo = {
importedImages: new Map(),
hasImages: false,
};
const imageToComponent = createImageToComponentPlugin(
localImagePaths,
remoteImagePaths,
imageImportInfo,
);
const imageToComponent = createImageToComponentPlugin(imageImportInfo);

const syntaxHighlight = mdxOptions.syntaxHighlight;
const excludeLangs =
typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined;

const allMdastPlugins: MdastPluginDefinition[] = satteriOptions.mdastPlugins.length
? [collectImages, ...satteriOptions.mdastPlugins]
: [collectImages];
// Collect last so image-URL rewrites by user plugins are captured.
const allMdastPlugins: MdastPluginDefinition[] = [...satteriOptions.mdastPlugins, collectImages];

const hastPlugins: HastPluginDefinition[] = [];
if (highlightFn) {
Expand Down Expand Up @@ -136,9 +133,23 @@ export function createMdxProcessor(
},
fileURL: pathToFileURL(filePath),
jsxImportSource: 'astro',
data: { astro: astroData },
});
let compiled = mdxResult.code;

// Read the returned bag, not the seeded reference, so a plugin that replaces it is honored.
const astro = mdxResult.data.astro;
const headings = astro?.headings ?? [];
const astroMetadata = mdxResult.data.__astroMetadata ?? createDefaultAstroMetadata();

// Plugins may have mutated frontmatter; emit the final value.
const resolvedFrontmatter = astro?.frontmatter;
if (!resolvedFrontmatter || !isFrontmatterValid(resolvedFrontmatter)) {
throw new Error(
'[MDX] A Sätteri plugin attempted to inject invalid frontmatter. Ensure `ctx.data.astro.frontmatter` is a valid object that is not `null` or `undefined`.',
);
}

compiled = compiled.replace(/^export default MDXContent;\s*$/m, '');

if (imageImportInfo.hasImages) {
Expand All @@ -152,14 +163,14 @@ export function createMdxProcessor(
compiled += `\nexport const ${USES_ASTRO_IMAGE_FLAG} = true;`;
}

compiled += `\nexport const frontmatter = ${JSON.stringify(frontmatter)};`;
compiled += `\nexport const frontmatter = ${JSON.stringify(resolvedFrontmatter)};`;
compiled += `\nexport function getHeadings() { return ${JSON.stringify(headings)}; }`;

if (frontmatter.layout) {
if (resolvedFrontmatter.layout) {
compiled = compiled.replace(/^function MDXContent\(/m, 'function __OriginalMDXContent__(');
compiled += `
import { jsx as __astro_layout_jsx__ } from 'astro/jsx-runtime';
import __astro_layout_component__ from ${JSON.stringify(frontmatter.layout)};
import __astro_layout_component__ from ${JSON.stringify(resolvedFrontmatter.layout)};
export default function MDXContent(props) {
const content = __OriginalMDXContent__(props);
const { layout, ...frontmatterContent } = frontmatter;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export async function GET() {
const mdxPages = await import.meta.glob('./*.mdx', { eager: true });
const entries = Object.entries(mdxPages ?? {});
return Response.json({
headingsByPage: Object.fromEntries(
Object.entries(mdxPages ?? {}).map(([k, v]) => [k, v?.getHeadings()])
),
headingsByPage: Object.fromEntries(entries.map(([k, v]) => [k, v?.getHeadings()])),
frontmatterByPage: Object.fromEntries(entries.map(([k, v]) => [k, v?.frontmatter])),
});
}
17 changes: 16 additions & 1 deletion packages/integrations/mdx/test/mdx-satteri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import { before, describe, it } from 'node:test';
import mdx from '@astrojs/mdx';
import { satteri } from '@astrojs/markdown-satteri';
import { parseHTML } from 'linkedom';
import type { MdastPluginDefinition } from 'satteri';
import { loadFixture, type Fixture } from './test-utils.ts';

const injectFrontmatter: MdastPluginDefinition = {
name: 'inject-frontmatter',
heading(_node, ctx) {
ctx.data.astro!.frontmatter.injected = 'from-plugin';
},
};

describe('MDX with the Sätteri processor', () => {
let fixture: Fixture;

Expand All @@ -13,7 +21,7 @@ describe('MDX with the Sätteri processor', () => {
root: new URL('./fixtures/mdx-get-headings/', import.meta.url),
integrations: [mdx()],
markdown: {
processor: satteri(),
processor: satteri({ mdastPlugins: [injectFrontmatter] }),
},
});

Expand All @@ -37,4 +45,11 @@ describe('MDX with the Sätteri processor', () => {
assert.ok(slugs.includes('section-1'));
assert.ok(slugs.includes('section-2'));
});

it('lets a Sätteri plugin modify frontmatter, exposed via the page export', async () => {
const { frontmatterByPage } = JSON.parse(await fixture.readFile('/pages.json'));
assert.equal(frontmatterByPage['./test.mdx'].injected, 'from-plugin');
assert.equal(frontmatterByPage['./test-with-frontmatter.mdx'].injected, 'from-plugin');
assert.equal(frontmatterByPage['./test-with-frontmatter.mdx'].title, 'The Frontmatter Title');
});
});
2 changes: 1 addition & 1 deletion packages/markdown/satteri/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/prism": "workspace:*",
"github-slugger": "^2.0.0",
"satteri": "^0.9.0"
"satteri": "^0.9.1"
},
"devDependencies": {
"astro-scripts": "workspace:*"
Expand Down
1 change: 1 addition & 0 deletions packages/markdown/satteri/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
createHighlightFn as satteriCreateHighlightFn,
collectHastText as satteriCollectHastText,
makeFragmentNode as satteriMakeFragmentNode,
type SatteriAstroData,
createSatteriMarkdownProcessor,
type SatteriMarkdownProcessorOptions,
} from './satteri-processor.js';
Expand Down
Loading
Loading