[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
This commit is contained in:
Ben Holmes 2023-03-24 07:58:56 -04:00 committed by GitHub
parent dfbd09b711
commit cfcf2e2ffd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 368 additions and 21 deletions

View file

@ -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
<!--Relative paths-->
![The Milky Way Galaxy](../assets/galaxy.jpg)
<!--Or configured aliases-->
![Houston smiling and looking cute](~/assets/houston-smiling.jpg)
```

View file

@ -1053,9 +1053,12 @@ export interface ContentEntryType {
fileUrl: URL;
contents: string;
}): GetEntryInfoReturnType | Promise<GetEntryInfoReturnType>;
getRenderModule?(params: {
entry: ContentEntryModule;
}): rollup.LoadResult | Promise<rollup.LoadResult>;
getRenderModule?(
this: rollup.PluginContext,
params: {
entry: ContentEntryModule;
}
): rollup.LoadResult | Promise<rollup.LoadResult>;
contentModuleTypes?: string;
}

View file

@ -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';

View file

@ -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<AstroSettings, 'config'>
) {
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<AstroConfig, 'root'>, 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;
}

View file

@ -139,7 +139,7 @@ export const _internal = {
});
}
return contentRenderer({ entry });
return contentRenderer.bind(this)({ entry });
},
});
}

View file

@ -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<string, AstroInstance['default']> = {
Image,
};
export function createTreeNode(
node: RenderableTreeNode,
components: Record<string, AstroInstance['default']> = {}
userComponents: Record<string, AstroInstance['default']> = {}
): 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)) {

View file

@ -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": {

View file

@ -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.'); }`;
}
},
};
}

View file

@ -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;
}
}

View file

@ -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()],
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/image-assets",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -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 %}

View file

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

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<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" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -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$/);
});
});
});

View file

@ -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: