main
1import { getImage } from "astro:assets";
2import type { RehypePlugin } from "@astrojs/markdown-remark";
3import { visit } from "unist-util-visit";
4
5import { ASSET_DIR, dataImageFilename } from "@/integrations/extract-images.ts";
6import { getImageMetadata } from "@/utils/post-images.ts";
7import { joinUrl, url } from "@/utils/url.ts";
8
9const JSX_IMAGE_SRC = /src=\{import\("([^"]+)"\)/;
10const JSX_IMAGE_ALT = /alt=\{("(?:[^"\\]|\\.)*")\}/;
11
12interface Parent {
13 children: unknown[];
14}
15
16interface Replacement {
17 parent: Parent;
18 index: number;
19 jsx: string;
20}
21
22function parseJsxString(literal: string | undefined): string {
23 if (!literal) {
24 return "";
25 }
26 try {
27 return JSON.parse(literal);
28 } catch {
29 return literal.slice(1, -1);
30 }
31}
32
33// `kp.img` renders Astro `<Image>` JSX into a script element. Feed readers
34// cannot execute it, so turn those scripts into plain `<img>` elements backed
35// by the same image service the pages use.
36export function rehypeRewriteJsxImages(options: {
37 site: string;
38 width: number;
39}): RehypePlugin {
40 return () => async (tree) => {
41 const replacements: Replacement[] = [];
42
43 visit(tree, "element", (node, index, parent) => {
44 const jsx = node.properties?.["data-jsx"];
45 if (
46 node.tagName !== "script" ||
47 typeof jsx !== "string" ||
48 typeof index !== "number" ||
49 !parent
50 ) {
51 return;
52 }
53 replacements.push({ parent, index, jsx });
54 });
55
56 await Promise.all(
57 replacements.map(async ({ parent, index, jsx }) => {
58 const source = jsx.match(JSX_IMAGE_SRC)?.[1];
59 if (!source) {
60 return;
61 }
62 const metadata = getImageMetadata(source);
63 const image = metadata
64 ? await getImage({ src: metadata, width: options.width })
65 : undefined;
66 parent.children[index] = {
67 type: "element",
68 tagName: "img",
69 properties: {
70 src: new URL(image?.src ?? url(source), options.site).toString(),
71 alt: parseJsxString(jsx.match(JSX_IMAGE_ALT)?.[1]),
72 width: image?.attributes.width,
73 height: image?.attributes.height,
74 loading: "lazy",
75 decoding: "async",
76 },
77 children: [],
78 };
79 }),
80 );
81
82 // Drop the remaining `import { Image } from 'astro:assets'` scripts.
83 const imports: Replacement[] = [];
84 visit(tree, "element", (node, index, parent) => {
85 const jsx = node.properties?.["data-jsx"];
86 if (
87 node.tagName === "script" &&
88 typeof jsx === "string" &&
89 jsx.trim().startsWith("import ") &&
90 typeof index === "number" &&
91 parent
92 ) {
93 imports.push({ parent, index, jsx });
94 }
95 });
96 imports.sort((a, b) => b.index - a.index);
97 for (const { parent, index } of imports) {
98 parent.children.splice(index, 1);
99 }
100 };
101}
102
103export function rehypeInlineImages(options: { site: string }): RehypePlugin {
104 return () => (tree) => {
105 visit(tree, "element", (node) => {
106 const src = node.properties?.src;
107 if (node.tagName !== "img" || typeof src !== "string") {
108 return;
109 }
110 const filename = dataImageFilename(src);
111 if (filename) {
112 node.properties.src = new URL(
113 url(joinUrl(ASSET_DIR, filename)),
114 options.site,
115 ).toString();
116 }
117 });
118 };
119}