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:
parent
8821f05048
commit
fb7af55114
13 changed files with 452 additions and 309 deletions
27
.changeset/sour-starfishes-behave.md
Normal file
27
.changeset/sour-starfishes-behave.md
Normal 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'),
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
|
@ -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 },
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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('/');
|
||||
}
|
||||
|
|
278
packages/integrations/markdoc/src/content-entry-type.ts
Normal file
278
packages/integrations/markdoc/src/content-entry-type.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue