Last active
December 14, 2023 06:16
-
-
Save birtles/28d5bfb1e1fa0d62b3e96ac640a2bc8c to your computer and use it in GitHub Desktop.
Resolve relative image paths in Astro markdown files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
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