fix: preserve HAST properties in image processing (#14902)

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
This commit is contained in:
五月七日千緒
2025-12-04 18:01:50 +09:00
committed by GitHub
parent 141c676df1
commit d8305f8abd
3 changed files with 58 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
'@astrojs/markdown-remark': patch
---
Prevents HAST-only props from being directly converted into HTML attributes

View File

@@ -2,6 +2,17 @@ import type { Properties, Root } from 'hast';
import { visit } from 'unist-util-visit';
import type { VFile } from 'vfile';
/**
* HAST properties to preserve on the node
*/
const HAST_PRESERVED_PROPERTIES = [
// HAST: className -> HTML: class
'className',
// HAST: htmlFor -> HTML: for
'htmlFor',
];
export function rehypeImages() {
return function (tree: Root, file: VFile) {
if (!file.data.astro?.localImagePaths?.length && !file.data.astro?.remoteImagePaths?.length) {
@@ -16,13 +27,13 @@ export function rehypeImages() {
if (typeof node.properties?.src !== 'string') return;
const src = decodeURI(node.properties.src);
let newProperties: Properties;
let imageProperties: Properties;
if (file.data.astro?.localImagePaths?.includes(src)) {
// Override the original `src` with the new, decoded `src` that Astro will better understand.
newProperties = { ...node.properties, src };
imageProperties = { ...node.properties, src };
} else if (file.data.astro?.remoteImagePaths?.includes(src)) {
newProperties = {
imageProperties = {
// By default, markdown images won't have width and height set. However, just in case another user plugin does set these, we should respect them.
inferSize: 'width' in node.properties && 'height' in node.properties ? undefined : true,
...node.properties,
@@ -33,12 +44,21 @@ export function rehypeImages() {
return;
}
// Separate HAST-only properties from image processing properties
const hastProperties: Properties = {};
for (const key of HAST_PRESERVED_PROPERTIES) {
if (key in imageProperties) {
hastProperties[key] = imageProperties[key];
delete imageProperties[key];
}
}
// Initialize or increment occurrence count for this image
const index = imageOccurrenceMap.get(node.properties.src) || 0;
imageOccurrenceMap.set(node.properties.src, index + 1);
// Set a special property on the image so later Astro code knows to process this image.
node.properties = { __ASTRO_IMAGE_: JSON.stringify({ ...newProperties, index }) };
node.properties = { ...hastProperties, __ASTRO_IMAGE_: JSON.stringify({ ...imageProperties, index }) };
});
};
}

View File

@@ -1,12 +1,28 @@
import assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';
import { visit } from 'unist-util-visit';
import { createMarkdownProcessor } from '../dist/index.js';
describe('collect images', async () => {
let processor;
let processorWithHastProperties;
before(async () => {
processor = await createMarkdownProcessor({ image: { domains: ['example.com'] } });
processor = await createMarkdownProcessor({ image: { domains: ['example.com'] }, });
processorWithHastProperties = await createMarkdownProcessor({
rehypePlugins: [
() => {
return (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName === 'img') {
node.properties.className = ['image-class'];
node.properties.htmlFor = 'some-id';
}
});
};
},
],
});
});
it('should collect inline image paths', async () => {
@@ -75,4 +91,16 @@ describe('collect images', async () => {
assert.deepEqual(metadata.localImagePaths, ['./img.webp']);
assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']);
});
it('should preserve className as HTML class attribute', async () => {
const markdown = `Hello ![image with class](./img.png)`;
const fileURL = 'file.md';
const { code } = await processorWithHastProperties.render(markdown, { fileURL });
assert.equal(
code,
'<p>Hello <img class="image-class" for="some-id" __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.png&#x22;,&#x22;alt&#x22;:&#x22;image with class&#x22;,&#x22;index&#x22;:0}"></p>',
);
});
});