diff --git a/.changeset/calm-socks-shake.md b/.changeset/calm-socks-shake.md new file mode 100644 index 000000000000..79f462d2902b --- /dev/null +++ b/.changeset/calm-socks-shake.md @@ -0,0 +1,6 @@ +--- +"@astrojs/markdown-remark": minor +"astro": minor +--- + +Allows remark plugins to pass options specifying how images in `.md` files will be optimized diff --git a/packages/astro/src/vite-plugin-markdown/images.ts b/packages/astro/src/vite-plugin-markdown/images.ts index 959d34ec97cd..1123c28784aa 100644 --- a/packages/astro/src/vite-plugin-markdown/images.ts +++ b/packages/astro/src/vite-plugin-markdown/images.ts @@ -2,33 +2,60 @@ export type MarkdownImagePath = { raw: string; resolved: string; safeName: strin export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: string) { return ` - import { getImage } from "astro:assets"; - ${imagePaths - .map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`) - .join('\n')} + import { getImage } from "astro:assets"; + ${imagePaths + .map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`) + .join('\n')} - const images = async function() { - return { - ${imagePaths - .map((entry) => `"${entry.raw}": await getImage({src: Astro__${entry.safeName}})`) - .join(',\n')} - } - } + const images = async function(html) { + const imageSources = {}; + ${imagePaths + .map((entry) => { + const rawUrl = JSON.stringify(entry.raw); + return `{ + const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl} + '[^"]*)"', 'g'); + let match; + let occurrenceCounter = 0; + while ((match = regex.exec(html)) !== null) { + const matchKey = ${rawUrl} + '_' + occurrenceCounter; + const imageProps = JSON.parse(match[1].replace(/"/g, '"')); + const { src, ...props } = imageProps; + + imageSources[matchKey] = await getImage({src: Astro__${entry.safeName}, ...props}); + occurrenceCounter++; + } + }`; + }) + .join('\n')} + return imageSources; + }; async function updateImageReferences(html) { - return images().then((images) => { - return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) => - spreadAttributes({ - src: images[imagePath].src, - ...images[imagePath].attributes, - }) - ); + return images(html).then((imageSources) => { + return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) => { + const decodedImagePath = JSON.parse(imagePath.replace(/"/g, '"')); + + // Use the 'index' property for each image occurrence + const srcKey = decodedImagePath.src + '_' + decodedImagePath.index; + + if (imageSources[srcKey].srcSet && imageSources[srcKey].srcSet.values.length > 0) { + imageSources[srcKey].attributes.srcset = imageSources[srcKey].srcSet.attribute; + } + + const { index, ...attributesWithoutIndex } = imageSources[srcKey].attributes; + + return spreadAttributes({ + src: imageSources[srcKey].src, + ...attributesWithoutIndex, + }); + }); }); - } + } + - // NOTE: This causes a top-level await to appear in the user's code, which can break very easily due to a Rollup - // bug and certain adapters not supporting it correctly. See: https://github.com/rollup/rollup/issues/4708 - // Tread carefully! + // NOTE: This causes a top-level await to appear in the user's code, which can break very easily due to a Rollup + // bug and certain adapters not supporting it correctly. See: https://github.com/rollup/rollup/issues/4708 + // Tread carefully! const html = await updateImageReferences(${JSON.stringify(html)}); - `; + `; } diff --git a/packages/astro/test/core-image-remark-imgattr.test.js b/packages/astro/test/core-image-remark-imgattr.test.js new file mode 100644 index 000000000000..8d29d9c81836 --- /dev/null +++ b/packages/astro/test/core-image-remark-imgattr.test.js @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { Writable } from 'node:stream'; + +import { Logger } from '../dist/core/logger/core.js'; +import { loadFixture } from './test-utils.js'; + +describe('astro:image', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + describe('dev', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + /** @type {Array<{ type: any, level: 'error', message: string; }>} */ + let logs = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-remark-imgattr/', + }); + + devServer = await fixture.startDevServer({ + logger: new Logger({ + level: 'error', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Test image attributes can be added by remark plugins', () => { + let $; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + it('Image has eager loading meaning getImage passed props it doesnt use through it', async () => { + let $img = $('img'); + expect($img.attr('loading')).to.equal('eager'); + }); + + it('Image src contains w=50 meaning getImage correctly used props added through the remark plugin', async () => { + let $img = $('img'); + expect(new URL($img.attr('src'), 'http://example.com').searchParams.get('w')).to.equal('50'); + }); + }); + }); +}); diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/astro.config.mjs b/packages/astro/test/fixtures/core-image-remark-imgattr/astro.config.mjs new file mode 100644 index 000000000000..1ccf7caf00e5 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-remark-imgattr/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; +import plugin from "./remarkPlugin" + +// https://astro.build/config +export default defineConfig({ + markdown: { + remarkPlugins:[plugin] + } +}); diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/package.json b/packages/astro/test/fixtures/core-image-remark-imgattr/package.json new file mode 100644 index 000000000000..3732356a4e9a --- /dev/null +++ b/packages/astro/test/fixtures/core-image-remark-imgattr/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/core-image-remark-imgattr", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + }, + "scripts": { + "dev": "astro dev" + } +} diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/remarkPlugin.js b/packages/astro/test/fixtures/core-image-remark-imgattr/remarkPlugin.js new file mode 100644 index 000000000000..4bad8fb77c82 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-remark-imgattr/remarkPlugin.js @@ -0,0 +1,20 @@ +export default function plugin() { + return transformer; + + function transformer(tree) { + function traverse(node) { + if (node.type === "image") { + node.data = node.data || {}; + node.data.hProperties = node.data.hProperties || {}; + node.data.hProperties.loading = "eager"; + node.data.hProperties.width = "50"; + } + + if (node.children) { + node.children.forEach(traverse); + } + } + + traverse(tree); + } +} diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/src/assets/penguin2.jpg b/packages/astro/test/fixtures/core-image-remark-imgattr/src/assets/penguin2.jpg new file mode 100644 index 000000000000..e859ac3c992f Binary files /dev/null and b/packages/astro/test/fixtures/core-image-remark-imgattr/src/assets/penguin2.jpg differ diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/src/pages/index.md b/packages/astro/test/fixtures/core-image-remark-imgattr/src/pages/index.md new file mode 100644 index 000000000000..e415e505d69d --- /dev/null +++ b/packages/astro/test/fixtures/core-image-remark-imgattr/src/pages/index.md @@ -0,0 +1 @@ +![alt](../assets/penguin2.jpg) diff --git a/packages/astro/test/fixtures/core-image-remark-imgattr/tsconfig.json b/packages/astro/test/fixtures/core-image-remark-imgattr/tsconfig.json new file mode 100644 index 000000000000..72b184b171ae --- /dev/null +++ b/packages/astro/test/fixtures/core-image-remark-imgattr/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "baseUrl": ".", + } +} diff --git a/packages/markdown/remark/src/rehype-images.ts b/packages/markdown/remark/src/rehype-images.ts index fd1e8f70f1cc..9f9b9ee46d2e 100644 --- a/packages/markdown/remark/src/rehype-images.ts +++ b/packages/markdown/remark/src/rehype-images.ts @@ -3,17 +3,28 @@ import type { MarkdownVFile } from './types.js'; export function rehypeImages() { return () => - function (tree: any, file: MarkdownVFile) { - visit(tree, (node) => { - if (node.type !== 'element') return; - if (node.tagName !== 'img') return; + function (tree: any, file: MarkdownVFile) { + const imageOccurrenceMap = new Map(); - if (node.properties?.src) { - if (file.data.imagePaths?.has(node.properties.src)) { - node.properties['__ASTRO_IMAGE_'] = node.properties.src; - delete node.properties.src; - } - } - }); - }; + visit(tree, (node) => { + if (node.type !== 'element') return; + if (node.tagName !== 'img') return; + + if (node.properties?.src) { + if (file.data.imagePaths?.has(node.properties.src)) { + const { ...props } = node.properties; + + // Initialize or increment occurrence count for this image + const index = imageOccurrenceMap.get(node.properties.src) || 0; + imageOccurrenceMap.set(node.properties.src, index + 1); + + node.properties['__ASTRO_IMAGE_'] = JSON.stringify({ ...props, index }); + + Object.keys(props).forEach((prop) => { + delete node.properties[prop]; + }); + } + } + }); + }; } diff --git a/packages/markdown/remark/test/remark-collect-images.test.js b/packages/markdown/remark/test/remark-collect-images.test.js index a5533695365f..5aed711b028a 100644 --- a/packages/markdown/remark/test/remark-collect-images.test.js +++ b/packages/markdown/remark/test/remark-collect-images.test.js @@ -2,32 +2,32 @@ import { createMarkdownProcessor } from '../dist/index.js'; import chai from 'chai'; describe('collect images', async () => { - const processor = await createMarkdownProcessor(); + const processor = await createMarkdownProcessor(); - it('should collect inline image paths', async () => { - const { - code, - metadata: { imagePaths }, - } = await processor.render(`Hello ![inline image url](./img.png)`, { - fileURL: 'file.md', - }); + it('should collect inline image paths', async () => { + const { + code, + metadata: { imagePaths }, + } = await processor.render(`Hello ![inline image url](./img.png)`, { + fileURL: 'file.md', + }); - chai - .expect(code) - .to.equal('

Hello inline image url

'); + chai + .expect(code) + .to.equal('

Hello

'); - chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.png']); - }); + chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.png']); + }); - it('should add image paths from definition', async () => { - const { - code, - metadata: { imagePaths }, - } = await processor.render(`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, { - fileURL: 'file.md', - }); + it('should add image paths from definition', async () => { + const { + code, + metadata: { imagePaths }, + } = await processor.render(`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, { + fileURL: 'file.md', + }); - chai.expect(code).to.equal('

Hello image ref

'); - chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.webp']); - }); + chai.expect(code).to.equal('

Hello

'); + chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.webp']); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab455435e4b4..8fefee926bef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2514,6 +2514,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-remark-imgattr: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-ssg: dependencies: astro: