[Markdoc] New config format with runtime variable support! (#6653)

* deps: esbuild

* feat: support direct component imports for render!

* deps: add devalue back

* refactor: remove unused components prop

* refactor: load experimental assets config separately

* fix: upate Content type def to support props

* refactor: replace astro stub with inline data

* feat: pass through viteId to getRenderMod

* fix: add back $entry var with defaults convention

* chore: remove unneeded validateRenderProps

* chore: remove uneeded validateComponents

* fix: remove userMarkdocConfig prop

* chore: add helpful error for legacy config

* deps: kleur

* fix: add back `isCapitalized`

* fix: log instead of throw to avoid scary stacktrace

* chore: delete more old logic (nice)

* chore: delete MORE unused utils

* chore: comment on separate assets config

* chore: remove console.log

* chore: general code cleanup

* test: new render config

* docs: new README

* fix: add expect-error on astro:assets

* feat: add defineMarkdocConfig helper

* docs: update example README

* test: add runtime variable

* chore: lint

* chore: changeset

* chore: add component import deletion

* docs: add notes on Vite fork

* fix: astro check

* chore: add `.mts` to markdoc config formats
This commit is contained in:
Ben Holmes 2023-03-27 18:04:37 -04:00 committed by GitHub
parent c13d428a78
commit 7c439868a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 734 additions and 633 deletions

View file

@ -0,0 +1,42 @@
---
'@astrojs/markdoc': minor
'astro': patch
---
Simplify Markdoc configuration with a new `markdoc.config.mjs` file. This lets you import Astro components directly to render as Markdoc tags and nodes, without the need for the previous `components` property. This new configuration also unlocks passing variables to your Markdoc from the `Content` component ([see the new docs](https://docs.astro.build/en/guides/integrations-guide/markdoc/#pass-markdoc-variables)).
## Migration
Move any existing Markdoc config from your `astro.config` to a new `markdoc.config.mjs` file at the root of your project. This should be applied as a default export, with the optional `defineMarkdocConfig()` helper for autocomplete in your editor.
This example configures an `aside` Markdoc tag. Note that components should be imported and applied to the `render` attribute _directly,_ instead of passing the name as a string:
```js
// markdoc.config.mjs
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import Aside from './src/components/Aside.astro';
export default defineMarkdocConfig({
tags: {
aside: {
render: Aside,
}
}
});
```
You should also remove the `components` prop from your `Content` components. Since components are imported into your config directly, this is no longer needed.
```diff
---
- import Aside from '../components/Aside.astro';
import { getEntryBySlug } from 'astro:content';
const entry = await getEntryBySlug('docs', 'why-markdoc');
const { Content } = await entry.render();
---
<Content
- components={{ Aside }}
/>
```

View file

@ -23,23 +23,20 @@ Inside of your Astro project, you'll see the following folders and files:
└── docs/ └── docs/
│ └── intro.mdoc │ └── intro.mdoc
| └── config.ts | └── config.ts
│ └── components/ │ └── components/Aside.astro
| ├── Aside.astro │ └── layouts/Layout.astro
│ └── DocsContent.astro │ └── pages/index.astro
│ └── layouts/
│ └── Layout.astro
│ └── pages/
│ └── index.astro
| └── env.d.ts | └── env.d.ts
├── astro.config.mjs ├── astro.config.mjs
├── markdoc.config.mjs
├── README.md ├── README.md
├── package.json ├── package.json
└── tsconfig.json └── tsconfig.json
``` ```
Markdoc (`.mdoc`) files can be used in content collections to author your Markdown content alongside Astro and server-rendered UI framework components (React, Vue, Svelte, and more). See `src/content/docs/` for an example file. Markdoc (`.mdoc`) files can be used in content collections. See `src/content/docs/` for an example file.
You can also apply Astro components and server-rendered UI components (React, Vue, Svelte, etc) to your Markdoc files. See `src/content/DocsContent.astro` for an example. You can also render Astro components from your Markdoc files using [tags](https://markdoc.dev/docs/tags). See the `markdoc.config.mjs` file for an example configuration.
## 🧞 Commands ## 🧞 Commands

View file

@ -3,17 +3,5 @@ import markdoc from '@astrojs/markdoc';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [markdoc()],
markdoc({
tags: {
aside: {
render: 'Aside',
attributes: {
type: { type: String },
title: { type: String },
},
},
},
}),
],
}); });

View file

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

View file

@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/markdoc": "^0.0.5", "@astrojs/markdoc": "^0.0.5",
"astro": "^2.1.7" "astro": "^2.1.7",
"kleur": "^4.1.5"
} }
} }

View file

@ -1,32 +0,0 @@
---
import Aside from './Aside.astro';
import type { CollectionEntry } from 'astro:content';
type Props = {
entry: CollectionEntry<'docs'>;
};
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<Content
components={{
// Pass a mapping from the component name
// To an Astro or UI component import
// See your `astro.config.mjs` for
// for the Markdoc tag mapping
Aside,
}}
/>
<style is:global>
table {
margin-block: 2rem;
margin-inline: auto;
}
table td {
padding-block: 0.3rem;
padding-inline: 0.5rem;
}
</style>

View file

@ -1,18 +1,25 @@
--- ---
import { getEntryBySlug } from 'astro:content'; import { getEntryBySlug } from 'astro:content';
import DocsContent from '../components/DocsContent.astro';
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
const intro = await getEntryBySlug('docs', 'intro'); const intro = await getEntryBySlug('docs', 'intro');
const { Content } = await intro.render();
--- ---
<Layout title={intro.data.title}> <Layout title={intro.data.title}>
<main> <main>
<h1>{intro.data.title}</h1> <h1>{intro.data.title}</h1>
<!-- `DocsContent` is a thin wrapper around --> <Content variables={{ revealSecret: true }} />
<!-- the `Content` component provided by Content Collections, -->
<!-- with added configuration for components. -->
<!-- This allows you to share global components wherever you render your Markdoc. -->
<DocsContent entry={intro} />
</main> </main>
</Layout> </Layout>
<style is:global>
table {
margin-block: 2rem;
margin-inline: auto;
}
table td {
padding-block: 0.3rem;
padding-inline: 0.5rem;
}
</style>

View file

@ -1056,6 +1056,7 @@ export interface ContentEntryType {
getRenderModule?( getRenderModule?(
this: rollup.PluginContext, this: rollup.PluginContext,
params: { params: {
viteId: string;
entry: ContentEntryModule; entry: ContentEntryModule;
} }
): rollup.LoadResult | Promise<rollup.LoadResult>; ): rollup.LoadResult | Promise<rollup.LoadResult>;

View file

@ -139,7 +139,7 @@ export const _internal = {
}); });
} }
return contentRenderer.bind(this)({ entry }); return contentRenderer.bind(this)({ entry, viteId });
}, },
}); });
} }

View file

@ -99,53 +99,46 @@ const { Content } = await entry.render();
### Using components ### Using components
You can add Astro and UI framework components (React, Vue, Svelte, etc.) to your Markdoc using both [Markdoc tags][markdoc-tags] and HTML element [nodes][markdoc-nodes]. You can add Astro components to your Markdoc using both [Markdoc tags][markdoc-tags] and HTML element [nodes][markdoc-nodes].
#### Render Markdoc tags as Astro components #### Render Markdoc tags as Astro components
You may configure [Markdoc tags][markdoc-tags] that map to components. You can configure a new tag from your `astro.config` using the `tags` attribute. You may configure [Markdoc tags][markdoc-tags] that map to components. You can configure a new tag by creating a `markdoc.config.mjs|ts` file at the root of your project and configuring the `tag` attribute.
This example renders an `Aside` component, and allows a `type` prop to be passed as a string:
```js ```js
// astro.config.mjs // markdoc.config.mjs
import { defineConfig } from 'astro/config'; import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import markdoc from '@astrojs/markdoc'; import Aside from './src/components/Aside.astro';
// https://astro.build/config export default defineMarkdocConfig({
export default defineConfig({ tags: {
integrations: [ aside: {
markdoc({ render: Aside,
tags: { attributes: {
aside: { // Markdoc requires type defs for each attribute.
render: 'Aside', // These should mirror the `Props` type of the component
attributes: { // you are rendering.
// Component props as attribute definitions // See Markdoc's documentation on defining attributes
// See Markdoc's documentation on defining attributes // https://markdoc.dev/docs/attributes#defining-attributes
// https://markdoc.dev/docs/attributes#defining-attributes type: { type: String },
type: { type: String }, }
} },
}, },
}, })
}),
],
});
``` ```
Then, you can wire this render name (`'Aside'`) to a component from the `components` prop via the `<Content />` component. Note the object key name (`Aside` in this case) should match the render name: This component can now be used in your Markdoc files with the `{% aside %}` tag. Children will be passed to your component's default slot:
```md
# Welcome to Markdoc 👋
```astro {% aside type="tip" %}
---
import { getEntryBySlug } from 'astro:content';
import Aside from '../components/Aside.astro';
const entry = await getEntryBySlug('docs', 'why-markdoc'); Use tags like this fancy "aside" to add some *flair* to your docs.
const { Content } = await entry.render();
---
<Content {% /aside %}
components={{ Aside }}
/>
``` ```
#### Render Markdoc nodes / HTML elements as Astro components #### Render Markdoc nodes / HTML elements as Astro components
@ -153,46 +146,22 @@ const { Content } = await entry.render();
You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, passing the built-in `level` attribute as a prop: You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, passing the built-in `level` attribute as a prop:
```js ```js
// astro.config.mjs // markdoc.config.mjs
import { defineConfig } from 'astro/config'; import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import markdoc from '@astrojs/markdoc'; import Heading from './src/components/Heading.astro';
// https://astro.build/config export default defineMarkdocConfig({
export default defineConfig({ nodes: {
integrations: [ heading: {
markdoc({ render: Heading,
nodes: { attributes: {
heading: { // Pass the attributes from Markdoc's default heading node
render: 'Heading', // as component props.
// Markdoc requires type defs for each attribute. level: { type: String },
// These should mirror the `Props` type of the component }
// you are rendering. },
// See Markdoc's documentation on defining attributes },
// https://markdoc.dev/docs/attributes#defining-attributes })
attributes: {
level: { type: String },
}
},
},
}),
],
});
```
Now, you can map the string passed to render (`'Heading'` in this example) to a component import. This is configured from the `<Content />` component used to render your Markdoc using the `components` prop:
```astro
---
import { getEntryBySlug } from 'astro:content';
import Heading from '../components/Heading.astro';
const entry = await getEntryBySlug('docs', 'why-markdoc');
const { Content } = await entry.render();
---
<Content
components={{ Heading }}
/>
``` ```
Now, all Markdown headings will render with the `Heading.astro` component. This example uses a level 3 heading, automatically passing `level: 3` as the component prop: Now, all Markdown headings will render with the `Heading.astro` component. This example uses a level 3 heading, automatically passing `level: 3` as the component prop:
@ -215,26 +184,26 @@ This example wraps a `Aside.tsx` component with a `ClientAside.astro` wrapper:
import Aside from './Aside'; import Aside from './Aside';
--- ---
<Aside client:load /> <Aside {...Astro.props} client:load />
``` ```
This component [can be applied via the `components` prop](#render-markdoc-nodes--html-elements-as-astro-components): This component can be passed to the `render` prop for any [tag][markdoc-tags] or [node][markdoc-nodes] in your config:
```astro ```js
--- // markdoc.config.mjs
// src/pages/why-markdoc.astro import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import { getEntryBySlug } from 'astro:content'; import Aside from './src/components/Aside.astro';
import ClientAside from '../components/ClientAside.astro';
const entry = await getEntryBySlug('docs', 'why-markdoc'); export default defineMarkdocConfig({
const { Content } = await entry.render(); tags: {
--- aside: {
render: Aside,
<Content attributes: {
components={{ type: { type: String },
Aside: ClientAside, }
}} },
/> },
})
``` ```
### Access frontmatter and content collection information from your templates ### Access frontmatter and content collection information from your templates
@ -253,35 +222,29 @@ The `$entry` object matches [the `CollectionEntry` type](https://docs.astro.buil
### Markdoc config ### Markdoc config
The Markdoc integration accepts [all Markdoc configuration options](https://markdoc.dev/docs/config), including [tags](https://markdoc.dev/docs/tags) and [functions](https://markdoc.dev/docs/functions). The `markdoc.config.mjs|ts` file accepts [all Markdoc configuration options](https://markdoc.dev/docs/config), including [tags](https://markdoc.dev/docs/tags) and [functions](https://markdoc.dev/docs/functions).
You can pass these options from the `markdoc()` integration in your `astro.config`. This example adds a global `getCountryEmoji` function: You can pass these options from the default export in your `markdoc.config.mjs|ts` file:
```js ```js
// astro.config.mjs // markdoc.config.mjs
import { defineConfig } from 'astro/config'; import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config export default defineMarkdocConfig({
export default defineConfig({ functions: {
integrations: [ getCountryEmoji: {
markdoc({ transform(parameters) {
functions: { const [country] = Object.values(parameters);
getCountryEmoji: { const countryToEmojiMap = {
transform(parameters) { japan: '🇯🇵',
const [country] = Object.values(parameters); spain: '🇪🇸',
const countryToEmojiMap = { france: '🇫🇷',
japan: '🇯🇵', }
spain: '🇪🇸', return countryToEmojiMap[country] ?? '🏳'
france: '🇫🇷',
}
return countryToEmojiMap[country] ?? '🏳'
},
},
}, },
}), },
], },
}); })
``` ```
Now, you can call this function from any Markdoc content entry: Now, you can call this function from any Markdoc content entry:
@ -290,47 +253,46 @@ Now, you can call this function from any Markdoc content entry:
¡Hola {% getCountryEmoji("spain") %}! ¡Hola {% getCountryEmoji("spain") %}!
``` ```
:::note
These options will be applied during [the Markdoc "transform" phase](https://markdoc.dev/docs/render#transform). This is run **at build time** (rather than server request time) both for static and SSR Astro projects. If you need to define configuration at runtime (ex. SSR variables), [see the next section](#define-markdoc-configuration-at-runtime).
:::
📚 [See the Markdoc documentation](https://markdoc.dev/docs/functions#creating-a-custom-function) for more on using variables or functions in your content. 📚 [See the Markdoc documentation](https://markdoc.dev/docs/functions#creating-a-custom-function) for more on using variables or functions in your content.
### Define Markdoc configuration at runtime ### Pass Markdoc variables
You may need to define Markdoc configuration at the component level, rather than the `astro.config.mjs` level. This is useful when mapping props and SSR parameters to [Markdoc variables](https://markdoc.dev/docs/variables). You may need to pass [variables][markdoc-variables] to your content. This is useful when passing SSR parameters like A/B tests.
Astro recommends running the Markdoc transform step manually. This allows you to define your configuration and call Markdoc's rendering functions in a `.astro` file directly, ignoring any Markdoc config in your `astro.config.mjs`. Variables can be passed as props via the `Content` component:
You will need to install the `@markdoc/markdoc` package into your project first:
```sh
# Using NPM
npm install @markdoc/markdoc
# Using Yarn
yarn add @markdoc/markdoc
# Using PNPM
pnpm add @markdoc/markdoc
```
Now, you can define Markdoc configuration options using `Markdock.transform()`.
This example defines an `abTestGroup` Markdoc variable based on an SSR param, transforming the raw entry `body`. The result is rendered using the `Renderer` component provided by `@astrojs/markdoc`:
```astro ```astro
--- ---
import Markdoc from '@markdoc/markdoc';
import { Renderer } from '@astrojs/markdoc/components';
import { getEntryBySlug } from 'astro:content'; import { getEntryBySlug } from 'astro:content';
const { body } = await getEntryBySlug('docs', 'with-ab-test'); const entry = await getEntryBySlug('docs', 'why-markdoc');
const ast = Markdoc.parse(body); const { Content } = await entry.render();
const content = Markdoc.transform({
variables: { abTestGroup: Astro.params.abTestGroup },
}, ast);
--- ---
<Renderer {content} components={{ /* same `components` prop used by the `Content` component */ }} /> <!--Pass the `abTest` param as a variable-->
<Content abTestGroup={Astro.params.abTestGroup} />
```
Now, `abTestGroup` is available as a variable in `docs/why-markdoc.mdoc`:
```md
{% if $abTestGroup === 'image-optimization-lover' %}
Let me tell you about image optimization...
{% /if %}
```
To make a variable global to all Markdoc files, you can use the `variables` attribute from your `markdoc.config.mjs|ts`:
```js
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
variables: {
environment: process.env.IS_PROD ? 'prod' : 'dev',
}
})
``` ```
## Examples ## Examples
@ -360,3 +322,5 @@ See [CHANGELOG.md](https://github.com/withastro/astro/tree/main/packages/integra
[markdoc-tags]: https://markdoc.dev/docs/tags [markdoc-tags]: https://markdoc.dev/docs/tags
[markdoc-nodes]: https://markdoc.dev/docs/nodes [markdoc-nodes]: https://markdoc.dev/docs/nodes
[markdoc-variables]: https://markdoc.dev/docs/variables

View file

@ -1,20 +1,17 @@
--- ---
import type { RenderableTreeNode } from '@markdoc/markdoc'; import type { Config } from '@markdoc/markdoc';
import type { AstroInstance } from 'astro'; import Markdoc from '@markdoc/markdoc';
import { validateComponentsProp } from '../dist/utils.js';
import { ComponentNode, createTreeNode } from './TreeNode.js'; import { ComponentNode, createTreeNode } from './TreeNode.js';
type Props = { type Props = {
content: RenderableTreeNode; config: Config;
components?: Record<string, AstroInstance['default']>; stringifiedAst: string;
}; };
const { content, components } = Astro.props as Props; const { stringifiedAst, config } = Astro.props as Props;
// Will throw if components is invalid const ast = Markdoc.Ast.fromJSON(stringifiedAst);
if (components) { const content = Markdoc.transform(ast, config);
validateComponentsProp(components);
}
--- ---
<ComponentNode treeNode={createTreeNode(content, components)} /> <ComponentNode treeNode={createTreeNode(content)} />

View file

@ -1,10 +1,7 @@
import type { AstroInstance } from 'astro'; import type { AstroInstance } from 'astro';
import type { RenderableTreeNode } from '@markdoc/markdoc'; import type { RenderableTreeNode } from '@markdoc/markdoc';
import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js';
// @ts-expect-error Cannot find module 'astro:markdoc-assets' or its corresponding type declarations
import { Image } from 'astro:markdoc-assets';
import Markdoc from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc';
import { MarkdocError, isCapitalized } from '../dist/utils.js'; import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js';
export type TreeNode = export type TreeNode =
| { | {
@ -47,26 +44,17 @@ export const ComponentNode = createComponent({
propagation: 'none', propagation: 'none',
}); });
const builtInComponents: Record<string, AstroInstance['default']> = { export function createTreeNode(node: RenderableTreeNode): TreeNode {
Image,
};
export function createTreeNode(
node: RenderableTreeNode,
userComponents: Record<string, AstroInstance['default']> = {}
): TreeNode {
const components = { ...userComponents, ...builtInComponents };
if (typeof node === 'string' || typeof node === 'number') { if (typeof node === 'string' || typeof node === 'number') {
return { type: 'text', content: String(node) }; return { type: 'text', content: String(node) };
} else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) { } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) {
return { type: 'text', content: '' }; return { type: 'text', content: '' };
} }
if (node.name in components) { if (typeof node.name === 'function') {
const component = components[node.name]; const component = node.name;
const props = node.attributes; const props = node.attributes;
const children = node.children.map((child) => createTreeNode(child, components)); const children = node.children.map((child) => createTreeNode(child));
return { return {
type: 'component', type: 'component',
@ -74,17 +62,12 @@ export function createTreeNode(
props, props,
children, children,
}; };
} else if (isCapitalized(node.name)) {
throw new MarkdocError({
message: `Unable to render ${JSON.stringify(node.name)}.`,
hint: 'Did you add this to the "components" prop on your <Content /> component?',
});
} else { } else {
return { return {
type: 'element', type: 'element',
tag: node.name, tag: node.name,
attributes: node.attributes, attributes: node.attributes,
children: node.children.map((child) => createTreeNode(child, components)), children: node.children.map((child) => createTreeNode(child)),
}; };
} }
} }

View file

@ -21,6 +21,9 @@
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./components": "./components/index.ts", "./components": "./components/index.ts",
"./default-config": "./dist/default-config.js",
"./config": "./dist/config.js",
"./experimental-assets-config": "./dist/experimental-assets-config.js",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },
"scripts": { "scripts": {
@ -32,17 +35,19 @@
}, },
"dependencies": { "dependencies": {
"@markdoc/markdoc": "^0.2.2", "@markdoc/markdoc": "^0.2.2",
"esbuild": "^0.17.12",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"kleur": "^4.1.5",
"zod": "^3.17.3" "zod": "^3.17.3"
}, },
"peerDependencies": { "peerDependencies": {
"astro": "workspace:*" "astro": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"astro": "workspace:*",
"@types/chai": "^4.3.1", "@types/chai": "^4.3.1",
"@types/html-escaper": "^3.0.0", "@types/html-escaper": "^3.0.0",
"@types/mocha": "^9.1.1", "@types/mocha": "^9.1.1",
"astro": "workspace:*",
"astro-scripts": "workspace:*", "astro-scripts": "workspace:*",
"chai": "^4.3.6", "chai": "^4.3.6",
"devalue": "^4.2.0", "devalue": "^4.2.0",

View file

@ -0,0 +1,5 @@
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
return config;
}

View file

@ -0,0 +1,18 @@
import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
import type { ContentEntryModule } from 'astro';
export function applyDefaultConfig(
config: MarkdocConfig,
ctx: {
entry: ContentEntryModule;
}
): MarkdocConfig {
return {
...config,
variables: {
entry: ctx.entry,
...config.variables,
},
// TODO: heading ID calculation, Shiki syntax highlighting
};
}

View file

@ -0,0 +1,29 @@
import type { Config as MarkdocConfig } from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
//@ts-expect-error Cannot find module 'astro:assets' or its corresponding type declarations.
import { Image } from 'astro:assets';
// Separate module to only import `astro:assets` when
// `experimental.assets` flag is set in a project.
// TODO: merge with `./default-config.ts` when `experimental.assets` is baselined.
export const experimentalAssetsConfig: MarkdocConfig = {
nodes: {
image: {
attributes: {
...Markdoc.nodes.image.attributes,
__optimizedSrc: { type: 'Object' },
},
transform(node, config) {
const attributes = node.transformAttributes(config);
const children = node.transformChildren(config);
if (node.type === 'image' && '__optimizedSrc' in node.attributes) {
const { __optimizedSrc, ...rest } = node.attributes;
return new Markdoc.Tag(Image, { ...rest, src: __optimizedSrc }, children);
} else {
return new Markdoc.Tag('img', attributes, children);
}
},
},
},
};

View file

@ -1,23 +1,15 @@
import type { import type { Node } from '@markdoc/markdoc';
Config as ReadonlyMarkdocConfig,
ConfigType as MarkdocConfig,
Node,
} from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc';
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
import fs from 'node:fs'; import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type * as rollup from 'rollup'; import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from './utils.js';
import {
getAstroConfigPath,
isValidUrl,
MarkdocError,
parseFrontmatter,
prependForwardSlash,
} from './utils.js';
// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations. // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
import { emitESMImage } from 'astro/assets'; import { emitESMImage } from 'astro/assets';
import type { Plugin as VitePlugin } from 'vite'; import { loadMarkdocConfig } from './load-config.js';
import { applyDefaultConfig } from './default-config.js';
import { bold, red } from 'kleur/colors';
import type * as rollup from 'rollup';
type SetupHookParams = HookParameters<'astro:config:setup'> & { type SetupHookParams = HookParameters<'astro:config:setup'> & {
// `contentEntryType` is not a public API // `contentEntryType` is not a public API
@ -25,24 +17,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
addContentEntryType: (contentEntryType: ContentEntryType) => void; addContentEntryType: (contentEntryType: ContentEntryType) => void;
}; };
export default function markdocIntegration( export default function markdocIntegration(legacyConfig: any): AstroIntegration {
userMarkdocConfig: ReadonlyMarkdocConfig = {} if (legacyConfig) {
): AstroIntegration { // eslint-disable-next-line no-console
console.log(
`${red(
bold('[Markdoc]')
)} Passing Markdoc config from your \`astro.config\` is no longer supported. Configuration should be exported from a \`markdoc.config.mjs\` file. See the configuration docs for more: https://docs.astro.build/en/guides/integrations-guide/markdoc/#configuration`
);
process.exit(0);
}
return { return {
name: '@astrojs/markdoc', name: '@astrojs/markdoc',
hooks: { hooks: {
'astro:config:setup': async (params) => { 'astro:config:setup': async (params) => {
const { const { config: astroConfig, addContentEntryType } = params as SetupHookParams;
updateConfig,
config: astroConfig,
addContentEntryType,
} = params as SetupHookParams;
updateConfig({ const configLoadResult = await loadMarkdocConfig(astroConfig);
vite: { const userMarkdocConfig = configLoadResult?.config ?? {};
plugins: [safeAssetsVirtualModulePlugin({ astroConfig })],
},
});
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
@ -56,49 +48,63 @@ export default function markdocIntegration(
addContentEntryType({ addContentEntryType({
extensions: ['.mdoc'], extensions: ['.mdoc'],
getEntryInfo, getEntryInfo,
async getRenderModule({ entry }) { async getRenderModule({ entry, viteId }) {
validateRenderProperties(userMarkdocConfig, astroConfig);
const ast = Markdoc.parse(entry.body); const ast = Markdoc.parse(entry.body);
const pluginContext = this; const pluginContext = this;
const markdocConfig: MarkdocConfig = { const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
...userMarkdocConfig,
variables: {
...userMarkdocConfig.variables,
entry,
},
};
if (astroConfig.experimental?.assets) { const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
// Ignore `variable-undefined` errors.
// Variables can be configured at runtime,
// so we cannot validate them at build time.
return e.error.id !== 'variable-undefined';
});
if (validationErrors.length) {
throw new MarkdocError({
message: [
`**${String(entry.collection)}${String(entry.id)}** failed to validate:`,
...validationErrors.map((e) => e.error.id),
].join('\n'),
});
}
if (astroConfig.experimental.assets) {
await emitOptimizedImages(ast.children, { await emitOptimizedImages(ast.children, {
astroConfig, astroConfig,
pluginContext, pluginContext,
filePath: entry._internal.filePath, filePath: entry._internal.filePath,
}); });
markdocConfig.nodes ??= {};
markdocConfig.nodes.image = {
...Markdoc.nodes.image,
transform(node, config) {
const attributes = node.transformAttributes(config);
const children = node.transformChildren(config);
if (node.type === 'image' && '__optimizedSrc' in node.attributes) {
const { __optimizedSrc, ...rest } = node.attributes;
return new Markdoc.Tag('Image', { ...rest, src: __optimizedSrc }, children);
} else {
return new Markdoc.Tag('img', attributes, children);
}
},
};
} }
const content = Markdoc.transform(ast, markdocConfig); const code = {
code: `import { jsx as h } from 'astro/jsx-runtime';
return { import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify( import { Renderer } from '@astrojs/markdoc/components';
content import * as entry from ${JSON.stringify(viteId + '?astroContent')};${
)};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`, configLoadResult
? `\nimport userConfig from ${JSON.stringify(configLoadResult.fileUrl.pathname)};`
: ''
}${
astroConfig.experimental.assets
? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';`
: ''
}
const stringifiedAst = ${JSON.stringify(
/* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
)};
export async function Content (props) {
const config = applyDefaultConfig(${
configLoadResult
? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }'
: '{ variables: props }'
}, { entry });${
astroConfig.experimental.assets
? `\nconfig.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };`
: ''
}
return h(Renderer, { stringifiedAst, config }); };`,
}; };
return code;
}, },
contentModuleTypes: await fs.promises.readFile( contentModuleTypes: await fs.promises.readFile(
new URL('../template/content-module-types.d.ts', import.meta.url), new URL('../template/content-module-types.d.ts', import.meta.url),
@ -156,87 +162,3 @@ function shouldOptimizeImage(src: string) {
// Optimize anything that is NOT external or an absolute path to `public/` // Optimize anything that is NOT external or an absolute path to `public/`
return !isValidUrl(src) && !src.startsWith('/'); return !isValidUrl(src) && !src.startsWith('/');
} }
function validateRenderProperties(markdocConfig: ReadonlyMarkdocConfig, astroConfig: AstroConfig) {
const tags = markdocConfig.tags ?? {};
const nodes = markdocConfig.nodes ?? {};
for (const [name, config] of Object.entries(tags)) {
validateRenderProperty({ type: 'tag', name, config, astroConfig });
}
for (const [name, config] of Object.entries(nodes)) {
validateRenderProperty({ type: 'node', name, config, astroConfig });
}
}
function validateRenderProperty({
name,
config,
type,
astroConfig,
}: {
name: string;
config: { render?: string };
type: 'node' | 'tag';
astroConfig: Pick<AstroConfig, 'root'>;
}) {
if (typeof config.render === 'string' && config.render.length === 0) {
throw new Error(
`Invalid ${type} configuration: ${JSON.stringify(
name
)}. The "render" property cannot be an empty string.`
);
}
if (typeof config.render === 'string' && !isCapitalized(config.render)) {
const astroConfigPath = getAstroConfigPath(fs, fileURLToPath(astroConfig.root));
throw new MarkdocError({
message: `Invalid ${type} configuration: ${JSON.stringify(
name
)}. The "render" property must reference a capitalized component name.`,
hint: 'If you want to render to an HTML element, see our docs on rendering Markdoc manually: https://docs.astro.build/en/guides/integrations-guide/markdoc/#render-markdoc-nodes--html-elements-as-astro-components',
location: astroConfigPath
? {
file: astroConfigPath,
}
: undefined,
});
}
}
function isCapitalized(str: string) {
return str.length > 0 && str[0] === str[0].toUpperCase();
}
/**
* TODO: remove when `experimental.assets` is baselined.
*
* `astro:assets` will fail to resolve if the `experimental.assets` flag is not enabled.
* This ensures a fallback for the Markdoc renderer to safely import at the top level.
* @see ../components/TreeNode.ts
*/
function safeAssetsVirtualModulePlugin({
astroConfig,
}: {
astroConfig: Pick<AstroConfig, 'experimental'>;
}): VitePlugin {
const virtualModuleId = 'astro:markdoc-assets';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: 'astro:markdoc-safe-assets-virtual-module',
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if (id !== resolvedVirtualModuleId) return;
if (astroConfig.experimental?.assets) {
return `export { Image } from 'astro:assets';`;
} else {
return `export const Image = () => { throw new Error('Cannot use the Image component without the \`experimental.assets\` flag.'); }`;
}
},
};
}

View file

@ -0,0 +1,102 @@
import type { AstroConfig } from 'astro';
import type { Config as MarkdocConfig } from '@markdoc/markdoc';
import { build as esbuild } from 'esbuild';
import { fileURLToPath } from 'node:url';
import * as fs from 'node:fs';
const SUPPORTED_MARKDOC_CONFIG_FILES = [
'markdoc.config.js',
'markdoc.config.mjs',
'markdoc.config.mts',
'markdoc.config.ts',
];
export async function loadMarkdocConfig(astroConfig: Pick<AstroConfig, 'root'>) {
let markdocConfigUrl: URL | undefined;
for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) {
const filePath = new URL(filename, astroConfig.root);
if (!fs.existsSync(filePath)) continue;
markdocConfigUrl = filePath;
break;
}
if (!markdocConfigUrl) return;
const { code, dependencies } = await bundleConfigFile({
markdocConfigUrl,
astroConfig,
});
const config: MarkdocConfig = await loadConfigFromBundledFile(astroConfig.root, code);
return {
config,
fileUrl: markdocConfigUrl,
};
}
/**
* Forked from Vite's `bundleConfigFile` function
* with added handling for `.astro` imports,
* and removed unused Deno patches.
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961
*/
async function bundleConfigFile({
markdocConfigUrl,
astroConfig,
}: {
markdocConfigUrl: URL;
astroConfig: Pick<AstroConfig, 'root'>;
}): Promise<{ code: string; dependencies: string[] }> {
const result = await esbuild({
absWorkingDir: fileURLToPath(astroConfig.root),
entryPoints: [fileURLToPath(markdocConfigUrl)],
outfile: 'out.js',
write: false,
target: ['node16'],
platform: 'node',
packages: 'external',
bundle: true,
format: 'esm',
sourcemap: 'inline',
metafile: true,
plugins: [
{
name: 'stub-astro-imports',
setup(build) {
build.onResolve({ filter: /.*\.astro$/ }, () => {
return {
// Stub with an unused default export
path: 'data:text/javascript,export default true',
external: true,
};
});
},
},
],
});
const { text } = result.outputFiles[0];
return {
code: text,
dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [],
};
}
/**
* Forked from Vite config loader, replacing CJS-based path concat
* with ESM only
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074
*/
async function loadConfigFromBundledFile(root: URL, code: string): Promise<MarkdocConfig> {
// Write it to disk, load it with native Node ESM, then delete the file.
const tmpFileUrl = new URL(`markdoc.config.timestamp-${Date.now()}.mjs`, root);
fs.writeFileSync(tmpFileUrl, code);
try {
return (await import(tmpFileUrl.pathname)).default;
} finally {
try {
fs.unlinkSync(tmpFileUrl);
} catch {
// already removed if this function is called twice simultaneously
}
}
}

View file

@ -1,5 +1,3 @@
import type { AstroInstance } from 'astro';
import z from 'astro/zod';
import matter from 'gray-matter'; import matter from 'gray-matter';
import type fsMod from 'node:fs'; import type fsMod from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -85,28 +83,6 @@ interface ErrorProperties {
frame?: string; frame?: string;
} }
/**
* Matches `search` function used for resolving `astro.config` files.
* Used by Markdoc for error handling.
* @see 'astro/src/core/config/config.ts'
*/
export function getAstroConfigPath(fs: typeof fsMod, root: string): string | undefined {
const paths = [
'astro.config.mjs',
'astro.config.js',
'astro.config.ts',
'astro.config.mts',
'astro.config.cjs',
'astro.config.cts',
].map((p) => path.join(root, p));
for (const file of paths) {
if (fs.existsSync(file)) {
return file;
}
}
}
/** /**
* @see 'astro/src/core/path.ts' * @see 'astro/src/core/path.ts'
*/ */
@ -114,38 +90,6 @@ export function prependForwardSlash(str: string) {
return str[0] === '/' ? str : '/' + str; return str[0] === '/' ? str : '/' + str;
} }
export function validateComponentsProp(components: Record<string, AstroInstance['default']>) {
try {
componentsPropValidator.parse(components);
} catch (e) {
throw new MarkdocError({
message:
e instanceof z.ZodError
? e.issues[0].message
: 'Invalid `components` prop. Ensure you are passing an object of components to <Content />',
});
}
}
const componentsPropValidator = z.record(
z
.string()
.min(1, 'Invalid `components` prop. Component names cannot be empty!')
.refine(
(value) => isCapitalized(value),
(value) => ({
message: `Invalid \`components\` prop: ${JSON.stringify(
value
)}. Component name must be capitalized. If you want to render HTML elements as components, try using a Markdoc node (https://docs.astro.build/en/guides/integrations-guide/markdoc/#render-markdoc-nodes--html-elements-as-astro-components)`,
})
),
z.any()
);
export function isCapitalized(str: string) {
return str.length > 0 && str[0] === str[0].toUpperCase();
}
export function isValidUrl(str: string): boolean { export function isValidUrl(str: string): boolean {
try { try {
new URL(str); new URL(str);

View file

@ -1,9 +1,7 @@
declare module 'astro:content' { declare module 'astro:content' {
interface Render { interface Render {
'.mdoc': Promise<{ '.mdoc': Promise<{
Content(props: { Content(props: Record<string, any>): import('astro').MarkdownInstance<{}>['Content'];
components?: Record<string, import('astro').AstroInstance['default']>;
}): import('astro').MarkdownInstance<{}>['Content'];
}>; }>;
} }
} }

View file

@ -1,4 +1,3 @@
import { parseHTML } from 'linkedom';
import { parse as parseDevalue } from 'devalue'; import { parse as parseDevalue } from 'devalue';
import { expect } from 'chai'; import { expect } from 'chai';
import { loadFixture, fixLineEndings } from '../../../astro/test/test-utils.js'; import { loadFixture, fixLineEndings } from '../../../astro/test/test-utils.js';
@ -37,70 +36,20 @@ describe('Markdoc - Content Collections', () => {
it('loads entry', async () => { it('loads entry', async () => {
const res = await baseFixture.fetch('/entry.json'); const res = await baseFixture.fetch('/entry.json');
const post = parseDevalue(await res.text()); const post = parseDevalue(await res.text());
expect(formatPost(post)).to.deep.equal(simplePostEntry); expect(formatPost(post)).to.deep.equal(post1Entry);
}); });
it('loads collection', async () => { it('loads collection', async () => {
const res = await baseFixture.fetch('/collection.json'); const res = await baseFixture.fetch('/collection.json');
const posts = parseDevalue(await res.text()); const posts = parseDevalue(await res.text());
expect(posts).to.not.be.null; expect(posts).to.not.be.null;
expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([ expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([
simplePostEntry, post1Entry,
withComponentsEntry, post2Entry,
withConfigEntry, post3Entry,
]); ]);
}); });
it('renders content - simple', async () => {
const res = await baseFixture.fetch('/content-simple');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Simple post');
const p = document.querySelector('p');
expect(p.textContent).to.equal('This is a simple Markdoc post.');
});
it('renders content - with config', async () => {
const fixture = await getFixtureWithConfig();
const server = await fixture.startDevServer();
const res = await fixture.fetch('/content-with-config');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with config');
const textContent = html;
expect(textContent).to.not.include('Hello');
expect(textContent).to.include('Hola');
expect(textContent).to.include(`Konnichiwa`);
await server.stop();
});
it('renders content - with components', async () => {
const fixture = await getFixtureWithComponents();
const server = await fixture.startDevServer();
const res = await fixture.fetch('/content-with-components');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with components');
// Renders custom shortcode component
const marquee = document.querySelector('marquee');
expect(marquee).to.not.be.null;
expect(marquee.hasAttribute('data-custom-marquee')).to.equal(true);
// Renders Astro Code component
const pre = document.querySelector('pre');
expect(pre).to.not.be.null;
expect(pre.className).to.equal('astro-code');
await server.stop();
});
}); });
describe('build', () => { describe('build', () => {
@ -111,7 +60,7 @@ describe('Markdoc - Content Collections', () => {
it('loads entry', async () => { it('loads entry', async () => {
const res = await baseFixture.readFile('/entry.json'); const res = await baseFixture.readFile('/entry.json');
const post = parseDevalue(res); const post = parseDevalue(res);
expect(formatPost(post)).to.deep.equal(simplePostEntry); expect(formatPost(post)).to.deep.equal(post1Entry);
}); });
it('loads collection', async () => { it('loads collection', async () => {
@ -119,140 +68,43 @@ describe('Markdoc - Content Collections', () => {
const posts = parseDevalue(res); const posts = parseDevalue(res);
expect(posts).to.not.be.null; expect(posts).to.not.be.null;
expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([ expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([
simplePostEntry, post1Entry,
withComponentsEntry, post2Entry,
withConfigEntry, post3Entry,
]); ]);
}); });
it('renders content - simple', async () => {
const html = await baseFixture.readFile('/content-simple/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Simple post');
const p = document.querySelector('p');
expect(p.textContent).to.equal('This is a simple Markdoc post.');
});
it('renders content - with config', async () => {
const fixture = await getFixtureWithConfig();
await fixture.build();
const html = await fixture.readFile('/content-with-config/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with config');
const textContent = html;
expect(textContent).to.not.include('Hello');
expect(textContent).to.include('Hola');
expect(textContent).to.include(`Konnichiwa`);
});
it('renders content - with components', async () => {
const fixture = await getFixtureWithComponents();
await fixture.build();
const html = await fixture.readFile('/content-with-components/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with components');
// Renders custom shortcode component
const marquee = document.querySelector('marquee');
expect(marquee).to.not.be.null;
expect(marquee.hasAttribute('data-custom-marquee')).to.equal(true);
// Renders Astro Code component
const pre = document.querySelector('pre');
expect(pre).to.not.be.null;
expect(pre.className).to.equal('astro-code');
});
}); });
}); });
function getFixtureWithConfig() { const post1Entry = {
return loadFixture({ id: 'post-1.mdoc',
root, slug: 'post-1',
integrations: [
markdoc({
variables: {
countries: ['ES', 'JP'],
},
functions: {
includes: {
transform(parameters) {
const [array, value] = Object.values(parameters);
return Array.isArray(array) ? array.includes(value) : false;
},
},
},
}),
],
});
}
function getFixtureWithComponents() {
return loadFixture({
root,
integrations: [
markdoc({
nodes: {
fence: {
render: 'Code',
attributes: {
language: { type: String },
content: { type: String },
},
},
},
tags: {
mq: {
render: 'CustomMarquee',
attributes: {
direction: {
type: String,
default: 'left',
matches: ['left', 'right', 'up', 'down'],
errorLevel: 'critical',
},
},
},
},
}),
],
});
}
const simplePostEntry = {
id: 'simple.mdoc',
slug: 'simple',
collection: 'blog', collection: 'blog',
data: { data: {
schemaWorks: true, schemaWorks: true,
title: 'Simple post', title: 'Post 1',
}, },
body: '\n## Simple post\n\nThis is a simple Markdoc post.\n', body: '\n## Post 1\n\nThis is the contents of post 1.\n',
}; };
const withComponentsEntry = { const post2Entry = {
id: 'with-components.mdoc', id: 'post-2.mdoc',
slug: 'with-components', slug: 'post-2',
collection: 'blog', collection: 'blog',
data: { data: {
schemaWorks: true, schemaWorks: true,
title: 'Post with components', title: 'Post 2',
}, },
body: '\n## Post with components\n\nThis uses a custom marquee component with a shortcode:\n\n{% mq direction="right" %}\nI\'m a marquee too!\n{% /mq %}\n\nAnd a code component for code blocks:\n\n```js\nconst isRenderedWithShiki = true;\n```\n', body: '\n## Post 2\n\nThis is the contents of post 2.\n',
}; };
const withConfigEntry = { const post3Entry = {
id: 'with-config.mdoc', id: 'post-3.mdoc',
slug: 'with-config', slug: 'post-3',
collection: 'blog', collection: 'blog',
data: { data: {
schemaWorks: true, schemaWorks: true,
title: 'Post with config', title: 'Post 3',
}, },
body: '\n## Post with config\n\n{% if includes($countries, "EN") %} Hello {% /if %}\n{% if includes($countries, "ES") %} Hola {% /if %}\n{% if includes($countries, "JP") %} Konnichiwa {% /if %}\n', body: '\n## Post 3\n\nThis is the contents of post 3.\n',
}; };

View file

@ -4,10 +4,6 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@astrojs/markdoc": "workspace:*", "@astrojs/markdoc": "workspace:*",
"@markdoc/markdoc": "^0.2.2",
"astro": "workspace:*" "astro": "workspace:*"
},
"devDependencies": {
"shiki": "^0.11.1"
} }
} }

View file

@ -0,0 +1,7 @@
---
title: Post 1
---
## Post 1
This is the contents of post 1.

View file

@ -0,0 +1,7 @@
---
title: Post 2
---
## Post 2
This is the contents of post 2.

View file

@ -0,0 +1,7 @@
---
title: Post 3
---
## Post 3
This is the contents of post 3.

View file

@ -3,7 +3,7 @@ import { stringify } from 'devalue';
import { stripRenderFn } from '../../utils.js'; import { stripRenderFn } from '../../utils.js';
export async function get() { export async function get() {
const post = await getEntryBySlug('blog', 'simple'); const post = await getEntryBySlug('blog', 'post-1');
return { return {
body: stringify(stripRenderFn(post)), body: stringify(stripRenderFn(post)),
}; };

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/markdoc-render-simple",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -1,5 +1,6 @@
--- ---
import { getEntryBySlug } from "astro:content"; import { getEntryBySlug } from "astro:content";
const post = await getEntryBySlug('blog', 'simple'); const post = await getEntryBySlug('blog', 'simple');
const { Content } = await post.render(); const { Content } = await post.render();
--- ---
@ -10,7 +11,7 @@ const { Content } = await post.render();
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content - Simple</title> <title>Content</title>
</head> </head>
<body> <body>
<Content /> <Content />

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});

View file

@ -0,0 +1,28 @@
import Code from './src/components/Code.astro';
import CustomMarquee from './src/components/CustomMarquee.astro';
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
nodes: {
fence: {
render: Code,
attributes: {
language: { type: String },
content: { type: String },
},
},
},
tags: {
mq: {
render: CustomMarquee,
attributes: {
direction: {
type: String,
default: 'left',
matches: ['left', 'right', 'up', 'down'],
errorLevel: 'critical',
},
},
},
},
})

View file

@ -0,0 +1,12 @@
{
"name": "@test/markdoc-render-with-components",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
},
"devDependencies": {
"shiki": "^0.11.1"
}
}

View file

@ -1,7 +1,5 @@
--- ---
import { getEntryBySlug } from "astro:content"; import { getEntryBySlug } from "astro:content";
import Code from '../components/Code.astro';
import CustomMarquee from '../components/CustomMarquee.astro';
const post = await getEntryBySlug('blog', 'with-components'); const post = await getEntryBySlug('blog', 'with-components');
const { Content } = await post.render(); const { Content } = await post.render();
@ -13,11 +11,9 @@ const { Content } = await post.render();
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content - with components</title> <title>Content</title>
</head> </head>
<body> <body>
<Content <Content />
components={{ CustomMarquee, Code }}
/>
</body> </body>
</html> </html>

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import markdoc from '@astrojs/markdoc';
// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});

View file

@ -0,0 +1,15 @@
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
export default defineMarkdocConfig({
variables: {
countries: ['ES', 'JP'],
},
functions: {
includes: {
transform(parameters) {
const [array, value] = Object.values(parameters);
return Array.isArray(array) ? array.includes(value) : false;
},
},
},
})

View file

@ -0,0 +1,9 @@
{
"name": "@test/markdoc-render-with-config",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -7,3 +7,7 @@ title: Post with config
{% if includes($countries, "EN") %} Hello {% /if %} {% if includes($countries, "EN") %} Hello {% /if %}
{% if includes($countries, "ES") %} Hola {% /if %} {% if includes($countries, "ES") %} Hola {% /if %}
{% if includes($countries, "JP") %} Konnichiwa {% /if %} {% if includes($countries, "JP") %} Konnichiwa {% /if %}
## Runtime variables
{% $runtimeVariable %} {% #runtime-variable %}

View file

@ -11,9 +11,9 @@ const { Content } = await post.render();
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Content - with config</title> <title>Content</title>
</head> </head>
<body> <body>
<Content /> <Content runtimeVariable="working!" />
</body> </body>
</html> </html>

View file

@ -0,0 +1,124 @@
import { parseHTML } from 'linkedom';
import { expect } from 'chai';
import { loadFixture } from '../../../astro/test/test-utils.js';
async function getFixture(name) {
return await loadFixture({
root: new URL(`./fixtures/${name}/`, import.meta.url),
});
}
describe('Markdoc - render', () => {
describe('dev', () => {
it('renders content - simple', async () => {
const fixture = await getFixture('render-simple');
const server = await fixture.startDevServer();
const res = await fixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Simple post');
const p = document.querySelector('p');
expect(p.textContent).to.equal('This is a simple Markdoc post.');
await server.stop();
});
it('renders content - with config', async () => {
const fixture = await getFixture('render-with-config');
const server = await fixture.startDevServer();
const res = await fixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with config');
const textContent = html;
expect(textContent).to.not.include('Hello');
expect(textContent).to.include('Hola');
expect(textContent).to.include(`Konnichiwa`);
const runtimeVariable = document.querySelector('#runtime-variable');
expect(runtimeVariable?.textContent?.trim()).to.equal('working!');
await server.stop();
});
it('renders content - with components', async () => {
const fixture = await getFixture('render-with-components');
const server = await fixture.startDevServer();
const res = await fixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with components');
// Renders custom shortcode component
const marquee = document.querySelector('marquee');
expect(marquee).to.not.be.null;
expect(marquee.hasAttribute('data-custom-marquee')).to.equal(true);
// Renders Astro Code component
const pre = document.querySelector('pre');
expect(pre).to.not.be.null;
expect(pre.className).to.equal('astro-code');
await server.stop();
});
});
describe('build', () => {
it('renders content - simple', async () => {
const fixture = await getFixture('render-simple');
await fixture.build();
const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Simple post');
const p = document.querySelector('p');
expect(p.textContent).to.equal('This is a simple Markdoc post.');
});
it('renders content - with config', async () => {
const fixture = await getFixture('render-with-config');
await fixture.build();
const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with config');
const textContent = html;
expect(textContent).to.not.include('Hello');
expect(textContent).to.include('Hola');
expect(textContent).to.include(`Konnichiwa`);
const runtimeVariable = document.querySelector('#runtime-variable');
expect(runtimeVariable?.textContent?.trim()).to.equal('working!');
});
it('renders content - with components', async () => {
const fixture = await getFixture('render-with-components');
await fixture.build();
const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);
const h2 = document.querySelector('h2');
expect(h2.textContent).to.equal('Post with components');
// Renders custom shortcode component
const marquee = document.querySelector('marquee');
expect(marquee).to.not.be.null;
expect(marquee.hasAttribute('data-custom-marquee')).to.equal(true);
// Renders Astro Code component
const pre = document.querySelector('pre');
expect(pre).to.not.be.null;
expect(pre.className).to.equal('astro-code');
});
});
});

View file

@ -330,9 +330,11 @@ importers:
specifiers: specifiers:
'@astrojs/markdoc': ^0.0.5 '@astrojs/markdoc': ^0.0.5
astro: ^2.1.7 astro: ^2.1.7
kleur: ^4.1.5
dependencies: dependencies:
'@astrojs/markdoc': link:../../packages/integrations/markdoc '@astrojs/markdoc': link:../../packages/integrations/markdoc
astro: link:../../packages/astro astro: link:../../packages/astro
kleur: 4.1.5
examples/with-markdown-plugins: examples/with-markdown-plugins:
specifiers: specifiers:
@ -3083,7 +3085,9 @@ importers:
astro-scripts: workspace:* astro-scripts: workspace:*
chai: ^4.3.6 chai: ^4.3.6
devalue: ^4.2.0 devalue: ^4.2.0
esbuild: ^0.17.12
gray-matter: ^4.0.3 gray-matter: ^4.0.3
kleur: ^4.1.5
linkedom: ^0.14.12 linkedom: ^0.14.12
mocha: ^9.2.2 mocha: ^9.2.2
rollup: ^3.20.1 rollup: ^3.20.1
@ -3091,7 +3095,9 @@ importers:
zod: ^3.17.3 zod: ^3.17.3
dependencies: dependencies:
'@markdoc/markdoc': 0.2.2 '@markdoc/markdoc': 0.2.2
esbuild: 0.17.12
gray-matter: 4.0.3 gray-matter: 4.0.3
kleur: 4.1.5
zod: 3.20.6 zod: 3.20.6
devDependencies: devDependencies:
'@types/chai': 4.3.4 '@types/chai': 4.3.4
@ -3109,15 +3115,10 @@ importers:
packages/integrations/markdoc/test/fixtures/content-collections: packages/integrations/markdoc/test/fixtures/content-collections:
specifiers: specifiers:
'@astrojs/markdoc': workspace:* '@astrojs/markdoc': workspace:*
'@markdoc/markdoc': ^0.2.2
astro: workspace:* astro: workspace:*
shiki: ^0.11.1
dependencies: dependencies:
'@astrojs/markdoc': link:../../.. '@astrojs/markdoc': link:../../..
'@markdoc/markdoc': 0.2.2
astro: link:../../../../../astro astro: link:../../../../../astro
devDependencies:
shiki: 0.11.1
packages/integrations/markdoc/test/fixtures/entry-prop: packages/integrations/markdoc/test/fixtures/entry-prop:
specifiers: specifiers:
@ -3135,6 +3136,33 @@ importers:
'@astrojs/markdoc': link:../../.. '@astrojs/markdoc': link:../../..
astro: link:../../../../../astro astro: link:../../../../../astro
packages/integrations/markdoc/test/fixtures/render-simple:
specifiers:
'@astrojs/markdoc': workspace:*
astro: workspace:*
dependencies:
'@astrojs/markdoc': link:../../..
astro: link:../../../../../astro
packages/integrations/markdoc/test/fixtures/render-with-components:
specifiers:
'@astrojs/markdoc': workspace:*
astro: workspace:*
shiki: ^0.11.1
dependencies:
'@astrojs/markdoc': link:../../..
astro: link:../../../../../astro
devDependencies:
shiki: 0.11.1
packages/integrations/markdoc/test/fixtures/render-with-config:
specifiers:
'@astrojs/markdoc': workspace:*
astro: workspace:*
dependencies:
'@astrojs/markdoc': link:../../..
astro: link:../../../../../astro
packages/integrations/mdx: packages/integrations/mdx:
specifiers: specifiers:
'@astrojs/markdown-remark': ^2.1.2 '@astrojs/markdown-remark': ^2.1.2