Skip to content

Commit 165cfc1

Browse files
OliverSpeirPrincesseuhsarah11918
authoredJan 17, 2024
Allow remark plugins to affect getImage call for .md files (#9566)
* pass hProperties to getImage for optimized imgs * fix to allow multiple images to have hProps added * update test to reflect new expected result * add comment back in Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com> * add srcset * works on multiple images * fix tests, fix images.ts type and remove console logs * add warning back to images.ts again lol * update changeset to be user oriented * Update calm-socks-shake.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * pass alt through getImage * added fixture and test * update lockfile * fix lockfile again (had installed an extra package during testing and had sharp33 installed) * update test to reflect passing alt through getImage --------- Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
1 parent e9a72d9 commit 165cfc1

File tree

12 files changed

+215
-58
lines changed

12 files changed

+215
-58
lines changed
 

‎.changeset/calm-socks-shake.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@astrojs/markdown-remark": minor
3+
"astro": minor
4+
---
5+
6+
Allows remark plugins to pass options specifying how images in `.md` files will be optimized

‎packages/astro/src/vite-plugin-markdown/images.ts

+50-23
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,60 @@ export type MarkdownImagePath = { raw: string; resolved: string; safeName: strin
22

33
export function getMarkdownCodeForImages(imagePaths: MarkdownImagePath[], html: string) {
44
return `
5-
import { getImage } from "astro:assets";
6-
${imagePaths
7-
.map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`)
8-
.join('\n')}
5+
import { getImage } from "astro:assets";
6+
${imagePaths
7+
.map((entry) => `import Astro__${entry.safeName} from ${JSON.stringify(entry.raw)};`)
8+
.join('\n')}
99
10-
const images = async function() {
11-
return {
12-
${imagePaths
13-
.map((entry) => `"${entry.raw}": await getImage({src: Astro__${entry.safeName}})`)
14-
.join(',\n')}
15-
}
16-
}
10+
const images = async function(html) {
11+
const imageSources = {};
12+
${imagePaths
13+
.map((entry) => {
14+
const rawUrl = JSON.stringify(entry.raw);
15+
return `{
16+
const regex = new RegExp('__ASTRO_IMAGE_="([^"]*' + ${rawUrl} + '[^"]*)"', 'g');
17+
let match;
18+
let occurrenceCounter = 0;
19+
while ((match = regex.exec(html)) !== null) {
20+
const matchKey = ${rawUrl} + '_' + occurrenceCounter;
21+
const imageProps = JSON.parse(match[1].replace(/&#x22;/g, '"'));
22+
const { src, ...props } = imageProps;
23+
24+
imageSources[matchKey] = await getImage({src: Astro__${entry.safeName}, ...props});
25+
occurrenceCounter++;
26+
}
27+
}`;
28+
})
29+
.join('\n')}
30+
return imageSources;
31+
};
1732
1833
async function updateImageReferences(html) {
19-
return images().then((images) => {
20-
return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) =>
21-
spreadAttributes({
22-
src: images[imagePath].src,
23-
...images[imagePath].attributes,
24-
})
25-
);
34+
return images(html).then((imageSources) => {
35+
return html.replaceAll(/__ASTRO_IMAGE_="([^"]+)"/gm, (full, imagePath) => {
36+
const decodedImagePath = JSON.parse(imagePath.replace(/&#x22;/g, '"'));
37+
38+
// Use the 'index' property for each image occurrence
39+
const srcKey = decodedImagePath.src + '_' + decodedImagePath.index;
40+
41+
if (imageSources[srcKey].srcSet && imageSources[srcKey].srcSet.values.length > 0) {
42+
imageSources[srcKey].attributes.srcset = imageSources[srcKey].srcSet.attribute;
43+
}
44+
45+
const { index, ...attributesWithoutIndex } = imageSources[srcKey].attributes;
46+
47+
return spreadAttributes({
48+
src: imageSources[srcKey].src,
49+
...attributesWithoutIndex,
50+
});
51+
});
2652
});
27-
}
53+
}
54+
2855
29-
// NOTE: This causes a top-level await to appear in the user's code, which can break very easily due to a Rollup
30-
// bug and certain adapters not supporting it correctly. See: https://github.com/rollup/rollup/issues/4708
31-
// Tread carefully!
56+
// NOTE: This causes a top-level await to appear in the user's code, which can break very easily due to a Rollup
57+
// bug and certain adapters not supporting it correctly. See: https://github.com/rollup/rollup/issues/4708
58+
// Tread carefully!
3259
const html = await updateImageReferences(${JSON.stringify(html)});
33-
`;
60+
`;
3461
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect } from 'chai';
2+
import * as cheerio from 'cheerio';
3+
import { Writable } from 'node:stream';
4+
5+
import { Logger } from '../dist/core/logger/core.js';
6+
import { loadFixture } from './test-utils.js';
7+
8+
describe('astro:image', () => {
9+
/** @type {import('./test-utils').Fixture} */
10+
let fixture;
11+
12+
describe('dev', () => {
13+
/** @type {import('./test-utils').DevServer} */
14+
let devServer;
15+
/** @type {Array<{ type: any, level: 'error', message: string; }>} */
16+
let logs = [];
17+
18+
before(async () => {
19+
fixture = await loadFixture({
20+
root: './fixtures/core-image-remark-imgattr/',
21+
});
22+
23+
devServer = await fixture.startDevServer({
24+
logger: new Logger({
25+
level: 'error',
26+
dest: new Writable({
27+
objectMode: true,
28+
write(event, _, callback) {
29+
logs.push(event);
30+
callback();
31+
},
32+
}),
33+
}),
34+
});
35+
});
36+
37+
after(async () => {
38+
await devServer.stop();
39+
});
40+
41+
describe('Test image attributes can be added by remark plugins', () => {
42+
let $;
43+
before(async () => {
44+
let res = await fixture.fetch('/');
45+
let html = await res.text();
46+
$ = cheerio.load(html);
47+
});
48+
49+
it('Image has eager loading meaning getImage passed props it doesnt use through it', async () => {
50+
let $img = $('img');
51+
expect($img.attr('loading')).to.equal('eager');
52+
});
53+
54+
it('Image src contains w=50 meaning getImage correctly used props added through the remark plugin', async () => {
55+
let $img = $('img');
56+
expect(new URL($img.attr('src'), 'http://example.com').searchParams.get('w')).to.equal('50');
57+
});
58+
});
59+
});
60+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from 'astro/config';
2+
import plugin from "./remarkPlugin"
3+
4+
// https://astro.build/config
5+
export default defineConfig({
6+
markdown: {
7+
remarkPlugins:[plugin]
8+
}
9+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@test/core-image-remark-imgattr",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
},
8+
"scripts": {
9+
"dev": "astro dev"
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default function plugin() {
2+
return transformer;
3+
4+
function transformer(tree) {
5+
function traverse(node) {
6+
if (node.type === "image") {
7+
node.data = node.data || {};
8+
node.data.hProperties = node.data.hProperties || {};
9+
node.data.hProperties.loading = "eager";
10+
node.data.hProperties.width = "50";
11+
}
12+
13+
if (node.children) {
14+
node.children.forEach(traverse);
15+
}
16+
}
17+
18+
traverse(tree);
19+
}
20+
}
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
![alt](../assets/penguin2.jpg)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "astro/tsconfigs/base",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
}
6+
}

‎packages/markdown/remark/src/rehype-images.ts

+23-12
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,28 @@ import type { MarkdownVFile } from './types.js';
33

44
export function rehypeImages() {
55
return () =>
6-
function (tree: any, file: MarkdownVFile) {
7-
visit(tree, (node) => {
8-
if (node.type !== 'element') return;
9-
if (node.tagName !== 'img') return;
6+
function (tree: any, file: MarkdownVFile) {
7+
const imageOccurrenceMap = new Map();
108

11-
if (node.properties?.src) {
12-
if (file.data.imagePaths?.has(node.properties.src)) {
13-
node.properties['__ASTRO_IMAGE_'] = node.properties.src;
14-
delete node.properties.src;
15-
}
16-
}
17-
});
18-
};
9+
visit(tree, (node) => {
10+
if (node.type !== 'element') return;
11+
if (node.tagName !== 'img') return;
12+
13+
if (node.properties?.src) {
14+
if (file.data.imagePaths?.has(node.properties.src)) {
15+
const { ...props } = node.properties;
16+
17+
// Initialize or increment occurrence count for this image
18+
const index = imageOccurrenceMap.get(node.properties.src) || 0;
19+
imageOccurrenceMap.set(node.properties.src, index + 1);
20+
21+
node.properties['__ASTRO_IMAGE_'] = JSON.stringify({ ...props, index });
22+
23+
Object.keys(props).forEach((prop) => {
24+
delete node.properties[prop];
25+
});
26+
}
27+
}
28+
});
29+
};
1930
}

‎packages/markdown/remark/test/remark-collect-images.test.js

+23-23
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@ import { createMarkdownProcessor } from '../dist/index.js';
22
import chai from 'chai';
33

44
describe('collect images', async () => {
5-
const processor = await createMarkdownProcessor();
5+
const processor = await createMarkdownProcessor();
66

7-
it('should collect inline image paths', async () => {
8-
const {
9-
code,
10-
metadata: { imagePaths },
11-
} = await processor.render(`Hello ![inline image url](./img.png)`, {
12-
fileURL: 'file.md',
13-
});
7+
it('should collect inline image paths', async () => {
8+
const {
9+
code,
10+
metadata: { imagePaths },
11+
} = await processor.render(`Hello ![inline image url](./img.png)`, {
12+
fileURL: 'file.md',
13+
});
1414

15-
chai
16-
.expect(code)
17-
.to.equal('<p>Hello <img alt="inline image url" __ASTRO_IMAGE_="./img.png"></p>');
15+
chai
16+
.expect(code)
17+
.to.equal('<p>Hello <img __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.png&#x22;,&#x22;alt&#x22;:&#x22;inline image url&#x22;,&#x22;index&#x22;:0}"></p>');
1818

19-
chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.png']);
20-
});
19+
chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.png']);
20+
});
2121

22-
it('should add image paths from definition', async () => {
23-
const {
24-
code,
25-
metadata: { imagePaths },
26-
} = await processor.render(`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, {
27-
fileURL: 'file.md',
28-
});
22+
it('should add image paths from definition', async () => {
23+
const {
24+
code,
25+
metadata: { imagePaths },
26+
} = await processor.render(`Hello ![image ref][img-ref]\n\n[img-ref]: ./img.webp`, {
27+
fileURL: 'file.md',
28+
});
2929

30-
chai.expect(code).to.equal('<p>Hello <img alt="image ref" __ASTRO_IMAGE_="./img.webp"></p>');
31-
chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.webp']);
32-
});
30+
chai.expect(code).to.equal('<p>Hello <img __ASTRO_IMAGE_="{&#x22;src&#x22;:&#x22;./img.webp&#x22;,&#x22;alt&#x22;:&#x22;image ref&#x22;,&#x22;index&#x22;:0}"></p>');
31+
chai.expect(Array.from(imagePaths)).to.deep.equal(['./img.webp']);
32+
});
3333
});

‎pnpm-lock.yaml

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.