@astrojs/markdoc 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- @astrojs/markdoc:build: cache hit, replaying output 30bf30e6debacf79
2
- @astrojs/markdoc:build: 
3
- @astrojs/markdoc:build: > @astrojs/markdoc@0.0.3 build /home/runner/work/astro/astro/packages/integrations/markdoc
4
- @astrojs/markdoc:build: > astro-scripts build "src/**/*.ts" && tsc
5
- @astrojs/markdoc:build: 
1
+ @astrojs/markdoc:build: cache hit, replaying output 0cf4b43fcaef6989
2
+ @astrojs/markdoc:build: 
3
+ @astrojs/markdoc:build: > @astrojs/markdoc@0.0.5 build /home/runner/work/astro/astro/packages/integrations/markdoc
4
+ @astrojs/markdoc:build: > astro-scripts build "src/**/*.ts" && tsc
5
+ @astrojs/markdoc:build: 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @astrojs/markdoc
2
2
 
3
+ ## 0.0.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [#6630](https://github.com/withastro/astro/pull/6630) [`cfcf2e2ff`](https://github.com/withastro/astro/commit/cfcf2e2ffdaa68ace5c84329c05b83559a29d638) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Support automatic image optimization for Markdoc images when using `experimental.assets`. You can [follow our Assets guide](https://docs.astro.build/en/guides/assets/#enabling-assets-in-your-project) to enable this feature in your project. Then, start using relative or aliased image sources in your Markdoc files for automatic optimization:
8
+
9
+ ```md
10
+ <!--Relative paths-->
11
+
12
+ ![The Milky Way Galaxy](../assets/galaxy.jpg)
13
+
14
+ <!--Or configured aliases-->
15
+
16
+ ![Houston smiling and looking cute](~/assets/houston-smiling.jpg)
17
+ ```
18
+
19
+ - Updated dependencies [[`b7194103e`](https://github.com/withastro/astro/commit/b7194103e39267bf59dcd6ba00f522e424219d16), [`cfcf2e2ff`](https://github.com/withastro/astro/commit/cfcf2e2ffdaa68ace5c84329c05b83559a29d638), [`45da39a86`](https://github.com/withastro/astro/commit/45da39a8642d64eb318840b18dfc2b5ccc6561bc), [`7daef9a29`](https://github.com/withastro/astro/commit/7daef9a2993b5d457f3d243a1ebfd1dd383b3327)]:
20
+ - astro@2.1.7
21
+
22
+ ## 0.0.4
23
+
24
+ ### Patch Changes
25
+
26
+ - [#6588](https://github.com/withastro/astro/pull/6588) [`f42f47dc6`](https://github.com/withastro/astro/commit/f42f47dc6a91cdb6534dab0ecbf9e8e85f00ba40) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Allow access to content collection entry information (including parsed frontmatter and the entry slug) from your Markdoc using the `$entry` variable:
27
+
28
+ ```mdx
29
+ ---
30
+ title: Hello Markdoc!
31
+ ---
32
+
33
+ # {% $entry.data.title %}
34
+ ```
35
+
36
+ - [#6607](https://github.com/withastro/astro/pull/6607) [`86273b588`](https://github.com/withastro/astro/commit/86273b5881cc61ebee11d40280b4c0aba8f4bb2e) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Fix: Update Markdoc renderer internals to remove unneeded dependencies
37
+
38
+ - [#6622](https://github.com/withastro/astro/pull/6622) [`b37b86540`](https://github.com/withastro/astro/commit/b37b865400e77e92878d7e150244acce47e933c6) Thanks [@paulrudy](https://github.com/paulrudy)! - Fix README instructions for installing Markdoc manually.
39
+
3
40
  ## 0.0.3
4
41
 
5
42
  ### Patch Changes
package/README.md CHANGED
@@ -237,6 +237,20 @@ const { Content } = await entry.render();
237
237
  />
238
238
  ```
239
239
 
240
+ ### Access frontmatter and content collection information from your templates
241
+
242
+ 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:
243
+
244
+ ```md
245
+ ---
246
+ title: Welcome to Markdoc 👋
247
+ ---
248
+
249
+ # {% $entry.data.title %}
250
+ ```
251
+
252
+ The `$entry` object matches [the `CollectionEntry` type](https://docs.astro.build/en/reference/api-reference/#collection-entry-type), excluding the `.render()` property.
253
+
240
254
  ### Markdoc config
241
255
 
242
256
  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).
@@ -292,11 +306,11 @@ You will need to install the `@markdoc/markdoc` package into your project first:
292
306
 
293
307
  ```sh
294
308
  # Using NPM
295
- npx astro add @markdoc/markdoc
309
+ npm install @markdoc/markdoc
296
310
  # Using Yarn
297
- yarn astro add @markdoc/markdoc
311
+ yarn add @markdoc/markdoc
298
312
  # Using PNPM
299
- pnpm astro add @markdoc/markdoc
313
+ pnpm add @markdoc/markdoc
300
314
  ```
301
315
 
302
316
  Now, you can define Markdoc configuration options using `Markdock.transform()`.
@@ -2,8 +2,7 @@
2
2
  import type { RenderableTreeNode } from '@markdoc/markdoc';
3
3
  import type { AstroInstance } from 'astro';
4
4
  import { validateComponentsProp } from '../dist/utils.js';
5
- import { createAstroNode } from './astroNode';
6
- import RenderNode from './RenderNode.astro';
5
+ import { ComponentNode, createTreeNode } from './TreeNode.js';
7
6
 
8
7
  type Props = {
9
8
  content: RenderableTreeNode;
@@ -18,4 +17,4 @@ if (components) {
18
17
  }
19
18
  ---
20
19
 
21
- <RenderNode node={createAstroNode(content, components)} />
20
+ <ComponentNode treeNode={createTreeNode(content, components)} />
@@ -0,0 +1,90 @@
1
+ import type { AstroInstance } from 'astro';
2
+ import type { RenderableTreeNode } from '@markdoc/markdoc';
3
+ import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js';
4
+ // @ts-expect-error Cannot find module 'astro:markdoc-assets' or its corresponding type declarations
5
+ import { Image } from 'astro:markdoc-assets';
6
+ import Markdoc from '@markdoc/markdoc';
7
+ import { MarkdocError, isCapitalized } from '../dist/utils.js';
8
+
9
+ export type TreeNode =
10
+ | {
11
+ type: 'text';
12
+ content: string;
13
+ }
14
+ | {
15
+ type: 'component';
16
+ component: AstroInstance['default'];
17
+ props: Record<string, any>;
18
+ children: TreeNode[];
19
+ }
20
+ | {
21
+ type: 'element';
22
+ tag: string;
23
+ attributes: Record<string, any>;
24
+ children: TreeNode[];
25
+ };
26
+
27
+ export const ComponentNode = createComponent({
28
+ factory(result: any, { treeNode }: { treeNode: TreeNode }) {
29
+ if (treeNode.type === 'text') return render`${treeNode.content}`;
30
+ const slots = {
31
+ default: () =>
32
+ render`${treeNode.children.map((child) =>
33
+ renderComponent(result, 'ComponentNode', ComponentNode, { treeNode: child })
34
+ )}`,
35
+ };
36
+ if (treeNode.type === 'component') {
37
+ return renderComponent(
38
+ result,
39
+ treeNode.component.name,
40
+ treeNode.component,
41
+ treeNode.props,
42
+ slots
43
+ );
44
+ }
45
+ return renderComponent(result, treeNode.tag, treeNode.tag, treeNode.attributes, slots);
46
+ },
47
+ propagation: 'none',
48
+ });
49
+
50
+ const builtInComponents: Record<string, AstroInstance['default']> = {
51
+ Image,
52
+ };
53
+
54
+ export function createTreeNode(
55
+ node: RenderableTreeNode,
56
+ userComponents: Record<string, AstroInstance['default']> = {}
57
+ ): TreeNode {
58
+ const components = { ...userComponents, ...builtInComponents };
59
+
60
+ if (typeof node === 'string' || typeof node === 'number') {
61
+ return { type: 'text', content: String(node) };
62
+ } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) {
63
+ return { type: 'text', content: '' };
64
+ }
65
+
66
+ if (node.name in components) {
67
+ const component = components[node.name];
68
+ const props = node.attributes;
69
+ const children = node.children.map((child) => createTreeNode(child, components));
70
+
71
+ return {
72
+ type: 'component',
73
+ component,
74
+ props,
75
+ children,
76
+ };
77
+ } else if (isCapitalized(node.name)) {
78
+ throw new MarkdocError({
79
+ message: `Unable to render ${JSON.stringify(node.name)}.`,
80
+ hint: 'Did you add this to the "components" prop on your <Content /> component?',
81
+ });
82
+ } else {
83
+ return {
84
+ type: 'element',
85
+ tag: node.name,
86
+ attributes: node.attributes,
87
+ children: node.children.map((child) => createTreeNode(child, components)),
88
+ };
89
+ }
90
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { Config } from '@markdoc/markdoc';
1
+ import type { Config as ReadonlyMarkdocConfig } from '@markdoc/markdoc';
2
2
  import type { AstroIntegration } from 'astro';
3
- export default function markdoc(markdocConfig?: Config): AstroIntegration;
3
+ export default function markdocIntegration(userMarkdocConfig?: ReadonlyMarkdocConfig): AstroIntegration;
package/dist/index.js CHANGED
@@ -3,16 +3,27 @@ import fs from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  getAstroConfigPath,
6
+ isValidUrl,
6
7
  MarkdocError,
7
8
  parseFrontmatter,
8
9
  prependForwardSlash
9
10
  } from "./utils.js";
10
- function markdoc(markdocConfig = {}) {
11
+ import { emitESMImage } from "astro/assets";
12
+ function markdocIntegration(userMarkdocConfig = {}) {
11
13
  return {
12
14
  name: "@astrojs/markdoc",
13
15
  hooks: {
14
16
  "astro:config:setup": async (params) => {
15
- const { updateConfig, config, addContentEntryType } = params;
17
+ const {
18
+ updateConfig,
19
+ config: astroConfig,
20
+ addContentEntryType
21
+ } = params;
22
+ updateConfig({
23
+ vite: {
24
+ plugins: [safeAssetsVirtualModulePlugin({ astroConfig })]
25
+ }
26
+ });
16
27
  function getEntryInfo({ fileUrl, contents }) {
17
28
  const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
18
29
  return {
@@ -25,42 +36,85 @@ function markdoc(markdocConfig = {}) {
25
36
  addContentEntryType({
26
37
  extensions: [".mdoc"],
27
38
  getEntryInfo,
39
+ async getRenderModule({ entry }) {
40
+ var _a;
41
+ validateRenderProperties(userMarkdocConfig, astroConfig);
42
+ const ast = Markdoc.parse(entry.body);
43
+ const pluginContext = this;
44
+ const markdocConfig = {
45
+ ...userMarkdocConfig,
46
+ variables: {
47
+ ...userMarkdocConfig.variables,
48
+ entry
49
+ }
50
+ };
51
+ if ((_a = astroConfig.experimental) == null ? void 0 : _a.assets) {
52
+ await emitOptimizedImages(ast.children, {
53
+ astroConfig,
54
+ pluginContext,
55
+ filePath: entry._internal.filePath
56
+ });
57
+ markdocConfig.nodes ??= {};
58
+ markdocConfig.nodes.image = {
59
+ ...Markdoc.nodes.image,
60
+ transform(node, config) {
61
+ const attributes = node.transformAttributes(config);
62
+ const children = node.transformChildren(config);
63
+ if (node.type === "image" && "__optimizedSrc" in node.attributes) {
64
+ const { __optimizedSrc, ...rest } = node.attributes;
65
+ return new Markdoc.Tag("Image", { ...rest, src: __optimizedSrc }, children);
66
+ } else {
67
+ return new Markdoc.Tag("img", attributes, children);
68
+ }
69
+ }
70
+ };
71
+ }
72
+ const content = Markdoc.transform(ast, markdocConfig);
73
+ return {
74
+ code: `import { jsx as h } from 'astro/jsx-runtime';
75
+ import { Renderer } from '@astrojs/markdoc/components';
76
+ const transformedContent = ${JSON.stringify(
77
+ content
78
+ )};
79
+ export async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }
80
+ Content[Symbol.for('astro.needsHeadRendering')] = true;`
81
+ };
82
+ },
28
83
  contentModuleTypes: await fs.promises.readFile(
29
84
  new URL("../template/content-module-types.d.ts", import.meta.url),
30
85
  "utf-8"
31
86
  )
32
87
  });
33
- const viteConfig = {
34
- plugins: [
35
- {
36
- name: "@astrojs/markdoc",
37
- async transform(code, id) {
38
- if (!id.endsWith(".mdoc"))
39
- return;
40
- validateRenderProperties(markdocConfig, config);
41
- const body = getEntryInfo({
42
- // Can't use `pathToFileUrl` - Vite IDs are not plain file paths
43
- fileUrl: new URL(prependForwardSlash(id), "file://"),
44
- contents: code
45
- }).body;
46
- const ast = Markdoc.parse(body);
47
- const content = Markdoc.transform(ast, markdocConfig);
48
- return `import { jsx as h } from 'astro/jsx-runtime';
49
- import { Renderer } from '@astrojs/markdoc/components';
50
- const transformedContent = ${JSON.stringify(
51
- content
52
- )};
53
- export async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }
54
- Content[Symbol.for('astro.needsHeadRendering')] = true;`;
55
- }
56
- }
57
- ]
58
- };
59
- updateConfig({ vite: viteConfig });
60
88
  }
61
89
  }
62
90
  };
63
91
  }
92
+ async function emitOptimizedImages(nodeChildren, ctx) {
93
+ for (const node of nodeChildren) {
94
+ if (node.type === "image" && typeof node.attributes.src === "string" && shouldOptimizeImage(node.attributes.src)) {
95
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
96
+ if ((resolved == null ? void 0 : resolved.id) && fs.existsSync(new URL(prependForwardSlash(resolved.id), "file://"))) {
97
+ const src = await emitESMImage(
98
+ resolved.id,
99
+ ctx.pluginContext.meta.watchMode,
100
+ ctx.pluginContext.emitFile,
101
+ { config: ctx.astroConfig }
102
+ );
103
+ node.attributes.__optimizedSrc = src;
104
+ } else {
105
+ throw new MarkdocError({
106
+ message: `Could not resolve image ${JSON.stringify(
107
+ node.attributes.src
108
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`
109
+ });
110
+ }
111
+ }
112
+ await emitOptimizedImages(node.children, ctx);
113
+ }
114
+ }
115
+ function shouldOptimizeImage(src) {
116
+ return !isValidUrl(src) && !src.startsWith("/");
117
+ }
64
118
  function validateRenderProperties(markdocConfig, astroConfig) {
65
119
  const tags = markdocConfig.tags ?? {};
66
120
  const nodes = markdocConfig.nodes ?? {};
@@ -100,6 +154,30 @@ function validateRenderProperty({
100
154
  function isCapitalized(str) {
101
155
  return str.length > 0 && str[0] === str[0].toUpperCase();
102
156
  }
157
+ function safeAssetsVirtualModulePlugin({
158
+ astroConfig
159
+ }) {
160
+ const virtualModuleId = "astro:markdoc-assets";
161
+ const resolvedVirtualModuleId = "\0" + virtualModuleId;
162
+ return {
163
+ name: "astro:markdoc-safe-assets-virtual-module",
164
+ resolveId(id) {
165
+ if (id === virtualModuleId) {
166
+ return resolvedVirtualModuleId;
167
+ }
168
+ },
169
+ load(id) {
170
+ var _a;
171
+ if (id !== resolvedVirtualModuleId)
172
+ return;
173
+ if ((_a = astroConfig.experimental) == null ? void 0 : _a.assets) {
174
+ return `export { Image } from 'astro:assets';`;
175
+ } else {
176
+ return `export const Image = () => { throw new Error('Cannot use the Image component without the \`experimental.assets\` flag.'); }`;
177
+ }
178
+ }
179
+ };
180
+ }
103
181
  export {
104
- markdoc as default
182
+ markdocIntegration as default
105
183
  };
package/dist/utils.d.ts CHANGED
@@ -47,4 +47,5 @@ export declare function getAstroConfigPath(fs: typeof fsMod, root: string): stri
47
47
  export declare function prependForwardSlash(str: string): string;
48
48
  export declare function validateComponentsProp(components: Record<string, AstroInstance['default']>): void;
49
49
  export declare function isCapitalized(str: string): boolean;
50
+ export declare function isValidUrl(str: string): boolean;
50
51
  export {};
package/dist/utils.js CHANGED
@@ -84,10 +84,19 @@ const componentsPropValidator = z.record(
84
84
  function isCapitalized(str) {
85
85
  return str.length > 0 && str[0] === str[0].toUpperCase();
86
86
  }
87
+ function isValidUrl(str) {
88
+ try {
89
+ new URL(str);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
87
95
  export {
88
96
  MarkdocError,
89
97
  getAstroConfigPath,
90
98
  isCapitalized,
99
+ isValidUrl,
91
100
  parseFrontmatter,
92
101
  prependForwardSlash,
93
102
  validateComponentsProp
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astrojs/markdoc",
3
3
  "description": "Add support for Markdoc pages in your Astro site",
4
- "version": "0.0.3",
4
+ "version": "0.0.5",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
7
7
  "author": "withastro",
@@ -26,19 +26,22 @@
26
26
  "dependencies": {
27
27
  "@markdoc/markdoc": "^0.2.2",
28
28
  "gray-matter": "^4.0.3",
29
- "stringify-attributes": "^3.0.0",
30
29
  "zod": "^3.17.3"
31
30
  },
31
+ "peerDependencies": {
32
+ "astro": "2.1.7"
33
+ },
32
34
  "devDependencies": {
35
+ "astro": "2.1.7",
33
36
  "@types/chai": "^4.3.1",
34
37
  "@types/html-escaper": "^3.0.0",
35
38
  "@types/mocha": "^9.1.1",
36
- "astro": "2.1.4",
37
39
  "astro-scripts": "0.0.14",
38
40
  "chai": "^4.3.6",
39
41
  "devalue": "^4.2.0",
40
42
  "linkedom": "^0.14.12",
41
43
  "mocha": "^9.2.2",
44
+ "rollup": "^3.20.1",
42
45
  "vite": "^4.0.3"
43
46
  },
44
47
  "engines": {
package/src/index.ts CHANGED
@@ -1,15 +1,23 @@
1
- import type { Config } from '@markdoc/markdoc';
1
+ import type {
2
+ Config as ReadonlyMarkdocConfig,
3
+ ConfigType as MarkdocConfig,
4
+ Node,
5
+ } from '@markdoc/markdoc';
2
6
  import Markdoc from '@markdoc/markdoc';
3
7
  import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
4
8
  import fs from 'node:fs';
5
9
  import { fileURLToPath } from 'node:url';
6
- import type { InlineConfig } from 'vite';
10
+ import type * as rollup from 'rollup';
7
11
  import {
8
12
  getAstroConfigPath,
13
+ isValidUrl,
9
14
  MarkdocError,
10
15
  parseFrontmatter,
11
16
  prependForwardSlash,
12
17
  } from './utils.js';
18
+ // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
19
+ import { emitESMImage } from 'astro/assets';
20
+ import type { Plugin as VitePlugin } from 'vite';
13
21
 
14
22
  type SetupHookParams = HookParameters<'astro:config:setup'> & {
15
23
  // `contentEntryType` is not a public API
@@ -17,12 +25,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
17
25
  addContentEntryType: (contentEntryType: ContentEntryType) => void;
18
26
  };
19
27
 
20
- export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
28
+ export default function markdocIntegration(
29
+ userMarkdocConfig: ReadonlyMarkdocConfig = {}
30
+ ): AstroIntegration {
21
31
  return {
22
32
  name: '@astrojs/markdoc',
23
33
  hooks: {
24
34
  'astro:config:setup': async (params) => {
25
- const { updateConfig, config, addContentEntryType } = params as SetupHookParams;
35
+ const {
36
+ updateConfig,
37
+ config: astroConfig,
38
+ addContentEntryType,
39
+ } = params as SetupHookParams;
40
+
41
+ updateConfig({
42
+ vite: {
43
+ plugins: [safeAssetsVirtualModulePlugin({ astroConfig })],
44
+ },
45
+ });
26
46
 
27
47
  function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
28
48
  const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
@@ -36,42 +56,108 @@ export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
36
56
  addContentEntryType({
37
57
  extensions: ['.mdoc'],
38
58
  getEntryInfo,
59
+ async getRenderModule({ entry }) {
60
+ validateRenderProperties(userMarkdocConfig, astroConfig);
61
+ const ast = Markdoc.parse(entry.body);
62
+ const pluginContext = this;
63
+ const markdocConfig: MarkdocConfig = {
64
+ ...userMarkdocConfig,
65
+ variables: {
66
+ ...userMarkdocConfig.variables,
67
+ entry,
68
+ },
69
+ };
70
+
71
+ if (astroConfig.experimental?.assets) {
72
+ await emitOptimizedImages(ast.children, {
73
+ astroConfig,
74
+ pluginContext,
75
+ filePath: entry._internal.filePath,
76
+ });
77
+
78
+ markdocConfig.nodes ??= {};
79
+ markdocConfig.nodes.image = {
80
+ ...Markdoc.nodes.image,
81
+ transform(node, config) {
82
+ const attributes = node.transformAttributes(config);
83
+ const children = node.transformChildren(config);
84
+
85
+ if (node.type === 'image' && '__optimizedSrc' in node.attributes) {
86
+ const { __optimizedSrc, ...rest } = node.attributes;
87
+ return new Markdoc.Tag('Image', { ...rest, src: __optimizedSrc }, children);
88
+ } else {
89
+ return new Markdoc.Tag('img', attributes, children);
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ const content = Markdoc.transform(ast, markdocConfig);
96
+
97
+ return {
98
+ code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify(
99
+ content
100
+ )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`,
101
+ };
102
+ },
39
103
  contentModuleTypes: await fs.promises.readFile(
40
104
  new URL('../template/content-module-types.d.ts', import.meta.url),
41
105
  'utf-8'
42
106
  ),
43
107
  });
44
-
45
- const viteConfig: InlineConfig = {
46
- plugins: [
47
- {
48
- name: '@astrojs/markdoc',
49
- async transform(code, id) {
50
- if (!id.endsWith('.mdoc')) return;
51
-
52
- validateRenderProperties(markdocConfig, config);
53
- const body = getEntryInfo({
54
- // Can't use `pathToFileUrl` - Vite IDs are not plain file paths
55
- fileUrl: new URL(prependForwardSlash(id), 'file://'),
56
- contents: code,
57
- }).body;
58
- const ast = Markdoc.parse(body);
59
- const content = Markdoc.transform(ast, markdocConfig);
60
-
61
- return `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify(
62
- content
63
- )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`;
64
- },
65
- },
66
- ],
67
- };
68
- updateConfig({ vite: viteConfig });
69
108
  },
70
109
  },
71
110
  };
72
111
  }
73
112
 
74
- function validateRenderProperties(markdocConfig: Config, astroConfig: AstroConfig) {
113
+ /**
114
+ * Emits optimized images, and appends the generated `src` to each AST node
115
+ * via the `__optimizedSrc` attribute.
116
+ */
117
+ async function emitOptimizedImages(
118
+ nodeChildren: Node[],
119
+ ctx: {
120
+ pluginContext: rollup.PluginContext;
121
+ filePath: string;
122
+ astroConfig: AstroConfig;
123
+ }
124
+ ) {
125
+ for (const node of nodeChildren) {
126
+ if (
127
+ node.type === 'image' &&
128
+ typeof node.attributes.src === 'string' &&
129
+ shouldOptimizeImage(node.attributes.src)
130
+ ) {
131
+ // Attempt to resolve source with Vite.
132
+ // This handles relative paths and configured aliases
133
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
134
+
135
+ if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
136
+ const src = await emitESMImage(
137
+ resolved.id,
138
+ ctx.pluginContext.meta.watchMode,
139
+ ctx.pluginContext.emitFile,
140
+ { config: ctx.astroConfig }
141
+ );
142
+ node.attributes.__optimizedSrc = src;
143
+ } else {
144
+ throw new MarkdocError({
145
+ message: `Could not resolve image ${JSON.stringify(
146
+ node.attributes.src
147
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
148
+ });
149
+ }
150
+ }
151
+ await emitOptimizedImages(node.children, ctx);
152
+ }
153
+ }
154
+
155
+ function shouldOptimizeImage(src: string) {
156
+ // Optimize anything that is NOT external or an absolute path to `public/`
157
+ return !isValidUrl(src) && !src.startsWith('/');
158
+ }
159
+
160
+ function validateRenderProperties(markdocConfig: ReadonlyMarkdocConfig, astroConfig: AstroConfig) {
75
161
  const tags = markdocConfig.tags ?? {};
76
162
  const nodes = markdocConfig.nodes ?? {};
77
163
 
@@ -120,3 +206,37 @@ function validateRenderProperty({
120
206
  function isCapitalized(str: string) {
121
207
  return str.length > 0 && str[0] === str[0].toUpperCase();
122
208
  }
209
+
210
+ /**
211
+ * TODO: remove when `experimental.assets` is baselined.
212
+ *
213
+ * `astro:assets` will fail to resolve if the `experimental.assets` flag is not enabled.
214
+ * This ensures a fallback for the Markdoc renderer to safely import at the top level.
215
+ * @see ../components/TreeNode.ts
216
+ */
217
+ function safeAssetsVirtualModulePlugin({
218
+ astroConfig,
219
+ }: {
220
+ astroConfig: Pick<AstroConfig, 'experimental'>;
221
+ }): VitePlugin {
222
+ const virtualModuleId = 'astro:markdoc-assets';
223
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
224
+
225
+ return {
226
+ name: 'astro:markdoc-safe-assets-virtual-module',
227
+ resolveId(id) {
228
+ if (id === virtualModuleId) {
229
+ return resolvedVirtualModuleId;
230
+ }
231
+ },
232
+ load(id) {
233
+ if (id !== resolvedVirtualModuleId) return;
234
+
235
+ if (astroConfig.experimental?.assets) {
236
+ return `export { Image } from 'astro:assets';`;
237
+ } else {
238
+ return `export const Image = () => { throw new Error('Cannot use the Image component without the \`experimental.assets\` flag.'); }`;
239
+ }
240
+ },
241
+ };
242
+ }
package/src/utils.ts CHANGED
@@ -145,3 +145,12 @@ const componentsPropValidator = z.record(
145
145
  export function isCapitalized(str: string) {
146
146
  return str.length > 0 && str[0] === str[0].toUpperCase();
147
147
  }
148
+
149
+ export function isValidUrl(str: string): boolean {
150
+ try {
151
+ new URL(str);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
@@ -0,0 +1,58 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { expect } from 'chai';
3
+ import { loadFixture } from '../../../astro/test/test-utils.js';
4
+ import markdoc from '../dist/index.js';
5
+
6
+ const root = new URL('./fixtures/entry-prop/', import.meta.url);
7
+
8
+ describe('Markdoc - Entry prop', () => {
9
+ let baseFixture;
10
+
11
+ before(async () => {
12
+ baseFixture = await loadFixture({
13
+ root,
14
+ integrations: [markdoc()],
15
+ });
16
+ });
17
+
18
+ describe('dev', () => {
19
+ let devServer;
20
+
21
+ before(async () => {
22
+ devServer = await baseFixture.startDevServer();
23
+ });
24
+
25
+ after(async () => {
26
+ await devServer.stop();
27
+ });
28
+
29
+ it('has expected entry properties', async () => {
30
+ const res = await baseFixture.fetch('/');
31
+ const html = await res.text();
32
+ const { document } = parseHTML(html);
33
+ expect(document.querySelector('h1')?.textContent).to.equal('Processed by schema: Test entry');
34
+ expect(document.getElementById('id')?.textContent?.trim()).to.equal('id: entry.mdoc');
35
+ expect(document.getElementById('slug')?.textContent?.trim()).to.equal('slug: entry');
36
+ expect(document.getElementById('collection')?.textContent?.trim()).to.equal(
37
+ 'collection: blog'
38
+ );
39
+ });
40
+ });
41
+
42
+ describe('build', () => {
43
+ before(async () => {
44
+ await baseFixture.build();
45
+ });
46
+
47
+ it('has expected entry properties', async () => {
48
+ const html = await baseFixture.readFile('/index.html');
49
+ const { document } = parseHTML(html);
50
+ expect(document.querySelector('h1')?.textContent).to.equal('Processed by schema: Test entry');
51
+ expect(document.getElementById('id')?.textContent?.trim()).to.equal('id: entry.mdoc');
52
+ expect(document.getElementById('slug')?.textContent?.trim()).to.equal('slug: entry');
53
+ expect(document.getElementById('collection')?.textContent?.trim()).to.equal(
54
+ 'collection: blog'
55
+ );
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import markdoc from '@astrojs/markdoc';
3
+
4
+ // https://astro.build/config
5
+ export default defineConfig({
6
+ integrations: [markdoc()],
7
+ });
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/runner/work/astro/astro/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="$NODE_PATH:/home/runner/work/astro/astro/node_modules/.pnpm/node_modules"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../../../../../../../astro/astro.js" "$@"
15
+ else
16
+ exec node "$basedir/../../../../../../../astro/astro.js" "$@"
17
+ fi
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@test/markdoc-entry-prop",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@astrojs/markdoc": "workspace:*",
7
+ "astro": "workspace:*"
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: Test entry
3
+ ---
4
+
5
+ # {% $entry.data.title %}
6
+
7
+ - id: {% $entry.id %} {% #id %}
8
+ - slug: {% $entry.slug %} {% #slug %}
9
+ - collection: {% $entry.collection %} {% #collection %}
@@ -0,0 +1,9 @@
1
+ import { defineCollection, z } from 'astro:content';
2
+
3
+ const blog = defineCollection({
4
+ schema: z.object({
5
+ title: z.string().transform(v => 'Processed by schema: ' + v),
6
+ }),
7
+ });
8
+
9
+ export const collections = { blog }
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { getEntryBySlug } from 'astro:content';
3
+
4
+ const entry = await getEntryBySlug('blog', 'entry');
5
+ const { Content } = await entry.render();
6
+ ---
7
+
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="utf-8" />
11
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
12
+ <meta name="viewport" content="width=device-width" />
13
+ <meta name="generator" content={Astro.generator} />
14
+ <title>Astro</title>
15
+ </head>
16
+ <body>
17
+ <Content />
18
+ </body>
19
+ </html>
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import markdoc from '@astrojs/markdoc';
3
+
4
+ // https://astro.build/config
5
+ export default defineConfig({
6
+ experimental: {
7
+ assets: true,
8
+ },
9
+ integrations: [markdoc()],
10
+ });
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/runner/work/astro/astro/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="$NODE_PATH:/home/runner/work/astro/astro/node_modules/.pnpm/node_modules"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../../../../../../../astro/astro.js" "$@"
15
+ else
16
+ exec node "$basedir/../../../../../../../astro/astro.js" "$@"
17
+ fi
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@test/image-assets",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@astrojs/markdoc": "workspace:*",
7
+ "astro": "workspace:*"
8
+ }
9
+ }
@@ -0,0 +1,7 @@
1
+ # Image assets
2
+
3
+ ![Favicon](/favicon.svg) {% #public %}
4
+
5
+ ![Oar](../../assets/relative/oar.jpg) {% #relative %}
6
+
7
+ ![Gray cityscape arial view](~/assets/alias/cityscape.jpg) {% #alias %}
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { getEntryBySlug } from 'astro:content';
3
+
4
+ const intro = await getEntryBySlug('docs', 'intro');
5
+ const { Content } = await intro.render();
6
+ ---
7
+
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="utf-8" />
11
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
12
+ <meta name="viewport" content="width=device-width" />
13
+ <meta name="generator" content={Astro.generator} />
14
+ <title>Astro</title>
15
+ </head>
16
+ <body>
17
+ <Content />
18
+ </body>
19
+ </html>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
2
+ <path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
3
+ <style>
4
+ path { fill: #000; }
5
+ @media (prefers-color-scheme: dark) {
6
+ path { fill: #FFF; }
7
+ }
8
+ </style>
9
+ </svg>
@@ -0,0 +1,76 @@
1
+ import { parseHTML } from 'linkedom';
2
+ import { expect } from 'chai';
3
+ import { loadFixture } from '../../../astro/test/test-utils.js';
4
+
5
+ const root = new URL('./fixtures/image-assets/', import.meta.url);
6
+
7
+ describe('Markdoc - Image assets', () => {
8
+ let baseFixture;
9
+
10
+ before(async () => {
11
+ baseFixture = await loadFixture({
12
+ root,
13
+ });
14
+ });
15
+
16
+ describe('dev', () => {
17
+ let devServer;
18
+
19
+ before(async () => {
20
+ devServer = await baseFixture.startDevServer();
21
+ });
22
+
23
+ after(async () => {
24
+ await devServer.stop();
25
+ });
26
+
27
+ it('uses public/ image paths unchanged', async () => {
28
+ const res = await baseFixture.fetch('/');
29
+ const html = await res.text();
30
+ const { document } = parseHTML(html);
31
+ expect(document.querySelector('#public > img')?.src).to.equal('/favicon.svg');
32
+ });
33
+
34
+ it('transforms relative image paths to optimized path', async () => {
35
+ const res = await baseFixture.fetch('/');
36
+ const html = await res.text();
37
+ const { document } = parseHTML(html);
38
+ expect(document.querySelector('#relative > img')?.src).to.equal(
39
+ '/_image?href=%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp'
40
+ );
41
+ });
42
+
43
+ it('transforms aliased image paths to optimized path', async () => {
44
+ const res = await baseFixture.fetch('/');
45
+ const html = await res.text();
46
+ const { document } = parseHTML(html);
47
+ expect(document.querySelector('#alias > img')?.src).to.equal(
48
+ '/_image?href=%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp'
49
+ );
50
+ });
51
+ });
52
+
53
+ describe('build', () => {
54
+ before(async () => {
55
+ await baseFixture.build();
56
+ });
57
+
58
+ it('uses public/ image paths unchanged', async () => {
59
+ const html = await baseFixture.readFile('/index.html');
60
+ const { document } = parseHTML(html);
61
+ expect(document.querySelector('#public > img')?.src).to.equal('/favicon.svg');
62
+ });
63
+
64
+ it('transforms relative image paths to optimized path', async () => {
65
+ const html = await baseFixture.readFile('/index.html');
66
+ const { document } = parseHTML(html);
67
+ expect(document.querySelector('#relative > img')?.src).to.match(/^\/_astro\/oar.*\.webp$/);
68
+ });
69
+
70
+ it('transforms aliased image paths to optimized path', async () => {
71
+ const html = await baseFixture.readFile('/index.html');
72
+ const { document } = parseHTML(html);
73
+ expect(document.querySelector('#alias > img')?.src).to.match(/^\/_astro\/cityscape.*\.webp$/);
74
+ });
75
+ });
76
+ });
@@ -1,30 +0,0 @@
1
- ---
2
- import stringifyAttributes from 'stringify-attributes';
3
- import type { AstroNode } from './astroNode';
4
-
5
- type Props = {
6
- node: AstroNode;
7
- };
8
-
9
- const Node = (Astro.props as Props).node;
10
- ---
11
-
12
- {
13
- typeof Node === 'string' ? (
14
- <Fragment set:text={Node} />
15
- ) : 'component' in Node ? (
16
- <Node.component {...Node.props}>
17
- {Node.children.map((child) => (
18
- <Astro.self node={child} />
19
- ))}
20
- </Node.component>
21
- ) : (
22
- <Fragment>
23
- <Fragment set:html={`<${Node.tag} ${stringifyAttributes(Node.attributes)}>`} />
24
- {Node.children.map((child) => (
25
- <Astro.self node={child} />
26
- ))}
27
- <Fragment set:html={`</${Node.tag}>`} />
28
- </Fragment>
29
- )
30
- }
@@ -1,51 +0,0 @@
1
- import type { AstroInstance } from 'astro';
2
- import type { RenderableTreeNode } from '@markdoc/markdoc';
3
- import Markdoc from '@markdoc/markdoc';
4
- import { MarkdocError, isCapitalized } from '../dist/utils.js';
5
-
6
- export type AstroNode =
7
- | string
8
- | {
9
- component: AstroInstance['default'];
10
- props: Record<string, any>;
11
- children: AstroNode[];
12
- }
13
- | {
14
- tag: string;
15
- attributes: Record<string, any>;
16
- children: AstroNode[];
17
- };
18
-
19
- export function createAstroNode(
20
- node: RenderableTreeNode,
21
- components: Record<string, AstroInstance['default']> = {}
22
- ): AstroNode {
23
- if (typeof node === 'string' || typeof node === 'number') {
24
- return String(node);
25
- } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) {
26
- return '';
27
- }
28
-
29
- if (node.name in components) {
30
- const component = components[node.name];
31
- const props = node.attributes;
32
- const children = node.children.map((child) => createAstroNode(child, components));
33
-
34
- return {
35
- component,
36
- props,
37
- children,
38
- };
39
- } else if (isCapitalized(node.name)) {
40
- throw new MarkdocError({
41
- message: `Unable to render ${JSON.stringify(node.name)}.`,
42
- hint: 'Did you add this to the "components" prop on your <Content /> component?',
43
- });
44
- } else {
45
- return {
46
- tag: node.name,
47
- attributes: node.attributes,
48
- children: node.children.map((child) => createAstroNode(child, components)),
49
- };
50
- }
51
- }