diff --git a/.changeset/big-rice-rest.md b/.changeset/big-rice-rest.md new file mode 100644 index 000000000..2b7efbaa5 --- /dev/null +++ b/.changeset/big-rice-rest.md @@ -0,0 +1,13 @@ +--- +'@astrojs/markdoc': patch +'astro': patch +--- + +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: + +```md + +![The Milky Way Galaxy](../assets/galaxy.jpg) + +![Houston smiling and looking cute](~/assets/houston-smiling.jpg) +``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0af084217..4eca60b34 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1053,9 +1053,12 @@ export interface ContentEntryType { fileUrl: URL; contents: string; }): GetEntryInfoReturnType | Promise; - getRenderModule?(params: { - entry: ContentEntryModule; - }): rollup.LoadResult | Promise; + getRenderModule?( + this: rollup.PluginContext, + params: { + entry: ContentEntryModule; + } + ): rollup.LoadResult | Promise; contentModuleTypes?: string; } diff --git a/packages/astro/src/assets/index.ts b/packages/astro/src/assets/index.ts index f768c58dd..04dde5979 100644 --- a/packages/astro/src/assets/index.ts +++ b/packages/astro/src/assets/index.ts @@ -2,3 +2,4 @@ export { getConfiguredImageService, getImage } from './internal.js'; export { baseService } from './services/service.js'; export { type LocalImageProps, type RemoteImageProps } from './types.js'; export { imageMetadata } from './utils/metadata.js'; +export { emitESMImage } from './utils/emitAsset.js'; diff --git a/packages/astro/src/assets/utils/emitAsset.ts b/packages/astro/src/assets/utils/emitAsset.ts index 74b851eed..d6b34d9aa 100644 --- a/packages/astro/src/assets/utils/emitAsset.ts +++ b/packages/astro/src/assets/utils/emitAsset.ts @@ -1,15 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import type { AstroSettings } from '../../@types/astro'; -import { rootRelativePath } from '../../core/util.js'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import slash from 'slash'; +import type { AstroSettings, AstroConfig } from '../../@types/astro'; import { imageMetadata } from './metadata.js'; export async function emitESMImage( id: string, watchMode: boolean, fileEmitter: any, - settings: AstroSettings + settings: Pick ) { const url = pathToFileURL(id); const meta = await imageMetadata(url); @@ -41,3 +41,29 @@ export async function emitESMImage( return meta; } + +/** + * Utilities inlined from `packages/astro/src/core/util.ts` + * Avoids ESM / CJS bundling failures when accessed from integrations + * due to Vite dependencies in core. + */ + +function rootRelativePath(config: Pick, url: URL) { + const basePath = fileURLToNormalizedPath(url); + const rootPath = fileURLToNormalizedPath(config.root); + return prependForwardSlash(basePath.slice(rootPath.length)); +} + +function prependForwardSlash(filePath: string) { + return filePath[0] === '/' ? filePath : '/' + filePath; +} + +function fileURLToNormalizedPath(filePath: URL): string { + // Uses `slash` package instead of Vite's `normalizePath` + // to avoid CJS bundling issues. + return slash(fileURLToPath(filePath) + filePath.search).replace(/\\/g, '/'); +} + +export function emoji(char: string, fallback: string) { + return process.platform !== 'win32' ? char : fallback; +} diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index b699ea73e..7ad71b31e 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -139,7 +139,7 @@ export const _internal = { }); } - return contentRenderer({ entry }); + return contentRenderer.bind(this)({ entry }); }, }); } diff --git a/packages/integrations/markdoc/components/TreeNode.ts b/packages/integrations/markdoc/components/TreeNode.ts index b9b4c5c4d..f46355d5c 100644 --- a/packages/integrations/markdoc/components/TreeNode.ts +++ b/packages/integrations/markdoc/components/TreeNode.ts @@ -1,6 +1,8 @@ import type { AstroInstance } from 'astro'; import type { RenderableTreeNode } from '@markdoc/markdoc'; import { createComponent, renderComponent, render } from 'astro/runtime/server/index.js'; +// @ts-expect-error Cannot find module 'astro:markdoc-assets' or its corresponding type declarations +import { Image } from 'astro:markdoc-assets'; import Markdoc from '@markdoc/markdoc'; import { MarkdocError, isCapitalized } from '../dist/utils.js'; @@ -45,10 +47,16 @@ export const ComponentNode = createComponent({ propagation: 'none', }); +const builtInComponents: Record = { + Image, +}; + export function createTreeNode( node: RenderableTreeNode, - components: Record = {} + userComponents: Record = {} ): TreeNode { + const components = { ...userComponents, ...builtInComponents }; + if (typeof node === 'string' || typeof node === 'number') { return { type: 'text', content: String(node) }; } else if (node === null || typeof node !== 'object' || !Markdoc.Tag.isTag(node)) { diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index 12bc6bacd..70c0eea99 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -35,16 +35,20 @@ "gray-matter": "^4.0.3", "zod": "^3.17.3" }, + "peerDependencies": { + "astro": "workspace:*" + }, "devDependencies": { + "astro": "workspace:*", "@types/chai": "^4.3.1", "@types/html-escaper": "^3.0.0", "@types/mocha": "^9.1.1", - "astro": "workspace:*", "astro-scripts": "workspace:*", "chai": "^4.3.6", "devalue": "^4.2.0", "linkedom": "^0.14.12", "mocha": "^9.2.2", + "rollup": "^3.20.1", "vite": "^4.0.3" }, "engines": { 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; +}): 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; + } +} diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/astro.config.mjs b/packages/integrations/markdoc/test/fixtures/image-assets/astro.config.mjs new file mode 100644 index 000000000..9e64af363 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/image-assets/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + experimental: { + assets: true, + }, + integrations: [markdoc()], +}); diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/package.json b/packages/integrations/markdoc/test/fixtures/image-assets/package.json new file mode 100644 index 000000000..30df52c2f --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/image-assets/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/image-assets", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/alias/cityscape.jpg b/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/alias/cityscape.jpg new file mode 100644 index 000000000..6130e2c14 Binary files /dev/null and b/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/alias/cityscape.jpg differ diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/relative/oar.jpg b/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/relative/oar.jpg new file mode 100644 index 000000000..e15f2e038 Binary files /dev/null and b/packages/integrations/markdoc/test/fixtures/image-assets/src/assets/relative/oar.jpg differ diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/src/content/docs/intro.mdoc b/packages/integrations/markdoc/test/fixtures/image-assets/src/content/docs/intro.mdoc new file mode 100644 index 000000000..ae5fced49 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/image-assets/src/content/docs/intro.mdoc @@ -0,0 +1,7 @@ +# Image assets + +![Favicon](/favicon.svg) {% #public %} + +![Oar](../../assets/relative/oar.jpg) {% #relative %} + +![Gray cityscape arial view](~/assets/alias/cityscape.jpg) {% #alias %} diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/src/pages/index.astro b/packages/integrations/markdoc/test/fixtures/image-assets/src/pages/index.astro new file mode 100644 index 000000000..51810b4a8 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/image-assets/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +import { getEntryBySlug } from 'astro:content'; + +const intro = await getEntryBySlug('docs', 'intro'); +const { Content } = await intro.render(); +--- + + + + + + + + Astro + + + + + diff --git a/packages/integrations/markdoc/test/fixtures/image-assets/src/public/favicon.svg b/packages/integrations/markdoc/test/fixtures/image-assets/src/public/favicon.svg new file mode 100644 index 000000000..f157bd1c5 --- /dev/null +++ b/packages/integrations/markdoc/test/fixtures/image-assets/src/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/packages/integrations/markdoc/test/image-assets.test.js b/packages/integrations/markdoc/test/image-assets.test.js new file mode 100644 index 000000000..313977934 --- /dev/null +++ b/packages/integrations/markdoc/test/image-assets.test.js @@ -0,0 +1,76 @@ +import { parseHTML } from 'linkedom'; +import { expect } from 'chai'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/image-assets/', import.meta.url); + +describe('Markdoc - Image assets', () => { + let baseFixture; + + before(async () => { + baseFixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer; + + before(async () => { + devServer = await baseFixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('uses public/ image paths unchanged', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + expect(document.querySelector('#public > img')?.src).to.equal('/favicon.svg'); + }); + + it('transforms relative image paths to optimized path', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + expect(document.querySelector('#relative > img')?.src).to.equal( + '/_image?href=%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&f=webp' + ); + }); + + it('transforms aliased image paths to optimized path', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + expect(document.querySelector('#alias > img')?.src).to.equal( + '/_image?href=%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&f=webp' + ); + }); + }); + + describe('build', () => { + before(async () => { + await baseFixture.build(); + }); + + it('uses public/ image paths unchanged', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + expect(document.querySelector('#public > img')?.src).to.equal('/favicon.svg'); + }); + + it('transforms relative image paths to optimized path', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + expect(document.querySelector('#relative > img')?.src).to.match(/^\/_astro\/oar.*\.webp$/); + }); + + it('transforms aliased image paths to optimized path', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + expect(document.querySelector('#alias > img')?.src).to.match(/^\/_astro\/cityscape.*\.webp$/); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37491d8c7..441fe16a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3080,6 +3080,7 @@ importers: gray-matter: ^4.0.3 linkedom: ^0.14.12 mocha: ^9.2.2 + rollup: ^3.20.1 vite: ^4.0.3 zod: ^3.17.3 dependencies: @@ -3096,6 +3097,7 @@ importers: devalue: 4.2.3 linkedom: 0.14.21 mocha: 9.2.2 + rollup: 3.20.1 vite: 4.1.2 packages/integrations/markdoc/test/fixtures/content-collections: @@ -3119,6 +3121,14 @@ importers: '@astrojs/markdoc': link:../../.. astro: link:../../../../../astro + packages/integrations/markdoc/test/fixtures/image-assets: + specifiers: + '@astrojs/markdoc': workspace:* + astro: workspace:* + dependencies: + '@astrojs/markdoc': link:../../.. + astro: link:../../../../../astro + packages/integrations/mdx: specifiers: '@astrojs/markdown-remark': ^2.1.2 @@ -14921,6 +14931,14 @@ packages: optionalDependencies: fsevents: 2.3.2 + /rollup/3.20.1: + resolution: {integrity: sha512-sz2w8cBJlWQ2E17RcpvHuf4sk2BQx4tfKDnjNPikEpLEevrbIAR7CH3PGa2hpPwWbNgPaA9yh9Jzljds5bc9zg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /run-parallel/1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: