Commit 86e3de4
Changed files (16)
src
components
contents
_lib
templates
utils
integrations
layouts
pages
utils
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",