Commit 86e3de4

HPCesia <me@hpcesia.com>
2026-09-18 15:19:08
feed: add atom feed
1 parent 6d108b1
src/components/Header.astro
@@ -12,6 +12,7 @@ const globalI18n = getIntlayer("global", SITE_CONFIG.locale);
     <ul>
       <li><a href={url("/posts")}>{globalI18n.posts}</a></li>
       <li><a href={url("/about")}>{globalI18n.about}</a></li>
+      <li><a href={url("/feed.atom")}>{globalI18n.feed}</a></li>
     </ul>
   </nav>
 </header>
src/contents/_lib/templates/raw.typ
@@ -1,8 +1,12 @@
 #import "@preview/bullseye:0.1.0": show-target
 #import "../hypraw/hypraw.typ": hypraw
+#import "../utils/renderer-check.typ": is-renderer
 
 #let raw-template-html(body) = {
-  show: hypraw.with(line-numbers: true)
+  show: it => if is-renderer("kerplunk") { it } else {
+    show: hypraw.with(line-numbers: true)
+    it
+  }
   body
 }
 
src/contents/_lib/utils/renderer-check.typ
@@ -0,0 +1,5 @@
+/// - renderer (str):
+/// -> boolean
+#let is-renderer(renderer) = (
+  sys.inputs.at("x-renderer", default: "astro-typst") == renderer
+)
src/integrations/extract-images.ts
@@ -7,7 +7,7 @@ import type { AstroIntegration, AstroIntegrationLogger } from "astro";
 
 import { joinUrl } from "../utils/url.ts";
 
-const ASSET_DIR = "_typst";
+export const ASSET_DIR = "_typst";
 
 // Typst's HTML export inlines images (including generated SVGs) as data URLs.
 // After `astro build`, pull them out into files so pages don't carry megabytes
@@ -43,6 +43,11 @@ function imageInfo(
   return { bytes, filename: `${hash}.${ext}` };
 }
 
+// Shared with the feed pipeline so inlined images resolve to the same files.
+export function dataImageFilename(src: string): string | null {
+  return imageInfo(src)?.filename ?? null;
+}
+
 async function collectImages(
   rootDir: string,
   onHtml: (filePath: string) => Promise<void>,
src/layouts/BaseLayout.astro
@@ -35,6 +35,12 @@ const locale = SITE_CONFIG.locale;
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <link rel="icon" href="/favicon.ico" />
     <link rel="sitemap" href="/sitemap-index.xml" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Atom Feed"
+      href={`${import.meta.env.BASE_URL}feed.atom`}
+    />
 
     <title>{title}</title>
     <meta name="title" content={title} />
src/pages/feed.atom.ts
@@ -0,0 +1,28 @@
+import type { APIRoute } from "astro";
+import { getCollection } from "astro:content";
+
+import { SITE_CONFIG } from "@/config.ts";
+import { createFeed } from "@/utils/feed.ts";
+import { url } from "@/utils/url.ts";
+
+export const GET: APIRoute = async ({ site }) => {
+  if (!site) {
+    throw new Error("`site` must be configured in astro.config to build feeds");
+  }
+  const origin = site.origin;
+  const feed = await createFeed({
+    id: new URL(url("/"), origin).toString(),
+    feedUrl: new URL(url("/feed.atom"), origin).toString(),
+    favicon: new URL(url("/favicon.ico"), origin).toString(),
+    title: SITE_CONFIG.title,
+    description: SITE_CONFIG.description,
+    language: SITE_CONFIG.locale,
+    site: origin,
+    posts: await getCollection("posts"),
+    postUrl: (post) => new URL(url(`/posts/${post.id}`), origin).toString(),
+  });
+
+  return new Response(feed.atom1(), {
+    headers: { "Content-Type": "application/atom+xml" },
+  });
+};
src/utils/plugins/absolute-links.ts
@@ -0,0 +1,20 @@
+import type { RehypePlugin } from "@astrojs/markdown-remark";
+import { visit } from "unist-util-visit";
+
+// Relative links break inside feed readers; point them back at the site.
+export function rehypeAbsoluteLinks(options: { site: string }): RehypePlugin {
+  return () => (tree) => {
+    visit(tree, "element", (node) => {
+      const href = node.properties?.href;
+      if (
+        node.tagName !== "a" ||
+        typeof href !== "string" ||
+        !href.startsWith("/") ||
+        href.startsWith("//")
+      ) {
+        return;
+      }
+      node.properties.href = new URL(href, options.site).toString();
+    });
+  };
+}
src/utils/plugins/clean-properties.ts
@@ -0,0 +1,20 @@
+import type { RehypePlugin } from "@astrojs/markdown-remark";
+import { visit } from "unist-util-visit";
+
+// Feed readers load neither the site's stylesheets nor its scripts, so
+// attributes that only serve them are dropped.
+export const rehypeCleanProperties: RehypePlugin = () => (tree) => {
+  visit(tree, "element", (node) => {
+    if (node.properties.class) {
+      delete node.properties.class;
+    }
+    for (const key of Object.keys(node.properties)) {
+      if (key.startsWith("data-")) {
+        delete node.properties[key];
+      }
+    }
+    if (node.properties.style) {
+      delete node.properties.style;
+    }
+  });
+};
src/utils/plugins/extract-body.ts
@@ -0,0 +1,22 @@
+import type { RehypePlugin } from "@astrojs/markdown-remark";
+import { visit } from "unist-util-visit";
+
+// Typst HTML export produces a full document; feed content only needs its body.
+export const rehypeExtractBody: RehypePlugin = () => (tree) => {
+  const root = tree as unknown as {
+    type?: string;
+    children: unknown[];
+    tagName?: string;
+    properties?: Record<string, unknown>;
+  };
+  visit(tree, "element", (node) => {
+    if (node.tagName !== "body") {
+      return;
+    }
+    root.children = node.children;
+    root.type = "root";
+    delete root.tagName;
+    delete root.properties;
+    return false;
+  });
+};
src/utils/plugins/rewrite-images.ts
@@ -0,0 +1,121 @@
+import type { RehypePlugin } from "@astrojs/markdown-remark";
+import { getImage } from "astro:assets";
+import { visit } from "unist-util-visit";
+
+import { ASSET_DIR, dataImageFilename } from "@/integrations/extract-images.ts";
+import { getImageMetadata } from "@/utils/post-images.ts";
+import { joinUrl, url } from "@/utils/url.ts";
+
+const JSX_IMAGE_SRC = /src=\{import\("([^"]+)"\)/;
+const JSX_IMAGE_ALT = /alt=\{("(?:[^"\\]|\\.)*")\}/;
+
+interface Parent {
+  children: unknown[];
+}
+
+interface Replacement {
+  parent: Parent;
+  index: number;
+  jsx: string;
+}
+
+function parseJsxString(literal: string | undefined): string {
+  if (!literal) {
+    return "";
+  }
+  try {
+    return JSON.parse(literal);
+  } catch {
+    return literal.slice(1, -1);
+  }
+}
+
+// `kp.img` renders Astro `<Image>` JSX into a script element. Feed readers
+// cannot execute it, so turn those scripts into plain `<img>` elements backed
+// by the same image service the pages use.
+export function rehypeRewriteJsxImages(
+  options: { site: string; width: number },
+): RehypePlugin {
+  return () => async (tree) => {
+    const replacements: Replacement[] = [];
+
+    visit(tree, "element", (node, index, parent) => {
+      const jsx = node.properties?.["data-jsx"];
+      if (
+        node.tagName !== "script" ||
+        typeof jsx !== "string" ||
+        typeof index !== "number" ||
+        !parent
+      ) {
+        return;
+      }
+      replacements.push({ parent, index, jsx });
+    });
+
+    await Promise.all(
+      replacements.map(async ({ parent, index, jsx }) => {
+        const source = jsx.match(JSX_IMAGE_SRC)?.[1];
+        if (!source) {
+          return;
+        }
+        const metadata = getImageMetadata(source);
+        const image = metadata
+          ? await getImage({ src: metadata, width: options.width })
+          : undefined;
+        parent.children[index] = {
+          type: "element",
+          tagName: "img",
+          properties: {
+            src: new URL(
+              image?.src ?? url(source),
+              options.site,
+            ).toString(),
+            alt: parseJsxString(jsx.match(JSX_IMAGE_ALT)?.[1]),
+            width: image?.attributes.width,
+            height: image?.attributes.height,
+            loading: "lazy",
+            decoding: "async",
+          },
+          children: [],
+        };
+      }),
+    );
+
+    // Drop the remaining `import { Image } from 'astro:assets'` scripts.
+    const imports: Replacement[] = [];
+    visit(tree, "element", (node, index, parent) => {
+      const jsx = node.properties?.["data-jsx"];
+      if (
+        node.tagName === "script" &&
+        typeof jsx === "string" &&
+        jsx.trim().startsWith("import ") &&
+        typeof index === "number" &&
+        parent
+      ) {
+        imports.push({ parent, index, jsx });
+      }
+    });
+    imports.sort((a, b) => b.index - a.index);
+    for (const { parent, index } of imports) {
+      parent.children.splice(index, 1);
+    }
+  };
+}
+
+export function rehypeInlineImages(options: { site: string }): RehypePlugin {
+  return () => (tree) => {
+    visit(tree, "element", (node) => {
+      const src = node.properties?.src;
+      if (node.tagName !== "img" || typeof src !== "string") {
+        return;
+      }
+      const filename = dataImageFilename(src);
+      if (filename) {
+        node.properties.src = new URL(
+          url(joinUrl(ASSET_DIR, filename)),
+          options.site,
+        ).toString();
+      }
+    });
+  };
+}
src/utils/feed.ts
@@ -0,0 +1,87 @@
+import type { CollectionEntry } from "astro:content";
+import { Feed } from "feed";
+
+import { SITE_CONFIG } from "@/config.ts";
+import { rehypeAbsoluteLinks } from "@/utils/plugins/absolute-links.ts";
+import { rehypeCleanEndnotes } from "@/utils/plugins/clean-endnotes.ts";
+import { rehypeCleanProperties } from "@/utils/plugins/clean-properties.ts";
+import { rehypeExtractBody } from "@/utils/plugins/extract-body.ts";
+import {
+  rehypeInlineImages,
+  rehypeRewriteJsxImages,
+} from "@/utils/plugins/rewrite-images.ts";
+import { renderTypstToHtml } from "@/utils/render-typst.ts";
+
+// Matches the `width` that `kp.img` passes to Astro's `<Image />`.
+const FEED_IMAGE_WIDTH = 780;
+
+function renderFeedContent(filePath: string, site: string) {
+  return renderTypstToHtml(filePath, [
+    rehypeCleanEndnotes,
+    rehypeExtractBody,
+    rehypeRewriteJsxImages({ site, width: FEED_IMAGE_WIDTH }),
+    rehypeInlineImages({ site }),
+    rehypeAbsoluteLinks({ site }),
+    rehypeCleanProperties,
+  ]);
+}
+
+export interface FeedData {
+  /** Absolute URL covered by this feed; used as its id and link. */
+  id: string;
+  /** Absolute URL of the feed file itself. */
+  feedUrl: string;
+  /** Absolute URL of the site favicon. */
+  favicon: string;
+  title: string;
+  description: string;
+  language: string;
+  /** Origin that content URLs are resolved against. */
+  site: string;
+  posts: CollectionEntry<"posts">[];
+  /** Absolute URL of a post's page. */
+  postUrl: (post: CollectionEntry<"posts">) => string;
+}
+
+export async function createFeed(data: FeedData): Promise<Feed> {
+  const feed = new Feed({
+    id: data.id,
+    title: data.title,
+    description: data.description,
+    language: data.language,
+    link: data.id,
+    favicon: data.favicon,
+    copyright: `All rights reserved ${new Date().getFullYear()}`,
+    author: { name: SITE_CONFIG.author },
+    feedLinks: { atom: data.feedUrl },
+  });
+
+  const posts = [...data.posts].sort((a, b) =>
+    Temporal.ZonedDateTime.compare(b.data.date, a.data.date)
+  );
+
+  const items = await Promise.all(
+    posts.map(async (post) => {
+      const link = data.postUrl(post);
+      return {
+        title: post.data.title,
+        id: link,
+        link,
+        description: post.data.desc,
+        content: typeof post.filePath === "string"
+          ? await renderFeedContent(post.filePath, data.site)
+          : undefined,
+        author: [{ name: post.data.author || SITE_CONFIG.author }],
+        date: new Date(
+          Temporal.ZonedDateTime.from(post.data.date).epochMilliseconds,
+        ),
+      };
+    }),
+  );
+
+  for (const item of items) {
+    feed.addItem(item);
+  }
+
+  return feed;
+}
src/utils/post-images.ts
@@ -0,0 +1,10 @@
+import type { ImageMetadata } from "astro";
+
+const images = import.meta.glob<{ default: ImageMetadata }>(
+  "/src/contents/**/*.{avif,gif,jpeg,jpg,png,svg,tiff,webp}",
+  { eager: true },
+);
+
+export function getImageMetadata(source: string): ImageMetadata | undefined {
+  return images[source]?.default;
+}
src/utils/render-typst.ts
@@ -0,0 +1,29 @@
+import { root } from "astro:config/server";
+import { fileURLToPath } from "node:url";
+
+import { NodeCompiler } from "@myriaddreamin/typst-ts-node-compiler";
+import { rehypeIt } from "astro-typst/dist/lib/rehype";
+import type { RehypePlugins } from "astro";
+
+const $typst = NodeCompiler.create({
+  workspace: fileURLToPath(root),
+  // Lets Typst templates detect feed rendering and skip site-only markup.
+  inputs: { "x-renderer": "kerplunk" },
+});
+
+export async function renderTypstToHtml(
+  mainFilePath: string,
+  plugins: RehypePlugins,
+): Promise<string> {
+  const compiled = $typst.compileHtml({ mainFilePath });
+  if (!compiled.result) {
+    compiled.printDiagnostics();
+    throw new Error(`Failed to compile Typst document: ${mainFilePath}`);
+  }
+  const html = $typst.tryHtml(compiled.result);
+  if (!html.result) {
+    html.printDiagnostics();
+    throw new Error(`Failed to render Typst document to HTML: ${mainFilePath}`);
+  }
+  return await rehypeIt(html.result.hast(), plugins);
+}
src/globalI18n.content.ts
@@ -3,6 +3,8 @@ import { type Dictionary, t } from "intlayer";
 interface Content {
   posts: string;
   about: string;
+  recentPosts: string;
+  feed: string;
 }
 
 export default {
@@ -20,6 +22,10 @@ export default {
       en: "About",
       "zh-CN": "关于",
     }),
+    feed: t({
+      en: "Feed",
+      "zh-CN": "订阅",
+    }),
   },
   schema: undefined,
 } satisfies Dictionary<Content>;
deno.lock
@@ -11,6 +11,7 @@
     "npm:astro-typst@~0.12.4": "0.12.4_@myriaddreamin+typst-ts-node-compiler@0.8.0-rc3_@myriaddreamin+typst-ts-renderer@0.8.0-rc3_@myriaddreamin+typst.ts@0.8.0-rc3_astro@7.2.0_sass@1.102.0",
     "npm:astro@*": "7.2.0_@astrojs+markdown-remark@7.2.2_sass@1.102.0",
     "npm:astro@^7.2.0": "7.2.0_@astrojs+markdown-remark@7.2.2_sass@1.102.0",
+    "npm:feed@6": "6.0.0",
     "npm:intlayer@^9.5.2": "9.5.2",
     "npm:sanitize.css@13": "13.0.0",
     "npm:sass@^1.102.0": "1.102.0",
@@ -1987,6 +1988,12 @@
         "picomatch@4.0.7"
       ]
     },
+    "feed@6.0.0": {
+      "integrity": "sha512-rvFOF1POce0ijAKy3tZN5T8PuprgIit/VbMyXJWcoYwcMOTSIJ7NqZb8DTLPJr77yiEO27kidm/fw0kcSIV+ww==",
+      "dependencies": [
+        "xml-js"
+      ]
+    },
     "fill-range@7.1.1": {
       "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
       "dependencies": [
@@ -3829,6 +3836,13 @@
     "whatwg-mimetype@4.0.0": {
       "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
     },
+    "xml-js@1.6.11": {
+      "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
+      "dependencies": [
+        "sax"
+      ],
+      "bin": true
+    },
     "xxhash-wasm@1.1.0": {
       "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="
     },
@@ -3863,6 +3877,7 @@
         "npm:astro-intlayer@^9.5.2",
         "npm:astro-typst@~0.12.4",
         "npm:astro@^7.2.0",
+        "npm:feed@6",
         "npm:intlayer@^9.5.2",
         "npm:sanitize.css@13",
         "npm:sass@^1.102.0",
package.json
@@ -20,6 +20,7 @@
     "astro": "^7.2.0",
     "astro-intlayer": "^9.5.2",
     "astro-typst": "^0.12.4",
+    "feed": "^6.0.0",
     "intlayer": "^9.5.2",
     "sanitize.css": "^13.0.0",
     "sass": "^1.102.0",