Compare commits

...

3 Commits

Author SHA1 Message Date
Matt Kane
70eb542f3b feat: print a more helpful error message for output: hybrid (#14958)
* feat: print a more helpful error message for `output: hybrid`

* Add type predicate
2025-12-04 12:02:43 +00:00
五月七日千緒
45c9accf3c [ci] format 2025-12-04 09:02:54 +00:00
五月七日千緒
d8305f8abd fix: preserve HAST properties in image processing (#14902)
Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>
2025-12-04 10:01:50 +01:00
6 changed files with 82 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
'astro': minor
---
Gives a helpful error message if a user sets `output: "hybrid"` in their Astro config.
The option was removed in Astro 5, but lots of content online still references it, and LLMs often suggest it. It's not always clear that the replacement is `output: "static"`, rather than `output: "server"`. This change adds a helpful error message to guide humans and robots.

View File

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

View File

@@ -149,9 +149,13 @@ export const AstroConfigSchema = z.object({
.optional()
.default(ASTRO_CONFIG_DEFAULTS.trailingSlash),
output: z
.union([z.literal('static'), z.literal('server')])
.union([z.literal('static'), z.literal('server'), z.literal('hybrid')])
.optional()
.default('static'),
.default('static')
.refine((val): val is 'static' | 'server' => val !== 'hybrid', {
message:
'The `output: "hybrid"` option has been removed. Use `output: "static"` (the default) instead, which now behaves the same way.',
}),
scopedStyleStrategy: z
.union([z.literal('where'), z.literal('class'), z.literal('attribute')])
.optional()

View File

@@ -90,6 +90,15 @@ describe('Config Validation', () => {
);
});
it('errors with helpful message when output is "hybrid"', async () => {
const configError = await validateConfig({ output: 'hybrid' }).catch((err) => err);
assert.equal(configError instanceof z.ZodError, true);
assert.ok(
configError.errors[0].message.includes('removed'),
'Error message should explain that "hybrid" has been removed',
);
});
describe('i18n', async () => {
it('defaultLocale is not in locales', async () => {
const configError = await validateConfig({

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