Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: automatic Markdoc partial resolution #10649

Merged
merged 23 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/moody-spies-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@astrojs/markdoc": patch
---

Add automatic resolution for Markdoc partials. This allows you to render other Markdoc files inside of a given entry. Reference files using the `partial` tag with a `file` attribute for the relative file path:

```md
<!--src/content/blog/post.mdoc-->

{% partial file="my-partials/_diagram.mdoc" /%}

<!--src/content/blog/my-partials/_diagram.mdoc-->

## Diagram

This partial will render inside of `post.mdoc.`

![Diagram](./diagram.png)
```
190 changes: 152 additions & 38 deletions packages/integrations/markdoc/src/content-entry-type.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { Config as MarkdocConfig, Node } from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
import type { AstroConfig, ContentEntryType } from 'astro';
Expand Down Expand Up @@ -38,9 +38,41 @@ export async function getContentEntryType({
}

const ast = Markdoc.parse(tokens);
const usedTags = getUsedTags(ast);
const userMarkdocConfig = markdocConfigResult?.config ?? {};
const markdocConfigUrl = markdocConfigResult?.fileUrl;
const pluginContext = this;
const markdocConfig = await setupConfig(userMarkdocConfig, options);
const filePath = fileURLToPath(fileUrl);
raiseValidationErrors({
ast,
/* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
markdocConfig: markdocConfig as MarkdocConfig,
entry,
viteId,
astroConfig,
filePath,
});
await resolvePartials({
ast,
markdocConfig: markdocConfig as MarkdocConfig,
fileUrl,
allowHTML: options?.allowHTML,
tokenizer,
pluginContext,
root: astroConfig.root,
raisePartialValidationErrors: (partialAst, partialPath) => {
raiseValidationErrors({
ast: partialAst,
markdocConfig: markdocConfig as MarkdocConfig,
entry,
viteId,
astroConfig,
filePath: partialPath,
});
},
});

const usedTags = getUsedTags(ast);

let componentConfigByTagMap: Record<string, ComponentConfig> = {};
// Only include component imports for tags used in the document.
Expand All @@ -59,42 +91,6 @@ export async function getContentEntryType({
}
}

const pluginContext = this;
const markdocConfig = await setupConfig(userMarkdocConfig, options);

const filePath = fileURLToPath(fileUrl);

const validationErrors = Markdoc.validate(
ast,
/* Raised generics issue with Markdoc core https://github.com/markdoc/markdoc/discussions/400 */
markdocConfig as MarkdocConfig
).filter((e) => {
return (
// Ignore `variable-undefined` errors.
// Variables can be configured at runtime,
// so we cannot validate them at build time.
e.error.id !== 'variable-undefined' &&
(e.error.level === 'error' || e.error.level === 'critical')
);
});
if (validationErrors.length) {
// Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
throw new MarkdocError({
message: [
`**${String(rootRelativePath)}** contains invalid content:`,
...validationErrors.map((e) => `- ${e.error.message}`),
].join('\n'),
location: {
// Error overlay does not support multi-line or ranges.
// Just point to the first line.
line: frontmatterBlockOffset + validationErrors[0].lines[0],
file: viteId,
},
});
}

await emitOptimizedImages(ast.children, {
astroConfig,
pluginContext,
Expand Down Expand Up @@ -142,6 +138,124 @@ export const Content = createContentComponent(
};
}

/**
* Recursively resolve partial tags to their content.
* Note: Mutates the `ast` object directly.
*/
async function resolvePartials({
ast,
fileUrl,
root,
tokenizer,
allowHTML,
markdocConfig,
pluginContext,
raisePartialValidationErrors,
}: {
ast: Node;
fileUrl: URL;
root: URL;
tokenizer: any;
allowHTML?: boolean;
markdocConfig: MarkdocConfig;
pluginContext: Rollup.PluginContext;
raisePartialValidationErrors: (ast: Node, filePath: string) => void;
}) {
const rootRelativePath = path.relative(fileURLToPath(root), fileURLToPath(fileUrl));
for (const node of ast.walk()) {
if (node.type === 'tag' && node.tag === 'partial') {
const { file } = node.attributes;
if (!file) {
throw new MarkdocError({
// Should be caught by Markdoc validation step.
message: `(Uncaught error) Partial tag requires a 'file' attribute`,
});
}

if (markdocConfig.partials?.[file]) continue;

const partialPath = path.resolve(path.dirname(fileURLToPath(fileUrl)), file);
pluginContext.addWatchFile(partialPath);
let partialContents: string;
try {
partialContents = await fs.promises.readFile(partialPath, 'utf-8');
} catch {
throw new MarkdocError({
message: [
`**${String(rootRelativePath)}** contains invalid content:`,
`Could not read partial file ${JSON.stringify(file)}. Does the file exist?`,
].join('\n'),
});
}
let partialTokens = tokenizer.tokenize(partialContents);
if (allowHTML) {
partialTokens = htmlTokenTransform(tokenizer, partialTokens);
}
const partialAst = Markdoc.parse(partialTokens);
raisePartialValidationErrors(partialAst, partialPath);
await resolvePartials({
ast: partialAst,
root,
fileUrl: pathToFileURL(partialPath),
tokenizer,
allowHTML,
markdocConfig,
pluginContext,
raisePartialValidationErrors,
});

Object.assign(node, partialAst);
}
}
}

function raiseValidationErrors({
ast,
markdocConfig,
entry,
viteId,
astroConfig,
filePath,
}: {
ast: Node;
markdocConfig: MarkdocConfig;
entry: ReturnType<typeof getEntryInfo>;
viteId: string;
astroConfig: AstroConfig;
filePath: string;
}) {
const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
return (
(e.error.level === 'error' || e.error.level === 'critical') &&
// Ignore `variable-undefined` errors.
// Variables can be configured at runtime,
// so we cannot validate them at build time.
e.error.id !== 'variable-undefined' &&
// Ignore missing partial errors.
// We will resolve these in `resolvePartials`.
!(e.error.id === 'attribute-value-invalid' && e.error.message.match(/^Partial .+ not found/))
);
});

if (validationErrors.length) {
// Heuristic: take number of newlines for `rawData` and add 2 for the `---` fences
const frontmatterBlockOffset = entry.rawData.split('\n').length + 2;
const rootRelativePath = path.relative(fileURLToPath(astroConfig.root), filePath);
throw new MarkdocError({
message: [
`**${String(rootRelativePath)}** contains invalid content:`,
...validationErrors.map((e) => `- ${e.error.message}`),
].join('\n'),
location: {
// Error overlay does not support multi-line or ranges.
// Just point to the first line.
line: frontmatterBlockOffset + validationErrors[0].lines[0],
file: viteId,
},
});
}
}

function getUsedTags(markdocAst: Node) {
const tags = new Set<string>();
const validationErrors = Markdoc.validate(markdocAst);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## HTML in a partial

<ul>
<li id="partial">List item</li>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: With Partial
---

{% partial file="_partial.mdoc" /%}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import markdoc from '@astrojs/markdoc';
import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Markdoc, defineMarkdocConfig } from '@astrojs/markdoc/config';

export default defineMarkdocConfig({
partials: {
configured: Markdoc.parse('# Configured partial {% #configured %}'),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/markdoc-render-partials",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Partial {% #top %}

{% partial file="../nested/_partial.mdoc" /%}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Post with partials
---

{% partial file="_partial.mdoc" /%}

{% partial file="configured" /%}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
## Nested partial {% #nested %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
import { getEntryBySlug } from 'astro:content';

const post = await getEntryBySlug('blog', 'with-partials');
const { Content } = await post.render();
---

<!doctype html>
<html lang="en">
<head>
<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</title>
</head>
<body>
<Content />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import markdoc from '@astrojs/markdoc';
import { defineConfig } from 'astro/config';

import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
integrations: [markdoc()],
});
integrations: [markdoc(), react()]
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { component, defineMarkdocConfig } from '@astrojs/markdoc/config';
import { Markdoc, component, defineMarkdocConfig } from '@astrojs/markdoc/config';

export default defineMarkdocConfig({
nodes: {
Expand All @@ -22,5 +22,11 @@ export default defineMarkdocConfig({
},
},
},
counter: {
render: component('./src/components/CounterWrapper.astro'),
},
'deeply-nested': {
render: component('./src/components/DeeplyNested.astro'),
},
},
})
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
"@astrojs/react": "^3.1.0",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23",
"astro": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(1);
return (
<button id="counter" onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import Counter from './Counter';
---

<Counter client:load />
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---

<p id="deeply-nested">Deeply nested partial</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Render components from a deeply nested partial:

{% deeply-nested /%}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Hello from a partial!

Render a component from a partial:

{% counter /%}

{% partial file="../_nested.mdoc" /%}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This uses a custom marquee component with a shortcode:
I'm a marquee too!
{% /marquee-element %}

{% partial file="_counter.mdoc" /%}

And a code component for code blocks:

```js
Expand Down
Loading
Loading