summaryrefslogtreecommitdiff
path: root/packages/integrations/markdoc/src
diff options
context:
space:
mode:
authorGravatar Ben Holmes <hey@bholmes.dev> 2023-03-24 07:58:56 -0400
committerGravatar GitHub <noreply@github.com> 2023-03-24 07:58:56 -0400
commitcfcf2e2ffdaa68ace5c84329c05b83559a29d638 (patch)
tree9f979bf4ac02ebea69192bd239f9ed41efac3c43 /packages/integrations/markdoc/src
parentdfbd09b711f45da230e75a09b12a186320a632a9 (diff)
downloadastro-cfcf2e2ffdaa68ace5c84329c05b83559a29d638.tar.gz
astro-cfcf2e2ffdaa68ace5c84329c05b83559a29d638.tar.zst
astro-cfcf2e2ffdaa68ace5c84329c05b83559a29d638.zip
[Markdoc] Support automatic image optimization with `experimental.assets` (#6630)
* wip: scrappy implementation. It works! 🥳 * chore: add code comments on inline utils * fix: code cleanup, run on experimental.assets * feat: support ~/assets alias * fix: spoof `astro:assets` when outside experimental * test: image paths in dev and prod * feat: support any vite alias with ctx.resolve * fix: avoid trying to process absolute paths * fix: raise helpful error for invalid vite paths * refactor: revert URL support on emitAsset * chore: lint * refactor: expose emitESMImage from assets base * wip: why doesn't assets exist * scary chore: make @astrojs/markdoc truly depend on astro * fix: import emitESMImage straight from dist * chore: remove type def from assets package * chore: screw it, just ts ignore * deps: rollup types * refactor: optimize images during parse step * chore: remove unneeded `.flat()` * fix: use file-based relative paths * fix: add back helpful error * chore: changeset * deps: move astro back to dev dep * fix: put emit assets behind flag * chore: change to markdoc patch
Diffstat (limited to 'packages/integrations/markdoc/src')
-rw-r--r--packages/integrations/markdoc/src/index.ts157
-rw-r--r--packages/integrations/markdoc/src/utils.ts9
2 files changed, 155 insertions, 11 deletions
diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts
index 70d005ee5..1d3556db7 100644
--- a/packages/integrations/markdoc/src/index.ts
+++ b/packages/integrations/markdoc/src/index.ts
@@ -1,9 +1,23 @@
-import type { Config } from '@markdoc/markdoc';
+import type {
+ Config as ReadonlyMarkdocConfig,
+ ConfigType as MarkdocConfig,
+ Node,
+} from '@markdoc/markdoc';
import Markdoc from '@markdoc/markdoc';
import type { AstroConfig, AstroIntegration, ContentEntryType, HookParameters } from 'astro';
import fs from 'node:fs';
+import type * as rollup from 'rollup';
import { fileURLToPath } from 'node:url';
-import { getAstroConfigPath, MarkdocError, parseFrontmatter } from './utils.js';
+import {
+ getAstroConfigPath,
+ isValidUrl,
+ MarkdocError,
+ parseFrontmatter,
+ prependForwardSlash,
+} from './utils.js';
+// @ts-expect-error Cannot find module 'astro/assets' or its corresponding type declarations.
+import { emitESMImage } from 'astro/assets';
+import type { Plugin as VitePlugin } from 'vite';
type SetupHookParams = HookParameters<'astro:config:setup'> & {
// `contentEntryType` is not a public API
@@ -11,12 +25,24 @@ type SetupHookParams = HookParameters<'astro:config:setup'> & {
addContentEntryType: (contentEntryType: ContentEntryType) => void;
};
-export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
+export default function markdocIntegration(
+ userMarkdocConfig: ReadonlyMarkdocConfig = {}
+): AstroIntegration {
return {
name: '@astrojs/markdoc',
hooks: {
'astro:config:setup': async (params) => {
- const { updateConfig, config, addContentEntryType } = params as SetupHookParams;
+ const {
+ updateConfig,
+ config: astroConfig,
+ addContentEntryType,
+ } = params as SetupHookParams;
+
+ updateConfig({
+ vite: {
+ plugins: [safeAssetsVirtualModulePlugin({ astroConfig })],
+ },
+ });
function getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) {
const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl));
@@ -30,16 +56,44 @@ export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
addContentEntryType({
extensions: ['.mdoc'],
getEntryInfo,
- getRenderModule({ entry }) {
- validateRenderProperties(markdocConfig, config);
+ async getRenderModule({ entry }) {
+ validateRenderProperties(userMarkdocConfig, astroConfig);
const ast = Markdoc.parse(entry.body);
- const content = Markdoc.transform(ast, {
- ...markdocConfig,
+ const pluginContext = this;
+ const markdocConfig: MarkdocConfig = {
+ ...userMarkdocConfig,
variables: {
- ...markdocConfig.variables,
+ ...userMarkdocConfig.variables,
entry,
},
- });
+ };
+
+ if (astroConfig.experimental?.assets) {
+ await emitOptimizedImages(ast.children, {
+ astroConfig,
+ pluginContext,
+ filePath: entry._internal.filePath,
+ });
+
+ markdocConfig.nodes ??= {};
+ markdocConfig.nodes.image = {
+ ...Markdoc.nodes.image,
+ transform(node, config) {
+ const attributes = node.transformAttributes(config);
+ const children = node.transformChildren(config);
+
+ if (node.type === 'image' && '__optimizedSrc' in node.attributes) {
+ const { __optimizedSrc, ...rest } = node.attributes;
+ return new Markdoc.Tag('Image', { ...rest, src: __optimizedSrc }, children);
+ } else {
+ return new Markdoc.Tag('img', attributes, children);
+ }
+ },
+ };
+ }
+
+ const content = Markdoc.transform(ast, markdocConfig);
+
return {
code: `import { jsx as h } from 'astro/jsx-runtime';\nimport { Renderer } from '@astrojs/markdoc/components';\nconst transformedContent = ${JSON.stringify(
content
@@ -56,7 +110,54 @@ export default function markdoc(markdocConfig: Config = {}): AstroIntegration {
};
}
-function validateRenderProperties(markdocConfig: Config, astroConfig: AstroConfig) {
+/**
+ * Emits optimized images, and appends the generated `src` to each AST node
+ * via the `__optimizedSrc` attribute.
+ */
+async function emitOptimizedImages(
+ nodeChildren: Node[],
+ ctx: {
+ pluginContext: rollup.PluginContext;
+ filePath: string;
+ astroConfig: AstroConfig;
+ }
+) {
+ for (const node of nodeChildren) {
+ if (
+ node.type === 'image' &&
+ typeof node.attributes.src === 'string' &&
+ shouldOptimizeImage(node.attributes.src)
+ ) {
+ // Attempt to resolve source with Vite.
+ // This handles relative paths and configured aliases
+ const resolved = await ctx.pluginContext.resolve(node.attributes.src, ctx.filePath);
+
+ if (resolved?.id && fs.existsSync(new URL(prependForwardSlash(resolved.id), 'file://'))) {
+ const src = await emitESMImage(
+ resolved.id,
+ ctx.pluginContext.meta.watchMode,
+ ctx.pluginContext.emitFile,
+ { config: ctx.astroConfig }
+ );
+ node.attributes.__optimizedSrc = src;
+ } else {
+ throw new MarkdocError({
+ message: `Could not resolve image ${JSON.stringify(
+ node.attributes.src
+ )} from ${JSON.stringify(ctx.filePath)}. Does the file exist?`,
+ });
+ }
+ }
+ await emitOptimizedImages(node.children, ctx);
+ }
+}
+
+function shouldOptimizeImage(src: string) {
+ // Optimize anything that is NOT external or an absolute path to `public/`
+ return !isValidUrl(src) && !src.startsWith('/');
+}
+
+function validateRenderProperties(markdocConfig: ReadonlyMarkdocConfig, astroConfig: AstroConfig) {
const tags = markdocConfig.tags ?? {};
const nodes = markdocConfig.nodes ?? {};
@@ -105,3 +206,37 @@ function validateRenderProperty({
function isCapitalized(str: string) {
return str.length > 0 && str[0] === str[0].toUpperCase();
}
+
+/**
+ * TODO: remove when `experimental.assets` is baselined.
+ *
+ * `astro:assets` will fail to resolve if the `experimental.assets` flag is not enabled.
+ * This ensures a fallback for the Markdoc renderer to safely import at the top level.
+ * @see ../components/TreeNode.ts
+ */
+function safeAssetsVirtualModulePlugin({
+ astroConfig,
+}: {
+ astroConfig: Pick<AstroConfig, 'experimental'>;
+}): VitePlugin {
+ const virtualModuleId = 'astro:markdoc-assets';
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
+
+ return {
+ name: 'astro:markdoc-safe-assets-virtual-module',
+ resolveId(id) {
+ if (id === virtualModuleId) {
+ return resolvedVirtualModuleId;
+ }
+ },
+ load(id) {
+ if (id !== resolvedVirtualModuleId) return;
+
+ if (astroConfig.experimental?.assets) {
+ return `export { Image } from 'astro:assets';`;
+ } else {
+ return `export const Image = () => { throw new Error('Cannot use the Image component without the \`experimental.assets\` flag.'); }`;
+ }
+ },
+ };
+}
diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts
index 275c711f0..9d6e5af26 100644
--- a/packages/integrations/markdoc/src/utils.ts
+++ b/packages/integrations/markdoc/src/utils.ts
@@ -145,3 +145,12 @@ const componentsPropValidator = z.record(
export function isCapitalized(str: string) {
return str.length > 0 && str[0] === str[0].toUpperCase();
}
+
+export function isValidUrl(str: string): boolean {
+ try {
+ new URL(str);
+ return true;
+ } catch {
+ return false;
+ }
+}