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}