Compare commits

...

11 commits

Author SHA1 Message Date
bholmesdev
d402d4b380 docs: add README reference 2023-03-20 14:34:18 -04:00
bholmesdev
a97781af62 Revert "deps: rollup types"
This reverts commit 484ccb1c81.
2023-03-17 19:06:06 -04:00
bholmesdev
0f06166eb4 fix: remove rollup type import 2023-03-17 19:05:40 -04:00
bholmesdev
1c41762e8c chore: changeset 2023-03-17 19:04:29 -04:00
bholmesdev
1db3825e98 test: entry properties can be rendered 2023-03-17 18:51:29 -04:00
bholmesdev
61e4425494 fix: wait for in-flight entry resolution 2023-03-17 18:32:49 -04:00
bholmesdev
acbb8b7e6d feat: implement with cache 2023-03-17 17:56:32 -04:00
bholmesdev
83af723ac5 fix: properly show mdoc errors in overlay 2023-03-17 17:56:13 -04:00
bholmesdev
ec003b5b53 feat: get entry data in there 2023-03-17 16:16:50 -04:00
bholmesdev
484ccb1c81 deps: rollup types 2023-03-17 16:16:40 -04:00
bholmesdev
255b0b6e0c wip: dream api 2023-03-15 17:13:30 -04:00
13 changed files with 371 additions and 130 deletions

View file

@ -0,0 +1,14 @@
---
'astro': patch
'@astrojs/markdoc': patch
---
Allow access to content collection entry information (including parsed frontmatter and the entry slug) from your Markdoc using the `$entry` variable:
```mdx
---
title: Hello Markdoc!
---
# {% $entry.data.title %}
```

View file

@ -10,6 +10,7 @@ import type {
import type * as babel from '@babel/core';
import type { OutgoingHttpHeaders } from 'http';
import type { AddressInfo } from 'net';
import type * as rollup from 'rollup';
import type { TsConfigJson } from 'tsconfig-resolver';
import type * as vite from 'vite';
import type { z } from 'zod';
@ -1034,12 +1035,27 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
integrations: AstroIntegration[];
}
export type ContentEntryModule = {
id: string;
collection: string;
slug: string;
body: string;
data: Record<string, unknown>;
_internal: {
rawData: string;
filePath: string;
};
};
export interface ContentEntryType {
extensions: string[];
getEntryInfo(params: {
fileUrl: URL;
contents: string;
}): GetEntryInfoReturnType | Promise<GetEntryInfoReturnType>;
getRenderModule?(params: {
entry: ContentEntryModule;
}): rollup.LoadResult | Promise<rollup.LoadResult>;
contentModuleTypes?: string;
}

View file

@ -1,6 +1,8 @@
import * as devalue from 'devalue';
import type fsMod from 'node:fs';
import type { ContentEntryModule } from '../@types/astro.js';
import { extname } from 'node:path';
import type { PluginContext } from 'rollup';
import { pathToFileURL } from 'url';
import type { Plugin } from 'vite';
import type { AstroSettings, ContentEntryType } from '../@types/astro.js';
@ -10,6 +12,7 @@ import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index
import { CONTENT_FLAG } from './consts.js';
import {
ContentConfig,
EntryInfo,
getContentEntryExts,
getContentPaths,
getEntryData,
@ -17,6 +20,7 @@ import {
getEntrySlug,
getEntryType,
globalContentConfigObserver,
NoCollectionError,
patchAssets,
} from './utils.js';
function isContentFlagImport(viteId: string, contentEntryExts: string[]) {
@ -30,7 +34,7 @@ export function astroContentImportPlugin({
}: {
fs: typeof fsMod;
settings: AstroSettings;
}): Plugin {
}): Plugin[] {
const contentPaths = getContentPaths(settings.config, fs);
const contentEntryExts = getContentEntryExts(settings);
@ -41,116 +45,201 @@ export function astroContentImportPlugin({
}
}
return {
name: 'astro:content-imports',
async load(id) {
const { fileId } = getFileInfo(id, settings.config);
if (isContentFlagImport(id, contentEntryExts)) {
const observable = globalContentConfigObserver.get();
// Content config should be loaded before this plugin is used
if (observable.status === 'init') {
throw new AstroError({
...AstroErrorData.UnknownContentCollectionError,
message: 'Content config failed to load.',
const plugins: Plugin[] = [
{
name: 'astro:content-imports',
async load(viteId) {
if (isContentFlagImport(viteId, contentEntryExts)) {
const { fileId } = getFileInfo(viteId, settings.config);
const { id, slug, collection, body, data, _internal } = await setContentEntryModuleCache({
fileId,
pluginContext: this,
});
}
if (observable.status === 'error') {
// Throw here to bubble content config errors
// to the error overlay in development
throw observable.error;
}
let contentConfig: ContentConfig | undefined =
observable.status === 'loaded' ? observable.config : undefined;
if (observable.status === 'loading') {
// Wait for config to load
contentConfig = await new Promise((resolve) => {
const unsubscribe = globalContentConfigObserver.subscribe((ctx) => {
if (ctx.status === 'loaded') {
resolve(ctx.config);
unsubscribe();
} else if (ctx.status === 'error') {
resolve(undefined);
unsubscribe();
}
});
});
}
const rawContents = await fs.promises.readFile(fileId, 'utf-8');
const fileExt = extname(fileId);
if (!contentEntryExtToParser.has(fileExt)) {
throw new AstroError({
...AstroErrorData.UnknownContentCollectionError,
message: `No parser found for content entry ${JSON.stringify(
fileId
)}. Did you apply an integration for this file type?`,
});
}
const contentEntryParser = contentEntryExtToParser.get(fileExt)!;
const info = await contentEntryParser.getEntryInfo({
fileUrl: pathToFileURL(fileId),
contents: rawContents,
});
const generatedInfo = getEntryInfo({
entry: pathToFileURL(fileId),
contentDir: contentPaths.contentDir,
});
if (generatedInfo instanceof Error) return;
const _internal = { filePath: fileId, rawData: info.rawData };
// TODO: move slug calculation to the start of the build
// to generate a performant lookup map for `getEntryBySlug`
const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug });
const collectionConfig = contentConfig?.collections[generatedInfo.collection];
let data = collectionConfig
? await getEntryData(
{ ...generatedInfo, _internal, unvalidatedData: info.data },
collectionConfig
)
: info.data;
await patchAssets(data, this.meta.watchMode, this.emitFile, settings);
const code = escapeViteEnvReferences(`
export const id = ${JSON.stringify(generatedInfo.id)};
export const collection = ${JSON.stringify(generatedInfo.collection)};
const code = escapeViteEnvReferences(`
export const id = ${JSON.stringify(id)};
export const collection = ${JSON.stringify(collection)};
export const slug = ${JSON.stringify(slug)};
export const body = ${JSON.stringify(info.body)};
export const body = ${JSON.stringify(body)};
export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */};
export const _internal = {
filePath: ${JSON.stringify(_internal.filePath)},
rawData: ${JSON.stringify(_internal.rawData)},
};
`);
return { code };
}
},
configureServer(viteServer) {
viteServer.watcher.on('all', async (event, entry) => {
if (
['add', 'unlink', 'change'].includes(event) &&
getEntryType(entry, contentPaths, contentEntryExts) === 'config'
) {
// Content modules depend on config, so we need to invalidate them.
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
if (isContentFlagImport(modUrl, contentEntryExts)) {
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
if (mod) {
viteServer.moduleGraph.invalidateModule(mod);
return { code };
}
},
configureServer(viteServer) {
viteServer.watcher.on('all', async (event, entry) => {
if (
['add', 'unlink', 'change'].includes(event) &&
getEntryType(entry, contentPaths, contentEntryExts) === 'config'
) {
// Content modules depend on config, so we need to invalidate them.
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
if (
isContentFlagImport(modUrl, contentEntryExts) ||
// TODO: refine to content types with getModule
contentEntryExts.some((ext) => modUrl.endsWith(ext))
) {
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
if (mod) {
viteServer.moduleGraph.invalidateModule(mod);
}
}
}
}
});
},
async transform(code, id) {
if (isContentFlagImport(id, contentEntryExts)) {
// Escape before Rollup internal transform.
// Base on MUCH trial-and-error, inspired by MDX integration 2-step transform.
return { code: escapeViteEnvReferences(code) };
}
},
},
];
if (settings.contentEntryTypes.some((t) => t.getRenderModule)) {
plugins.push({
name: 'astro:content-render-imports',
async load(viteId) {
if (!contentEntryExts.some((ext) => viteId.endsWith(ext))) return;
const { fileId } = getFileInfo(viteId, settings.config);
for (const contentEntryType of settings.contentEntryTypes) {
if (contentEntryType.getRenderModule) {
const entry = await getContentEntryModuleFromCache(fileId);
// Cached entry must exist (or be in-flight) when importing the module via content collections.
// This is ensured by the `astro:content-imports` plugin.
if (!entry)
throw new AstroError({
...AstroErrorData.UnknownContentCollectionError,
message: `Unable to render ${JSON.stringify(
fileId
)}. Did you import this module directly without using a content collection query?`,
});
return contentEntryType.getRenderModule({ entry });
}
}
},
});
}
// Used by the `render-module` plugin to avoid double-parsing your schema
const contentEntryModuleByIdCache = new Map<string, ContentEntryModule | 'loading'>();
const awaitingCacheById = new Map<string, ((val: ContentEntryModule) => void)[]>();
function getContentEntryModuleFromCache(id: string) {
const value = contentEntryModuleByIdCache.get(id);
// It's possible for Vite to load modules that depend on this cache
// before the cache is populated. In that case, we queue a promise
// to be resolved by `setContentEntryModuleCache`.
if (value === 'loading') {
return new Promise<ContentEntryModule>((resolve, reject) => {
const awaiting = awaitingCacheById.get(id) ?? [];
awaiting.push(resolve);
awaitingCacheById.set(id, awaiting);
});
},
async transform(code, id) {
if (isContentFlagImport(id, contentEntryExts)) {
// Escape before Rollup internal transform.
// Base on MUCH trial-and-error, inspired by MDX integration 2-step transform.
return { code: escapeViteEnvReferences(code) };
} else if (value) {
return Promise.resolve(value);
}
return Promise.resolve(undefined);
}
async function setContentEntryModuleCache({
fileId,
pluginContext,
}: {
fileId: string;
pluginContext: PluginContext;
}): Promise<ContentEntryModule> {
contentEntryModuleByIdCache.set(fileId, 'loading');
const observable = globalContentConfigObserver.get();
// Content config should be loaded before this plugin is used
if (observable.status === 'init') {
throw new AstroError({
...AstroErrorData.UnknownContentCollectionError,
message: 'Content config failed to load.',
});
}
if (observable.status === 'error') {
// Throw here to bubble content config errors
// to the error overlay in development
throw observable.error;
}
let contentConfig: ContentConfig | undefined =
observable.status === 'loaded' ? observable.config : undefined;
if (observable.status === 'loading') {
// Wait for config to load
contentConfig = await new Promise((resolve) => {
const unsubscribe = globalContentConfigObserver.subscribe((ctx) => {
if (ctx.status === 'loaded') {
resolve(ctx.config);
unsubscribe();
} else if (ctx.status === 'error') {
resolve(undefined);
unsubscribe();
}
});
});
}
const rawContents = await fs.promises.readFile(fileId, 'utf-8');
const fileExt = extname(fileId);
if (!contentEntryExtToParser.has(fileExt)) {
throw new AstroError({
...AstroErrorData.UnknownContentCollectionError,
message: `No parser found for content entry ${JSON.stringify(
fileId
)}. Did you apply an integration for this file type?`,
});
}
const contentEntryParser = contentEntryExtToParser.get(fileExt)!;
const info = await contentEntryParser.getEntryInfo({
fileUrl: pathToFileURL(fileId),
contents: rawContents,
});
const generatedInfo = getEntryInfo({
entry: pathToFileURL(fileId),
contentDir: contentPaths.contentDir,
});
if (generatedInfo instanceof NoCollectionError) throw generatedInfo;
const _internal = { filePath: fileId, rawData: info.rawData };
// TODO: move slug calculation to the start of the build
// to generate a performant lookup map for `getEntryBySlug`
const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug });
const collectionConfig = contentConfig?.collections[generatedInfo.collection];
let data = collectionConfig
? await getEntryData(
{ ...generatedInfo, _internal, unvalidatedData: info.data },
collectionConfig
)
: info.data;
await patchAssets(data, pluginContext.meta.watchMode, pluginContext.emitFile, settings);
const contentEntryModule: ContentEntryModule = {
...generatedInfo,
_internal,
slug,
data,
body: info.body,
};
contentEntryModuleByIdCache.set(fileId, contentEntryModule);
const awaiting = awaitingCacheById.get(fileId);
if (awaiting) {
for (const resolve of awaiting) {
resolve(contentEntryModule);
}
},
};
awaitingCacheById.delete(fileId);
}
return contentEntryModule;
}
return plugins;
}

View file

@ -125,6 +125,7 @@ export interface AstroErrorPayload {
// Shiki does not support `mjs` or `cjs` aliases by default.
// Map these to `.js` during error highlighting.
const ALTERNATIVE_JS_EXTS = ['cjs', 'mjs'];
const ALTERNATIVE_MD_EXTS = ['mdoc'];
/**
* Generate a payload for Vite's error overlay
@ -158,6 +159,9 @@ export async function getViteErrorPayload(err: ErrorWithMetadata): Promise<Astro
if (ALTERNATIVE_JS_EXTS.includes(highlighterLang ?? '')) {
highlighterLang = 'js';
}
if (ALTERNATIVE_MD_EXTS.includes(highlighterLang ?? '')) {
highlighterLang = 'md';
}
const highlightedCode = err.fullCode
? highlighter.codeToHtml(err.fullCode, {
lang: highlighterLang,

View file

@ -237,6 +237,20 @@ const { Content } = await entry.render();
/>
```
### Access frontmatter and content collection information from your templates
You can access content collection information from your Markdoc templates using the `$entry` variable. This includes the entry `slug`, `collection` name, and frontmatter `data` parsed by your content collection schema (if any). This example renders the `title` frontmatter property as a heading:
```md
---
title: Welcome to Markdoc 👋
---
# {% $entry.data.title %}
```
The `$entry` object matches [the `CollectionEntry`type](https://docs.astro.build/en/reference/api-reference/#collection-entry-type), excluding the `.render()` property.
### 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).

View file

@ -3,13 +3,7 @@ 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 { InlineConfig } from 'vite';
import {
getAstroConfigPath,
MarkdocError,
parseFrontmatter,
prependForwardSlash,
} from './utils.js';
import { getAstroConfigPath, MarkdocError, parseFrontmatter } from './utils.js';
type IntegrationWithPrivateHooks = {
name: string;
@ -41,36 +35,27 @@ export default function markdoc(markdocConfig: Config = {}): IntegrationWithPriv
addContentEntryType({
extensions: ['.mdoc'],
getEntryInfo,
getRenderModule({ entry }) {
validateRenderProperties(markdocConfig, config);
const ast = Markdoc.parse(entry.body);
const content = Markdoc.transform(ast, {
...markdocConfig,
variables: {
...markdocConfig.variables,
entry,
},
});
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;`,
};
},
contentModuleTypes: await fs.promises.readFile(
new URL('../template/content-module-types.d.ts', import.meta.url),
'utf-8'
),
});
const viteConfig: InlineConfig = {
plugins: [
{
name: '@astrojs/markdoc',
async transform(code, id) {
if (!id.endsWith('.mdoc')) return;
validateRenderProperties(markdocConfig, config);
const body = getEntryInfo({
// Can't use `pathToFileUrl` - Vite IDs are not plain file paths
fileUrl: new URL(prependForwardSlash(id), 'file://'),
contents: code,
}).body;
const ast = Markdoc.parse(body);
const content = Markdoc.transform(ast, markdocConfig);
return `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;`;
},
},
],
};
updateConfig({ vite: viteConfig });
},
},
};

View file

@ -0,0 +1,58 @@
import { parseHTML } from 'linkedom';
import { expect } from 'chai';
import { loadFixture } from '../../../astro/test/test-utils.js';
import markdoc from '../dist/index.js';
const root = new URL('./fixtures/entry-prop/', import.meta.url);
describe('Markdoc - Entry prop', () => {
let baseFixture;
before(async () => {
baseFixture = await loadFixture({
root,
integrations: [markdoc()],
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await baseFixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('has expected entry properties', async () => {
const res = await baseFixture.fetch('/');
const html = await res.text();
const { document } = parseHTML(html);
expect(document.querySelector('h1')?.textContent).to.equal('Processed by schema: Test entry');
expect(document.getElementById('id')?.textContent?.trim()).to.equal('id: entry.mdoc');
expect(document.getElementById('slug')?.textContent?.trim()).to.equal('slug: entry');
expect(document.getElementById('collection')?.textContent?.trim()).to.equal(
'collection: blog'
);
});
});
describe('build', () => {
before(async () => {
await baseFixture.build();
});
it('has expected entry properties', async () => {
const html = await baseFixture.readFile('/index.html');
const { document } = parseHTML(html);
expect(document.querySelector('h1')?.textContent).to.equal('Processed by schema: Test entry');
expect(document.getElementById('id')?.textContent?.trim()).to.equal('id: entry.mdoc');
expect(document.getElementById('slug')?.textContent?.trim()).to.equal('slug: entry');
expect(document.getElementById('collection')?.textContent?.trim()).to.equal(
'collection: blog'
);
});
});
});

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-entry-prop",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,9 @@
---
title: Test entry
---
# {% $entry.data.title %}
- id: {% $entry.id %} {% #id %}
- slug: {% $entry.slug %} {% #slug %}
- collection: {% $entry.collection %} {% #collection %}

View file

@ -0,0 +1,9 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: z.object({
title: z.string().transform(v => 'Processed by schema: ' + v),
}),
});
export const collections = { blog }

View file

@ -0,0 +1,19 @@
---
import { getEntryBySlug } from 'astro:content';
const entry = await getEntryBySlug('blog', 'entry');
const { Content } = await entry.render();
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<Content />
</body>
</html>

View file

@ -3101,6 +3101,14 @@ importers:
devDependencies:
shiki: 0.11.1
packages/integrations/markdoc/test/fixtures/entry-prop:
specifiers:
'@astrojs/markdoc': workspace:*
astro: workspace:*
dependencies:
'@astrojs/markdoc': link:../../..
astro: link:../../../../../astro
packages/integrations/mdx:
specifiers:
'@astrojs/markdown-remark': ^2.1.0