@astrojs/markdoc 0.0.4 → 0.1.0

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.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +5 -5
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +110 -146
  4. package/components/Renderer.astro +8 -11
  5. package/components/TreeNode.ts +6 -15
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.js +6 -0
  8. package/dist/default-config.d.ts +5 -0
  9. package/dist/default-config.js +13 -0
  10. package/dist/experimental-assets-config.d.ts +2 -0
  11. package/dist/experimental-assets-config.js +25 -0
  12. package/dist/index.d.ts +1 -2
  13. package/dist/index.js +74 -53
  14. package/dist/load-config.d.ts +14 -0
  15. package/dist/load-config.js +82 -0
  16. package/dist/utils.d.ts +1 -11
  17. package/dist/utils.js +7 -41
  18. package/package.json +11 -2
  19. package/src/config.ts +5 -0
  20. package/src/default-config.ts +18 -0
  21. package/src/experimental-assets-config.ts +29 -0
  22. package/src/index.ts +116 -59
  23. package/src/load-config.ts +102 -0
  24. package/src/utils.ts +5 -54
  25. package/template/content-module-types.d.ts +1 -3
  26. package/test/content-collections.test.js +24 -172
  27. package/test/fixtures/content-collections/package.json +0 -4
  28. package/test/fixtures/content-collections/src/content/blog/post-1.mdoc +7 -0
  29. package/test/fixtures/content-collections/src/content/blog/post-2.mdoc +7 -0
  30. package/test/fixtures/content-collections/src/content/blog/post-3.mdoc +7 -0
  31. package/test/fixtures/content-collections/src/pages/entry.json.js +1 -1
  32. package/test/fixtures/image-assets/astro.config.mjs +10 -0
  33. package/test/fixtures/image-assets/node_modules/.bin/astro +17 -0
  34. package/test/fixtures/image-assets/package.json +9 -0
  35. package/test/fixtures/image-assets/src/assets/alias/cityscape.jpg +0 -0
  36. package/test/fixtures/image-assets/src/assets/relative/oar.jpg +0 -0
  37. package/test/fixtures/image-assets/src/content/docs/intro.mdoc +7 -0
  38. package/test/fixtures/image-assets/src/pages/index.astro +19 -0
  39. package/test/fixtures/image-assets/src/public/favicon.svg +9 -0
  40. package/test/fixtures/render-simple/astro.config.mjs +7 -0
  41. package/test/fixtures/render-simple/node_modules/.bin/astro +17 -0
  42. package/test/fixtures/render-simple/package.json +9 -0
  43. package/test/fixtures/{content-collections/src/pages/content-simple.astro → render-simple/src/pages/index.astro} +2 -1
  44. package/test/fixtures/render-with-components/astro.config.mjs +7 -0
  45. package/test/fixtures/render-with-components/markdoc.config.mjs +28 -0
  46. package/test/fixtures/render-with-components/node_modules/.bin/astro +17 -0
  47. package/test/fixtures/render-with-components/package.json +12 -0
  48. package/test/fixtures/{content-collections/src/pages/content-with-components.astro → render-with-components/src/pages/index.astro} +2 -6
  49. package/test/fixtures/render-with-config/astro.config.mjs +7 -0
  50. package/test/fixtures/render-with-config/markdoc.config.mjs +15 -0
  51. package/test/fixtures/render-with-config/node_modules/.bin/astro +17 -0
  52. package/test/fixtures/render-with-config/package.json +9 -0
  53. package/test/fixtures/{content-collections → render-with-config}/src/content/blog/with-config.mdoc +4 -0
  54. package/test/fixtures/{content-collections/src/pages/content-with-config.astro → render-with-config/src/pages/index.astro} +2 -2
  55. package/test/image-assets.test.js +76 -0
  56. package/test/render.test.js +124 -0
  57. /package/test/fixtures/{content-collections → render-simple}/src/content/blog/simple.mdoc +0 -0
  58. /package/test/fixtures/{content-collections → render-with-components}/src/components/Code.astro +0 -0
  59. /package/test/fixtures/{content-collections → render-with-components}/src/components/CustomMarquee.astro +0 -0
  60. /package/test/fixtures/{content-collections → render-with-components}/src/content/blog/with-components.mdoc +0 -0
package/dist/index.js CHANGED
@@ -1,13 +1,27 @@
1
1
  import Markdoc from "@markdoc/markdoc";
2
2
  import fs from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
- import { getAstroConfigPath, MarkdocError, parseFrontmatter } from "./utils.js";
5
- function markdoc(markdocConfig = {}) {
4
+ import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from "./utils.js";
5
+ import { emitESMImage } from "astro/assets";
6
+ import { bold, red } from "kleur/colors";
7
+ import { applyDefaultConfig } from "./default-config.js";
8
+ import { loadMarkdocConfig } from "./load-config.js";
9
+ function markdocIntegration(legacyConfig) {
10
+ if (legacyConfig) {
11
+ console.log(
12
+ `${red(
13
+ bold("[Markdoc]")
14
+ )} 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`
15
+ );
16
+ process.exit(0);
17
+ }
6
18
  return {
7
19
  name: "@astrojs/markdoc",
8
20
  hooks: {
9
21
  "astro:config:setup": async (params) => {
10
- const { updateConfig, config, addContentEntryType } = params;
22
+ const { config: astroConfig, addContentEntryType } = params;
23
+ const configLoadResult = await loadMarkdocConfig(astroConfig);
24
+ const userMarkdocConfig = (configLoadResult == null ? void 0 : configLoadResult.config) ?? {};
11
25
  function getEntryInfo({ fileUrl, contents }) {
12
26
  const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
13
27
  return {
@@ -20,25 +34,45 @@ function markdoc(markdocConfig = {}) {
20
34
  addContentEntryType({
21
35
  extensions: [".mdoc"],
22
36
  getEntryInfo,
23
- getRenderModule({ entry }) {
24
- validateRenderProperties(markdocConfig, config);
37
+ async getRenderModule({ entry, viteId }) {
25
38
  const ast = Markdoc.parse(entry.body);
26
- const content = Markdoc.transform(ast, {
27
- ...markdocConfig,
28
- variables: {
29
- ...markdocConfig.variables,
30
- entry
31
- }
39
+ const pluginContext = this;
40
+ const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
41
+ const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
42
+ return e.error.id !== "variable-undefined";
32
43
  });
33
- return {
44
+ if (validationErrors.length) {
45
+ throw new MarkdocError({
46
+ message: [
47
+ `**${String(entry.collection)} \u2192 ${String(entry.id)}** failed to validate:`,
48
+ ...validationErrors.map((e) => e.error.id)
49
+ ].join("\n")
50
+ });
51
+ }
52
+ if (astroConfig.experimental.assets) {
53
+ await emitOptimizedImages(ast.children, {
54
+ astroConfig,
55
+ pluginContext,
56
+ filePath: entry._internal.filePath
57
+ });
58
+ }
59
+ const code = {
34
60
  code: `import { jsx as h } from 'astro/jsx-runtime';
61
+ import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
35
62
  import { Renderer } from '@astrojs/markdoc/components';
36
- const transformedContent = ${JSON.stringify(
37
- content
63
+ import * as entry from ${JSON.stringify(viteId + "?astroContent")};${configLoadResult ? `
64
+ import userConfig from ${JSON.stringify(configLoadResult.fileUrl.pathname)};` : ""}${astroConfig.experimental.assets ? `
65
+ import { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';` : ""}
66
+ const stringifiedAst = ${JSON.stringify(
67
+ /* Double stringify to encode *as* stringified JSON */
68
+ JSON.stringify(ast)
38
69
  )};
39
- export async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }
40
- Content[Symbol.for('astro.needsHeadRendering')] = true;`
70
+ export async function Content (props) {
71
+ const config = applyDefaultConfig(${configLoadResult ? "{ ...userConfig, variables: { ...userConfig.variables, ...props } }" : "{ variables: props }"}, { entry });${astroConfig.experimental.assets ? `
72
+ config.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };` : ""}
73
+ return h(Renderer, { stringifiedAst, config }); };`
41
74
  };
75
+ return code;
42
76
  },
43
77
  contentModuleTypes: await fs.promises.readFile(
44
78
  new URL("../template/content-module-types.d.ts", import.meta.url),
@@ -49,45 +83,32 @@ Content[Symbol.for('astro.needsHeadRendering')] = true;`
49
83
  }
50
84
  };
51
85
  }
52
- function validateRenderProperties(markdocConfig, astroConfig) {
53
- const tags = markdocConfig.tags ?? {};
54
- const nodes = markdocConfig.nodes ?? {};
55
- for (const [name, config] of Object.entries(tags)) {
56
- validateRenderProperty({ type: "tag", name, config, astroConfig });
57
- }
58
- for (const [name, config] of Object.entries(nodes)) {
59
- validateRenderProperty({ type: "node", name, config, astroConfig });
60
- }
61
- }
62
- function validateRenderProperty({
63
- name,
64
- config,
65
- type,
66
- astroConfig
67
- }) {
68
- if (typeof config.render === "string" && config.render.length === 0) {
69
- throw new Error(
70
- `Invalid ${type} configuration: ${JSON.stringify(
71
- name
72
- )}. The "render" property cannot be an empty string.`
73
- );
74
- }
75
- if (typeof config.render === "string" && !isCapitalized(config.render)) {
76
- const astroConfigPath = getAstroConfigPath(fs, fileURLToPath(astroConfig.root));
77
- throw new MarkdocError({
78
- message: `Invalid ${type} configuration: ${JSON.stringify(
79
- name
80
- )}. The "render" property must reference a capitalized component name.`,
81
- 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",
82
- location: astroConfigPath ? {
83
- file: astroConfigPath
84
- } : void 0
85
- });
86
+ async function emitOptimizedImages(nodeChildren, ctx) {
87
+ for (const node of nodeChildren) {
88
+ if (node.type === "image" && typeof node.attributes.src === "string" && shouldOptimizeImage(node.attributes.src)) {
89
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
90
+ if ((resolved == null ? void 0 : resolved.id) && fs.existsSync(new URL(prependForwardSlash(resolved.id), "file://"))) {
91
+ const src = await emitESMImage(
92
+ resolved.id,
93
+ ctx.pluginContext.meta.watchMode,
94
+ ctx.pluginContext.emitFile,
95
+ { config: ctx.astroConfig }
96
+ );
97
+ node.attributes.__optimizedSrc = src;
98
+ } else {
99
+ throw new MarkdocError({
100
+ message: `Could not resolve image ${JSON.stringify(
101
+ node.attributes.src
102
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`
103
+ });
104
+ }
105
+ }
106
+ await emitOptimizedImages(node.children, ctx);
86
107
  }
87
108
  }
88
- function isCapitalized(str) {
89
- return str.length > 0 && str[0] === str[0].toUpperCase();
109
+ function shouldOptimizeImage(src) {
110
+ return !isValidUrl(src) && !src.startsWith("/");
90
111
  }
91
112
  export {
92
- markdoc as default
113
+ markdocIntegration as default
93
114
  };
@@ -0,0 +1,14 @@
1
+ import type { AstroConfig } from 'astro';
2
+ export declare function loadMarkdocConfig(astroConfig: Pick<AstroConfig, 'root'>): Promise<{
3
+ config: Readonly<Partial<{
4
+ nodes: Partial<Record<import("@markdoc/markdoc").NodeType, import("@markdoc/markdoc").Schema<Readonly<Partial<any>>, string>>>;
5
+ tags: Record<string, import("@markdoc/markdoc").Schema<Readonly<Partial<any>>, string>>;
6
+ variables: Record<string, any>;
7
+ functions: Record<string, import("@markdoc/markdoc").ConfigFunction>;
8
+ partials: Record<string, any>;
9
+ validation?: {
10
+ validateFunctions?: boolean | undefined;
11
+ } | undefined;
12
+ }>>;
13
+ fileUrl: URL;
14
+ } | undefined>;
@@ -0,0 +1,82 @@
1
+ import { build as esbuild } from "esbuild";
2
+ import * as fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ const SUPPORTED_MARKDOC_CONFIG_FILES = [
5
+ "markdoc.config.js",
6
+ "markdoc.config.mjs",
7
+ "markdoc.config.mts",
8
+ "markdoc.config.ts"
9
+ ];
10
+ async function loadMarkdocConfig(astroConfig) {
11
+ let markdocConfigUrl;
12
+ for (const filename of SUPPORTED_MARKDOC_CONFIG_FILES) {
13
+ const filePath = new URL(filename, astroConfig.root);
14
+ if (!fs.existsSync(filePath))
15
+ continue;
16
+ markdocConfigUrl = filePath;
17
+ break;
18
+ }
19
+ if (!markdocConfigUrl)
20
+ return;
21
+ const { code, dependencies } = await bundleConfigFile({
22
+ markdocConfigUrl,
23
+ astroConfig
24
+ });
25
+ const config = await loadConfigFromBundledFile(astroConfig.root, code);
26
+ return {
27
+ config,
28
+ fileUrl: markdocConfigUrl
29
+ };
30
+ }
31
+ async function bundleConfigFile({
32
+ markdocConfigUrl,
33
+ astroConfig
34
+ }) {
35
+ const result = await esbuild({
36
+ absWorkingDir: fileURLToPath(astroConfig.root),
37
+ entryPoints: [fileURLToPath(markdocConfigUrl)],
38
+ outfile: "out.js",
39
+ write: false,
40
+ target: ["node16"],
41
+ platform: "node",
42
+ packages: "external",
43
+ bundle: true,
44
+ format: "esm",
45
+ sourcemap: "inline",
46
+ metafile: true,
47
+ plugins: [
48
+ {
49
+ name: "stub-astro-imports",
50
+ setup(build) {
51
+ build.onResolve({ filter: /.*\.astro$/ }, () => {
52
+ return {
53
+ // Stub with an unused default export
54
+ path: "data:text/javascript,export default true",
55
+ external: true
56
+ };
57
+ });
58
+ }
59
+ }
60
+ ]
61
+ });
62
+ const { text } = result.outputFiles[0];
63
+ return {
64
+ code: text,
65
+ dependencies: result.metafile ? Object.keys(result.metafile.inputs) : []
66
+ };
67
+ }
68
+ async function loadConfigFromBundledFile(root, code) {
69
+ const tmpFileUrl = new URL(`markdoc.config.timestamp-${Date.now()}.mjs`, root);
70
+ fs.writeFileSync(tmpFileUrl, code);
71
+ try {
72
+ return (await import(tmpFileUrl.pathname)).default;
73
+ } finally {
74
+ try {
75
+ fs.unlinkSync(tmpFileUrl);
76
+ } catch {
77
+ }
78
+ }
79
+ }
80
+ export {
81
+ loadMarkdocConfig
82
+ };
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,4 @@
1
- /// <reference types="node" />
2
- import type { AstroInstance } from 'astro';
3
1
  import matter from 'gray-matter';
4
- import type fsMod from 'node:fs';
5
2
  /**
6
3
  * Match YAML exception handling from Astro core errors
7
4
  * @see 'astro/src/core/errors.ts'
@@ -35,16 +32,9 @@ interface ErrorProperties {
35
32
  stack?: string;
36
33
  frame?: string;
37
34
  }
38
- /**
39
- * Matches `search` function used for resolving `astro.config` files.
40
- * Used by Markdoc for error handling.
41
- * @see 'astro/src/core/config/config.ts'
42
- */
43
- export declare function getAstroConfigPath(fs: typeof fsMod, root: string): string | undefined;
44
35
  /**
45
36
  * @see 'astro/src/core/path.ts'
46
37
  */
47
38
  export declare function prependForwardSlash(str: string): string;
48
- export declare function validateComponentsProp(components: Record<string, AstroInstance['default']>): void;
49
- export declare function isCapitalized(str: string): boolean;
39
+ export declare function isValidUrl(str: string): boolean;
50
40
  export {};
package/dist/utils.js CHANGED
@@ -1,6 +1,4 @@
1
- import z from "astro/zod";
2
1
  import matter from "gray-matter";
3
- import path from "node:path";
4
2
  function parseFrontmatter(fileContents, filePath) {
5
3
  try {
6
4
  matter.clearCache();
@@ -43,52 +41,20 @@ class MarkdocError extends Error {
43
41
  this.frame = frame;
44
42
  }
45
43
  }
46
- function getAstroConfigPath(fs, root) {
47
- const paths = [
48
- "astro.config.mjs",
49
- "astro.config.js",
50
- "astro.config.ts",
51
- "astro.config.mts",
52
- "astro.config.cjs",
53
- "astro.config.cts"
54
- ].map((p) => path.join(root, p));
55
- for (const file of paths) {
56
- if (fs.existsSync(file)) {
57
- return file;
58
- }
59
- }
60
- }
61
44
  function prependForwardSlash(str) {
62
45
  return str[0] === "/" ? str : "/" + str;
63
46
  }
64
- function validateComponentsProp(components) {
47
+ function isValidUrl(str) {
65
48
  try {
66
- componentsPropValidator.parse(components);
67
- } catch (e) {
68
- throw new MarkdocError({
69
- message: e instanceof z.ZodError ? e.issues[0].message : "Invalid `components` prop. Ensure you are passing an object of components to <Content />"
70
- });
49
+ new URL(str);
50
+ return true;
51
+ } catch {
52
+ return false;
71
53
  }
72
54
  }
73
- const componentsPropValidator = z.record(
74
- z.string().min(1, "Invalid `components` prop. Component names cannot be empty!").refine(
75
- (value) => isCapitalized(value),
76
- (value) => ({
77
- message: `Invalid \`components\` prop: ${JSON.stringify(
78
- value
79
- )}. 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)`
80
- })
81
- ),
82
- z.any()
83
- );
84
- function isCapitalized(str) {
85
- return str.length > 0 && str[0] === str[0].toUpperCase();
86
- }
87
55
  export {
88
56
  MarkdocError,
89
- getAstroConfigPath,
90
- isCapitalized,
57
+ isValidUrl,
91
58
  parseFrontmatter,
92
- prependForwardSlash,
93
- validateComponentsProp
59
+ prependForwardSlash
94
60
  };
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.4",
4
+ "version": "0.1.0",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
7
7
  "author": "withastro",
@@ -21,23 +21,32 @@
21
21
  "exports": {
22
22
  ".": "./dist/index.js",
23
23
  "./components": "./components/index.ts",
24
+ "./default-config": "./dist/default-config.js",
25
+ "./config": "./dist/config.js",
26
+ "./experimental-assets-config": "./dist/experimental-assets-config.js",
24
27
  "./package.json": "./package.json"
25
28
  },
26
29
  "dependencies": {
27
30
  "@markdoc/markdoc": "^0.2.2",
31
+ "esbuild": "^0.17.12",
28
32
  "gray-matter": "^4.0.3",
33
+ "kleur": "^4.1.5",
29
34
  "zod": "^3.17.3"
30
35
  },
36
+ "peerDependencies": {
37
+ "astro": "^2.1.8"
38
+ },
31
39
  "devDependencies": {
32
40
  "@types/chai": "^4.3.1",
33
41
  "@types/html-escaper": "^3.0.0",
34
42
  "@types/mocha": "^9.1.1",
35
- "astro": "2.1.5",
43
+ "astro": "2.1.8",
36
44
  "astro-scripts": "0.0.14",
37
45
  "chai": "^4.3.6",
38
46
  "devalue": "^4.2.0",
39
47
  "linkedom": "^0.14.12",
40
48
  "mocha": "^9.2.2",
49
+ "rollup": "^3.20.1",
41
50
  "vite": "^4.0.3"
42
51
  },
43
52
  "engines": {
package/src/config.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
2
+
3
+ export function defineMarkdocConfig(config: MarkdocConfig): MarkdocConfig {
4
+ return config;
5
+ }
@@ -0,0 +1,18 @@
1
+ import type { ConfigType as MarkdocConfig } from '@markdoc/markdoc';
2
+ import type { ContentEntryModule } from 'astro';
3
+
4
+ export function applyDefaultConfig(
5
+ config: MarkdocConfig,
6
+ ctx: {
7
+ entry: ContentEntryModule;
8
+ }
9
+ ): MarkdocConfig {
10
+ return {
11
+ ...config,
12
+ variables: {
13
+ entry: ctx.entry,
14
+ ...config.variables,
15
+ },
16
+ // TODO: heading ID calculation, Shiki syntax highlighting
17
+ };
18
+ }
@@ -0,0 +1,29 @@
1
+ import type { Config as MarkdocConfig } from '@markdoc/markdoc';
2
+ import Markdoc from '@markdoc/markdoc';
3
+ //@ts-expect-error Cannot find module 'astro:assets' or its corresponding type declarations.
4
+ import { Image } from 'astro:assets';
5
+
6
+ // Separate module to only import `astro:assets` when
7
+ // `experimental.assets` flag is set in a project.
8
+ // TODO: merge with `./default-config.ts` when `experimental.assets` is baselined.
9
+ export const experimentalAssetsConfig: MarkdocConfig = {
10
+ nodes: {
11
+ image: {
12
+ attributes: {
13
+ ...Markdoc.nodes.image.attributes,
14
+ __optimizedSrc: { type: 'Object' },
15
+ },
16
+ transform(node, config) {
17
+ const attributes = node.transformAttributes(config);
18
+ const children = node.transformChildren(config);
19
+
20
+ if (node.type === 'image' && '__optimizedSrc' in node.attributes) {
21
+ const { __optimizedSrc, ...rest } = node.attributes;
22
+ return new Markdoc.Tag(Image, { ...rest, src: __optimizedSrc }, children);
23
+ } else {
24
+ return new Markdoc.Tag('img', attributes, children);
25
+ }
26
+ },
27
+ },
28
+ },
29
+ };
package/src/index.ts CHANGED
@@ -1,9 +1,15 @@
1
- import type { Config } from '@markdoc/markdoc';
1
+ import type { Node } from '@markdoc/markdoc';
2
2
  import Markdoc from '@markdoc/markdoc';
3
3
  import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
4
4
  import fs from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { getAstroConfigPath, MarkdocError, parseFrontmatter } from './utils.js';
6
+ import { isValidUrl, MarkdocError, parseFrontmatter, prependForwardSlash } from './utils.js';
7
+ // @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
8
+ import { emitESMImage } from 'astro/assets';
9
+ import { bold, red } from 'kleur/colors';
10
+ import type * as rollup from 'rollup';
11
+ import { applyDefaultConfig } from './default-config.js';
12
+ import { loadMarkdocConfig } from './load-config.js';
7
13
 
8
14
  type SetupHookParams = HookParameters<'astro:config:setup'> & {
9
15
  // `contentEntryType` is not a public API
@@ -11,12 +17,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
11
17
  addContentEntryType: (contentEntryType: ContentEntryType) => void;
12
18
  };
13
19
 
14
- export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
20
+ export default function markdocIntegration(legacyConfig: any): AstroIntegration {
21
+ if (legacyConfig) {
22
+ // eslint-disable-next-line no-console
23
+ console.log(
24
+ `${red(
25
+ bold('[Markdoc]')
26
+ )} 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`
27
+ );
28
+ process.exit(0);
29
+ }
15
30
  return {
16
31
  name: '@astrojs/markdoc',
17
32
  hooks: {
18
33
  'astro:config:setup': async (params) => {
19
- const { updateConfig, config, addContentEntryType } = params as SetupHookParams;
34
+ const { config: astroConfig, addContentEntryType } = params as SetupHookParams;
35
+
36
+ const configLoadResult = await loadMarkdocConfig(astroConfig);
37
+ const userMarkdocConfig = configLoadResult?.config ?? {};
20
38
 
21
39
  function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
22
40
  const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
@@ -30,21 +48,63 @@ export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
30
48
  addContentEntryType({
31
49
  extensions: ['.mdoc'],
32
50
  getEntryInfo,
33
- getRenderModule({ entry }) {
34
- validateRenderProperties(markdocConfig, config);
51
+ async getRenderModule({ entry, viteId }) {
35
52
  const ast = Markdoc.parse(entry.body);
36
- const content = Markdoc.transform(ast, {
37
- ...markdocConfig,
38
- variables: {
39
- ...markdocConfig.variables,
40
- entry,
41
- },
53
+ const pluginContext = this;
54
+ const markdocConfig = applyDefaultConfig(userMarkdocConfig, { entry });
55
+
56
+ const validationErrors = Markdoc.validate(ast, markdocConfig).filter((e) => {
57
+ // Ignore `variable-undefined` errors.
58
+ // Variables can be configured at runtime,
59
+ // so we cannot validate them at build time.
60
+ return e.error.id !== 'variable-undefined';
42
61
  });
43
- return {
44
- code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify(
45
- content
46
- )};\nexport async function Content ({ components }) { return h(Renderer, { content: transformedContent, components }); }\nContent[Symbol.for('astro.needsHeadRendering')] = true;`,
62
+ if (validationErrors.length) {
63
+ throw new MarkdocError({
64
+ message: [
65
+ `**${String(entry.collection)} ${String(entry.id)}** failed to validate:`,
66
+ ...validationErrors.map((e) => e.error.id),
67
+ ].join('\n'),
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
+
79
+ const code = {
80
+ code: `import { jsx as h } from 'astro/jsx-runtime';
81
+ import { applyDefaultConfig } from '@astrojs/markdoc/default-config';
82
+ import { Renderer } from '@astrojs/markdoc/components';
83
+ import * as entry from ${JSON.stringify(viteId + '?astroContent')};${
84
+ configLoadResult
85
+ ? `\nimport userConfig from ${JSON.stringify(configLoadResult.fileUrl.pathname)};`
86
+ : ''
87
+ }${
88
+ astroConfig.experimental.assets
89
+ ? `\nimport { experimentalAssetsConfig } from '@astrojs/markdoc/experimental-assets-config';`
90
+ : ''
91
+ }
92
+ const stringifiedAst = ${JSON.stringify(
93
+ /* Double stringify to encode *as* stringified JSON */ JSON.stringify(ast)
94
+ )};
95
+ export async function Content (props) {
96
+ const config = applyDefaultConfig(${
97
+ configLoadResult
98
+ ? '{ ...userConfig, variables: { ...userConfig.variables, ...props } }'
99
+ : '{ variables: props }'
100
+ }, { entry });${
101
+ astroConfig.experimental.assets
102
+ ? `\nconfig.nodes = { ...experimentalAssetsConfig.nodes, ...config.nodes };`
103
+ : ''
104
+ }
105
+ return h(Renderer, { stringifiedAst, config }); };`,
47
106
  };
107
+ return code;
48
108
  },
49
109
  contentModuleTypes: await fs.promises.readFile(
50
110
  new URL('../template/content-module-types.d.ts', import.meta.url),
@@ -56,52 +116,49 @@ export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
56
116
  };
57
117
  }
58
118
 
59
- function validateRenderProperties(markdocConfig: Config, astroConfig: AstroConfig) {
60
- const tags = markdocConfig.tags ?? {};
61
- const nodes = markdocConfig.nodes ?? {};
62
-
63
- for (const [name, config] of Object.entries(tags)) {
64
- validateRenderProperty({ type: 'tag', name, config, astroConfig });
65
- }
66
- for (const [name, config] of Object.entries(nodes)) {
67
- validateRenderProperty({ type: 'node', name, config, astroConfig });
119
+ /**
120
+ * Emits optimized images, and appends the generated `src` to each AST node
121
+ * via the `__optimizedSrc` attribute.
122
+ */
123
+ async function emitOptimizedImages(
124
+ nodeChildren: Node[],
125
+ ctx: {
126
+ pluginContext: rollup.PluginContext;
127
+ filePath: string;
128
+ astroConfig: AstroConfig;
68
129
  }
69
- }
130
+ ) {
131
+ for (const node of nodeChildren) {
132
+ if (
133
+ node.type === 'image' &&
134
+ typeof node.attributes.src === 'string' &&
135
+ shouldOptimizeImage(node.attributes.src)
136
+ ) {
137
+ // Attempt to resolve source with Vite.
138
+ // This handles relative paths and configured aliases
139
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
70
140
 
71
- function validateRenderProperty({
72
- name,
73
- config,
74
- type,
75
- astroConfig,
76
- }: {
77
- name: string;
78
- config: { render?: string };
79
- type: 'node' | 'tag';
80
- astroConfig: Pick<AstroConfig, 'root'>;
81
- }) {
82
- if (typeof config.render === 'string' && config.render.length === 0) {
83
- throw new Error(
84
- `Invalid ${type} configuration: ${JSON.stringify(
85
- name
86
- )}. The "render" property cannot be an empty string.`
87
- );
88
- }
89
- if (typeof config.render === 'string' && !isCapitalized(config.render)) {
90
- const astroConfigPath = getAstroConfigPath(fs, fileURLToPath(astroConfig.root));
91
- throw new MarkdocError({
92
- message: `Invalid ${type} configuration: ${JSON.stringify(
93
- name
94
- )}. The "render" property must reference a capitalized component name.`,
95
- 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',
96
- location: astroConfigPath
97
- ? {
98
- file: astroConfigPath,
99
- }
100
- : undefined,
101
- });
141
+ if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
142
+ const src = await emitESMImage(
143
+ resolved.id,
144
+ ctx.pluginContext.meta.watchMode,
145
+ ctx.pluginContext.emitFile,
146
+ { config: ctx.astroConfig }
147
+ );
148
+ node.attributes.__optimizedSrc = src;
149
+ } else {
150
+ throw new MarkdocError({
151
+ message: `Could not resolve image ${JSON.stringify(
152
+ node.attributes.src
153
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
154
+ });
155
+ }
156
+ }
157
+ await emitOptimizedImages(node.children, ctx);
102
158
  }
103
159
  }
104
160
 
105
- function isCapitalized(str: string) {
106
- return str.length > 0 && str[0] === str[0].toUpperCase();
161
+ function shouldOptimizeImage(src: string) {
162
+ // Optimize anything that is NOT external or an absolute path to `public/`
163
+ return !isValidUrl(src) && !src.startsWith('/');
107
164
  }