0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: New Markdoc render API (#7468)

* feat: URL support for markdoc tags

* refactor: move to separate file

* feat: support URL for markdoc nodes

* feat: support `extends` with URL

* chore: changeset

* fix: bad AstroMarkdocConfig type

* fix: experimentalAssetsConfig missing

* fix: correctly merge runtime config

* chore: formatting

* deps: astro internal helpers

* feat: component() util, new astro bundling

* chore: remove now unused code

* todo: missing hint

* fix: import.meta.url type error

* wip: test nested collection calls

* feat: resolve paths from project root

* refactor: move getHeadings() to runtime module

* fix: broken collectHeadings

* test: update fixture configs

* chore: remove suggestions. Out of scope!

* fix: throw outside esbuild

* refactor: shuffle imports around

* Revert "wip: test nested collection calls"

This reverts commit 9354b3cf9222fd65b974b0cddf4e7a95ab3cd2b2.

* chore: revert back to mjs config

* chore: add jsdocs to stringified helpers

* fix: restore updated changeset

---------

Co-authored-by: bholmesdev <bholmesdev@gmail.com>
This commit is contained in:
Ben Holmes 2023-06-27 15:05:17 -04:00 committed by GitHub
parent 8821f05048
commit fb7af55114
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 452 additions and 309 deletions

View file

@ -0,0 +1,27 @@
---
'@astrojs/markdoc': minor
---
Updates the Markdoc config object for rendering Astro components as tags or nodes. Rather than importing components directly, Astro includes a new `component()` function to specify your component path. This unlocks using Astro components from npm packages and `.ts` files.
### Migration
Update all component imports to instead import the new `component()` function and use it to render your Astro components:
```diff
// markdoc.config.mjs
import {
defineMarkdocConfig,
+ component,
} from '@astrojs/markdoc/config';
- import Aside from './src/components/Aside.astro';
export default defineMarkdocConfig({
tags: {
aside: {
render: Aside,
+ render: component('./src/components/Aside.astro'),
}
}
});
```

View file

@ -1,10 +1,9 @@
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import Aside from './src/components/Aside.astro';
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
tags: {
aside: {
render: Aside,
render: component('./src/components/Aside.astro'),
attributes: {
type: { type: String },
title: { type: String },

View file

@ -63,6 +63,7 @@
"test:match": "mocha --timeout 20000 -g"
},
"dependencies": {
"@astrojs/internal-helpers": "^0.1.0",
"@astrojs/prism": "^2.1.2",
"@markdoc/markdoc": "^0.3.0",
"esbuild": "^0.17.19",

View file

@ -5,11 +5,19 @@ import type {
NodeType,
Schema,
} from '@markdoc/markdoc';
import _Markdoc from '@markdoc/markdoc';
import type { AstroInstance } from 'astro';
import _Markdoc from '@markdoc/markdoc';
import { heading } from './heading-ids.js';
import { isRelativePath } from '@astrojs/internal-helpers/path';
import { componentConfigSymbol } from './utils.js';
type Render = AstroInstance['default'] | string;
export type Render = ComponentConfig | AstroInstance['default'] | string;
export type ComponentConfig = {
type: 'package' | 'local';
path: string;
namedExport?: string;
[componentConfigSymbol]: true;
};
export type AstroMarkdocConfig<C extends Record<string, any> = Record<string, any>> = Omit<
MarkdocConfig,
@ -30,3 +38,16 @@ export const nodes = { ...Markdoc.nodes, heading };
export function defineMarkdocConfig(config: AstroMarkdocConfig): AstroMarkdocConfig {
return config;
}
export function component(pathnameOrPkgName: string, namedExport?: string): ComponentConfig {
return {
type: isNpmPackageName(pathnameOrPkgName) ? 'package' : 'local',
path: pathnameOrPkgName,
namedExport,
[componentConfigSymbol]: true,
};
}
function isNpmPackageName(pathname: string) {
return !isRelativePath(pathname) && !pathname.startsWith('/');
}

View file

@ -0,0 +1,278 @@
/* eslint-disable no-console */
import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc';
import type { ErrorPayload as ViteErrorPayload } from 'vite';
import matter from 'gray-matter';
import Markdoc from '@markdoc/markdoc';
import type { AstroConfig, ContentEntryType } from 'astro';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isValidUrl, MarkdocError, prependForwardSlash, isComponentConfig } from './utils.js';
import type { ComponentConfig } from './config.js';
// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
import { emitESMImage } from 'astro/assets';
import path from 'node:path';
import type * as rollup from 'rollup';
import { setupConfig } from './runtime.js';
import type { MarkdocConfigResult } from './load-config.js';
export async function getContentEntryType({
markdocConfigResult,
astroConfig,
}: {
astroConfig: AstroConfig;
markdocConfigResult?: MarkdocConfigResult;
}): Promise<ContentEntryType> {
return {
extensions: ['.mdoc'],
getEntryInfo,
handlePropagation: true,
async getRenderModule({ contents, fileUrl, viteId }) {
const entry = getEntryInfo({ contents, fileUrl });
const tokens = markdocTokenizer.tokenize(entry.body);
const ast = Markdoc.parse(tokens);
const usedTags = getUsedTags(ast);
const userMarkdocConfig = markdocConfigResult?.config ?? {};
const markdocConfigUrl = markdocConfigResult?.fileUrl;
let componentConfigByTagMap: Record<string, ComponentConfig> = {};
// Only include component imports for tags used in the document.
// Avoids style and script bleed.
for (const tag of usedTags) {
const render = userMarkdocConfig.tags?.[tag]?.render;
if (isComponentConfig(render)) {
componentConfigByTagMap[tag] = render;
}
}
let componentConfigByNodeMap: Record<string, ComponentConfig> = {};
for (const [nodeType, schema] of Object.entries(userMarkdocConfig.nodes ?? {})) {
const render = schema?.render;
if (isComponentConfig(render)) {
componentConfigByNodeMap[nodeType] = render;
}
}
const pluginContext = this;
const markdocConfig = await setupConfig(userMarkdocConfig);
const filePath = fileURLToPath(fileUrl);
const validationErrors = Markdoc.validate(
ast,
/* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
markdocConfig as MarkdocConfig
).filter((e) => {
return (
// Ignore `variable-undefined` errors.
// Variables can be configured at runtime,
// so we cannot validate them at build time.
e.error.id !== 'variable-undefined' &&
(e.error.level === 'error' || e.error.level === 'critical')
);
});
if (validationErrors.length) {
// Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
throw new MarkdocError({
message: [
`**${String(rootRelativePath)}** contains invalid content:`,
...validationErrors.map((e) => `- ${e.error.message}`),
].join('\n'),
location: {
// Error overlay does not support multi-line or ranges.
// Just point to the first line.
line: frontmatterBlockOffset + validationErrors[0].lines[0],
file: viteId,
},
});
}
if (astroConfig.experimental.assets) {
await emitOptimizedImages(ast.children, {
astroConfig,
pluginContext,
filePath,
});
}
const res = `import { Renderer } from '@astrojs/markdoc/components';
import { createGetHeadings, createContentComponent } from '@astrojs/markdoc/runtime';
${
markdocConfigUrl
? `import markdocConfig from ${JSON.stringify(markdocConfigUrl.pathname)};`
: 'const markdocConfig = {};'
}${
astroConfig.experimental.assets
? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';
markdocConfig.nodes = { ...experimentalAssetsConfig.nodes, ...markdocConfig.nodes };`
: ''
}
${getStringifiedImports(componentConfigByTagMap, 'Tag', astroConfig.root)}
${getStringifiedImports(componentConfigByNodeMap, 'Node', astroConfig.root)}
const tagComponentMap = ${getStringifiedMap(componentConfigByTagMap, 'Tag')};
const nodeComponentMap = ${getStringifiedMap(componentConfigByNodeMap, 'Node')};
const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
)};
export const getHeadings = createGetHeadings(stringifiedAst, markdocConfig);
export const Content = createContentComponent(
Renderer,
stringifiedAst,
markdocConfig,
tagComponentMap,
nodeComponentMap,
)`;
return { code: res };
},
contentModuleTypes: await fs.promises.readFile(
new URL('../template/content-module-types.d.ts', import.meta.url),
'utf-8'
),
};
}
const markdocTokenizer = new Markdoc.Tokenizer({
// Strip <!-- comments --> from rendered output
// Without this, they're rendered as strings!
allowComments: true,
});
function getUsedTags(markdocAst: Node) {
const tags = new Set<string>();
const validationErrors = Markdoc.validate(markdocAst);
// Hack: run the validator with an empty config and look for 'tag-undefined'.
// This is our signal that a tag is being used!
for (const { error } of validationErrors) {
if (error.id === 'tag-undefined') {
const [, tagName] = error.message.match(/Undefined tag: '(.*)'/) ?? [];
tags.add(tagName);
}
}
return tags;
}
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
return {
data: parsed.data,
body: parsed.content,
slug: parsed.data.slug,
rawData: parsed.matter,
};
}
/**
* Emits optimized images, and appends the generated `src` to each AST node
* via the `__optimizedSrc` attribute.
*/
async function emitOptimizedImages(
nodeChildren: Node[],
ctx: {
pluginContext: rollup.PluginContext;
filePath: string;
astroConfig: AstroConfig;
}
) {
for (const node of nodeChildren) {
if (
node.type === 'image' &&
typeof node.attributes.src === 'string' &&
shouldOptimizeImage(node.attributes.src)
) {
// Attempt to resolve source with Vite.
// This handles relative paths and configured aliases
const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
const src = await emitESMImage(
resolved.id,
ctx.pluginContext.meta.watchMode,
ctx.pluginContext.emitFile,
{ config: ctx.astroConfig }
);
node.attributes.__optimizedSrc = src;
} else {
throw new MarkdocError({
message: `Could not resolve image ${JSON.stringify(
node.attributes.src
)} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
});
}
}
await emitOptimizedImages(node.children, ctx);
}
}
function shouldOptimizeImage(src: string) {
// Optimize anything that is NOT external or an absolute path to `public/`
return !isValidUrl(src) && !src.startsWith('/');
}
/**
* Get stringified import statements for configured tags or nodes.
* `componentNamePrefix` is appended to the import name for namespacing.
*
* Example output: `import Tagaside from '/Users/.../src/components/Aside.astro';`
*/
function getStringifiedImports(
componentConfigMap: Record<string, ComponentConfig>,
componentNamePrefix: string,
root: URL
) {
let stringifiedComponentImports = '';
for (const [key, config] of Object.entries(componentConfigMap)) {
const importName = config.namedExport
? `{ ${config.namedExport} as ${componentNamePrefix + key} }`
: componentNamePrefix + key;
const resolvedPath =
config.type === 'local' ? new URL(config.path, root).pathname : config.path;
stringifiedComponentImports += `import ${importName} from ${JSON.stringify(resolvedPath)};\n`;
}
return stringifiedComponentImports;
}
/**
* Get a stringified map from tag / node name to component import name.
* This uses the same `componentNamePrefix` used by `getStringifiedImports()`.
*
* Example output: `{ aside: Tagaside, heading: Tagheading }`
*/
function getStringifiedMap(
componentConfigMap: Record<string, ComponentConfig>,
componentNamePrefix: string
) {
let stringifiedComponentMap = '{';
for (const key in componentConfigMap) {
stringifiedComponentMap += `${key}: ${componentNamePrefix + key},\n`;
}
stringifiedComponentMap += '}';
return stringifiedComponentMap;
}
/**
* Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts'
*/
function parseFrontmatter(fileContents: string, filePath: string) {
try {
// `matter` is empty string on cache results
// clear cache to prevent this
(matter as any).clearCache();
return matter(fileContents);
} catch (e: any) {
if (e.name === 'YAMLException') {
const err: Error & ViteErrorPayload['err'] = e;
err.id = filePath;
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
err.message = e.reason;
throw err;
} else {
throw e;
}
}
}

View file

@ -1,30 +1,14 @@
/* eslint-disable no-console */
import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
import crypto from 'node:crypto';
import fs from 'node:fs';
import type { AstroIntegration, ContentEntryType, HookParameters, AstroConfig } from 'astro';
import { fileURLToPath } from 'node:url';
import {
hasContentFlag,
isValidUrl,
MarkdocError,
parseFrontmatter,
prependForwardSlash,
PROPAGATED_ASSET_FLAG,
} from './utils.js';
// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
import { emitESMImage } from 'astro/assets';
import { bold, red } from 'kleur/colors';
import path from 'node:path';
import type * as rollup from 'rollup';
import { normalizePath } from 'vite';
import {
loadMarkdocConfig,
SUPPORTED_MARKDOC_CONFIG_FILES,
type MarkdocConfigResult,
SUPPORTED_MARKDOC_CONFIG_FILES,
} from './load-config.js';
import { setupConfig } from './runtime.js';
import { getContentEntryType } from './content-entry-type.js';
type SetupHookParams = HookParameters<'astro:config:setup'> & {
// `contentEntryType` is not a public API
@ -32,12 +16,6 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
addContentEntryType: (contentEntryType: ContentEntryType) => void;
};
const markdocTokenizer = new Markdoc.Tokenizer({
// Strip <!-- comments --> from rendered output
// Without this, they're rendered as strings!
allowComments: true,
});
export default function markdocIntegration(legacyConfig?: any): AstroIntegration {
if (legacyConfig) {
console.log(
@ -61,173 +39,14 @@ export default function markdocIntegration(legacyConfig?: any): AstroIntegration
if (markdocConfigResult) {
markdocConfigResultId = normalizePath(fileURLToPath(markdocConfigResult.fileUrl));
}
const userMarkdocConfig = markdocConfigResult?.config ?? {};
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
return {
data: parsed.data,
body: parsed.content,
slug: parsed.data.slug,
rawData: parsed.matter,
};
}
addContentEntryType({
extensions: ['.mdoc'],
getEntryInfo,
// Markdoc handles script / style propagation
// for Astro components internally
handlePropagation: false,
async getRenderModule({ contents, fileUrl, viteId }) {
const entry = getEntryInfo({ contents, fileUrl });
const tokens = markdocTokenizer.tokenize(entry.body);
const ast = Markdoc.parse(tokens);
const pluginContext = this;
const markdocConfig = await setupConfig(userMarkdocConfig);
const filePath = fileURLToPath(fileUrl);
const validationErrors = Markdoc.validate(
ast,
/* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
markdocConfig as MarkdocConfig
).filter((e) => {
return (
// Ignore `variable-undefined` errors.
// Variables can be configured at runtime,
// so we cannot validate them at build time.
e.error.id !== 'variable-undefined' &&
(e.error.level === 'error' || e.error.level === 'critical')
);
});
if (validationErrors.length) {
// Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
throw new MarkdocError({
message: [
`**${String(rootRelativePath)}** contains invalid content:`,
...validationErrors.map((e) => `- ${e.error.message}`),
].join('\n'),
location: {
// Error overlay does not support multi-line or ranges.
// Just point to the first line.
line: frontmatterBlockOffset + validationErrors[0].lines[0],
file: viteId,
},
});
}
if (astroConfig.experimental.assets) {
await emitOptimizedImages(ast.children, {
astroConfig,
pluginContext,
filePath,
});
}
const res = `import {
createComponent,
renderComponent,
} from 'astro/runtime/server/index.js';
import { Renderer } from '@astrojs/markdoc/components';
import { collectHeadings, setupConfig, setupConfigSync, Markdoc } from '@astrojs/markdoc/runtime';
${
markdocConfigResult
? `import _userConfig from ${JSON.stringify(
markdocConfigResultId
)};\nconst userConfig = _userConfig ?? {};`
: 'const userConfig = {};'
}${
astroConfig.experimental.assets
? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';\nuserConfig.nodes = { ...experimentalAssetsConfig.nodes, ...userConfig.nodes };`
: ''
}
const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
)};
export function getHeadings() {
${
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
''
}
const headingConfig = userConfig.nodes?.heading;
const config = setupConfigSync(headingConfig ? { nodes: { heading: headingConfig } } : {});
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast, config);
return collectHeadings(Array.isArray(content) ? content : content.children);
}
export const Content = createComponent({
async factory(result, props) {
const config = await setupConfig({
...userConfig,
variables: { ...userConfig.variables, ...props },
});
return renderComponent(
result,
Renderer.name,
Renderer,
{ stringifiedAst, config },
{}
);
},
propagation: 'self',
});`;
return { code: res };
},
contentModuleTypes: await fs.promises.readFile(
new URL('../template/content-module-types.d.ts', import.meta.url),
'utf-8'
),
});
let rollupOptions: rollup.RollupOptions = {};
if (markdocConfigResult) {
rollupOptions = {
output: {
// Split Astro components from your `markdoc.config`
// to only inject component styles and scripts at runtime.
manualChunks(id, { getModuleInfo }) {
if (
markdocConfigResult &&
hasContentFlag(id, PROPAGATED_ASSET_FLAG) &&
getModuleInfo(id)?.importers?.includes(markdocConfigResultId)
) {
return createNameHash(id, [id]);
}
},
},
};
}
addContentEntryType(await getContentEntryType({ markdocConfigResult, astroConfig }));
updateConfig({
vite: {
ssr: {
external: ['@astrojs/markdoc/prism', '@astrojs/markdoc/shiki'],
},
build: {
rollupOptions,
},
plugins: [
{
name: '@astrojs/markdoc:astro-propagated-assets',
enforce: 'pre',
// Astro component styles and scripts should only be injected
// When a given Markdoc file actually uses that component.
// Add the `astroPropagatedAssets` flag to inject only when rendered.
resolveId(this: rollup.TransformPluginContext, id: string, importer: string) {
if (importer === markdocConfigResultId && id.endsWith('.astro')) {
return this.resolve(id + '?astroPropagatedAssets', importer, {
skipSelf: true,
});
}
},
},
],
},
});
},
@ -241,65 +60,3 @@ export const Content = createComponent({
},
};
}
/**
* Emits optimized images, and appends the generated `src` to each AST node
* via the `__optimizedSrc` attribute.
*/
async function emitOptimizedImages(
nodeChildren: Node[],
ctx: {
pluginContext: rollup.PluginContext;
filePath: string;
astroConfig: AstroConfig;
}
) {
for (const node of nodeChildren) {
if (
node.type === 'image' &&
typeof node.attributes.src === 'string' &&
shouldOptimizeImage(node.attributes.src)
) {
// Attempt to resolve source with Vite.
// This handles relative paths and configured aliases
const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
const src = await emitESMImage(
resolved.id,
ctx.pluginContext.meta.watchMode,
ctx.pluginContext.emitFile,
{ config: ctx.astroConfig }
);
node.attributes.__optimizedSrc = src;
} else {
throw new MarkdocError({
message: `Could not resolve image ${JSON.stringify(
node.attributes.src
)} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
});
}
}
await emitOptimizedImages(node.children, ctx);
}
}
function shouldOptimizeImage(src: string) {
// Optimize anything that is NOT external or an absolute path to `public/`
return !isValidUrl(src) && !src.startsWith('/');
}
/**
* Create build hash for manual Rollup chunks.
* @see 'packages/astro/src/core/build/plugins/plugin-css.ts'
*/
function createNameHash(baseId: string, hashIds: string[]): string {
const baseName = baseId ? path.parse(baseId).name : 'index';
const hash = crypto.createHash('sha256');
for (const id of hashIds) {
hash.update(id, 'utf-8');
}
const h = hash.digest('hex').slice(0, 8);
const proposedName = baseName + '.' + h;
return proposedName;
}

View file

@ -3,6 +3,7 @@ import { build as esbuild } from 'esbuild';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { AstroMarkdocConfig } from './config.js';
import { MarkdocError } from './utils.js';
export const SUPPORTED_MARKDOC_CONFIG_FILES = [
'markdoc.config.js',
@ -42,9 +43,8 @@ export async function loadMarkdocConfig(
}
/**
* Forked from Vite's `bundleConfigFile` function
* with added handling for `.astro` imports,
* and removed unused Deno patches.
* Bundle config file to support `.ts` files.
* Simplified fork from Vite's `bundleConfigFile` function:
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961
*/
async function bundleConfigFile({
@ -54,6 +54,8 @@ async function bundleConfigFile({
markdocConfigUrl: URL;
astroConfig: Pick<AstroConfig, 'root'>;
}): Promise<{ code: string; dependencies: string[] }> {
let markdocError: MarkdocError | undefined;
const result = await esbuild({
absWorkingDir: fileURLToPath(astroConfig.root),
entryPoints: [fileURLToPath(markdocConfigUrl)],
@ -71,8 +73,14 @@ async function bundleConfigFile({
name: 'stub-astro-imports',
setup(build) {
build.onResolve({ filter: /.*\.astro$/ }, () => {
// Avoid throwing within esbuild.
// This swallows the `hint` and blows up the stacktrace.
markdocError = new MarkdocError({
message: '`.astro` files are no longer supported in the Markdoc config.',
hint: 'Use the `component()` utility to specify a component path instead.',
});
return {
// Stub with an unused default export
// Stub with an unused default export.
path: 'data:text/javascript,export default true',
external: true,
};
@ -81,6 +89,7 @@ async function bundleConfigFile({
},
],
});
if (markdocError) throw markdocError;
const { text } = result.outputFiles[0];
return {
code: text,

View file

@ -1,19 +1,25 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
import Markdoc, { type RenderableTreeNode } from '@markdoc/markdoc';
import type { AstroInstance } from 'astro';
import {
createComponent,
renderComponent,
// @ts-expect-error Cannot find module 'astro/runtime/server/index.js' or its corresponding type declarations.
} from 'astro/runtime/server/index.js';
import Markdoc, {
type ConfigType,
type Node,
type NodeType,
type RenderableTreeNode,
} from '@markdoc/markdoc';
import type { AstroMarkdocConfig } from './config.js';
import { setupHeadingConfig } from './heading-ids.js';
/** Used to call `Markdoc.transform()` and `Markdoc.Ast` in runtime modules */
export { default as Markdoc } from '@markdoc/markdoc';
/**
* Merge user config with default config and set up context (ex. heading ID slugger)
* Called on each file's individual transform.
* TODO: virtual module to merge configs per-build instead of per-file?
*/
export async function setupConfig(
userConfig: AstroMarkdocConfig
): Promise<Omit<AstroMarkdocConfig, 'extends'>> {
export async function setupConfig(userConfig: AstroMarkdocConfig = {}): Promise<MergedConfig> {
let defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
if (userConfig.extends) {
@ -30,16 +36,19 @@ export async function setupConfig(
}
/** Used for synchronous `getHeadings()` function */
export function setupConfigSync(
userConfig: AstroMarkdocConfig
): Omit<AstroMarkdocConfig, 'extends'> {
export function setupConfigSync(userConfig: AstroMarkdocConfig = {}): MergedConfig {
const defaultConfig: AstroMarkdocConfig = setupHeadingConfig();
return mergeConfig(defaultConfig, userConfig);
}
type MergedConfig = Required<Omit<AstroMarkdocConfig, 'extends'>>;
/** Merge function from `@markdoc/markdoc` internals */
function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig): AstroMarkdocConfig {
export function mergeConfig(
configA: AstroMarkdocConfig,
configB: AstroMarkdocConfig
): MergedConfig {
return {
...configA,
...configB,
@ -63,9 +72,33 @@ function mergeConfig(configA: AstroMarkdocConfig, configB: AstroMarkdocConfig):
...configA.variables,
...configB.variables,
},
partials: {
...configA.partials,
...configB.partials,
},
validation: {
...configA.validation,
...configB.validation,
},
};
}
export function resolveComponentImports(
markdocConfig: Required<Pick<AstroMarkdocConfig, 'tags' | 'nodes'>>,
tagComponentMap: Record<string, AstroInstance['default']>,
nodeComponentMap: Record<NodeType, AstroInstance['default']>
) {
for (const [tag, render] of Object.entries(tagComponentMap)) {
const config = markdocConfig.tags[tag];
if (config) config.render = render;
}
for (const [node, render] of Object.entries(nodeComponentMap)) {
const config = markdocConfig.nodes[node as NodeType];
if (config) config.render = render;
}
return markdocConfig;
}
/**
* Get text content as a string from a Markdoc transform AST
*/
@ -87,8 +120,10 @@ const headingLevels = [1, 2, 3, 4, 5, 6] as const;
* Collect headings from Markdoc transform AST
* for `headings` result on `render()` return value
*/
export function collectHeadings(children: RenderableTreeNode[]): MarkdownHeading[] {
let collectedHeadings: MarkdownHeading[] = [];
export function collectHeadings(
children: RenderableTreeNode[],
collectedHeadings: MarkdownHeading[]
) {
for (const node of children) {
if (typeof node !== 'object' || !Markdoc.Tag.isTag(node)) continue;
@ -110,7 +145,42 @@ export function collectHeadings(children: RenderableTreeNode[]): MarkdownHeading
});
}
}
collectedHeadings.concat(collectHeadings(node.children));
collectHeadings(node.children, collectedHeadings);
}
return collectedHeadings;
}
export function createGetHeadings(stringifiedAst: string, userConfig: AstroMarkdocConfig) {
return function getHeadings() {
/* Yes, we are transforming twice (once from `getHeadings()` and again from <Content /> in case of variables).
TODO: propose new `render()` API to allow Markdoc variable passing to `render()` itself,
instead of the Content component. Would remove double-transform and unlock variable resolution in heading slugs. */
const config = setupConfigSync(userConfig);
const ast = Markdoc.Ast.fromJSON(stringifiedAst);
const content = Markdoc.transform(ast as Node, config as ConfigType);
let collectedHeadings: MarkdownHeading[] = [];
collectHeadings(Array.isArray(content) ? content : [content], collectedHeadings);
return collectedHeadings;
};
}
export function createContentComponent(
Renderer: AstroInstance['default'],
stringifiedAst: string,
userConfig: AstroMarkdocConfig,
tagComponentMap: Record<string, AstroInstance['default']>,
nodeComponentMap: Record<NodeType, AstroInstance['default']>
) {
return createComponent({
async factory(result: any, props: Record<string, any>) {
const withVariables = mergeConfig(userConfig, { variables: props });
const config = resolveComponentImports(
await setupConfig(withVariables),
tagComponentMap,
nodeComponentMap
);
return renderComponent(result, Renderer.name, Renderer, { stringifiedAst, config }, {});
},
propagation: 'self',
});
}

View file

@ -1,28 +1,4 @@
import matter from 'gray-matter';
import type { ErrorPayload as ViteErrorPayload } from 'vite';
/**
* Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts'
*/
export function parseFrontmatter(fileContents: string, filePath: string) {
try {
// `matter` is empty string on cache results
// clear cache to prevent this
(matter as any).clearCache();
return matter(fileContents);
} catch (e: any) {
if (e.name === 'YAMLException') {
const err: Error & ViteErrorPayload['err'] = e;
err.id = filePath;
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
err.message = e.reason;
throw err;
} else {
throw e;
}
}
}
import type { ComponentConfig } from './config.js';
/**
* Matches AstroError object with types like error codes stubbed out
@ -97,3 +73,10 @@ export function hasContentFlag(viteId: string, flag: string): boolean {
const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
return flags.has(flag);
}
/** Identifier for components imports passed as `tags` or `nodes` configuration. */
export const componentConfigSymbol = Symbol.for('@astrojs/markdoc/component-config');
export function isComponentConfig(value: unknown): value is ComponentConfig {
return typeof value === 'object' && value !== null && componentConfigSymbol in value;
}

View file

@ -1,11 +1,10 @@
import { defineMarkdocConfig, nodes } from '@astrojs/markdoc/config';
import Heading from './src/components/Heading.astro';
import { defineMarkdocConfig, component, nodes } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
nodes: {
heading: {
...nodes.heading,
render: Heading,
render: component('./src/components/Heading.astro'),
}
}
});

View file

@ -1,18 +1,16 @@
import Aside from './src/components/Aside.astro';
import LogHello from './src/components/LogHello.astro';
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
tags: {
aside: {
render: Aside,
render: component('./src/components/Aside.astro'),
attributes: {
type: { type: String },
title: { type: String },
}
},
logHello: {
render: LogHello,
render: component('./src/components/LogHello.astro'),
}
},
})

View file

@ -1,11 +1,9 @@
import Code from './src/components/Code.astro';
import CustomMarquee from './src/components/CustomMarquee.astro';
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
nodes: {
fence: {
render: Code,
render: component('./src/components/Code.astro'),
attributes: {
language: { type: String },
content: { type: String },
@ -14,7 +12,7 @@ export default defineMarkdocConfig({
},
tags: {
mq: {
render: CustomMarquee,
render: component('./src/components/CustomMarquee.astro'),
attributes: {
direction: {
type: String,

View file

@ -3971,6 +3971,9 @@ importers:
packages/integrations/markdoc:
dependencies:
'@astrojs/internal-helpers':
specifier: ^0.1.0
version: link:../../internal-helpers
'@astrojs/prism':
specifier: ^2.1.2
version: link:../../astro-prism