diff options
44 files changed, 737 insertions, 636 deletions
diff --git a/.changeset/metal-cameras-bow.md b/.changeset/metal-cameras-bow.md new file mode 100644 index 000000000..2275c4804 --- /dev/null +++ b/.changeset/metal-cameras-bow.md @@ -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 }} +/> +``` diff --git a/examples/with-markdoc/README.md b/examples/with-markdoc/README.md index 62f7cbfc8..b5adbf27b 100644 --- a/examples/with-markdoc/README.md +++ b/examples/with-markdoc/README.md @@ -23,23 +23,20 @@ Inside of your Astro project, you'll see the following folders and files: └── docs/ │ └── intro.mdoc | └── config.ts -│ └── components/ -| ├── Aside.astro -│ └── DocsContent.astro -│ └── layouts/ -│ └── Layout.astro -│ └── pages/ -│ └── index.astro +│ └── components/Aside.astro +│ └── layouts/Layout.astro +│ └── pages/index.astro | └── env.d.ts ├── astro.config.mjs +├── markdoc.config.mjs ├── README.md ├── package.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 diff --git a/examples/with-markdoc/astro.config.mjs b/examples/with-markdoc/astro.config.mjs index d88ed2098..29d846359 100644 --- a/examples/with-markdoc/astro.config.mjs +++ b/examples/with-markdoc/astro.config.mjs @@ -3,17 +3,5 @@ import markdoc from '@astrojs/markdoc'; // https://astro.build/config export default defineConfig({ - integrations: [ - markdoc({ - tags: { - aside: { - render: 'Aside', - attributes: { - type: { type: String }, - title: { type: String }, - }, - }, - }, - }), - ], + integrations: [markdoc()], }); diff --git a/examples/with-markdoc/markdoc.config.mjs b/examples/with-markdoc/markdoc.config.mjs new file mode 100644 index 000000000..0ae63d4ee --- /dev/null +++ b/examples/with-markdoc/markdoc.config.mjs @@ -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 }, + }, + }, + }, +}); diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index 9ca562fa3..f31392840 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@astrojs/markdoc": "^0.0.5", - "astro": "^2.1.7" + "astro": "^2.1.7", + "kleur": "^4.1.5" } } diff --git a/examples/with-markdoc/src/components/DocsContent.astro b/examples/with-markdoc/src/components/DocsContent.astro deleted file mode 100644 index 162c1fc6d..000000000 --- a/examples/with-markdoc/src/components/DocsContent.astro +++ /dev/null @@ -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> diff --git a/examples/with-markdoc/src/pages/index.astro b/examples/with-markdoc/src/pages/index.astro index 01412cce1..7efcbeda8 100644 --- a/examples/with-markdoc/src/pages/index.astro +++ b/examples/with-markdoc/src/pages/index.astro @@ -1,18 +1,25 @@ --- import { getEntryBySlug } from 'astro:content'; -import DocsContent from '../components/DocsContent.astro'; import Layout from '../layouts/Layout.astro'; const intro = await getEntryBySlug('docs', 'intro'); +const { Content } = await intro.render(); --- <Layout title={intro.data.title}> <main> <h1>{intro.data.title}</h1> - <!-- `DocsContent` is a thin wrapper around --> - <!-- 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} /> + <Content variables={{ revealSecret: true }} /> </main> </Layout> + +<style is:global> + table { + margin-block: 2rem; + margin-inline: auto; + } + table td { + padding-block: 0.3rem; + padding-inline: 0.5rem; + } +</style> diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index a496a5aa0..e1d817607 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1056,6 +1056,7 @@ export interface ContentEntryType { getRenderModule?( this: rollup.PluginContext, params: { + viteId: string; entry: ContentEntryModule; } ): rollup.LoadResult | Promise<rollup.LoadResult>; diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index 7ad71b31e..4437f4fa0 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -139,7 +139,7 @@ export const _internal = { }); } - return contentRenderer.bind(this)({ entry }); + return contentRenderer.bind(this)({ entry, viteId }); }, }); } diff --git a/packages/integrations/markdoc/README.md b/packages/integrations/markdoc/README.md index 76291e763..85a657ea4 100644 --- a/packages/integrations/markdoc/README.md +++ b/packages/integrations/markdoc/README.md @@ -99,53 +99,46 @@ const { Content } = await entry.render(); ### 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 -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 -// astro.config.mjs -import { defineConfig } from 'astro/config'; -import markdoc from '@astrojs/markdoc'; - -// https://astro.build/config -export default defineConfig({ - integrations: [ - markdoc({ - tags: { - aside: { - render: 'Aside', - attributes: { - // Component props as attribute definitions - // See Markdoc's documentation on defining attributes - // https://markdoc.dev/docs/attributes#defining-attributes - type: { type: String }, - } - }, - }, - }), - ], -}); +// markdoc.config.mjs +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import Aside from './src/components/Aside.astro'; + +export default defineMarkdocConfig({ + tags: { + aside: { + render: Aside, + attributes: { + // Markdoc requires type defs for each attribute. + // 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 + 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 ---- -import { getEntryBySlug } from 'astro:content'; -import Aside from '../components/Aside.astro'; +{% aside type="tip" %} -const entry = await getEntryBySlug('docs', 'why-markdoc'); -const { Content } = await entry.render(); ---- +Use tags like this fancy "aside" to add some *flair* to your docs. -<Content - components={{ Aside }} -/> +{% /aside %} ``` #### 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: ```js -// astro.config.mjs -import { defineConfig } from 'astro/config'; -import markdoc from '@astrojs/markdoc'; - -// https://astro.build/config -export default defineConfig({ - integrations: [ - markdoc({ - nodes: { - heading: { - render: 'Heading', - // Markdoc requires type defs for each attribute. - // 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 }} -/> +// markdoc.config.mjs +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import Heading from './src/components/Heading.astro'; + +export default defineMarkdocConfig({ + nodes: { + heading: { + render: Heading, + attributes: { + // Pass the attributes from Markdoc's default heading node + // as component props. + level: { type: String }, + } + }, + }, +}) ``` 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'; --- -<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): - -```astro ---- -// src/pages/why-markdoc.astro -import { getEntryBySlug } from 'astro:content'; -import ClientAside from '../components/ClientAside.astro'; - -const entry = await getEntryBySlug('docs', 'why-markdoc'); -const { Content } = await entry.render(); ---- +This component can be passed to the `render` prop for any [tag][markdoc-tags] or [node][markdoc-nodes] in your config: -<Content - components={{ - Aside: ClientAside, - }} -/> +```js +// markdoc.config.mjs +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; +import Aside from './src/components/Aside.astro'; + +export default defineMarkdocConfig({ + tags: { + aside: { + render: Aside, + attributes: { + type: { type: String }, + } + }, + }, +}) ``` ### 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 -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 -// astro.config.mjs -import { defineConfig } from 'astro/config'; -import markdoc from '@astrojs/markdoc'; - -// https://astro.build/config -export default defineConfig({ - integrations: [ - markdoc({ - functions: { - getCountryEmoji: { - transform(parameters) { - const [country] = Object.values(parameters); - const countryToEmojiMap = { - japan: '🇯🇵', - spain: '🇪🇸', - france: '🇫🇷', - } - return countryToEmojiMap[country] ?? '🏳' - }, - }, +// markdoc.config.mjs +import { defineMarkdocConfig } from '@astrojs/markdoc/config'; + +export default defineMarkdocConfig({ + functions: { + getCountryEmoji: { + transform(parameters) { + const [country] = Object.values(parameters); + const countryToEmojiMap = { + japan: '🇯🇵', + spain: '🇪🇸', + france: '🇫🇷', + } + return countryToEmojiMap[country] ?? '🏳' }, - }), - ], -}); + }, + }, +}) ``` 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") %}! ``` -:::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. -### 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: +```astro +--- +import { getEntryBySlug } from 'astro:content'; -```sh -# Using NPM -npm install @markdoc/markdoc -# Using Yarn -yarn add @markdoc/markdoc -# Using PNPM -pnpm add @markdoc/markdoc +const entry = await getEntryBySlug('docs', 'why-markdoc'); +const { Content } = await entry.render(); +--- + +<!--Pass the `abTest` param as a variable--> +<Content abTestGroup={Astro.params.abTestGroup} /> ``` -Now, you can define Markdoc configuration options using `Markdock.transform()`. +Now, `abTestGroup` is available as a variable in `docs/why-markdoc.mdoc`: -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`: +```md +{% if $abTestGroup === 'image-optimization-lover' %} -```astro ---- -import Markdoc from '@markdoc/markdoc'; -import { Renderer } from '@astrojs/markdoc/components'; -import { getEntryBySlug } from 'astro:content'; +Let me tell you about image optimization... -const { body } = await getEntryBySlug('docs', 'with-ab-test'); -const ast = Markdoc.parse(body); -const content = Markdoc.transform({ - variables: { abTestGroup: Astro.params.abTestGroup }, -}, ast); ---- +{% /if %} +``` -<Renderer {content} components={{ /* same `components` prop used by the `Content` component */ }} /> +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 @@ -360,3 +322,5 @@ See [CHANGELOG.md](https://github.com/withastro/astro/tree/main/packages/integra [markdoc-tags]: https://markdoc.dev/docs/tags [markdoc-nodes]: https://markdoc.dev/docs/nodes + +[markdoc-variables]: https://markdoc.dev/docs/variables diff --git a/packages/integrations/markdoc/components/Renderer.astro b/packages/integrations/markdoc/components/Renderer.astro index 6ae8ee850..5e2b6833a 100644 --- a/packages/integrations/markdoc/components/Renderer.astro +++ b/packages/integrations/markdoc/components/Renderer.astro @@ -1,20 +1,17 @@ --- -import type { RenderableTreeNode } from '@markdoc/markdoc'; -import type { AstroInstance } from 'astro'; -import { validateComponentsProp } from '../dist/utils.js'; +import type { Config } from '@markdoc/markdoc'; +import Markdoc from '@markdoc/markdoc'; import { ComponentNode, createTreeNode } from './TreeNode.js'; type Props = { - content: RenderableTreeNode; - components?: Record<string, AstroInstance['default']>; + config: Config; + stringifiedAst: string; }; -const { content, components } = Astro.props as Props; +const { stringifiedAst, config } = Astro.props as Props; -// Will throw if components is invalid -if (components) { - validateComponentsProp(components); -} +const ast = Markdoc.Ast.fromJSON(stringifiedAst); +const content = Markdoc.transform(ast, config); --- -<ComponentNode treeNode={createTreeNode(content, components)} /> +<ComponentNode treeNode={createTreeNode(content)} /> diff --git a/packages/integrations/markdoc/components/TreeNode.ts b/packages/integrations/markdoc/components/TreeNode.ts index f46355d5c..8a3158589 100644 --- a/packages/integrations/markdoc/components/TreeNode.ts +++ b/packages/integrations/markdoc/components/TreeNode.ts @@ -1,10 +1,7 @@ import type { AstroInstance } from 'astro'; 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 { MarkdocError, isCapitalized } from '../dist/utils.js'; +import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js'; export type TreeNode = | { @@ -47,26 +44,17 @@ export const ComponentNode = createComponent({ propagation: 'none', }); -const builtInComponents: Record<string, AstroInstance['default']> = { - Image, -}; - -export function createTreeNode( - node: RenderableTreeNode, - userComponents: Record<string, AstroInstance['default']> = {} -): TreeNode { - const components = { ...userComponents, ...builtInComponents }; - +export function createTreeNode(node: RenderableTreeNode): TreeNode { if (typeof node === 'string' || typeof node === 'number') { return { type: 'text', content: String(node) }; } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) { return { type: 'text', content: '' }; } - if (node.name in components) { - const component = components[node.name]; + if (typeof node.name === 'function') { + const component = node.name; const props = node.attributes; - const children = node.children.map((child) => createTreeNode(child, components)); + const children = node.children.map((child) => createTreeNode(child)); return { type: 'component', @@ -74,17 +62,12 @@ export function createTreeNode( props, 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 { return { type: 'element', tag: node.name, attributes: node.attributes, - children: node.children.map((child) => createTreeNode(child, components)), + children: node.children.map((child) => createTreeNode(child)), }; } } diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index 67b311c65..5fb778b27 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -21,6 +21,9 @@ "exports": { ".": "./dist/index.js", "./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" }, "scripts": { @@ -32,17 +35,19 @@ }, "dependencies": { "@markdoc/markdoc": "^0.2.2", + "esbuild": "^0.17.12", "gray-matter": "^4.0.3", + "kleur": "^4.1.5", "zod": "^3.17.3" }, "peerDependencies": { "astro": "workspace:*" }, "devDependencies": { - "astro": "workspace:*", "@types/chai": "^4.3.1", "@types/html-escaper": "^3.0.0", "@types/mocha": "^9.1.1", + "astro": "workspace:*", "astro-scripts": "workspace:*", "chai": "^4.3.6", "devalue": "^4.2.0", diff --git a/packages/integrations/markdoc/src/config.ts b/packages/integrations/markdoc/src/config.ts new file mode 100644 index 000000000..4c20e311f --- /dev/null +++ b/packages/integrations/markdoc/src/config.ts @@ -0,0 +1,5 @@ +import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc'; + +export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig { + return config; +} diff --git a/packages/integrations/markdoc/src/default-config.ts b/packages/integrations/markdoc/src/default-config.ts new file mode 100644 index 000000000..16bd2c41f --- /dev/null +++ b/packages/integrations/markdoc/src/default-config.ts @@ -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 + }; +} diff --git a/packages/integrations/markdoc/src/experimental-assets-config.ts b/packages/integrations/markdoc/src/experimental-assets-config.ts new file mode 100644 index 000000000..962755355 --- /dev/null +++ b/packages/integrations/markdoc/src/experimental-assets-config.ts @@ -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); + } + }, + }, + }, +}; diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index ebfc09ba7..feb9a501c 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -1,23 +1,15 @@ -import type { - Config as ReadonlyMarkdocConfig, - ConfigType as MarkdocConfig, - Node, -} from '@markdoc/markdoc'; +import type { Node } from '@markdoc/markdoc'; import Markdoc from '@markdoc/markdoc'; import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import type * as rollup from 'rollup'; -import { - getAstroConfigPath, - isValidUrl, - MarkdocError, - parseFrontmatter, - prependForwardSlash, -} from './utils.js'; +import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from './utils.js'; // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations. 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'> & { // `contentEntryType` is not a public API @@ -25,24 +17,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & { addContentEntryType: (contentEntryType: ContentEntryType) => void; }; -export default function markdocIntegration( - userMarkdocConfig: ReadonlyMarkdocConfig = {} -): AstroIntegration { +export default function markdocIntegration(legacyConfig: any): AstroIntegration { + if (legacyConfig) { + // 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 { name: '@astrojs/markdoc', hooks: { 'astro:config:setup': async (params) => { - const { - updateConfig, - config: astroConfig, - addContentEntryType, - } = params as SetupHookParams; + const { config: astroConfig, addContentEntryType } = params as SetupHookParams; - updateConfig({ - vite: { - plugins: [safeAssetsVirtualModulePlugin({ astroConfig })], - }, - }); + const configLoadResult = await loadMarkdocConfig(astroConfig); + const userMarkdocConfig = configLoadResult?.config ?? {}; function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); @@ -56,49 +48,63 @@ export default function markdocIntegration( addContentEntryType({ extensions: ['.mdoc'], getEntryInfo, - async getRenderModule({ entry }) { - validateRenderProperties(userMarkdocConfig, astroConfig); + async getRenderModule({ entry, viteId }) { const ast = Markdoc.parse(entry.body); const pluginContext = this; - const markdocConfig: MarkdocConfig = { - ...userMarkdocConfig, - variables: { - ...userMarkdocConfig.variables, - entry, - }, - }; + const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry }); + + 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) { + if (astroConfig.experimental.assets) { await emitOptimizedImages(ast.children, { astroConfig, pluginContext, 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); - - return { - code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify( - content - )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`, + const code = { + code: `import { jsx as h } from 'astro/jsx-runtime'; +import { applyDefaultConfig } from '@astrojs/markdoc/default-config'; +import { Renderer } from '@astrojs/markdoc/components'; +import * as entry from ${JSON.stringify(viteId + '?astroContent')};${ + 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( 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/` 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.'); }`; - } - }, - }; -} diff --git a/packages/integrations/markdoc/src/load-config.ts b/packages/integrations/markdoc/src/load-config.ts new file mode 100644 index 000000000..db36edf25 --- /dev/null +++ b/packages/integrations/markdoc/src/load-config.ts @@ -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 + } + } +} diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts index 9d6e5af26..95f84700c 100644 --- a/packages/integrations/markdoc/src/utils.ts +++ b/packages/integrations/markdoc/src/utils.ts @@ -1,5 +1,3 @@ -import type { AstroInstance } from 'astro'; -import z from 'astro/zod'; import matter from 'gray-matter'; import type fsMod from 'node:fs'; import path from 'node:path'; @@ -86,66 +84,12 @@ interface ErrorProperties { } /** - * 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' */ export function prependForwardSlash(str: string) { 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 { try { new URL(str); diff --git a/packages/integrations/markdoc/template/content-module-types.d.ts b/packages/integrations/markdoc/template/content-module-types.d.ts index 87c17af24..7b82eb18a 100644 --- a/packages/integrations/markdoc/template/content-module-types.d.ts +++ b/packages/integrations/markdoc/template/content-module-types.d.ts @@ -1,9 +1,7 @@ declare module 'astro:content' { interface Render { '.mdoc': Promise<{ - Content(props: { - components?: Record<string, import('astro').AstroInstance['default']>; - }): import('astro').MarkdownInstance<{}>['Content']; + Content(props: Record<string, any>): import('astro').MarkdownInstance<{}>['Content']; }>; } } diff --git a/packages/integrations/markdoc/test/content-collections.test.js b/packages/integrations/markdoc/test/content-collections.test.js index 5822c181a..aad389e0c 100644 --- a/packages/integrations/markdoc/test/content-collections.test.js +++ b/packages/integrations/markdoc/test/content-collections.test.js @@ -1,4 +1,3 @@ -import { parseHTML } from 'linkedom'; import { parse as parseDevalue } from 'devalue'; import { expect } from 'chai'; import { loadFixture, fixLineEndings } from '../../../astro/test/test-utils.js'; @@ -37,70 +36,20 @@ describe('Markdoc - Content Collections', () => { it('loads entry', async () => { const res = await baseFixture.fetch('/entry.json'); const post = parseDevalue(await res.text()); - expect(formatPost(post)).to.deep.equal(simplePostEntry); + expect(formatPost(post)).to.deep.equal(post1Entry); }); it('loads collection', async () => { const res = await baseFixture.fetch('/collection.json'); const posts = parseDevalue(await res.text()); expect(posts).to.not.be.null; + expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([ - simplePostEntry, - withComponentsEntry, - withConfigEntry, + post1Entry, + post2Entry, + 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', () => { @@ -111,7 +60,7 @@ describe('Markdoc - Content Collections', () => { it('loads entry', async () => { const res = await baseFixture.readFile('/entry.json'); const post = parseDevalue(res); - expect(formatPost(post)).to.deep.equal(simplePostEntry); + expect(formatPost(post)).to.deep.equal(post1Entry); }); it('loads collection', async () => { @@ -119,140 +68,43 @@ describe('Markdoc - Content Collections', () => { const posts = parseDevalue(res); expect(posts).to.not.be.null; expect(posts.sort().map((post) => formatPost(post))).to.deep.equal([ - simplePostEntry, - withComponentsEntry, - withConfigEntry, + post1Entry, + post2Entry, + 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() { - return loadFixture({ - root, - 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', +const post1Entry = { + id: 'post-1.mdoc', + slug: 'post-1', collection: 'blog', data: { 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 = { - id: 'with-components.mdoc', - slug: 'with-components', +const post2Entry = { + id: 'post-2.mdoc', + slug: 'post-2', collection: 'blog', data: { 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 = { - id: 'with-config.mdoc', - slug: 'with-config', +const post3Entry = { + id: 'post-3.mdoc', + slug: 'post-3', collection: 'blog', data: { 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', }; diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/package.json b/packages/integrations/markdoc/test/fixtures/content-collections/package.json index a6403f49b..370b87957 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/package.json +++ b/packages/integrations/markdoc/test/fixtures/content-collections/package.json @@ -4,10 +4,6 @@ "private": true, "dependencies": { "@astrojs/markdoc": "workspace:*", - "@markdoc/markdoc": "^0.2.2", "astro": "workspace:*" - }, - "devDependencies": { - "shiki": "^0.11.1" } } diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-1.mdoc b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-1.mdoc new file mode 100644 index 000000000..06c900963 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-1.mdoc @@ -0,0 +1,7 @@ +--- +title: Post 1 +--- + +## Post 1 + +This is the contents of post 1. diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-2.mdoc b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-2.mdoc new file mode 100644 index 000000000..cf4dc162f --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-2.mdoc @@ -0,0 +1,7 @@ +--- +title: Post 2 +--- + +## Post 2 + +This is the contents of post 2. diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-3.mdoc b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-3.mdoc new file mode 100644 index 000000000..6c601eb65 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/post-3.mdoc @@ -0,0 +1,7 @@ +--- +title: Post 3 +--- + +## Post 3 + +This is the contents of post 3. diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/entry.json.js b/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/entry.json.js index 842291826..7899a757a 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/entry.json.js +++ b/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/entry.json.js @@ -3,7 +3,7 @@ import { stringify } from 'devalue'; import { stripRenderFn } from '../../utils.js'; export async function get() { - const post = await getEntryBySlug('blog', 'simple'); + const post = await getEntryBySlug('blog', 'post-1'); return { body: stringify(stripRenderFn(post)), }; diff --git a/packages/integrations/markdoc/test/fixtures/render-simple/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/render-simple/astro.config.mjs new file mode 100644 index 000000000..29d846359 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-simple/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/render-simple/package.json b/packages/integrations/markdoc/test/fixtures/render-simple/package.json new file mode 100644 index 000000000..9354cdc58 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-simple/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/markdoc-render-simple", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/simple.mdoc b/packages/integrations/markdoc/test/fixtures/render-simple/src/content/blog/simple.mdoc index 557f7b8e5..557f7b8e5 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/simple.mdoc +++ b/packages/integrations/markdoc/test/fixtures/render-simple/src/content/blog/simple.mdoc diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-simple.astro b/packages/integrations/markdoc/test/fixtures/render-simple/src/pages/index.astro index effbbee1c..940eef154 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-simple.astro +++ b/packages/integrations/markdoc/test/fixtures/render-simple/src/pages/index.astro @@ -1,5 +1,6 @@ --- import { getEntryBySlug } from "astro:content"; + const post = await getEntryBySlug('blog', 'simple'); const { Content } = await post.render(); --- @@ -10,7 +11,7 @@ const { Content } = await post.render(); <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Content - Simple</title> + <title>Content</title> </head> <body> <Content /> diff --git a/packages/integrations/markdoc/test/fixtures/render-with-components/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/render-with-components/astro.config.mjs new file mode 100644 index 000000000..29d846359 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.mjs new file mode 100644 index 000000000..ada03f5f4 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/markdoc.config.mjs @@ -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', + }, + }, + }, + }, +}) diff --git a/packages/integrations/markdoc/test/fixtures/render-with-components/package.json b/packages/integrations/markdoc/test/fixtures/render-with-components/package.json new file mode 100644 index 000000000..f14c97f0f --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/package.json @@ -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" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/components/Code.astro b/packages/integrations/markdoc/test/fixtures/render-with-components/src/components/Code.astro index 18bf1399f..18bf1399f 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/components/Code.astro +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/src/components/Code.astro diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/components/CustomMarquee.astro b/packages/integrations/markdoc/test/fixtures/render-with-components/src/components/CustomMarquee.astro index 3108b9973..3108b9973 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/components/CustomMarquee.astro +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/src/components/CustomMarquee.astro diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/with-components.mdoc b/packages/integrations/markdoc/test/fixtures/render-with-components/src/content/blog/with-components.mdoc index 2c9bae972..2c9bae972 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/with-components.mdoc +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/src/content/blog/with-components.mdoc diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-with-components.astro b/packages/integrations/markdoc/test/fixtures/render-with-components/src/pages/index.astro index dfb9b1de5..52239acce 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-with-components.astro +++ b/packages/integrations/markdoc/test/fixtures/render-with-components/src/pages/index.astro @@ -1,7 +1,5 @@ --- 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 { Content } = await post.render(); @@ -13,11 +11,9 @@ const { Content } = await post.render(); <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Content - with components</title> + <title>Content</title> </head> <body> - <Content - components={{ CustomMarquee, Code }} - /> + <Content /> </body> </html> diff --git a/packages/integrations/markdoc/test/fixtures/render-with-config/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/render-with-config/astro.config.mjs new file mode 100644 index 000000000..29d846359 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-config/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/render-with-config/markdoc.config.mjs b/packages/integrations/markdoc/test/fixtures/render-with-config/markdoc.config.mjs new file mode 100644 index 000000000..c43ee93a3 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-config/markdoc.config.mjs @@ -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; + }, + }, + }, +}) diff --git a/packages/integrations/markdoc/test/fixtures/render-with-config/package.json b/packages/integrations/markdoc/test/fixtures/render-with-config/package.json new file mode 100644 index 000000000..d4751388c --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/render-with-config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/markdoc-render-with-config", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/with-config.mdoc b/packages/integrations/markdoc/test/fixtures/render-with-config/src/content/blog/with-config.mdoc index 199eadb9c..5376404ea 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/content/blog/with-config.mdoc +++ b/packages/integrations/markdoc/test/fixtures/render-with-config/src/content/blog/with-config.mdoc @@ -7,3 +7,7 @@ title: Post with config {% if includes($countries, "EN") %} Hello {% /if %} {% if includes($countries, "ES") %} Hola {% /if %} {% if includes($countries, "JP") %} Konnichiwa {% /if %} + +## Runtime variables + +{% $runtimeVariable %} {% #runtime-variable %} diff --git a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-with-config.astro b/packages/integrations/markdoc/test/fixtures/render-with-config/src/pages/index.astro index f37217a62..616d5ec0a 100644 --- a/packages/integrations/markdoc/test/fixtures/content-collections/src/pages/content-with-config.astro +++ b/packages/integrations/markdoc/test/fixtures/render-with-config/src/pages/index.astro @@ -11,9 +11,9 @@ const { Content } = await post.render(); <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Content - with config</title> + <title>Content</title> </head> <body> - <Content /> + <Content runtimeVariable="working!" /> </body> </html> diff --git a/packages/integrations/markdoc/test/render.test.js b/packages/integrations/markdoc/test/render.test.js new file mode 100644 index 000000000..acb17577b --- /dev/null +++ b/packages/integrations/markdoc/test/render.test.js @@ -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'); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c81b8f4ba..b8547b9a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -330,9 +330,11 @@ importers: specifiers: '@astrojs/markdoc': ^0.0.5 astro: ^2.1.7 + kleur: ^4.1.5 dependencies: '@astrojs/markdoc': link:../../packages/integrations/markdoc astro: link:../../packages/astro + kleur: 4.1.5 examples/with-markdown-plugins: specifiers: @@ -3083,7 +3085,9 @@ importers: astro-scripts: workspace:* chai: ^4.3.6 devalue: ^4.2.0 + esbuild: ^0.17.12 gray-matter: ^4.0.3 + kleur: ^4.1.5 linkedom: ^0.14.12 mocha: ^9.2.2 rollup: ^3.20.1 @@ -3091,7 +3095,9 @@ importers: zod: ^3.17.3 dependencies: '@markdoc/markdoc': 0.2.2 + esbuild: 0.17.12 gray-matter: 4.0.3 + kleur: 4.1.5 zod: 3.20.6 devDependencies: '@types/chai': 4.3.4 @@ -3109,15 +3115,10 @@ importers: packages/integrations/markdoc/test/fixtures/content-collections: specifiers: '@astrojs/markdoc': workspace:* - '@markdoc/markdoc': ^0.2.2 astro: workspace:* - shiki: ^0.11.1 dependencies: '@astrojs/markdoc': link:../../.. - '@markdoc/markdoc': 0.2.2 astro: link:../../../../../astro - devDependencies: - shiki: 0.11.1 packages/integrations/markdoc/test/fixtures/entry-prop: specifiers: @@ -3135,6 +3136,33 @@ importers: '@astrojs/markdoc': link:../../.. 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: specifiers: '@astrojs/markdown-remark': ^2.1.2 |