Commit d5f9f21
Changed files (6)
src
contents
posts
integrations
styles
article
src/contents/_lib/components/frame.typ
@@ -0,0 +1,14 @@
+#import "@preview/bullseye:0.1.0": match-target
+
+/// Frame content as an SVG image in HTML output (like `#html.frame`),
+/// but keeps the original content in PDF output.
+///
+/// - content (content):
+/// -> content
+#let frame(content) = context {
+ let render = match-target(
+ html: () => html.frame(content),
+ paged: () => content,
+ )
+ render()
+}
src/contents/_lib/lib.typ
@@ -1,5 +1,6 @@
#import "@preview/bullseye:0.1.0": match-target
+#import "./components/frame.typ": frame
#import "./components/image.typ": img
#import "./components/ruby.typ": ruby
#import "./components/sidenote.typ": sidenote
src/contents/posts/typst-syntax.typ
@@ -165,6 +165,21 @@ Pass a `path` value or a root-absolute string to ```typ #kp.img()``` and Astro's
Or just use Typst's original ```typ #image()``` function to avoid Astro's optimization.
For web image, use ```typ #html.img()``` instead.
+Images that can't be imported as a file are extracted to `/_typst/` at build time.
+
+#figure(
+ image(read("../avatar.png", encoding: none), alt: "HPCesia, author of Kerplunk's avatar", width: 30%),
+ caption: [An image built from bytes.],
+)
+
+#figure(
+ kp.frame({
+ import "@preview/lilaq:0.5.0" as lq
+ lq.diagram(lq.plot(lq.linspace(0, 10), x => calc.sin(x)))
+ }),
+ caption: [A function plot rendered as SVG by ```typ #kp.frame()```.],
+)
+
= Extra Components
These components are added by Kerplunk.
src/integrations/extract-images.ts
@@ -0,0 +1,126 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type { AstroIntegration, AstroIntegrationLogger } from "astro";
+
+import { joinUrl } from "../utils/url.ts";
+
+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
+// of base64.
+
+const EXT_BY_MIME: Record<string, string> = {
+ png: "png",
+ jpeg: "jpg",
+ jpg: "jpg",
+ gif: "gif",
+ webp: "webp",
+ "svg+xml": "svg",
+ avif: "avif",
+ tiff: "tiff",
+ "x-icon": "ico",
+};
+
+const IMG_TAG = /<img\b[^>]*>/g;
+const IMG_SRC = /src=(["'])(data:image\/[^"']+)\1/;
+
+function imageInfo(
+ src: string,
+): { bytes: Buffer; filename: string } | null {
+ const match = src.match(/^data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)$/);
+ if (!match) return null;
+ const ext = EXT_BY_MIME[match[1]];
+ if (!ext) return null;
+ const bytes = Buffer.from(match[2], "base64");
+ const hash = crypto.createHash("sha256").update(bytes).digest("hex").slice(
+ 0,
+ 12,
+ );
+ return { bytes, filename: `${hash}.${ext}` };
+}
+
+async function collectImages(
+ rootDir: string,
+ onHtml: (filePath: string) => Promise<void>,
+): Promise<void> {
+ for (const entry of await fs.readdir(rootDir, { withFileTypes: true })) {
+ const fullPath = path.join(rootDir, entry.name);
+ if (entry.isDirectory()) {
+ await collectImages(fullPath, onHtml);
+ } else if (entry.isFile() && entry.name.endsWith(".html")) {
+ await onHtml(fullPath);
+ }
+ }
+}
+
+export function extractImages(): AstroIntegration {
+ let base = "/";
+
+ return {
+ name: "extract-images",
+ hooks: {
+ "astro:config:done": ({ config }) => {
+ base = config.base;
+ },
+ "astro:build:done": async ({ dir, logger }) => {
+ const outDir = path.join(fileURLToPath(dir), ASSET_DIR);
+ const written = new Map<string, Buffer>();
+
+ const rewrite = (
+ html: string,
+ filePath: string,
+ logger: AstroIntegrationLogger,
+ ) =>
+ html.replace(IMG_TAG, (tag) => {
+ const srcMatch = tag.match(IMG_SRC);
+ if (!srcMatch) return tag;
+ const info = imageInfo(srcMatch[2]);
+ if (!info) {
+ logger.warn(
+ `[${filePath}] keeping data:image as-is (non-base64 or unknown mime)`,
+ );
+ return tag;
+ }
+ written.set(info.filename, info.bytes);
+ let out = tag.replace(
+ IMG_SRC,
+ `src=${srcMatch[1]}${joinUrl(base, ASSET_DIR, info.filename)}${
+ srcMatch[1]
+ }`,
+ );
+ if (!/\sloading=/.test(out)) {
+ out = out.replace(
+ /^<img\b/,
+ '<img loading="lazy" decoding="async"',
+ );
+ }
+ return out;
+ });
+
+ await collectImages(fileURLToPath(dir), async (filePath) => {
+ const html = await fs.readFile(filePath, "utf-8");
+ const rewritten = rewrite(html, filePath, logger);
+ if (rewritten !== html) {
+ await fs.writeFile(filePath, rewritten);
+ }
+ });
+
+ if (written.size > 0) {
+ await fs.mkdir(outDir, { recursive: true });
+ await Promise.all(
+ [...written].map(([filename, bytes]) =>
+ fs.writeFile(path.join(outDir, filename), bytes)
+ ),
+ );
+ logger.info(
+ `Extracted ${written.size} inlined images to /${ASSET_DIR}`,
+ );
+ }
+ },
+ },
+ };
+}
src/styles/article/index.scss
@@ -16,4 +16,8 @@ article {
width: calc(4/3 * 100%);
}
}
+
+ img:not([data-astro-image]) {
+ height: auto;
+ }
}
astro.config.ts
@@ -4,6 +4,7 @@ import { typst } from "astro-typst";
import { SITE_CONFIG } from "./src/config.ts";
+import { extractImages } from "./src/integrations/extract-images.ts";
import { rehypeCleanEndnotes } from "./src/utils/plugins/clean-endnotes.ts";
const protocol = SITE_CONFIG.website.split("://")[0];
@@ -25,6 +26,8 @@ export default defineConfig({
},
target: "html",
}),
+ // Order-independent: only hooks into `astro:build:done`.
+ extractImages(),
],
markdown: {
// NOTE: `markdown.rehypePlugins` is deprecated in Astro v7.