Skip to content

Commit

Permalink
[Markdoc] Support automatic image optimization with `experimental.ass…
Browse files Browse the repository at this point in the history
…ets` (#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
  • Loading branch information
bholmesdev authored Mar 24, 2023
1 parent dfbd09b commit cfcf2e2
Show file tree
Hide file tree
Showing 18 changed files with 368 additions and 21 deletions.
13 changes: 13 additions & 0 deletions .changeset/big-rice-rest.md
Original file line number Diff line number Diff line change
@@ -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)
```
9 changes: 6 additions & 3 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
34 changes: 30 additions & 4 deletions packages/astro/src/assets/utils/emitAsset.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion packages/astro/src/content/vite-plugin-content-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const _internal = {
});
}

return contentRenderer({ entry });
return contentRenderer.bind(this)({ entry });
},
});
}
Expand Down
10 changes: 9 additions & 1 deletion packages/integrations/markdoc/components/TreeNode.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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)) {
Expand Down
6 changes: 5 additions & 1 deletion packages/integrations/markdoc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
157 changes: 146 additions & 11 deletions packages/integrations/markdoc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,48 @@
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
// Add type defs here
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));
Expand All @@ -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
Expand All @@ -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 ?? {};

Expand Down Expand Up @@ -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.'); }`;
}
},
};
}
9 changes: 9 additions & 0 deletions packages/integrations/markdoc/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/image-assets",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/markdoc": "workspace:*",
"astro": "workspace:*"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 %}
Loading

0 comments on commit cfcf2e2

Please sign in to comment.