Skip to content

Instantly share code, notes, and snippets.

@birtles
Last active December 14, 2023 06:16
Show Gist options
  • Save birtles/28d5bfb1e1fa0d62b3e96ac640a2bc8c to your computer and use it in GitHub Desktop.
Save birtles/28d5bfb1e1fa0d62b3e96ac640a2bc8c to your computer and use it in GitHub Desktop.
Resolve relative image paths in Astro markdown files
import { remarkCollectImages } from '@astrojs/markdown-remark';
import { unified, type Processor } from 'unified';
import rehypeStringify from 'rehype-stringify';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { rehypeAstroImages } from '@utils/rehype-astro-images.mjs';
import { getProjectRoot } from '@utils/path';
import viteConfig from '../../vite.config.mjs';
let trustedProcessor: Promise<Processor> | undefined;
export function getTrustedMarkdownProcessor(): Promise<Processor> {
if (!trustedProcessor) {
trustedProcessor = (async () =>
unified()
.use(remarkParse)
.use(remarkCollectImages)
.use(remarkRehype)
.use(rehypeAstroImages, { rootPath: getProjectRoot(), viteConfig })
.use(rehypeStringify))();
}
return trustedProcessor;
}
import { fileURLToPath } from 'node:url';
export function getProjectRoot() {
const currentFolder = fileURLToPath(new URL('.', import.meta.url));
// Detect production mode or whatever it's called
//
// Basically, when running `astro build`,
// `fileURLToPath(new URL('.', import.meta.url))` seems to resolve to
// `/home/me/blog/dist/chunk`.
const distIndex = currentFolder.indexOf('dist/');
if (distIndex !== -1) {
return currentFolder.slice(0, distIndex);
}
return fileURLToPath(new URL('../..', import.meta.url));
}
import { getImage } from 'astro:assets';
import { imageMetadata } from 'astro/assets/utils';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as url from 'node:url';
import { visit } from 'unist-util-visit';
import { xxhashBase64Url } from '@rollup/wasm-node/dist/wasm-node/bindings_wasm.js';
/**
* @typedef {import('hast').Root} Root
* @typedef {import('hast').Element} Element
* @typedef {import('hast').Properties} Properties
* @typedef {import('vite').UserConfig} ViteConfig
*/
/**
* @typedef {object} Options
*
* @property {string=} assetsDir - The Astro
* [build.assets](https://docs.astro.build/en/reference/configuration-reference/#buildassets)
* configuration setting. Not needed if you
* haven't changed this or if you have specified
* `assetFileNames` in your vite config (and
* passed that instead).
*
* @property {string} rootPath - The absolute path to the root of your Astro
* project (i.e. the parent of your `dist` folder
* etc)
*
* @property {URL | string=} rootUrl - The root URL for your site. If set the
* resolved image paths will be converted to
* absolute URLs using this as the base URL.
*
* @property {ViteConfig=} viteConfig - The vite config for your Astro project.
* Only needed if you override Rollup's
* `assetFileNames` setting.
*/
/**
* Rehype plugin to resolve Astro image paths.
*
* @type {import('unified').Plugin<[Options], Root>}
*/
export function rehypeAstroImages(options) {
return async function (tree, file) {
if (
!file.path ||
!(file.data.imagePaths instanceof Set) ||
!file.data.imagePaths?.size
) {
return;
}
// Collect the images we need to resolve.
/**
* @typedef {Omit<Element, 'properties'> & { properties: { src: string; width?: string; height?: string } }} ElementWithSrcProperty
*/
/** @type ElementWithSrcProperty[] */
const imageNodes = [];
/** @type Map<string, string> */
const imagesToResolve = new Map();
visit(tree, (node) => {
if (
node.type !== 'element' ||
node.tagName !== 'img' ||
typeof node.properties?.src !== 'string' ||
!node.properties?.src ||
!(
/** @type {Set<string>} */ (file.data.imagePaths).has(
node.properties.src
)
)
) {
return;
}
const nodeWithSrcProperty = /** @type ElementWithSrcProperty */ (node);
if (imagesToResolve.has(nodeWithSrcProperty.properties.src)) {
imageNodes.push(nodeWithSrcProperty);
return;
}
let absolutePath;
// Special handling for the ~/assets alias
if (nodeWithSrcProperty.properties.src.startsWith('~/assets/')) {
absolutePath = path.resolve(
options.rootPath,
'src',
'assets',
node.properties.src.substring('~/assets/'.length)
);
} else {
absolutePath = path.resolve(
path.dirname(file.path),
nodeWithSrcProperty.properties.src
);
}
if (!fs.existsSync(absolutePath)) {
return;
}
imageNodes.push(nodeWithSrcProperty);
imagesToResolve.set(nodeWithSrcProperty.properties.src, absolutePath);
});
// Resolve all the images
/** @type Promise<[string, { src: string; attributes: Record<string, any> }]>[] */
const imagePromises = [];
for (const [relativePath, absolutePath] of imagesToResolve.entries()) {
imagePromises.push(
fs.promises
.readFile(absolutePath)
.then(
(buffer) =>
/** @type Promise<[ImageMetadata, Buffer]> */
new Promise((resolve) => {
imageMetadata(buffer).then((meta) => {
resolve([meta, buffer]);
});
})
)
.then(([meta, buffer]) => {
if (!meta) {
throw new Error(
`Failed to get metadata for image ${relativePath}`
);
}
let assetPath;
// We want to detect "watch mode" here but I'm not sure how to do
// that. As far as I know, when running `astro build`
// import.meta.env.PROD is true but when running `astro dev` it's
// not so hopefully this is close enough?
if (import.meta.env.PROD) {
assetPath = getImageAssetFileName(
absolutePath,
buffer,
options.assetsDir,
options.viteConfig
);
} else {
const fileUrl = url.pathToFileURL(absolutePath);
fileUrl.searchParams.append('origWidth', meta.width.toString());
fileUrl.searchParams.append('origHeight', meta.height.toString());
fileUrl.searchParams.append('origFormat', meta.format);
assetPath =
'/@fs' +
absolutize(url.fileURLToPath(fileUrl) + fileUrl.search).replace(
/\\/g,
'/'
);
}
return getImage({ src: { ...meta, src: assetPath } });
})
.then((image) => [
relativePath,
{ src: image.src, attributes: image.attributes },
])
);
}
// Process the result
/** @type {Map<string, { src: string; attributes: Record<string, any> }>} */
const resolvedImages = new Map();
for (const result of await Promise.allSettled(imagePromises)) {
if (result.status === 'fulfilled') {
resolvedImages.set(...result.value);
} else {
console.warn('Failed to resolve image', result.reason);
}
}
for (const node of imageNodes) {
const imageDetails = resolvedImages.get(node.properties.src);
if (imageDetails) {
const { src: resolvedSrc, attributes } = imageDetails;
if (options.rootUrl) {
node.properties.src = new URL(
resolvedSrc,
options.rootUrl
).toString();
} else {
node.properties.src = absolutize(resolvedSrc);
}
node.properties.width = attributes.width;
node.properties.height = attributes.height;
}
}
};
}
/**
* @param {string} absolutePath
* @param {Buffer} data
* @param {string=} assetsDir
* @param {ViteConfig=} viteConfig
*/
function getImageAssetFileName(absolutePath, data, assetsDir, viteConfig) {
const source = new Uint8Array(data);
const sourceHash = getImageHash(source);
if (Array.isArray(viteConfig?.build?.rollupOptions?.output)) {
throw new Error("We don't know how to handle multiple output options 😬");
}
// Defaults to _astro
//
// https://docs.astro.build/en/reference/configuration-reference/#buildassets
assetsDir = assetsDir || '_astro';
// Defaults to `${settings.config.build.assets}/[name].[hash][extname]`
//
// https://github.com/withastro/astro/blob/astro%404.0.3/packages/astro/src/core/build/static-build.ts#L208C22-L208C78
const assetFileNames =
viteConfig?.build?.rollupOptions?.output?.assetFileNames ||
`${assetsDir}/[name].[hash][extname]`;
return generateAssetFileName(
path.basename(absolutePath),
source,
sourceHash,
assetFileNames
);
}
/**
* @param {Uint8Array} imageSource
*/
function getImageHash(imageSource) {
return xxhashBase64Url(imageSource).slice(0, 8);
}
/**
* @typedef {object} AssetInfo
* @property {string=} fileName
* @property {string} name
* @property {boolean=} needsCodeReference
* @property {string | Uint8Array} source
* @property {'asset'} type
*
* @param {string} name
* @param {Uint8Array} source
* @param {string} sourceHash
* @param {string | ((assetInfo: AssetInfo) => string)} assetFileNames
*/
function generateAssetFileName(name, source, sourceHash, assetFileNames) {
const defaultHashSize = 8;
return renderNamePattern(
typeof assetFileNames === 'function'
? assetFileNames({ name, source, type: 'asset' })
: assetFileNames,
{
ext: () => path.extname(name).slice(1),
extname: () => path.extname(name),
hash: (size) => sourceHash.slice(0, Math.max(0, size || defaultHashSize)),
name: () =>
name.slice(0, Math.max(0, name.length - path.extname(name).length)),
}
);
}
/**
* @param {string} pattern
* @param {{ [name: string]: (size?: number) => string }} replacements
*/
function renderNamePattern(pattern, replacements) {
return pattern.replace(/\[(\w+)(:\d+)?]/g, (_match, type, size) =>
replacements[type](size && Number.parseInt(size.slice(1)))
);
}
/**
* @param {string} path
*/
function absolutize(path) {
return !path.startsWith('/') ? `/${path}` : path;
}
---
import { getTrustedMarkdownProcessor } from '@utils/markdown';
export type Props = {
content: string;
path?: string;
};
const html = await (await getTrustedMarkdownProcessor()).process({
path: Astro.props.path,
value: Astro.props.content
});
---
<Fragment set:html={html} />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment