authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-11 22:43:27-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
log98be3e2bd40a40c6595de522d087092df63cdbb7
treeac5bf4b195a15f8e87222dad3ccceeff0592e8b4
parent8d0985c06cb96067dc084cb17f5c3bfe8c43f56d
signature Commit is signed but in an unrecognized format.

feat: dynamic page regeneration (#24)

the asset system is reworked to support "dynamic" entries, where each entry is a separate file on disk containing the latest generation's headers+raw+gzip+zstd. when calling view.regenerate, it will look for pages that had "export const regenerate" during generation, and render those pages using the view system, but then store the results as assets instead of sending as a response. pages configured as regenerable are also bundled as views, using the non-aliasing key "page:${page.id}". this cannot alias because file paths may not contain a colon.

42 files changed, 1415 insertions(+), 912 deletions(-)

flake.nix+23-23
......@@ -3,27 +3,27 @@
33 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
44 utils.url = "github:numtide/flake-utils";
55 };
6 outputs =
7 { nixpkgs, utils, ... }:
8 utils.lib.eachDefaultSystem (
9 system:
10 let
11 pkgs = nixpkgs.legacyPackages.${system};
12 in
13 {
14 devShells.default = pkgs.mkShell {
15 buildInputs = [
16 pkgs.nodejs_24 # runtime
17 pkgs.deno # formatter
18 (pkgs.ffmpeg.override {
19 withOpus = true;
20 withSvtav1 = true;
21 withJxl = true;
22 withWebp = true;
23 })
24 pkgs.rsync
25 ];
26 };
27 }
28 );
6 outputs = inputs: inputs.utils.lib.eachDefaultSystem (system:
7 with inputs.nixpkgs.legacyPackages.${system}; {
8 devShells.default = pkgs.mkShell {
9 buildInputs = [
10 pkgs.nodejs_24 # runtime
11 pkgs.deno # formatter
12 (pkgs.ffmpeg.override {
13 withOpus = true;
14 withSvtav1 = true;
15 withJxl = true;
16 withWebp = true;
17 })
18 pkgs.rsync
19 ];
20 };
21 devShells.min = pkgs.mkShell {
22 buildInputs = [
23 pkgs.nodejs_24 # runtime
24 pkgs.deno # formatter
25 pkgs.rsync
26 ];
27 };
28 });
2929}
framework/bundle.ts+189-139
......@@ -88,19 +88,19 @@ export async function bundleClientJavaScript(
8888 for (const file of outputFiles) {
8989 const { text } = file;
9090 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
91 const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
92 const sources = Object.keys(inputs).filter((x) => !isIgnoredSource(x));
91 const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
9392
9493 // Register non-chunks as script entries.
9594 const chunk = route.startsWith("/js/c.");
9695 if (!chunk) {
97 const key = hot.getScriptId(path.resolve(sources[sources.length - 1]));
96 const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
97 console.log(route, key);
9898 route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
9999 scripts[key] = text;
100100 }
101101 // Register chunks and public scripts as assets.
102102 if (chunk || publicScriptRoutes.includes(route)) {
103 p.push(io.writeAsset(route, text));
103 p.push(io.writeAsset({ pathname: route, buffer: text }));
104104 }
105105 }
106106 await Promise.all(p);
......@@ -111,7 +111,7 @@ export type ServerPlatform = "node" | "passthru";
111111export interface ServerSideOptions {
112112 entries: string[];
113113 viewItems: sg.FileItem[];
114 viewRefs: incr.Ref<PreparedView>[];
114 viewRefs: incr.Ref<PageOrView>[];
115115 styleMap: Map<string, incr.Ref<string>>;
116116 scriptMap: incr.Ref<Record<string, string>>;
117117 platform: ServerPlatform;
......@@ -124,144 +124,192 @@ export async function bundleServerJavaScript({
124124 entries,
125125 platform,
126126}: ServerSideOptions) {
127 const wViewSource = incr.work(async (_, viewItems: sg.FileItem[]) => {
128 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
129 return {
130 magicWord,
131 file: [
132 ...viewItems.map(
133 (view, i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
134 ),
135 `const styles = ${magicWord}[-2]`,
136 `export const scripts = ${magicWord}[-1]`,
137 "export const views = {",
138 ...viewItems.map((view, i) =>
139 [
140 ` ${JSON.stringify(view.id)}: {`,
141 ` component: view${i}.default,`,
142 ` meta: view${i}.meta,`,
143 ` layout: view${i}.layout?.default ?? null,`,
144 ` inlineCss: styles[${magicWord}[${i}]]`,
145 ` },`,
146 ].join("\n"),
147 ),
148 "}",
149 ].join("\n"),
150 };
151 }, viewItems);
152
153 const wBundles = entries.map(
154 (entry) =>
155 [
156 entry,
157 incr.work(async (io, entry) => {
158 const pkg = await io.readJson<{
159 dependencies: Record<string, string>;
160 }>("package.json");
127 const regenKeys: Record<string, string[]> = {};
128 const regenTtls: view.Ttl[] = [];
129 for (const ref of viewRefs) {
130 const value = UNWRAP(ref.value);
131 if (value.type === "page" && (value.regenerate?.tags?.length ?? 0) > 0) {
132 for (const tag of value.regenerate!.tags!) {
133 (regenKeys[tag] ??= []).push(`page:${value.id}`);
134 }
135 }
136 if (value.type === "page" && (value.regenerate?.seconds ?? 0) > 0) {
137 regenTtls.push({
138 key: `page:${value.id}` as view.Key,
139 seconds: value.regenerate!.seconds!,
140 });
141 }
142 }
161143
162 let magicWord = null as string | null;
163 // -- plugins --
164 const serverPlugins: esbuild.Plugin[] = [
165 virtualFiles({
166 // only add dependency when imported.
167 $views: async () => {
168 const view = await io.readWork(wViewSource);
169 ({ magicWord } = view);
170 return view.file;
171 },
172 }),
173 projectRelativeResolution(),
174 markoViaBuildCache(),
175 {
176 name: "replace client references",
177 setup(b) {
178 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
179 contents: hot.resolveClientRefs(
180 await fs.readFile(file, "utf-8"),
181 file,
182 ).code,
183 loader: path.extname(file).slice(1) as esbuild.Loader,
184 }));
185 },
186 },
187 {
188 name: "mark css external",
189 setup(b) {
190 b.onResolve({ filter: /\.css$/ }, () => ({
191 path: ".",
192 namespace: "dropped",
193 }));
194 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
195 contents: "",
196 }));
197 },
198 },
199 ];
144 const wViewSource = incr.work(
145 async (
146 _,
147 { viewItems, regenKeys, regenTtls }: {
148 viewItems: sg.FileItem[];
149 regenKeys: Record<string, string[]>;
150 regenTtls: view.Ttl[];
151 },
152 ) => {
153 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
154 return {
155 magicWord,
156 file: [
157 ...viewItems.map(
158 (view, i) =>
159 `import * as view${i} from ${JSON.stringify(view.file)}`,
160 ),
161 `const styles = ${magicWord}[-2]`,
162 `export const scripts = ${magicWord}[-1]`,
163 "export const views = {",
164 ...viewItems.map((view, i) =>
165 [
166 ` ${JSON.stringify(view.id)}: {`,
167 ` component: view${i}.default,`,
168 ` meta: view${i}.meta,`,
169 ` layout: view${i}.layout?.default ?? null,`,
170 ` inlineCss: styles[${magicWord}[${i}]]`,
171 ` },`,
172 ].join("\n")
173 ),
174 "}",
175 `export const regenTags = ${JSON.stringify(regenKeys)};`,
176 `export const regenTtls = ${JSON.stringify(regenTtls)};`,
177 ].join("\n"),
178 };
179 },
180 { viewItems, regenKeys, regenTtls },
181 );
200182
201 const { metafile, outputFiles } = await esbuild.build({
202 bundle: true,
203 chunkNames: "c.[hash]",
204 entryNames: path.basename(entry, path.extname(entry)),
205 entryPoints: [
206 path.join(
207 import.meta.dirname,
208 "backend/entry-" + platform + ".ts",
209 ),
210 ],
211 platform: "node",
212 format: "esm",
213 minify: false,
214 outdir: "out!",
215 plugins: serverPlugins,
216 splitting: true,
217 logLevel: "silent",
218 write: false,
219 metafile: true,
220 jsx: "automatic",
221 jsxImportSource: "#engine",
222 jsxDev: false,
223 define: {
224 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
225 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
226 },
227 external: Object.keys(pkg.dependencies).filter(
228 (x) => !x.startsWith("@paperclover"),
183 await incr.work(
184 async (io, { regenKeys, viewItems }) => {
185 io.writeFile(
186 "../ts/view.d.ts",
187 [
188 "export interface RegisteredViews {",
189 ...viewItems
190 .filter((view) => !view.id.startsWith("page:"))
191 .map(
192 (view) =>
193 ` ${JSON.stringify(view.id)}: ` +
194 `typeof import(${
195 JSON.stringify(path.relative(".clover/ts", toAbs(view.file)))
196 }),`,
229197 ),
230 });
231 await trackEsbuild(io, metafile);
198 "}",
199 "export type RegenKey = " +
200 (regenKeys.map((key) => JSON.stringify(key)).join(" | ") ||
201 "never"),
202 ].join("\n"),
203 );
204 },
205 { regenKeys: Object.keys(regenKeys), viewItems },
206 );
232207
233 let fileWithMagicWord: {
234 bytes: Buffer;
235 basename: string;
236 magicWord: string;
237 } | null = null;
238 for (const output of outputFiles) {
239 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
240 const key = "out!/" + basename.replaceAll("\\", "/");
241 // If this contains the generated "$views" file, then
242 // mark this file as the one for replacement. Because
243 // `splitting` is `true`, esbuild will not emit this
244 // file in more than one chunk.
245 if (
246 magicWord &&
247 metafile.outputs[key].inputs["framework/lib/view.ts"]
248 ) {
249 ASSERT(!fileWithMagicWord);
250 fileWithMagicWord = {
251 basename,
252 bytes: Buffer.from(output.contents),
253 magicWord,
254 };
255 } else {
256 io.writeFile(basename, Buffer.from(output.contents));
257 }
258 }
259 return fileWithMagicWord;
260 }, entry),
261 ] as const,
208 const wBundles = entries.map((entry) =>
209 incr.work(async (io, entry) => {
210 const pkg = await io.readJson<{
211 dependencies: Record<string, string>;
212 }>("package.json");
213
214 let magicWord = null as string | null;
215 // -- plugins --
216 const serverPlugins: esbuild.Plugin[] = [
217 virtualFiles({
218 // only add dependency when imported.
219 $views: async () => {
220 const view = await io.readWork(wViewSource);
221 ({ magicWord } = view);
222 return view.file;
223 },
224 }),
225 projectRelativeResolution(),
226 markoViaBuildCache(),
227 {
228 name: "replace client references",
229 setup(b) {
230 b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
231 contents: hot.resolveClientRefs(
232 await fs.readFile(file, "utf-8"),
233 file,
234 ).code,
235 loader: path.extname(file).slice(1) as esbuild.Loader,
236 }));
237 },
238 },
239 {
240 name: "mark css external",
241 setup(b) {
242 b.onResolve({ filter: /\.css$/ }, () => ({
243 path: ".",
244 namespace: "dropped",
245 }));
246 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
247 contents: "",
248 }));
249 },
250 },
251 ];
252
253 const { metafile, outputFiles } = await esbuild.build({
254 bundle: true,
255 chunkNames: "c.[hash]",
256 entryNames: path.basename(entry, path.extname(entry)),
257 entryPoints: [
258 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
259 ],
260 platform: "node",
261 format: "esm",
262 minify: false,
263 outdir: "out!",
264 plugins: serverPlugins,
265 splitting: true,
266 logLevel: "silent",
267 write: false,
268 metafile: true,
269 jsx: "automatic",
270 jsxImportSource: "#engine",
271 jsxDev: false,
272 define: {
273 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
274 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
275 },
276 external: Object.keys(pkg.dependencies).filter(
277 (x) => !x.startsWith("@paperclover"),
278 ),
279 });
280 await trackEsbuild(io, metafile);
281
282 let fileWithMagicWord: {
283 bytes: Buffer;
284 basename: string;
285 magicWord: string;
286 } | null = null;
287 for (const output of outputFiles) {
288 const basename = output.path.replace(/^.*?!(?:\/|\\)/, "");
289 const key = "out!/" + basename.replaceAll("\\", "/");
290 // If this contains the generated "$views" file, then
291 // mark this file as the one for replacement. Because
292 // `splitting` is `true`, esbuild will not emit this
293 // file in more than one chunk.
294 if (
295 magicWord &&
296 UNWRAP(metafile.outputs[key]).inputs["framework/lib/view.ts"]
297 ) {
298 ASSERT(!fileWithMagicWord);
299 fileWithMagicWord = {
300 basename,
301 bytes: Buffer.from(output.contents),
302 magicWord,
303 };
304 } else {
305 io.writeFile(basename, Buffer.from(output.contents));
306 }
307 }
308 return fileWithMagicWord;
309 }, entry)
262310 );
263311
264 const wProcessed = wBundles.map(async ([entry, wBundle]) => {
312 const wProcessed = wBundles.map(async (wBundle) => {
265313 if (!(await wBundle)) return;
266314 await incr.work(async (io) => {
267315 // Only the reachable resources need to be read and inserted into the bundle.
......@@ -278,7 +326,7 @@ export async function bundleServerJavaScript({
278326 const viewStyleKeys = views.map((view) => view.styleKey);
279327 const viewCssBundles = await Promise.all(
280328 viewStyleKeys.map((key) =>
281 io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key)),
329 io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))
282330 ),
283331 );
284332 const styleList = Array.from(new Set(viewCssBundles));
......@@ -297,7 +345,7 @@ export async function bundleServerJavaScript({
297345 return JSON.stringify(Object.fromEntries(neededScripts));
298346 }
299347 // Reference an index into `styleList`
300 return `${styleList.indexOf(viewCssBundles[i])}`;
348 return `${styleList.indexOf(UNWRAP(viewCssBundles[i]))}`;
301349 });
302350
303351 io.writeFile(basename, text);
......@@ -322,3 +370,5 @@ import * as fs from "#sitegen/fs";
322370import * as mime from "#sitegen/mime";
323371import * as incr from "./incremental.ts";
324372import * as sg from "#sitegen";
373import type { PageOrView } from "./generate.ts";
374import type * as view from "#sitegen/view";
framework/css.ts+9-6
......@@ -57,12 +57,14 @@ export function styleKey(
5757export async function bundleCssFiles(
5858 io: Io,
5959 { cssImports, theme, dev }: {
60 cssImports: string[],
61 theme: Theme,
62 dev: boolean,
63 }
60 cssImports: string[];
61 theme: Theme;
62 dev: boolean;
63 },
6464) {
65 cssImports = await Promise.all(cssImports.map((file) => io.trackFile('src/' + file)));
65 cssImports = await Promise.all(
66 cssImports.map((file) => io.trackFile("src/" + file)),
67 );
6668 const plugin = {
6769 name: "clover css",
6870 setup(b) {
......@@ -111,4 +113,5 @@ import * as esbuild from "esbuild";
111113import * as fs from "#sitegen/fs";
112114import * as hot from "./hot.ts";
113115import * as path from "node:path";
114import { virtualFiles } from "./esbuild-support.ts";import type { Io } from "./incremental.ts";
116import { virtualFiles } from "./esbuild-support.ts";
117import type { Io } from "./incremental.ts";
framework/debug.safe.ts+1-1
......@@ -9,4 +9,4 @@ globalThis.UNWRAP = (t, ...args) => {
99globalThis.ASSERT = assert.ok;
1010
1111import * as util from "node:util";
12import * as assert from 'node:assert'
12import * as assert from "node:assert";
framework/engine/jsx-runtime.ts+1-1
......@@ -51,4 +51,4 @@ declare global {
5151 }
5252}
5353
54import * as render from "./render.ts";
54import * as render from "#engine/render";
framework/engine/marko-runtime.ts+1
......@@ -18,6 +18,7 @@ export const createTemplate = (
1818 const r = render.current;
1919 // Support using Marko outside of Clover SSR
2020 if (!r) return renderer(props, n);
21 render.setCurrent(null);
2122 const markoResult = renderFn.call(renderer, {
2223 ...props,
2324 $global: { clover: r, cloverAsyncMarker },
framework/engine/render.test.tsx+1-1
......@@ -1,5 +1,5 @@
11import { test } from "node:test";
2import * as render from "./render.ts";
2import * as render from "#engine/render";
33
44test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3"));
55test("simple tree", (t) =>
framework/engine/render.ts+30-8
......@@ -118,8 +118,11 @@ export function resolveNode(r: State, node: unknown): ResolvedNode {
118118 if (!node && node !== 0) return ""; // falsy, non numeric
119119 if (typeof node !== "object") {
120120 if (node === true) return ""; // booleans are ignored
121 if (typeof node === "string") return escapeHtml(node);
121 if (typeof node === "string") return escapeHtmlContent(node);
122122 if (typeof node === "number") return String(node); // no escaping ever
123 if (typeof node === "symbol" && node.toString() === kElement.toString()) {
124 throw new Error(`There are two instances of Clover SSR loaded!`);
125 }
123126 throw new Error(`Cannot render ${inspect(node)} to HTML`);
124127 }
125128 if (node instanceof Promise) {
......@@ -217,12 +220,14 @@ function stringifyElement(element: ResolvedElement) {
217220 let attr;
218221 switch (prop) {
219222 default:
220 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
223 attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`;
221224 break;
222225 case "className":
223226 // Legacy React Compat
224227 case "class":
225 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
228 attr = `class=${
229 quoteIfNeeded(escapeAttribute(clsx(value as ClsxInput)))
230 }`;
226231 break;
227232 case "htmlFor":
228233 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
......@@ -233,7 +238,7 @@ function stringifyElement(element: ResolvedElement) {
233238 case "key":
234239 continue;
235240 }
236 if (needSpace) (out += " "), (needSpace = !attr.endsWith('"'));
241 if (needSpace) ((out += " "), (needSpace = !attr.endsWith('"')));
237242 out += attr;
238243 }
239244 out += ">";
......@@ -254,14 +259,16 @@ export function stringifyStyleAttribute(style: Record<string, string>) {
254259 let out = ``;
255260 for (const styleName in style) {
256261 if (out) out += ";";
257 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${escapeHtml(
258 String(style[styleName]),
259 )}`;
262 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
263 escapeAttribute(
264 String(style[styleName]),
265 )
266 }`;
260267 }
261268 return "style=" + quoteIfNeeded(out);
262269}
263270export function quoteIfNeeded(text: string) {
264 if (text.includes(" ")) return '"' + text + '"';
271 if (text.match(/["/>]/)) return '"' + text + '"';
265272 return text;
266273}
267274
......@@ -303,6 +310,21 @@ export function clsx(mix: ClsxInput) {
303310 return str;
304311}
305312
313export const escapeHtmlContent = (unsafeText: string) =>
314 String(unsafeText)
315 .replace(/&/g, "&amp;")
316 .replace(/</g, "&lt;")
317 .replace(/>/g, "&gt;")
318 .replace(/"/g, "&quot;");
319// TODO: combine into one function which decides if an attribute needs quotes
320// and escapes it correctly depending on the context.
321const escapeAttribute = (unsafeText: string) =>
322 String(unsafeText)
323 .replace(/&/g, "&amp;")
324 .replace(/</g, "&lt;")
325 .replace(/>/g, "&gt;")
326 .replace(/"/g, "&quot;");
327/** @deprecated */
306328export const escapeHtml = (unsafeText: string) =>
307329 String(unsafeText)
308330 .replace(/&/g, "&amp;")
framework/engine/suspense.ts+2-2
......@@ -42,7 +42,7 @@ export function Suspense({ children, fallback }: SuspenseProps): render.Node {
4242 r.asyncDone = () => {
4343 const rejections = r.rejections;
4444 if (rejections && rejections.length > 0) throw new Error("TODO");
45 state.pushChunk?.(name, (ip[0] = resolved));
45 state.pushChunk?.(name, ip[0] = resolved);
4646 };
4747 return render.raw(ip);
4848}
......@@ -99,4 +99,4 @@ export async function* renderStreaming<
9999 return addonOutput as unknown as T;
100100}
101101
102import * as render from "./render.ts";
102import * as render from "#engine/render";
framework/esbuild-support.ts+10-5
......@@ -1,7 +1,12 @@
11type Awaitable<T> = T | Promise<T>;
22
33export function virtualFiles(
4 map: Record<string, string | esbuild.OnLoadResult | (() => Awaitable<string | esbuild.OnLoadResult>)>,
4 map: Record<
5 string,
6 | string
7 | esbuild.OnLoadResult
8 | (() => Awaitable<string | esbuild.OnLoadResult>)
9 >,
510) {
611 return {
712 name: "clover vfs",
......@@ -22,7 +27,7 @@ export function virtualFiles(
2227 { filter: /./, namespace: "vfs" },
2328 async ({ path }) => {
2429 let entry = map[path];
25 if (typeof entry === 'function') entry = await entry();
30 if (typeof entry === "function") entry = await entry();
2631 return ({
2732 resolveDir: ".",
2833 loader: "ts",
......@@ -88,7 +93,6 @@ export function markoViaBuildCache(): esbuild.Plugin {
8893 if (!fs.existsSync(file)) {
8994 console.warn(`File does not exist: ${file}`);
9095 }
91 console.log(markoCache.keys());
9296 throw new Error("Marko file not in cache: " + file);
9397 }
9498 return ({
......@@ -106,7 +110,7 @@ export function isIgnoredSource(source: string) {
106110 return source.includes("<define:") ||
107111 source.startsWith("vfs:") ||
108112 source.startsWith("dropped:") ||
109 source.includes("node_modules")
113 source.includes("node_modules");
110114}
111115
112116import * as esbuild from "esbuild";
......@@ -114,4 +118,5 @@ import * as string from "#sitegen/string";
114118import * as path from "node:path";
115119import * as fs from "#sitegen/fs";
116120import * as incr from "./incremental.ts";
117import * as hot from "./hot.ts";import { markoCache } from "./marko.ts";
121import * as hot from "./hot.ts";
122import { markoCache } from "./marko.ts";
framework/generate.ts+63-36
......@@ -7,15 +7,16 @@ const { toRel, toAbs } = incr;
77const globalCssPath = toAbs("src/global.css");
88
99export async function main() {
10 await incr.restore();
10 if (!process.argv.includes("-f")) await incr.restore();
1111 await incr.compile(generate);
1212}
1313
1414export async function generate() {
1515 // -- read config and discover files --
1616 const siteConfig = await incr.work(readManifest);
17 const { staticFiles, scripts, views, pages } =
18 await discoverAllFiles(siteConfig);
17 const { staticFiles, scripts, views, pages } = await discoverAllFiles(
18 siteConfig,
19 );
1920
2021 // TODO: make sure that `static` and `pages` does not overlap
2122
......@@ -28,13 +29,21 @@ export async function generate() {
2829 staticFiles.map((item) =>
2930 incr.work(
3031 async (io, { id, file }) =>
31 void (await io.writeAsset(id, await io.readFile(file))),
32 void (await io.writeAsset({
33 pathname: id,
34 buffer: await io.readFile(file),
35 })),
3236 item,
33 ),
37 )
3438 ),
3539 );
3640 const routes = await Promise.all([...builtViews, ...builtPages]);
3741
42 const viewsAndDynPages: incr.Ref<PageOrView>[] = [
43 ...builtViews,
44 ...builtPages.filter((page) => UNWRAP(page.value).regenerate),
45 ];
46
3847 // -- page resources --
3948 const scriptMap = incr.work(bundle.bundleClientJavaScript, {
4049 clientRefs: routes.flatMap((x) => x.clientRefs),
......@@ -49,13 +58,16 @@ export async function generate() {
4958 platform: "node",
5059 styleMap,
5160 scriptMap,
52 viewItems: views,
53 viewRefs: builtViews,
61 viewItems: viewsAndDynPages.map((ref) => {
62 const { id, file, type } = UNWRAP(ref.value);
63 return { id: type === "page" ? `page:${id}` : id, file };
64 }),
65 viewRefs: viewsAndDynPages,
5466 });
5567
5668 // -- assemble page assets --
5769 const pAssemblePages = builtPages.map((page) =>
58 assembleAndWritePage(page, styleMap, scriptMap),
70 assembleAndWritePage(page, styleMap, scriptMap)
5971 );
6072
6173 await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]);
......@@ -77,7 +89,7 @@ export async function discoverAllFiles(
7789 return (
7890 await Promise.all(
7991 siteConfig.siteSections.map(({ root: sectionRoot }) =>
80 incr.work(scanSiteSection, toAbs(sectionRoot)),
92 incr.work(scanSiteSection, toAbs(sectionRoot))
8193 ),
8294 )
8395 ).reduce((acc, next) => ({
......@@ -105,10 +117,9 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
105117 let scripts: FileItem[] = [];
106118
107119 const sectionPath = (...sub: string[]) => path.join(sectionRoot, ...sub);
108 const rootPrefix =
109 hot.projectSrc === sectionRoot
110 ? ""
111 : path.relative(hot.projectSrc, sectionRoot) + "/";
120 const rootPrefix = hot.projectSrc === sectionRoot
121 ? ""
122 : path.relative(hot.projectSrc, sectionRoot) + "/";
112123 const kinds = [
113124 {
114125 dir: sectionPath("pages"),
......@@ -181,7 +192,8 @@ export async function preparePage(io: Io, item: sg.FileItem) {
181192 meta: metadata,
182193 theme: pageTheme,
183194 layout,
184 } = await io.import<any>(item.file);
195 regenerate,
196 } = await io.import<sg.PageExports>(item.file);
185197 if (!Page) throw new Error("Page is missing a 'default' export.");
186198 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
187199
......@@ -219,16 +231,20 @@ export async function preparePage(io: Io, item: sg.FileItem) {
219231 "Page is missing 'meta.title'. " + "All pages need a title tag.",
220232 );
221233 }
234
222235 const styleKey = css.styleKey(cssImports, theme);
223236 return {
237 type: "page",
224238 id: item.id,
239 file: item.file,
240 regenerate,
225241 html: text,
226242 meta: renderedMeta,
227243 cssImports,
228244 theme: theme ?? null,
229245 styleKey,
230246 clientRefs: Array.from(addon[sg.userData.key].scripts),
231 };
247 } as const;
232248}
233249
234250export async function prepareView(io: Io, item: sg.FileItem) {
......@@ -243,13 +259,15 @@ export async function prepareView(io: Io, item: sg.FileItem) {
243259 );
244260 const styleKey = css.styleKey(cssImports, theme);
245261 return {
246 file: path.relative(hot.projectRoot, item.file),
262 type: "view",
263 id: item.id,
264 file: item.file,
247265 cssImports,
248266 theme,
249267 clientRefs: hot.getClientScriptRefs(item.file),
250268 hasLayout: !!module.layout?.default,
251269 styleKey,
252 };
270 } as const;
253271}
254272export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
255273
......@@ -274,28 +292,38 @@ export async function assembleAndWritePage(
274292 scriptWork: incr.Ref<Record<string, string>>,
275293) {
276294 const page = await pageWork;
277 return incr.work(async (io, { id, html, meta, styleKey, clientRefs }) => {
278 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
295 return incr.work(
296 async (io, { id, html, meta, styleKey, clientRefs, regenerate }) => {
297 const inlineCss = await io.readWork(UNWRAP(styleMap.get(styleKey)));
279298
280 const scriptIds = clientRefs.map(hot.getScriptId);
281 const scriptMap = await io.readWork(scriptWork);
282 const scripts = scriptIds
283 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
284 .map((x) => `{${x}}`)
285 .join("\n");
299 const scriptIds = clientRefs.map(hot.getScriptId);
300 const scriptMap = await io.readWork(scriptWork);
301 const scripts = scriptIds
302 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
303 .map((x) => `{${x}}`)
304 .join("\n");
286305
287 const doc = wrapDocument({
288 body: html,
289 head: meta,
290 inlineCss,
291 scripts,
292 });
293 await io.writeAsset(id, doc, {
294 "Content-Type": "text/html",
295 });
296 }, page);
306 const buffer = sg.wrapDocument({
307 body: html,
308 head: meta,
309 inlineCss,
310 scripts,
311 });
312 await io.writeAsset({
313 pathname: id,
314 buffer,
315 headers: {
316 "Content-Type": "text/html",
317 },
318 regenerative: !!regenerate,
319 });
320 },
321 page,
322 );
297323}
298324
325export type PageOrView = PreparedPage | PreparedView;
326
299327import * as sg from "#sitegen";
300328import * as incr from "./incremental.ts";
301329import { Io } from "./incremental.ts";
......@@ -307,4 +335,3 @@ import * as fs from "#sitegen/fs";
307335import type { FileItem } from "#sitegen";
308336import * as path from "node:path";
309337import * as meta from "#sitegen/meta";
310import { wrapDocument } from "./lib/view.ts";
framework/hot.ts+6-8
......@@ -78,10 +78,9 @@ Module.prototype._compile = function (
7878 }
7979 }
8080 fileStats.set(filename, {
81 cssImportsRecursive:
82 cssImportsMaybe.length > 0
83 ? Array.from(new Set(cssImportsMaybe))
84 : null,
81 cssImportsRecursive: cssImportsMaybe.length > 0
82 ? Array.from(new Set(cssImportsMaybe))
83 : null,
8584 imports,
8685 lastModified: Math.floor(stat.mtimeMs),
8786 });
......@@ -137,14 +136,13 @@ export function loadEsbuildCode(
137136 src = code;
138137 }
139138 if (src.includes("import.meta")) {
140 src =
141 `
139 src = `
142140 import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
143141 import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
144142 import.meta.filename = ${JSON.stringify(filepath)};
145143 `
146 .trim()
147 .replace(/[\n\s]/g, "") + src;
144 .trim()
145 .replace(/[\n\s]/g, "") + src;
148146 }
149147 src = esbuild.transformSync(src, {
150148 loader,
framework/incremental.test.ts+11-11
......@@ -7,38 +7,38 @@ test("trivial case", async () => {
77 async function compilation() {
88 const first = incr.work({
99 label: "first compute",
10 async run (io) {
10 async run(io) {
1111 await setTimeout(1000);
1212 const contents = await io.readFile(file1.path);
1313 return [contents, Math.random()] as const;
14 }
14 },
1515 });
1616 const second = incr.work({
1717 label: "second compute",
1818 wait: first,
19 async run (io) {
19 async run(io) {
2020 await setTimeout(1000);
2121 return io.readWork(first)[0].toUpperCase();
22 }
22 },
2323 });
2424 const third = incr.work({
2525 label: "third compute",
2626 wait: first,
27 async run (io) {
27 async run(io) {
2828 await setTimeout(1000);
2929 return io.readWork(first)[1] * 1000;
30 }
30 },
3131 });
3232 return incr.work({
3333 label: "last compute",
3434 wait: [second, third],
35 async run (io) {
35 async run(io) {
3636 await setTimeout(1000);
3737 return {
3838 second: io.readWork(second),
3939 third: io.readWork(third),
40 }
41 }
40 };
41 },
4242 });
4343 }
4444 const { value: first } = await incr.compile(compilation);
......@@ -52,5 +52,5 @@ test("trivial case", async () => {
5252
5353import * as incr from "./incremental2.ts";
5454import { beforeEach, test } from "node:test";
55import { tmpFile } from "#sitegen/testing";import { setTimeout } from "node:timers/promises";
56
55import { tmpFile } from "#sitegen/testing";
56import { setTimeout } from "node:timers/promises";
framework/incremental.ts+186-142
......@@ -2,8 +2,7 @@
22// See `work()`, `compile()`, and `invalidate()` for details.
33//
44// All state is serializable to allow recovering state across sessions.
5// This framework special-cases the asset map, but is otherwise
6// agnostic of what it is a compiler for.
5// This library special-cases the asset map, but is otherwise agnostic.
76let running = false;
87let jobs = 0;
98let newKeys = 0;
......@@ -15,27 +14,25 @@ let writes = new Map<string, FileWrite>();
1514let assets = new Map<string, Asset>(); // keyed by hash
1615
1716export interface Ref<T> {
18 /** This method is compatible with `await` syntax */
19 then(
20 onFulfilled: (value: T) => void,
21 onRejected: (error: unknown) => void,
22 ): void;
2317 key: string;
18 /** This method is compatible with `await` syntax */
19 then(resolve: (value: T) => void, reject: (error: unknown) => void): void;
20 get value(): T | null;
2421}
2522type Job<I = any, O = any> = (io: Io, input: I) => Promise<O>;
2623
2724/**
28 * Declare and a unit of work. Return value is memoized and
29 * only rebuilt when inputs (declared via `Io`) change. Outputs
30 * are written at the end of a compilation (see `compile`).
25 * Declare and a unit of work. Return value is memoized and only rebuilt when
26 * inputs change. Inputs are tracked via the `io` interface, as well as a hash
27 * of the `input` param and caller source code. Outputs are written at the end
28 * of a compilation (see `compile`).
3129 *
32 * If the returned `Ref` is not awaited or read
33 * via io.readWork, the job is never started.
30 * Work items are lazy, only started when `Ref` is awaited or `io.readWork`ed.
3431 */
3532export function work<O>(job: Job<void, O>): Ref<O>;
3633export function work<I, O>(job: Job<I, O>, input: I): Ref<O>;
3734export function work<I, O>(job: Job<I, O>, input: I = null as I): Ref<O> {
38 const source = JSON.stringify(util.getCallSites(2)[1]);
35 const source = JSON.stringify(UNWRAP(util.getCallSites(2)[1]));
3936 const keySource = [source, util.inspect(input)].join(":");
4037 const key = crypto.createHash("sha1").update(keySource).digest("base64url");
4138 ASSERT(running);
......@@ -48,7 +45,7 @@ export function work<I, O>(job: Job<I, O>, input: I = null as I): Ref<O> {
4845
4946 const prev = works.get(key) as Work<O> | null;
5047 if (prev) {
51 return { key, then: (done) => done(prev.value) };
48 return { key, then: (done) => done(prev.value), value: prev.value };
5249 }
5350
5451 async function perform() {
......@@ -59,13 +56,7 @@ export function work<I, O>(job: Job<I, O>, input: I = null as I): Ref<O> {
5956 const value = await job(io, input);
6057 validateSerializable(value, "");
6158 const { reads, writes } = io;
62 works.set(key, {
63 value,
64 affects: [],
65 reads,
66 writes,
67 debug: source,
68 });
59 works.set(key, { value, affects: [], reads, writes });
6960 for (const add of reads.files) {
7061 const { affects } = UNWRAP(files.get(add));
7162 ASSERT(!affects.includes(key));
......@@ -85,7 +76,12 @@ export function work<I, O>(job: Job<I, O>, input: I = null as I): Ref<O> {
8576 let cached: Promise<O>;
8677 return {
8778 key,
88 then: (fufill, reject) => void (cached ??= perform()).then(fufill, reject),
79 then(fufill, reject) {
80 (cached ??= perform()).then(fufill, reject);
81 },
82 get value() {
83 return (works.get(this.key)?.value as O) ?? null;
84 },
8985 };
9086}
9187
......@@ -97,11 +93,7 @@ export async function compile<T>(compiler: () => Promise<T>) {
9793 const start = performance.now();
9894 const timerSpinner = new Spinner({
9995 text: () =>
100 `sitegen! [${
101 ((performance.now() - start) / 1000).toFixed(
102 1,
103 )
104 }s]`,
96 `sitegen! [${((performance.now() - start) / 1000).toFixed(1)}s]`,
10597 fps: 10,
10698 });
10799 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
......@@ -114,8 +106,10 @@ export async function compile<T>(compiler: () => Promise<T>) {
114106 return {
115107 value,
116108 watchFiles: new Set(files.keys()),
117 newOutputs: Array.from(seenWrites).filter(x => x.startsWith('f:')).map(x => x.slice(2)),
118 newAssets: !Array.from(seenWrites).some(x => x.startsWith('a:')),
109 newOutputs: Array.from(seenWrites)
110 .filter((x) => x.startsWith("f:"))
111 .map((x) => x.slice(2)),
112 newAssets: !Array.from(seenWrites).some((x) => x.startsWith("a:")),
119113 };
120114 } finally {
121115 running = false;
......@@ -127,9 +121,10 @@ export async function compile<T>(compiler: () => Promise<T>) {
127121
128122export async function flush(start: number) {
129123 // Trim
130 const detachedFiles = new Set<string>;
131 const referencedAssets = new Set<string>;
132 for (const [k, { writes: { assets } }] of works) {
124 const detachedFiles = new Set<string>();
125 const referencedAssets = new Set<string>();
126 for (const [k, v] of works) {
127 const assets = v.writes.assets;
133128 if (seenWorks.has(k)) {
134129 for (const asset of assets.values()) referencedAssets.add(asset.hash);
135130 continue;
......@@ -141,12 +136,9 @@ export async function flush(start: number) {
141136 files.delete(k);
142137 detachedFiles.add(k);
143138 }
144 for (const k of assets.keys()) {
145 if (!referencedAssets.has(k))
146 assets.delete(k);
147 }
139 for (const k of assets.keys()) if (!referencedAssets.has(k)) assets.delete(k);
148140
149 const p = [];
141 const p: Promise<void>[] = [];
150142 // File writes
151143 let dist = 0;
152144 for (const [key, { buffer, size }] of writes) {
......@@ -155,10 +147,14 @@ export async function flush(start: number) {
155147 }
156148 // Asset map
157149 {
158 const { json, blob } = getAssetManifest();
150 const { json, blob, dynamic, dts } = getAssetManifest();
159151 const jsonString = Buffer.from(JSON.stringify(json));
160 p.push(fs.writeMkdir(".clover/o/static.json", jsonString));
161 p.push(fs.writeMkdir(".clover/o/static.blob", blob));
152 p.push(fs.writeMkdir(".clover/o/asset.json", jsonString));
153 p.push(fs.writeMkdir(".clover/o/asset.blob", blob));
154 p.push(fs.writeMkdir(".clover/ts/asset.d.ts", dts));
155 for (const [k, v] of dynamic) {
156 p.push(fs.writeMkdir(`.clover/o/dynamic/${k}`, v));
157 }
162158 dist += blob.byteLength + jsonString.byteLength;
163159 }
164160 await Promise.all(p);
......@@ -171,9 +167,8 @@ export async function flush(start: number) {
171167 console.writeLine(` - ${works.size} keys (${works.size - newKeys} cached)`);
172168 console.writeLine(` - ${assets.size} static assets`);
173169 console.writeLine(
174 ` - dist: ${formatSize(dist)}, incremental: ${
175 formatSize(serialized.byteLength)
176 }`,
170 ` - dist: ${formatSize(dist)},` +
171 ` incremental: ${formatSize(serialized.byteLength)}`,
177172 );
178173}
179174
......@@ -212,13 +207,14 @@ function deleteWork(key: string) {
212207 affects.splice(affects.indexOf(key), 1);
213208 }
214209 for (const remove of affects) {
215 const { reads: { works: list } } = UNWRAP(works.get(remove), remove);
210 const {
211 reads: { works: list },
212 } = UNWRAP(works.get(remove), remove);
216213 ASSERT(list.has(key));
217214 list.delete(key);
218215 }
219216 for (const file of w.files) {
220 if (UNWRAP(writes.get(file)).work === key)
221 writes.delete(file);
217 if (UNWRAP(writes.get(file)).work === key) writes.delete(file);
222218 }
223219 // Assets are temporarily kept, trimmed via manual GC after compilation.
224220
......@@ -234,23 +230,32 @@ export function reset() {
234230}
235231
236232export function serialize() {
237 const fileEntries = Array.from(files, ([k, v]) =>
238 [
239 k,
240 v.type,
241 v.type === 'f' ? v.lastModified : v.type === 'd' ? v.contentHash : null,
242 ...v.affects,
243 ] as const);
244 const workEntries = Array.from(works, ([k, v]) =>
245 [
246 k,
247 v.value,
248 Array.from(v.reads.files),
249 Array.from(v.reads.works),
250 Array.from(v.writes.files),
251 Array.from(v.writes.assets, ([k, { headers }]) => [k, headers] as const),
252 v.affects,
253 ] as const);
233 const fileEntries = Array.from(
234 files,
235 ([k, v]) =>
236 [
237 k,
238 v.type,
239 v.type === "f" ? v.lastModified : v.type === "d" ? v.contentHash : null,
240 ...v.affects,
241 ] as const,
242 );
243 const workEntries = Array.from(
244 works,
245 ([k, v]) =>
246 [
247 k,
248 v.value,
249 Array.from(v.reads.files),
250 Array.from(v.reads.works),
251 Array.from(v.writes.files),
252 Array.from(
253 v.writes.assets,
254 ([k, { headers }]) => [k, headers] as const,
255 ),
256 v.affects,
257 ] as const,
258 );
254259 const expectedFilesOnDisk = Array.from(
255260 writes,
256261 ([k, { size, work }]) => [k, size, work] as const,
......@@ -280,7 +285,7 @@ async function deserialize(buffer: Buffer) {
280285 if (type === "f") {
281286 ASSERT(typeof content === "number");
282287 files.set(k, { type, affects, lastModified: content });
283 } else if (type === 'd') {
288 } else if (type === "d") {
284289 ASSERT(typeof content === "string");
285290 files.set(k, { type, affects, contentHash: content, contents: [] });
286291 } else {
......@@ -288,15 +293,8 @@ async function deserialize(buffer: Buffer) {
288293 }
289294 }
290295 for (const entry of workEntries) {
291 const [
292 k,
293 value,
294 readFiles,
295 readWorks,
296 writeFiles,
297 writeAssets,
298 affects,
299 ] = entry;
296 const [k, value, readFiles, readWorks, writeFiles, writeAssets, affects] =
297 entry;
300298 works.set(k, {
301299 value,
302300 reads: {
......@@ -305,23 +303,30 @@ async function deserialize(buffer: Buffer) {
305303 },
306304 writes: {
307305 files: new Set(writeFiles),
308 assets: new Map(Array.from(writeAssets, ([k, headers]) => [k, {
309 hash: JSON.parse(UNWRAP(headers.etag)),
310 headers,
311 }])),
306 assets: new Map(
307 Array.from(writeAssets, ([k, headers]) => [
308 k,
309 {
310 hash: JSON.parse(UNWRAP(headers.etag)),
311 headers,
312 },
313 ]),
314 ),
312315 },
313316 affects,
314317 });
315318 }
316 const statFiles = await Promise.all(expectedFilesOnDisk
317 .map(([k, size, work]) =>
318 fs.stat(path.join(".clover/o", k))
319 const statFiles = await Promise.all(
320 expectedFilesOnDisk.map(([k, size, work]) =>
321 fs
322 .stat(path.join(".clover/o", k))
319323 .catch((err) => {
320324 if (err.code === "ENOENT") return null;
321325 throw err;
322326 })
323327 .then((stat) => ({ k, size, work, stat }))
324 ));
328 ),
329 );
325330 for (const { k, stat, work, size } of statFiles) {
326331 if (stat?.size === size) {
327332 writes.set(k, {
......@@ -337,19 +342,28 @@ async function deserialize(buffer: Buffer) {
337342 assets.set(hash, { raw, gzip, zstd });
338343 }
339344
340 await Promise.all(Array.from(files, ([key, file]) => invalidateEntry(key, file)));
345 await Promise.all(
346 Array.from(files, ([key, file]) => invalidateEntry(key, file, false)),
347 );
341348}
342349
343export async function invalidate(filePath: string): Promise<boolean> {
350export async function invalidate(
351 filePath: string,
352 unloadModule: boolean = true,
353): Promise<boolean> {
344354 const key = toRel(toAbs(filePath));
345 const file = UNWRAP(files.get(key), `Untracked file '${key}'`)
346 return invalidateEntry(key, file)
355 const file = UNWRAP(files.get(key), `Untracked file '${key}'`);
356 return invalidateEntry(key, file, unloadModule);
347357}
348358
349export async function invalidateEntry(key: string, file: TrackedFile): Promise<boolean> {
359export async function invalidateEntry(
360 key: string,
361 file: TrackedFile,
362 unloadModule: boolean,
363): Promise<boolean> {
350364 try {
351365 if (file.type === "d") {
352 const contents = file.contents = await fs.readdir(key);
366 const contents = (file.contents = await fs.readdir(key));
353367 contents.sort();
354368 const contentHash = crypto
355369 .createHash("sha1")
......@@ -359,48 +373,79 @@ export async function invalidateEntry(key: string, file: TrackedFile): Promise<b
359373 file.contentHash = contentHash;
360374 throw new Error();
361375 }
362 } else if (file.type === 'f') {
363 const lastModified = await fs.stat(key)
364 .then(x => Math.floor(x.mtimeMs), () => 0);
376 } else if (file.type === "f") {
377 const lastModified = await fs.stat(key).then(
378 (x) => Math.floor(x.mtimeMs),
379 () => 0,
380 );
365381 if (file.lastModified !== lastModified) {
366382 file.lastModified = lastModified;
367383 throw new Error();
368384 }
369385 } else {
370 file.type satisfies 'null';
386 file.type satisfies "null";
371387 const stat = await fs.stat(key).catch(() => null);
372 if (stat) throw new Error();
388 if (stat) throw new Error();
373389 }
374390 return false;
375391 } catch (e) {
376392 forceInvalidate(file);
377 hot.unload(toAbs(key));
378 if (file.type === 'null') files.delete(key);
393 if (unloadModule) {
394 // TODO: handle when this triggers unloading of `generate.ts`
395 hot.unload(toAbs(key));
396 }
397 if (file.type === "null") files.delete(key);
379398 return true;
380399 }
381400}
382401
383402export function getAssetManifest() {
403 const dynamic = new Map<string, Buffer>();
384404 const writer = new BufferWriter();
385 const asset = Object.fromEntries(
405 const assetMap = Object.fromEntries(
386406 Array.from(works, (work) => work[1].writes.assets)
387407 .filter((map) => map.size > 0)
388408 .flatMap((map) =>
389 Array.from(map, ([key, { hash, headers }]) => {
409 Array.from(map, ([key, { hash, headers, regenerative }]) => {
390410 const { raw, gzip, zstd } = UNWRAP(
391411 assets.get(hash),
392412 `Asset ${key} (${hash})`,
393413 );
394 return [key, {
395 raw: writer.write(raw, "raw:" + hash),
396 gzip: writer.write(gzip, "gzip:" + hash),
397 zstd: writer.write(zstd, "zstd:" + hash),
398 headers,
399 }] as const;
414 if (regenerative) {
415 const id = crypto
416 .createHash("sha1")
417 .update(key)
418 .digest("hex")
419 .slice(0, 16); /* TODO */
420 dynamic.set(
421 id,
422 manifest.packDynamicBuffer(raw, gzip, zstd, headers),
423 );
424 return [key, { type: 1, id }] as const;
425 }
426 return [
427 key,
428 {
429 type: 0,
430 raw: writer.write(raw, "raw:" + hash),
431 gzip: writer.write(gzip, "gzip:" + hash),
432 zstd: writer.write(zstd, "zstd:" + hash),
433 headers,
434 },
435 ] as const;
400436 })
401437 ),
402 ) satisfies BuiltAssetMap;
403 return { json: asset, blob: writer.get() };
438 ) satisfies manifest.Manifest;
439 return {
440 json: assetMap,
441 blob: writer.get(),
442 dynamic,
443 dts: "export type AssetKey = " +
444 Object.keys(assetMap)
445 .map((key) => JSON.stringify(key))
446 .join(" | ") +
447 "\n",
448 };
404449}
405450
406451/* Input/Output with automatic tracking.
......@@ -444,9 +489,9 @@ export class Io {
444489 }
445490 async readDir(dir: string) {
446491 const { key, resolved } = this.#trackFs(dir);
447 const existing = files.get(key);
492 const existing = files.get(key);
448493 try {
449 if (existing?.type === 'd') return existing.contents;
494 if (existing?.type === "d") return existing.contents;
450495 const contents = await fs.readdir(resolved);
451496 contents.sort();
452497 const contentHash = crypto
......@@ -512,26 +557,27 @@ export class Io {
512557 }
513558 }
514559 }
515 async writeAsset(
516 pathname: string,
517 blob: string | Buffer,
518 headersOption?: HeadersInit,
519 ) {
520 ASSERT(pathname.startsWith("/"));
521 ASSERT(!seenWrites.has("a:" + pathname));
522
523 const buffer = typeof blob === "string" ? Buffer.from(blob) : blob;
524
525 const headers = new Headers(headersOption ?? {});
560 async writeAsset(asset: {
561 pathname: string;
562 buffer: string | Buffer;
563 regenerative?: boolean;
564 headers?: HeadersInit;
565 }) {
566 ASSERT(asset.pathname.startsWith("/"));
567 ASSERT(!seenWrites.has("a:" + asset.pathname));
568 const buffer = typeof asset.buffer === "string"
569 ? Buffer.from(asset.buffer)
570 : asset.buffer;
571 const headers = new Headers(asset.headers ?? {});
526572 const hash = crypto.createHash("sha1").update(buffer).digest("hex");
527573 if (!headers.has("Content-Type")) {
528 headers.set("Content-Type", mime.contentTypeFor(pathname));
574 headers.set("Content-Type", mime.contentTypeFor(asset.pathname));
529575 }
530 headers.set("ETag", JSON.stringify(hash));
531 this.writes.assets.set(pathname, {
576 headers.set("etag", JSON.stringify(hash));
577 this.writes.assets.set(asset.pathname, {
532578 hash,
533 // @ts-expect-error TODO
534579 headers: Object.fromEntries(headers),
580 regenerative: !!asset.regenerative,
535581 });
536582 if (!assets.has(hash)) {
537583 jobs += 1;
......@@ -593,7 +639,10 @@ export function validateSerializable(value: unknown, key: string) {
593639 } else if (value && typeof value === "object") {
594640 if (Array.isArray(value)) {
595641 value.forEach((item, i) => validateSerializable(item, `${key}[${i}]`));
596 } else if (Object.getPrototypeOf(value) === Object.prototype || Buffer.isBuffer(value)) {
642 } else if (
643 Object.getPrototypeOf(value) === Object.prototype ||
644 Buffer.isBuffer(value)
645 ) {
597646 Object.entries(value).forEach(([k, v]) =>
598647 validateSerializable(v, `${key}.${k}`)
599648 );
......@@ -631,53 +680,48 @@ interface FileWrite {
631680}
632681interface Writes {
633682 files: Set<string>;
634 assets: Map<string, {
635 hash: string;
636 headers: Record<string, string>;
637 }>;
683 assets: Map<string, AssetWrite>;
638684}
639685interface Asset {
640686 raw: Buffer;
641687 gzip: Buffer;
642688 zstd: Buffer;
643689}
690interface AssetWrite {
691 hash: string;
692 headers: Record<string, string>;
693 regenerative: boolean;
694}
644695interface Work<T = unknown> {
645 debug?: string;
646696 value: T;
647697 reads: Reads;
648698 writes: Writes;
649699 affects: string[];
650700}
651701type TrackedFile =
652 & {
653 affects: string[];
654 }
702 & { affects: string[] }
655703 & (
656704 | { type: "f"; lastModified: number }
657705 | { type: "d"; contentHash: string; contents: string[] }
658 | { type: "null"; }
706 | { type: "null" }
659707 );
660export interface BuiltAssetMap {
661 [route: string]: BuiltAsset;
662}
663export interface BuiltAsset {
664 raw: BufferView;
665 gzip: BufferView;
666 zstd: BufferView;
667 headers: Record<string, string>;
668}
669708
670709const gzip = util.promisify(zlib.gzip);
671710const zstdCompress = util.promisify(zlib.zstdCompress);
672711
712import * as hot from "./hot.ts";
713
673714import * as fs from "#sitegen/fs";
715import * as mime from "#sitegen/mime";
716import * as manifest from "#sitegen/assets";
717
674718import * as path from "node:path";
675import * as hot from "./hot.ts";
676719import * as util from "node:util";
677720import * as crypto from "node:crypto";
678import * as mime from "#sitegen/mime";
679721import * as zlib from "node:zlib";
722
680723import * as console from "@paperclover/console";
681724import { Spinner } from "@paperclover/console/Spinner";
682725import { formatSize } from "@/file-viewer/format.ts";
726
683727import * as msgpackr from "msgpackr";
framework/lib/assets.ts+167-43
......@@ -1,73 +1,114 @@
1// Static and dynamic assets are built alongside the server binary.
2// This module implements decoding and serving of the asset blobs,
3// but also implements patching of dynamic assets. The `Manifest`
4// is generated by `incremental.ts`
5const root = import.meta.dirname;
6let current: Loaded | null = null;
7
8// TODO: rename all these types
9type DynamicId = string;
10export type { Key };
11export type Manifest =
12 & {
13 [K in Key]: StaticAsset | DynamicAsset;
14 }
15 & {
16 [string: string]: StaticAsset | DynamicAsset;
17 };
18export interface StaticAsset extends AssetBase {
19 type: 0;
20}
21interface AssetBase {
22 headers: Record<string, string> & { etag: string };
23 raw: BufferView;
24 gzip: BufferView;
25 zstd: BufferView;
26}
27export interface DynamicAsset {
28 type: 1;
29 id: DynamicId;
30}
131interface Loaded {
2 map: BuiltAssetMap;
3 buf: Buffer;
32 map: Manifest;
33 static: Buffer;
34 dynamic: Map<DynamicId, DynamicEntry>;
435}
5let assets: Loaded | null = null;
6
7export type StaticPageId = string;
8
9export async function reload() {
10 const [map, buf] = await Promise.all([
11 fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
12 fs.readFile(path.join(import.meta.dirname, "static.blob")),
13 ]);
14 return (assets = { map: JSON.parse(map), buf });
36export interface DynamicEntry extends AssetBase {
37 buffer: Buffer;
1538}
1639
17export function reloadSync() {
18 const map = fs.readFileSync(
19 path.join(import.meta.dirname, "static.json"),
20 "utf8",
40export async function reload() {
41 const map = await fs.readJson<Manifest>(path.join(root, "asset.json"));
42 const statics = await fs.readFile(path.join(root, "asset.blob"));
43 const dynamic = new Map(
44 await Promise.all(
45 Object.entries(map)
46 .filter((entry): entry is [string, DynamicAsset] => entry[1].type === 1)
47 .map(async ([k, v]) =>
48 [
49 v.id,
50 await fs.readFile(path.join(root, "dynamic", v.id))
51 .then(loadRegenerative),
52 ] as const
53 ),
54 ),
2155 );
22 const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
23 return (assets = { map: JSON.parse(map), buf });
56 return (current = { map, static: statics, dynamic });
2457}
2558
2659export async function middleware(c: Context, next: Next) {
27 if (!assets) await reload();
28 const asset = assets!.map[c.req.path];
60 if (!current) current = await reload();
61 const asset = current.map[c.req.path];
2962 if (asset) return assetInner(c, asset, 200);
3063 return next();
3164}
3265
3366export async function notFound(c: Context) {
34 if (!assets) await reload();
67 if (!current) current = await reload();
3568 let pathname = c.req.path;
3669 do {
37 const asset = assets!.map[pathname + "/404"];
70 const asset = current.map[pathname + "/404"];
3871 if (asset) return assetInner(c, asset, 404);
3972 pathname = pathname.slice(0, pathname.lastIndexOf("/"));
4073 } while (pathname);
41 const asset = assets!.map["/404"];
74 const asset = current.map["/404"];
4275 if (asset) return assetInner(c, asset, 404);
4376 return c.text("the 'Not Found' page was not found", 404);
4477}
4578
46export async function serveAsset(
47 c: Context,
48 id: StaticPageId,
49 status: StatusCode,
50) {
51 return assetInner(c, (assets ?? (await reload())).map[id], status);
79export async function serveAsset(c: Context, id: Key, status: StatusCode) {
80 return assetInner(c, (current ?? (await reload())).map[id], status);
5281}
5382
83/** @deprecated */
5484export function hasAsset(id: string) {
55 return (assets ?? reloadSync()).map[id] !== undefined;
85 return UNWRAP(current).map[id] !== undefined;
5686}
5787
5888export function etagMatches(etag: string, ifNoneMatch: string) {
5989 return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1;
6090}
6191
62function subarrayAsset([start, end]: BufferView) {
63 return assets!.buf.subarray(start, end);
92function assetInner(c: Context, asset: Manifest[Key], status: StatusCode) {
93 ASSERT(current);
94 if (asset.type === 0) {
95 return respondWithBufferAndViews(c, current.static, asset, status);
96 } else {
97 const entry = UNWRAP(current.dynamic.get(asset.id));
98 return respondWithBufferAndViews(c, entry.buffer, entry, status);
99 }
64100}
65101
66function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
67 const ifnonematch = c.req.header("If-None-Match");
68 if (ifnonematch) {
69 const etag = asset.headers.ETag;
70 if (etagMatches(etag, ifnonematch)) {
102function respondWithBufferAndViews(
103 c: Context,
104 buffer: Buffer,
105 asset: AssetBase,
106 status: StatusCode,
107) {
108 const ifNoneMatch = c.req.header("If-None-Match");
109 if (ifNoneMatch) {
110 const etag = asset.headers.etag;
111 if (etagMatches(etag, ifNoneMatch)) {
71112 return (c.res = new Response(null, {
72113 status: 304,
73114 statusText: "Not Modified",
......@@ -80,24 +121,103 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
80121 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
81122 let body;
82123 let headers = asset.headers;
83 if (acceptEncoding.includes("zstd") && asset.zstd) {
84 body = subarrayAsset(asset.zstd);
124 if (acceptEncoding.includes("zstd")) {
125 body = buffer.subarray(...asset.zstd);
85126 headers = {
86127 ...asset.headers,
87128 "Content-Encoding": "zstd",
88129 };
89 } else if (acceptEncoding.includes("gzip") && asset.gzip) {
90 body = subarrayAsset(asset.gzip);
130 } else if (acceptEncoding.includes("gzip")) {
131 body = buffer.subarray(...asset.gzip);
91132 headers = {
92133 ...asset.headers,
93134 "Content-Encoding": "gzip",
94135 };
95136 } else {
96 body = subarrayAsset(asset.raw);
137 body = buffer.subarray(...asset.raw);
97138 }
98139 return (c.res = new Response(body, { headers, status }));
99140}
100141
142export function packDynamicBuffer(
143 raw: Buffer,
144 gzip: Buffer,
145 zstd: Buffer,
146 headers: Record<string, string>,
147) {
148 const headersBuffer = Buffer.from(
149 Object.entries(headers)
150 .map((entry) => entry.join(":"))
151 .join("\n"),
152 "utf-8",
153 );
154 const header = new Uint32Array(3);
155 header[0] = headersBuffer.byteLength + header.byteLength;
156 header[1] = header[0] + raw.byteLength;
157 header[2] = header[1] + gzip.byteLength;
158 return Buffer.concat([
159 Buffer.from(header.buffer),
160 headersBuffer,
161 raw,
162 gzip,
163 zstd,
164 ]);
165}
166
167function loadRegenerative(buffer: Buffer): DynamicEntry {
168 const headersEnd = buffer.readUInt32LE(0);
169 const headers = Object.fromEntries(
170 buffer
171 .subarray(3 * 4, headersEnd)
172 .toString("utf-8")
173 .split("\n")
174 .map((line) => {
175 const i = line.indexOf(":");
176 return [line.slice(0, i), line.slice(i + 1)];
177 }),
178 );
179 const raw = buffer.readUInt32LE(4);
180 const gzip = buffer.readUInt32LE(8);
181 const hasEtag = (v: object): v is typeof v & { etag: string } =>
182 "etag" in v && typeof v.etag === "string";
183 ASSERT(hasEtag(headers));
184 return {
185 headers,
186 buffer,
187 raw: [headersEnd, raw],
188 gzip: [raw, gzip],
189 zstd: [gzip, buffer.byteLength],
190 };
191}
192
193const gzip = util.promisify(zlib.gzip);
194const zstdCompress = util.promisify(zlib.zstdCompress);
195
196export async function overwriteDynamic(
197 key: Key,
198 value: string | Buffer,
199 headers: Record<string, string>,
200) {
201 if (!current) current = await reload();
202 const asset = UNWRAP(current.map[key]);
203 ASSERT(asset.type === 1);
204 UNWRAP(current.dynamic.has(asset.id));
205 const buffer = Buffer.from(value);
206 const etag = JSON.stringify(
207 crypto.createHash("sha1").update(buffer).digest("hex"),
208 );
209 const [gzipBuffer, zstdBuffer] = await Promise.all([
210 gzip(buffer),
211 zstdCompress(buffer),
212 ]);
213 const packed = packDynamicBuffer(buffer, gzipBuffer, zstdBuffer, {
214 ...headers,
215 etag,
216 });
217 current.dynamic.set(asset.id, loadRegenerative(packed));
218 await fs.writeFile(path.join(root, "dynamic", asset.id), packed);
219}
220
101221process.on("message", (msg: any) => {
102222 if (msg?.type === "clover.assets.reload") reload();
103223});
......@@ -105,6 +225,10 @@ process.on("message", (msg: any) => {
105225import * as fs from "#sitegen/fs";
106226import type { Context, Next } from "hono";
107227import type { StatusCode } from "hono/utils/http-status";
108import type { BuiltAsset, BuiltAssetMap, BufferView } from "../incremental.ts";
228import type { BufferView } from "../incremental.ts";
109229import { Buffer } from "node:buffer";
110230import * as path from "node:path";
231import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts";
232import * as crypto from "node:crypto";
233import * as zlib from "node:zlib";
234import * as util from "node:util";
framework/lib/async.ts+2-2
......@@ -51,8 +51,8 @@ export class Queue<T, R> {
5151 let n = 0;
5252 for (const item of active) {
5353 let itemText = "- " + item.format(now);
54 text +=
55 `\n` + itemText.slice(0, Math.max(0, process.stdout.columns - 1));
54 text += `\n` +
55 itemText.slice(0, Math.max(0, process.stdout.columns - 1));
5656 if (n > 10) {
5757 text += `\n ... + ${active.length - n} more`;
5858 break;
framework/lib/fs.ts+2-2
......@@ -4,6 +4,7 @@ export {
44 createReadStream,
55 createWriteStream,
66 existsSync,
7 type FileHandle,
78 open,
89 readdir,
910 readdirSync,
......@@ -15,7 +16,6 @@ export {
1516 statSync,
1617 writeFile,
1718 writeFileSync,
18 type FileHandle,
1919};
2020
2121export function mkdir(dir: string) {
......@@ -98,6 +98,7 @@ import {
9898 writeFileSync,
9999} from "node:fs";
100100import {
101 type FileHandle,
101102 mkdir as nodeMkdir,
102103 open,
103104 readdir,
......@@ -106,6 +107,5 @@ import {
106107 rmdir,
107108 stat,
108109 writeFile,
109 type FileHandle,
110110} from "node:fs/promises";
111111export { Stats } from "node:fs";
framework/lib/markdown.tsx+12-8
......@@ -1,9 +1,11 @@
1/* Impementation of CommonMark specification for markdown with support
1/* Implementation of [CommonMark] specification for markdown with support
22 * for custom syntax extensions via the parser options. Instead of
33 * returning an AST that has a second conversion pass to JSX, the
44 * returned value of 'parse' is 'engine.Node' which can be stringified
5 * via clover's SSR engine. This way, generation optimizations, async
5 * via Clover's SSR engine. This way, generation optimizations, async
66 * components, and other features are gained for free here.
7 *
8 * [CommonMark]: https://spec.commonmark.org/0.31.2/
79 */
810function parse(src: string, options: Partial<ParseOpts> = {}) {}
911
......@@ -16,6 +18,9 @@ export function Markdown({
1618 return parse(src, options);
1719}
1820
21// TODO: This implementation is flawed because it is impossible to sanely handle
22// emphasis and strong emphasis, and all their edge cases. Instead of making these
23// using extensions interface, they should be special cased.
1924function parseInline(src: string, options: Partial<InlineOpts> = {}) {
2025 const { rules = inlineRules, links = new Map() } = options;
2126 const opts: InlineOpts = { rules, links };
......@@ -110,12 +115,11 @@ export const inlineRules: Record<string, InlineRule> = {
110115 } else if (afterText[0] === "[") {
111116 const splitTarget = splitFirst(afterText.slice(1), /]/);
112117 if (!splitTarget) return null;
113 const name =
114 splitTarget.first.trim().length === 0
115 ? // Collapsed reference link
116 textSrc.trim()
117 : // Full Reference Link
118 splitTarget.first.trim();
118 const name = splitTarget.first.trim().length === 0
119 // Collapsed reference link
120 ? textSrc.trim()
121 // Full Reference Link
122 : splitTarget.first.trim();
119123 const target = opts.links.get(name);
120124 if (!target) return null;
121125 ({ href, title } = target);
framework/lib/sitegen.ts+40-1
......@@ -1,6 +1,25 @@
1// Import this file with 'import * as sg from "#sitegen";'
21export type ScriptId = string;
32
3export interface PageExports extends ViewExports {
4 regenerate?: PageRegenerateOptions;
5}
6export interface ViewExports {
7 default: render.Component;
8 meta: meta.Meta | ((props: { ssr: true }) => Promise<meta.Meta> | meta.Meta);
9 theme?: css.Theme;
10 layout?: Layout;
11}
12export interface Layout {
13 default: render.Component;
14 theme?: css.Theme;
15 // TODO: nested layout
16}
17export interface PageRegenerateOptions {
18 tags?: string[];
19 seconds?: number;
20 debounce?: number;
21}
22
423/**
524 * A filesystem object associated with some ID,
625 * such as a page's route to it's source file.
......@@ -30,4 +49,24 @@ export function addScript(id: ScriptId | { value: ScriptId }) {
3049 userData.get().scripts.add(typeof id === "string" ? id : id.value);
3150}
3251
52export function wrapDocument({
53 body,
54 head,
55 inlineCss,
56 scripts,
57}: {
58 head: string;
59 body: string;
60 inlineCss: string;
61 scripts: string;
62}) {
63 return `<!doctype html><html lang=en><head>${head}${
64 inlineCss ? `<style>${inlineCss}</style>` : ""
65 }</head><body>${body}${
66 scripts ? `<script>${scripts}</script>` : ""
67 }</body></html>`;
68}
69
3370import * as render from "#engine/render";
71import type * as meta from "./meta.ts";
72import type * as css from "../css.ts";
framework/lib/sqlite.ts+3-1
......@@ -10,7 +10,9 @@ export function getDb(file: string) {
1010 if (db) return db;
1111 const fileWithExt = file.includes(".") ? file : file + ".sqlite";
1212 db = new WrappedDatabase(
13 new DatabaseSync(path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt)),
13 new DatabaseSync(
14 path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt),
15 ),
1416 );
1517 map.set(file, db);
1618 return db;
framework/lib/testing.ts+7-3
......@@ -1,5 +1,9 @@
11export function tmpFile(basename: string) {
2 const file = path.join(import.meta.dirname, '../../.clover/testing', basename);
2 const file = path.join(
3 import.meta.dirname,
4 "../../.clover/testing",
5 basename,
6 );
37 return {
48 path: file,
59 read: fs.readFile.bind(fs, file),
......@@ -7,5 +11,5 @@ export function tmpFile(basename: string) {
711 };
812}
913
10import * as path from 'node:path';
11import * as fs from './fs.ts';
14import * as path from "node:path";
15import * as fs from "./fs.ts";
framework/lib/view.ts+70-44
......@@ -1,38 +1,64 @@
1export interface View {
1// The "view" system allows rendering dynamic pages within backends.
2// This is done by scanning all `views` dirs, bundling their client
3// resources, and then providing `serve` which renders a page.
4//
5// This system also implements page regeneration.
6let codegen: Codegen;
7try {
8 codegen = require("$views");
9} catch {
10 throw new Error("Can only import '#sitegen/view' in backends.");
11}
12
13// Generated in `bundle.ts`
14export interface Codegen {
15 views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
16 scripts: Record<string, string>;
17 regenTtls: Ttl[];
18 regenTags: Record<RegenKey, Key[]>;
19}
20// The view contains pre-bundled CSS and scripts, but keeps the scripts
21// separate for run-time dynamic scripts. For example, the file viewer
22// includes the canvas for the current page, but only the current page.
23export interface View<Props extends Record<string, unknown>> {
224 component: render.Component;
3 meta:
4 | meta.Meta
5 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
25 meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta);
626 layout?: render.Component;
727 inlineCss: string;
828 scripts: Record<string, string>;
929}
30export interface Ttl {
31 seconds: number;
32 key: Key;
33}
34export type Key = keyof ViewMap;
1035
11let views: Record<string, View> = null!;
12let scripts: Record<string, string> = null!;
13
14export async function renderView(
36export async function serve<K extends Key>(
1537 context: hono.Context,
16 id: string,
17 props: Record<string, unknown>,
38 id: K,
39 props: PropsFromModule<ViewMap[K]>,
1840) {
19 return context.html(await renderViewToString(id, { context, ...props }));
41 return context.html(await renderToString(id, { context, ...props }));
2042}
2143
22export async function renderViewToString(
23 id: string,
24 props: Record<string, unknown>,
44type PropsFromModule<M extends any> = M extends {
45 default: (props: infer T) => render.Node;
46} ? T
47 : never;
48
49export async function renderToString<K extends Key>(
50 id: K,
51 props: PropsFromModule<ViewMap[K]>,
2552) {
26 views ?? ({ views, scripts } = require("$views"));
27 // The view contains pre-bundled CSS and scripts, but keeps the scripts
28 // separate for run-time dynamic scripts. For example, the file viewer
29 // includes the canvas for the current page, but only the current page.
3053 const {
3154 component,
3255 inlineCss,
3356 layout,
3457 meta: metadata,
35 }: View = UNWRAP(views[id], `Missing view ${id}`);
58 }: View<PropsFromModule<ViewMap[K]>> = UNWRAP(
59 codegen.views[id],
60 `Missing view ${id}`,
61 );
3662
3763 // -- metadata --
3864 const renderedMetaPromise = Promise.resolve(
......@@ -48,48 +74,48 @@ export async function renderViewToString(
4874 } = await render.async(page, { [sg.userData.key]: sg.initRender() });
4975
5076 // -- join document and send --
51 return wrapDocument({
77 return sg.wrapDocument({
5278 body,
5379 head: await renderedMetaPromise,
5480 inlineCss,
5581 scripts: joinScripts(
56 Array.from(sitegen.scripts, (id) =>
57 UNWRAP(scripts[id], `Missing script ${id}`),
82 Array.from(
83 sitegen!.scripts,
84 (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
5885 ),
5986 ),
6087 });
6188}
6289
63export function provideViewData(v: typeof views, s: typeof scripts) {
64 (views = v), (scripts = s);
90export function regenerate(tag: RegenKey) {
91 for (const view of codegen.regenTags[tag]) {
92 const key = view.slice("page:".length);
93 renderToString(view, {})
94 .then((result) => {
95 console.info(`regenerate ${key}`);
96 asset.overwriteDynamic(key as asset.Key, result, {
97 "content-type": "text/html",
98 });
99 })
100 .catch((e) => {
101 console.error(`Failed regenerating ${view} from tag ${tag}`, e);
102 });
103 }
65104}
66105
67export function joinScripts(scriptSources: string[]) {
106function joinScripts(scriptSources: string[]) {
68107 const { length } = scriptSources;
69108 if (length === 0) return "";
70 if (length === 1) return scriptSources[0];
109 if (0 in scriptSources) return scriptSources[0];
71110 return scriptSources.map((source) => `{${source}}`).join(";");
72111}
73112
74export function wrapDocument({
75 body,
76 head,
77 inlineCss,
78 scripts,
79}: {
80 head: string;
81 body: string;
82 inlineCss: string;
83 scripts: string;
84}) {
85 return `<!doctype html><html lang=en><head>${head}${
86 inlineCss ? `<style>${inlineCss}</style>` : ""
87 }</head><body>${body}${
88 scripts ? `<script>${scripts}</script>` : ""
89 }</body></html>`;
90}
91
92113import * as meta from "./meta.ts";
93114import type * as hono from "#hono";
94115import * as render from "#engine/render";
95116import * as sg from "./sitegen.ts";
117import * as asset from "./assets.ts";
118import type {
119 RegenKey,
120 RegisteredViews as ViewMap,
121} from "../../.clover/ts/view.d.ts";
framework/marko.ts+12-11
......@@ -14,18 +14,19 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {
1414 // bare client import statements to it's own usage.
1515 const scannedClientRefs = new Set<string>();
1616 if (src.match(/^\s*client\s+import\s+["']/m)) {
17 src =
18 src.replace(
19 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
20 (_, src) => {
21 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
22 const resolved = hot.resolveClientRef(filepath, ref);
23 scannedClientRefs.add(resolved);
24 return `<CloverScriptInclude=${JSON.stringify(
17 src = src.replace(
18 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
19 (_, src) => {
20 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
21 const resolved = hot.resolveClientRef(filepath, ref);
22 scannedClientRefs.add(resolved);
23 return `<CloverScriptInclude=${
24 JSON.stringify(
2525 hot.getScriptId(resolved),
26 )} />`;
27 },
28 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
26 )
27 } />`;
28 },
29 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
2930 }
3031
3132 src = marko.compileSync(src, filepath).code;
framework/watch.ts+10-9
......@@ -11,7 +11,7 @@ let watch: Watch;
1111
1212export async function main() {
1313 // Catch up state by running a main build.
14 await incr.restore();
14 if (!process.argv.includes("-f")) await incr.restore();
1515 watch = new Watch(rebuild);
1616 rebuild([]);
1717}
......@@ -36,15 +36,16 @@ function onSubprocessClose(code: number | null, signal: string | null) {
3636}
3737
3838async function rebuild(files: string[]) {
39 const hasInvalidated = files.length === 0
40 || (await Promise.all(files.map(incr.invalidate))).some(Boolean);
39 const hasInvalidated = files.length === 0 ||
40 (await Promise.all(files.map((file) => incr.invalidate(file))))
41 .some(Boolean);
4142 if (!hasInvalidated) return;
4243 incr.compile(generate.generate).then(({
4344 watchFiles,
4445 newOutputs,
45 newAssets
46 newAssets,
4647 }) => {
47 const removeWatch = [...watch.files].filter(x => !watchFiles.has(x))
48 const removeWatch = [...watch.files].filter((x) => !watchFiles.has(x));
4849 for (const file of removeWatch) watch.remove(file);
4950 watch.add(...watchFiles);
5051 // Restart the server if it was changed or not running.
......@@ -60,8 +61,8 @@ async function rebuild(files: string[]) {
6061
6162function statusLine() {
6263 console.info(
63 `Watching ${watch.files.size} files `
64 + `\x1b[36m[last change: ${new Date().toLocaleTimeString()}]\x1b[39m`,
64 `Watching ${watch.files.size} files ` +
65 `\x1b[36m[last change: ${new Date().toLocaleTimeString()}]\x1b[39m`,
6566 );
6667}
6768
......@@ -142,7 +143,7 @@ class Watch {
142143 #getFiles(absPath: string, event: fs.WatchEventType) {
143144 const files = [];
144145 if (this.files.has(absPath)) files.push(absPath);
145 if (event === 'rename') {
146 if (event === "rename") {
146147 const dir = path.dirname(absPath);
147148 if (this.files.has(dir)) files.push(dir);
148149 }
......@@ -153,7 +154,7 @@ class Watch {
153154 if (!subPath) return;
154155 const files = this.#getFiles(path.join(root, subPath), event);
155156 if (files.length === 0) return;
156 for(const file of files) this.stale.add(file);
157 for (const file of files) this.stale.add(file);
157158 const { debounce } = this;
158159 if (debounce !== null) clearTimeout(debounce);
159160 this.debounce = setTimeout(() => {
src/backend.ts+3-3
......@@ -1,6 +1,6 @@
1// This is the main file for the backend
1// This is the main file for paperclover.net's server.
22const app = new Hono();
3const logHttp = scoped("http", { color: "magenta" });
3const logHttp = console.scoped("http", { color: "magenta" });
44
55// Middleware
66app.use(trimTrailingSlash());
......@@ -38,4 +38,4 @@ import { logger } from "hono/logger";
3838import { trimTrailingSlash } from "hono/trailing-slash";
3939import * as assets from "#sitegen/assets";
4040import * as admin from "./admin.ts";
41import { scoped } from "@paperclover/console";
41import * as console from "@paperclover/console";
src/blog/pages/25/file-viewer-internals.mdx deleted-10
......@@ -1,10 +0,0 @@
1export const blog: BlogMeta = {
2 title: "Marko is the coziest HTML templating language",
3 desc: "...todo...",
4 date: "2025-07-08",
5 draft: true,
6};
7export const meta = formatBlogMeta(blob);
8export * as layout from "@/blog/layout.tsx";
9
10
src/blog/pages/25/marko-intro.markodown deleted-290
......@@ -1,290 +0,0 @@
1export const blog: BlogMeta = {
2 title: "Marko is the coziest HTML templating language",
3 desc: "...todo...",
4 date: "2025-06-13",
5 draft: true,
6};
7export const meta = formatBlogMeta(blob);
8export * as layout from "@/blog/layout.tsx";
9
10I've been recently playing around [Marko][1], and after adding limited support
11for it in my website generator, [sitegen][2], I instantly fell in love with how
12minimalistic it is in comparison to JSX, Astro components, and Svelte.
13
14## Introduction
15
16If JSX was taking HTML and shoving its syntax into JavaScript, Marko is shoving
17JavaScript into HTML. Attributes are JavaScript expressions.
18
19```marko
20<div>
21 // `input` is like props, but given in the top-level scope
22 <time datetime=input.date.toISOString()>
23 // Interpolation with JS template string syntax
24 ${formatTimeNicely(input.date)}
25 </time>
26 <div>
27 <a href=`/users/${input.user.id}`>${input.user.name}</a>
28 </div>
29
30 // Capital letter variables for imported components
31 <MarkdownContent message=input.message />
32
33 // Components also can be auto-imported by lowercase.
34 // This will look upwards for a `tags/` folder containing
35 // "custom-footer.marko", similar to how Node.js finds
36 // package names in all upwards `node_modules` folders.
37 <custom-footer />
38</div>
39
40// ESM `import` / `export` just work as expected.
41// I prefer my imports at the end, to highlight the markup.
42import MarkdownContent from "./MarkdownContent.marko";
43import { formatTimeNicely } from "../date-helpers.ts";
44```
45
46Tags with the `value` attribute have a shorthand, which is used by the built-in
47`<if>` for conditional rendering.
48
49```marko
50// Sugar for <input value="string" />
51<input="string" />
52
53// and it composes amazingly to the 'if' built-in
54<if=input.user>
55 <UserProfile=input.user />
56</if>
57```
58
59Tags can also return values into the scope for use in the template using `/`, such as `<id>` for unique ID generation. This is available to components that `<return=output/>`.
60
61```
62<id/uniqueId />
63
64<input id=uniqueId type="checkbox" name="allow_trans_rights" />
65<label for=uniqueId>click me!</>
66// ^ oh, you can also omit the
67// closing tag name if you want.
68```
69
70It's important that I started with the two forms of "Tag I/O": `=` for input
71and `/` for output. With those building blocks, we introduce local variables
72with `const`
73
74```
75<const/rendered = markdownToHtml(input.value) />
76
77// This is how you insert raw HTML to the document
78<inline-html=rendered />
79
80// It supports all of the cozy destructuring syntax JS has
81<const/{ id, name } = user />
82```
83
84Unlike JSX, when you pass content within a tag (`input.content` instead of
85JSX's `children`), instead of it being a JSX element, it is actually a
86function. This means that the `for` tag can render the content multiple times.
87
88```
89<ul>
90 <for from=1 to=10>
91 // Renders a new random number for each iteration.
92 <li>${Math.random()}</li>
93 </>
94</ul>
95```
96
97Since `content` is a function, it can take arguments. This is done with `|`
98
99```
100<h1>my friends</h1>
101<ul>
102 // I tend to omit the closing tag names for the built-in control
103 // flow tags, but I keep them for HTML tags. It's kinda like how
104 // in JavaScript you just write `}` to close your `if`s and loops.
105 //
106 // Anyways <for> also has 'of'
107 <for|item| of=user.friends>
108 <li class="friend">${item.name}</li>
109 </>
110
111 // They support the same syntax JavaScript function params allows,
112 // so you can have destructuring here too, and multiple params.
113 <for|{ name }, index| of=user.friends>
114 // By the way you can also use emmet-style class and ID shorthands.
115 <li.friend>My #${index + 1} friend is ${name}</li>
116 </>
117</ul>
118```
119
120Instead of named slots, Marko has attribute tags. These are more powerful than
121slots since they are functions, and can also act as sugar for more complicated
122attributes.
123
124```
125<Layout title="Welcome">
126 <@header variant="big">
127 <h1>the next big thing</h1>
128 </@header>
129
130 <p>body text...</p>
131</Layout>
132
133// The `input` variable inside of <Layout /> is:
134//
135// {
136// title: "Welcome",
137// header: {
138// content: /* function rendering "<h1>the next big thing</h1>" */,
139// variant: "big",
140// },
141// content: /* function rendering "<p>body text</p>" */
142// }
143```
144
145This layout could be implemented as such:
146
147```marko
148<main>
149 <if=input.header />
150 <const/{ ...headerProps, content }=input.header />
151 <header ...headerProps>
152 // Instead of assigning to a variable with a capital letter,
153 // template interpolation works on tag names. This can also
154 // be a string to render the native HTML tag of that kind.
155 <${content} />
156 </header>
157 <hr />
158 </>
159
160 <${input.content} />
161</main>
162```
163
164The last syntax feature missing is calling a tag with parameters. That is done
165just like a regular function call, with '('.
166
167```
168<Something(item, index) />
169```
170
171In fact, attributes can just be sugar over this syntax; _this technically isn't
172true but it's close enough for the example_
173
174```
175<SpecialButton type="submit" class="red" />
176
177// is equal to
178
179<SpecialButton({ type: "submit", class: "red" }) />
180```
181
182All of the above is about how Marko's syntax works, and how it performs HTML
183generation with components. Marko also allows interactive components, but an
184explaination of that is beyond the scope of this page, mostly since I have not
185used it. A brief example of it, modified from their documentation.
186
187```marko
188// Reactive variables with <let/> just work...
189<let/basicCounter=0 />
190<button onClick() { basicCounter += 1 }>${basicCounter}</button>
191// ...but a counter is boring.
192
193<let/todos=[
194 { id: 0, text: "Learn Marko" },
195 { id: 1, text: "Make a Website" },
196]/>
197
198// 'by' is like React JSX's "key" property, but it's optional.
199<ul><for|todo, i| of=todos by=(todo => todo.id)>
200 <li.todo>
201 // this variable remains stable even if the list
202 // re-orders, because 'by' was specified.
203 <let/done=false/>
204 <label>
205 <span>${todo.text}</span>
206 // ':=' creates a two-way reactive binding,
207 // (it passes a callback for `checkedChanged`)
208 <input type="checkbox" checked:=done />
209 </label>
210 <button
211 title="delete"
212 disabled=!done
213 onClick() {
214 todos = todos.toSpliced(i, 1);
215 }
216 > &times; </button>
217 </li>
218</></ul>
219
220// Form example
221<let/nextId=2/>
222<form onSubmit(e) {
223 e.preventDefault();
224 todos = todos.concat({
225 id: nextId++,
226 // HTMLFormElement exposes all its named input
227 // elements as extra properties on the object.
228 text: e.target.text.value,
229 });
230 // And you can clear it with 'reset()'
231 e.target.reset();
232}>
233 // We don't 'onChange' like a React loser. The form
234 // value can be read in the submit event like normal.
235 <input name="text" placeholder="Another Item">
236 <button type="submit">Add</button>
237</form>
238```
239
240## Usage on `paperclover.net`
241
242TODO: document a lot of feedback, how i embedded Marko
243
244My website uses statically generated HTML. That is why I have not needed to use
245reactive variables. My generator doesn't even try compiling components
246client-side.
247
248Here is the actual component used to render [questions on the clover q+a][/q+a].
249
250```marko
251// Renders a `Question` entry including its markdown body.
252export interface Input {
253 question: Question;
254 admin?: boolean;
255}
256
257// 2024-12-31 05:00:00 EST
258export const transitionDate = 1735639200000;
259
260<const/{ question, admin } = input />
261<const/{ id, date, text } = question/>
262
263<${"e-"}
264 f=(date > transitionDate ? true : undefined)
265 id=admin ? `q${id}` : undefined
266>
267 <if=admin>
268 <a
269 style="margin-right: 0.5rem"
270 href=`/admin/q+a/${id}`
271 >[EDIT]</a>
272 </>
273 <a>
274 <time
275 datetime=formatQuestionISOTimestamp(date)
276 >${formatQuestionTimestamp(date)}</time>
277 </a>
278
279 <CloverMarkdown ...{ text } />
280</>
281
282// this singleton script will make all the '<time>' tags clickable.
283client import "./clickable-links.client.ts";
284
285import type { Question } from "@/q+a/models/Question.ts";
286import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";
287import { CloverMarkdown } from "@/q+a/clover-markdown.tsx";
288```
289
290import { type BlogMeta, formatBlogMeta } from '@/blog/helpers.ts';
src/blog/pages/webdev/deranged-typescript.md created+87
......@@ -0,0 +1,87 @@
1- imports at the bottom
2 - order your file by importance.
3 - 'G' to jump to imports, etc
4 - prefer namespace imports
5 - easier to type and refactor. easier to read.
6- large files are okay
7 - all files are their own library
8 - split files up by making components modular, not by "oh it's too big"
9 - engine/render.ts is a standalone library, in order to split JSX, Suspense,
10 and Marko out, the main file was made modular.
11- lowercase
12- name objects ultra-concisely
13 - filenames are often one word describing what they contain
14 - avoid useless descriptors like "utils", "helpers", and "data"
15 - examples
16 - async.ts contains all the async library functions.
17 - watch.ts contains the file watcher and watch-reload mode.
18 - render.*, Io
19- be ultra-concise in comments
20- no "discarded" variables, embrace `void x`
21 - makes code more readable
22 - note how i want to write a lint for this
23 - note the one proposal i want about void
24- push the ts inference engine (as const, ReturnType, etc)
25 - reduces how much you repeat yourself making it easier to refactor things
26 - use the code as the source of truth
27- push the ts inference engine (generics)
28 - do not implement crazy things with the TS engine, instead use generic input
29 types, and then use regular control to narrow and transform the return type.
30 source of truth is your code.
31- UNWRAP, ASSERT utility globals are amazing
32 - ban postfix '!'
33 - stripped for production frontend builds
34- destructure often
35 - use the one example from work lol
36- package.json "imports" are amazing
37 - remapping
38 - implementation switching
39 - testing
40- embrace the web and node.js APIs
41 - sitegen relies on so many node features that bun and deno fail to run it.
42 - overlay modules are great
43- avoid dependencies
44 - once you build your own mini standard library you win
45 - talk about regrets with mdx
46
47## imports at the bottom
48
49Here is an abridged version of my website's `backend.ts`. When reading it from
50top to bottom it is immediately obvious that it is a Hono web server.
51
52```ts
53// This is the main file for paperclover.net's server.
54const app = new Hono();
55const logHttp = console.scoped("http", { color: "magenta" });
56
57// Middleware
58app.use(...);
59...
60
61// Backends
62app.route("", require("./q+a/backend.ts").app);
63...
64
65export default app;
66
67...
68
69import { type Context, Hono, type Next } from "#hono";
70import { logger } from "hono/logger";
71import { trimTrailingSlash } from "hono/trailing-slash";
72import * as assets from "#sitegen/assets";
73import * as admin from "./admin.ts";
74import * as console from "@paperclover/console";
75```
76
77Since `import`/`export` statements are hoisted like `var` and `function`, the
78position of these statements within the file does not matter. The imported
79modules have to be loaded first before this file can start. With this, I've
80found it nicer to sort the file by _importance_ rather than by arbitrary rules
81dictated by how C-style `#include`s worked.
82
83Start with a documentation comment, then the most important
84functions/variables/types, sort the file by importance. Imports are not really
85important since you very quickly get to know where common namespaces come from.
86And since they're at the bottom, you can just press `G` in Vim or `CMD+Down` on
87the Mac to scroll to the end of the file.
src/blog/pages/webdev/marko-intro.markodown created+339
......@@ -0,0 +1,339 @@
1export const blog: BlogMeta = {
2 title: "Marko is the coziest HTML templating language",
3 desc: "...todo...",
4 created: "2025-06-13",
5 draft: true,
6};
7export const meta = formatBlogMeta(blob);
8export * as layout from "@/blog/layout.tsx";
9
10I've been recently playing around [Marko], and after adding limited support
11for it in my website generator, [sitegen], I instantly fell in love with how
12minimalistic it is in comparison to JSX, Astro components, and Svelte.
13
14[Marko]: https://next.markojs.com
15[sitegen]: https://paperclover.dev/clo/sitegen
16
17## Introduction to Marko
18
19If JSX was taking HTML and shoving its syntax into JavaScript, Marko is shoving
20JavaScript into HTML. Attributes are JavaScript expressions.
21
22```marko
23<div>
24 // `input` is like props, but given in the top-level scope
25 <time datetime=input.date.toISOString()>
26 // Interpolation with JS template string syntax
27 ${formatTimeNicely(input.date)}
28 </time>
29 <div>
30 <a href=`/users/${input.user.id}`>${input.user.name}</a>
31 </div>
32
33 // Capital letter variables for imported components
34 <MarkdownContent message=input.message />
35
36 // Components also can be auto-imported by lowercase.
37 // This will look upwards for a `tags/` folder containing
38 // "custom-footer.marko", similar to how Node.js finds
39 // package names in all upwards `node_modules` folders.
40 <custom-footer />
41</div>
42
43// ESM `import` / `export` just work as expected.
44// I prefer my imports at the end, to highlight the markup.
45import MarkdownContent from "./MarkdownContent.marko";
46import { formatTimeNicely } from "../date-helpers.ts";
47```
48
49Tags with the `value` attribute have a shorthand, which is used by the built-in
50`<if>` for conditional rendering.
51
52```marko
53// Sugar for <input value="string" />
54<input="string" />
55
56// and it composes amazingly to the 'if' built-in
57<if=input.user>
58 <UserProfile=input.user />
59</if>
60```
61
62Tags can also return values into the scope for use in the template using `/`, such as `<id>` for unique ID generation. This is available to components that `<return=output/>`.
63
64```
65<id/uniqueId />
66
67<input id=uniqueId type="checkbox" name="allow_trans_rights" />
68<label for=uniqueId>click me!</>
69// ^ oh, you can also omit the
70// closing tag name if you want.
71```
72
73It's important that I started with the two forms of "Tag I/O": `=` for input
74and `/` for output. With those building blocks, we introduce local variables
75with `const`
76
77```
78<const/rendered = markdownToHtml(input.value) />
79
80// This is how you insert raw HTML to the document
81<inline-html=rendered />
82
83// It supports all of the cozy destructuring syntax JS has
84<const/{ id, name } = user />
85```
86
87Unlike JSX, when you pass content within a tag (`input.content` instead of
88JSX's `children`), instead of it being a JSX element, it is actually a
89function. This means that the `for` tag can render the content multiple times.
90
91```
92<ul>
93 <for from=1 to=10>
94 // Renders a new random number for each iteration.
95 <li>${Math.random()}</li>
96 </>
97</ul>
98```
99
100Since `content` is a function, it can take arguments. This is done with `|`
101
102```
103<h1>my friends</h1>
104<ul>
105 // I tend to omit the closing tag names for the built-in control
106 // flow tags, but I keep them for HTML tags. It's kinda like how
107 // in JavaScript you just write `}` to close your `if`s and loops.
108 //
109 // Anyways <for> also has 'of'
110 <for|item| of=user.friends>
111 <li class="friend">${item.name}</li>
112 </>
113
114 // They support the same syntax JavaScript function params allows,
115 // so you can have destructuring here too, and multiple params.
116 <for|{ name }, index| of=user.friends>
117 // By the way you can also use emmet-style class and ID shorthands.
118 <li.friend>My #${index + 1} friend is ${name}</li>
119 </>
120</ul>
121```
122
123Instead of named slots, Marko has attribute tags. These are more powerful than
124slots since they are functions, and can also act as sugar for more complicated
125attributes.
126
127```
128<Layout title="Welcome">
129 <@header variant="big">
130 <h1>the next big thing</h1>
131 </@header>
132
133 <p>body text...</p>
134</Layout>
135
136// The `input` variable inside of <Layout /> is:
137//
138// {
139// title: "Welcome",
140// header: {
141// content: /* function rendering "<h1>the next big thing</h1>" */,
142// variant: "big",
143// },
144// content: /* function rendering "<p>body text</p>" */
145// }
146```
147
148This layout could be implemented as such:
149
150```marko
151<main>
152 <if=input.header />
153 <const/{ ...headerProps, content }=input.header />
154 <header ...headerProps>
155 // Instead of assigning to a variable with a capital letter,
156 // template interpolation works on tag names. This can also
157 // be a string to render the native HTML tag of that kind.
158 <${content} />
159 </header>
160 <hr />
161 </>
162
163 <${input.content} />
164</main>
165```
166
167The last syntax feature missing is calling a tag with parameters. That is done
168just like a regular function call, with '('.
169
170```
171<Something(item, index) />
172```
173
174In fact, attributes can just be sugar over this syntax. (this technically isn't
175true but it's close enough for the example)
176
177```
178<SpecialButton type="submit" class="red" />
179
180// is equal to
181
182<SpecialButton({ type: "submit", class: "red" }) />
183```
184
185All of the above is about how Marko's syntax works, and how it performs HTML
186generation with components. Marko also allows interactive components, but an
187explaination of that is beyond the scope of this page, mostly since I have not
188used it. A brief example of it, modified from their documentation.
189
190```marko
191// Reactive variables with <let/> just work...
192<let/basicCounter=0 />
193<button onClick() { basicCounter += 1 }>${basicCounter}</button>
194// ...but a counter is boring.
195
196<let/todos=[
197 { id: 0, text: "Learn Marko" },
198 { id: 1, text: "Make a Website" },
199]/>
200
201// 'by' is like React JSX's "key" property, but it's optional.
202<ul><for|todo, i| of=todos by=(todo => todo.id)>
203 <li.todo>
204 // this variable remains stable even if the list
205 // re-orders, because 'by' was specified.
206 <let/done=false/>
207 <label>
208 <span>${todo.text}</span>
209 // ':=' creates a two-way reactive binding,
210 // (it passes a callback for `checkedChanged`)
211 <input type="checkbox" checked:=done />
212 </label>
213 <button
214 title="delete"
215 disabled=!done
216 onClick() {
217 todos = todos.toSpliced(i, 1);
218 }
219 > &times; </button>
220 </li>
221</></ul>
222
223// Form example
224<let/nextId=2/>
225<form onSubmit(e) {
226 e.preventDefault();
227 todos = todos.concat({
228 id: nextId++,
229 // HTMLFormElement exposes all its named input
230 // elements as extra properties on the object.
231 text: e.target.text.value,
232 });
233 // And you can clear it with 'reset()'
234 e.target.reset();
235}>
236 // We don't 'onChange' like a React loser. The form
237 // value can be read in the submit event like normal.
238 <input name="text" placeholder="Another Item">
239 <button type="submit">Add</button>
240</form>
241```
242
243<SectionHeader updated="2025-08-11">Usage on `paperclover.net`</Section>
244
245Using Marko for HTML generation is quite easy. `.marko` files can be compiled
246into `.js` using the `@marko/compiler` library.
247
248```ts
249const src = fs.readFileSync("page.marko", "utf8");
250const compile = marko.compileSync(src, filepath);
251fs.writeFileSync("page.js", compile.code);
252
253const page = require("./page.js");
254console.info(page);
255
256import * as fs from "node:fs";
257import * as marko from "@marko/compiler";
258```
259
260To get client side JavaScript, an option can be passed to the Marko compiler to
261generate the client side code. While it is a big selling point of Marko, I do
262not use any of their client side features, instead deferring to manually-written
263frontend scripts. This is because that is how my website has been for years,
264statically generated. And for websites like mine that are content focused, this
265is the correct way to do things.
266
267Since I have a custom HTML generation library (built on JSX and some React-like
268patterns), I have written a simple integration for it to utilize Marko
269components, which is loaded by replacing the generated import to `marko/html`,
270which lets me overwrite functions like `createTemplate` (to change the signature
271of a component), `dynamicTag` (to allow Marko to render non-Marko components),
272and `fork` (to enable async integration with the rendering framework). An
273additional feature of this is I have a Node.js loader hook to allow importing
274these files directly.
275
276```tsx
277function Page() {
278 const q = Question.getByDate(new Date("2025-06-07 12:12 EST"));
279 return <div>
280 <h1>example question</h1>
281 <QuestionRender question={q} />
282 </div>;
283}
284
285// The synchronous render can be used because `Page` and `question.marko`
286// do not await any promises (SQLite runs synchronously)
287console.info(render.sync(<Page />).text);
288
289import * as render from "#engine/render";
290import QuestionRender from "@/q+a/tags/question.marko";
291import { Question } from "@/q+a/models/Question.ts";
292```
293
294Here is the `question.marko` tag used to render [questions on the clover q+a](/q+a).
295
296```marko
297// Renders a `Question` entry including its markdown body.
298export interface Input {
299 question: Question;
300 admin?: boolean;
301}
302
303// 2024-12-31 05:00:00 EST
304export const transitionDate = 1735639200000;
305
306<const/{ question, admin } = input />
307<const/{ id, date, text } = question/>
308
309<${"e-"}
310 f=(date > transitionDate ? true : undefined)
311 id=admin ? `q${id}` : undefined
312>
313 <if=admin>
314 <a
315 style="margin-right: 0.5rem"
316 href=`/admin/q+a/${id}`
317 >[EDIT]</a>
318 </>
319 <a>
320 <time
321 datetime=formatQuestionISOTimestamp(date)
322 >${formatQuestionTimestamp(date)}</time>
323 </a>
324
325 <CloverMarkdown ...{ text } />
326</>
327
328// this singleton script will make all the '<time>' tags clickable.
329client import "./clickable-links.client.ts";
330
331import type { Question } from "@/q+a/models/Question.ts";
332import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";
333import { CloverMarkdown } from "@/q+a/clover-markdown.tsx";
334```
335
336The integration is great, `client import` is quite a magical concept, and I've
337tuned it to do the expected thing in my framework.
338
339import { type BlogMeta, formatBlogMeta } from '@/blog/helpers.ts';
src/file-viewer/backend.tsx+11-13
......@@ -93,7 +93,7 @@ app.get("/file/*", async (c, next) => {
9393 } satisfies APIDirectoryList;
9494 return c.json(json);
9595 }
96 c.res = await renderView(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, {
96 c.res = await view.serve(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, {
9797 file,
9898 hasCotyledonCookie,
9999 });
......@@ -111,7 +111,7 @@ app.get("/file/*", async (c, next) => {
111111 !lofi
112112 ) {
113113 prefetchFile(file.path);
114 c.res = await renderView(c, "file-viewer/clofi", {
114 c.res = await view.serve(c, "file-viewer/clofi", {
115115 file,
116116 hasCotyledonCookie,
117117 });
......@@ -125,11 +125,10 @@ app.get("/file/*", async (c, next) => {
125125
126126 let encoding = decideEncoding(c.req.header("Accept-Encoding"));
127127
128 let sizeHeader =
129 encoding === "raw"
130 ? expectedSize
131 : // Size cannot be known because of compression modes
132 undefined;
128 let sizeHeader = encoding === "raw"
129 ? expectedSize
130 // Size cannot be known because of compression modes
131 : undefined;
133132
134133 // Etag
135134 {
......@@ -217,7 +216,7 @@ app.get("/canvas/:script", async (c, next) => {
217216 if (!hasAsset(`/js/canvas/${script}.js`)) {
218217 return next();
219218 }
220 return renderView(c, "file-viewer/canvas", {
219 return view.serve(c, "file-viewer/canvas", {
221220 script,
222221 });
223222});
......@@ -295,10 +294,9 @@ function handleRanges(
295294): Response {
296295 // TODO: multiple ranges
297296 const rangeSize = ranges.reduce((a, b) => a + (b[1] - b[0] + 1), 0);
298 const rangeBody =
299 streamOrBuffer instanceof ReadableStream
300 ? applySingleRangeToStream(streamOrBuffer, ranges)
301 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
297 const rangeBody = streamOrBuffer instanceof ReadableStream
298 ? applySingleRangeToStream(streamOrBuffer, ranges)
299 : applyRangesToBuffer(streamOrBuffer, ranges, rangeSize);
302300 return new Response(rangeBody, {
303301 status: 206,
304302 headers: {
......@@ -420,7 +418,7 @@ import { type Context, Hono } from "hono";
420418
421419import * as render from "#engine/render";
422420import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
423import { renderView } from "#sitegen/view";
421import * as view from "#sitegen/view";
424422import { contentTypeFor } from "#sitegen/mime";
425423
426424import { requireFriendAuth } from "@/friend-auth.ts";
src/file-viewer/bin/scan3.ts+20-14
......@@ -17,9 +17,11 @@ export async function main() {
1717 const start = performance.now();
1818 const timerSpinner = new Spinner({
1919 text: () =>
20 `paper clover's scan3 [${((performance.now() - start) / 1000).toFixed(
21 1,
22 )}s]`,
20 `paper clover's scan3 [${
21 ((performance.now() - start) / 1000).toFixed(
22 1,
23 )
24 }s]`,
2325 fps: 10,
2426 });
2527 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
......@@ -44,7 +46,7 @@ export async function main() {
4446 .flatMap((child) =>
4547 child.kind === MediaFileKind.directory
4648 ? child.getRecursiveFileChildren()
47 : child,
49 : child
4850 );
4951
5052 qMeta.addMany(
......@@ -103,9 +105,11 @@ export async function main() {
103105 ) {
104106 date = mediaFile.date;
105107 console.warn(
106 `M-time on ${publicPath} was likely corrupted. ${formatDate(
107 mediaFile.date,
108 )} -> ${formatDate(stat.mtime)}`,
108 `M-time on ${publicPath} was likely corrupted. ${
109 formatDate(
110 mediaFile.date,
111 )
112 } -> ${formatDate(stat.mtime)}`,
109113 );
110114 }
111115 mediaFile = MediaFile.createFile({
......@@ -162,7 +166,7 @@ export async function main() {
162166 }: Omit<ProcessFileArgs, "spin">) {
163167 const ext = mediaFile.extensionNonEmpty.toLowerCase();
164168 let possible = processors.filter((p) =>
165 p.include ? p.include.has(ext) : !p.exclude?.has(ext),
169 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
166170 );
167171 if (possible.length === 0) return;
168172
......@@ -196,7 +200,7 @@ export async function main() {
196200 );
197201 } else {
198202 possible = order.map(({ id }) =>
199 UNWRAP(possible.find((p) => p.id === id)),
203 UNWRAP(possible.find((p) => p.id === id))
200204 );
201205 }
202206
......@@ -260,8 +264,9 @@ export async function main() {
260264 const children = dir.getChildren();
261265
262266 // readme.txt
263 const readmeContent =
264 children.find((x) => x.basename === "readme.txt")?.contents ?? "";
267 const readmeContent = children.find((x) =>
268 x.basename === "readme.txt"
269 )?.contents ?? "";
265270
266271 // dirsort
267272 let dirsort: string[] | null = null;
......@@ -354,7 +359,9 @@ export async function main() {
354359 console.info(
355360 "Global Stats:\n" +
356361 `- File Count: \x1b[1m${count}\x1b[0m\n` +
357 `- Canonical Size: \x1b[1m${formatSize(MediaFile.getByPath("/")!.size)}\x1b[0m\n` +
362 `- Canonical Size: \x1b[1m${
363 formatSize(MediaFile.getByPath("/")!.size)
364 }\x1b[0m\n` +
358365 `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n`,
359366 );
360367}
......@@ -459,7 +466,6 @@ const procLoadTextContents: Process = {
459466 async run({ absPath, mediaFile, stat }) {
460467 if (stat.size > 1_000_000) return;
461468 const text = await fs.readFile(absPath, "utf-8");
462 console.log({ text });
463469 mediaFile.setContents(text);
464470 },
465471};
......@@ -696,7 +702,7 @@ async function unproduceAsset(key: string) {
696702 const ref = AssetRef.get(key);
697703 if (ref) {
698704 ref.unref();
699 console.log(`unref ${key}`);
705 console.warn(`TODO: unref ${key}`);
700706 // TODO: remove associated files from target
701707 }
702708}
src/file-viewer/format.ts+34-20
......@@ -29,7 +29,9 @@ export function formatDurationLong(seconds: number) {
2929 const hours = Math.floor(seconds / 3600);
3030 const minutes = Math.floor((seconds % 3600) / 60);
3131 const remainingSeconds = seconds % 60;
32 return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
32 return `${hours}:${minutes.toString().padStart(2, "0")}:${
33 remainingSeconds.toString().padStart(2, "0")
34 }`;
3335}
3436
3537export function escapeUri(uri: string) {
......@@ -100,21 +102,27 @@ export function highlightLinksInTextView(
100102 // Case 1: https:// or http:// URLs
101103 if (match.startsWith("http")) {
102104 if (match.includes(findDomain)) {
103 return `<a href="${match
104 .replace(/https?:\/\/paperclover\.net\/+/, "/")
105 .replace(/\/\/+/g, "/")}">${match}</a>`;
105 return `<a href="${
106 match
107 .replace(/https?:\/\/paperclover\.net\/+/, "/")
108 .replace(/\/\/+/g, "/")
109 }">${match}</a>`;
106110 }
107 return `<a href="${match.replace(
108 /\/\/+/g,
109 "/",
110 )}" target="_blank" rel="noopener noreferrer">${match}</a>`;
111 return `<a href="${
112 match.replace(
113 /\/\/+/g,
114 "/",
115 )
116 }" target="_blank" rel="noopener noreferrer">${match}</a>`;
111117 }
112118
113119 // Case 2: domain URLs without protocol
114120 if (match.startsWith(findDomain)) {
115 return `<a href="${match
116 .replace(findDomain + "/", "/")
117 .replace(/\/\/+/g, "/")}">${match}</a>`;
121 return `<a href="${
122 match
123 .replace(findDomain + "/", "/")
124 .replace(/\/\/+/g, "/")
125 }">${match}</a>`;
118126 }
119127
120128 // Case 3: /file/ URLs
......@@ -145,7 +153,7 @@ export function highlightLinksInTextView(
145153 // Match sibling file names (only if they're not already part of a link)
146154 if (siblingFiles.length > 0) {
147155 const escapedBasenames = siblingFiles.map((f) =>
148 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
156 f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
149157 );
150158 const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
151159 const parts = processedText.split(/(<[^>]*>)/);
......@@ -155,9 +163,11 @@ export function highlightLinksInTextView(
155163 parts[i] = parts[i].replace(pattern, (match: string) => {
156164 const file = siblingLookup[match];
157165 if (file) {
158 return `<a href="/file/${file.path
159 .replace(/^\//, "")
160 .replace(/\/\/+/g, "/")}">${match}</a>`;
166 return `<a href="/file/${
167 file.path
168 .replace(/^\//, "")
169 .replace(/\/\/+/g, "/")
170 }">${match}</a>`;
161171 }
162172 return match;
163173 });
......@@ -240,9 +250,11 @@ export function highlightConvo(text: string) {
240250
241251 return paras
242252 .map(({ speaker, lines }) => {
243 return `<div class="s-${speaker}">${lines
244 .map((line) => `<div class="line">${line}</div>`)
245 .join("\n")}</div>`;
253 return `<div class="s-${speaker}">${
254 lines
255 .map((line) => `<div class="line">${line}</div>`)
256 .join("\n")
257 }</div>`;
246258 })
247259 .join("\n");
248260}
......@@ -267,10 +279,12 @@ export function formatDate(dateTime: Date) {
267279 ? dateTime < unknownDate
268280 ? "??.??.??"
269281 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
270 : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${dateTime
282 : `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
283 dateTime
271284 .getDate()
272285 .toString()
273 .padStart(2, "0")}.${dateTime.getFullYear().toString().slice(2)}`;
286 .padStart(2, "0")
287 }.${dateTime.getFullYear().toString().slice(2)}`;
274288}
275289
276290import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
src/file-viewer/rsync.ts+6-2
......@@ -131,8 +131,12 @@ export class Parse {
131131 percentage: Number(percentageStr),
132132 timeElapsed,
133133 transferNumber: this.currentTransfer,
134 filesToCheck: toCheckStr ? this.toCheck = Number(toCheckStr) : this.toCheck,
135 totalFiles: totalStr ? this.totalFiles = Number(totalStr) : this.totalFiles,
134 filesToCheck: toCheckStr
135 ? this.toCheck = Number(toCheckStr)
136 : this.toCheck,
137 totalFiles: totalStr
138 ? this.totalFiles = Number(totalStr)
139 : this.totalFiles,
136140 speed: speed || null,
137141 };
138142 }
src/global.css+15-11
......@@ -4,9 +4,7 @@
44 font-weight: 400 750;
55 font-style: normal;
66 font-display: swap;
7 font-variation-settings:
8 "CASL" 0.25,
9 "MONO" 0;
7 font-variation-settings: "CASL" 0.25, "MONO" 0;
108 font-style: oblique -15deg 0deg;
119 unicode-range: U+0020-007E;
1210}
......@@ -16,9 +14,7 @@
1614 font-weight: 400 800;
1715 font-style: normal;
1816 font-display: swap;
19 font-variation-settings:
20 "CASL" 0.25,
21 "MONO" 1;
17 font-variation-settings: "CASL" 0.25, "MONO" 1;
2218 font-style: oblique -15deg 0deg;
2319 unicode-range: U+0020-007E;
2420}
......@@ -28,13 +24,21 @@
2824 font-weight: 400 800;
2925 font-style: normal;
3026 font-display: swap;
31 font-variation-settings:
32 "CASL" 0.25,
33 "MONO" 1;
27 font-variation-settings: "CASL" 0.25, "MONO" 1;
3428 font-style: oblique -15deg 0deg;
3529 unicode-range:
36 U+00C0-00FF, U+00A9, U+2190-2193, U+2018, U+2019, U+201C, U+201D, U+2022,
37 U+00A0-00A8, U+00AA-00BF, U+2194-2199, U+0100-017F;
30 U+00C0-00FF,
31 U+00A9,
32 U+2190-2193,
33 U+2018,
34 U+2019,
35 U+201C,
36 U+201D,
37 U+2022,
38 U+00A0-00A8,
39 U+00AA-00BF,
40 U+2194-2199,
41 U+0100-017F;
3842}
3943
4044*,
src/q+a/backend.ts+20-22
......@@ -45,7 +45,7 @@ app.post("/q+a", async (c) => {
4545 return sendSuccess(c, new Date());
4646 }
4747
48 const ipAddr = c.req.header("cf-connecting-ip");
48 const ipAddr = c.req.header("x-forwarded-for");
4949 if (ipAddr) {
5050 input.sourceName = uniqueNamesGenerator({
5151 dictionaries: [adjectives, colors, animals],
......@@ -54,11 +54,6 @@ app.post("/q+a", async (c) => {
5454 });
5555 }
5656
57 const cfIPCountry = c.req.header("cf-ipcountry");
58 if (cfIPCountry) {
59 input.sourceLocation = cfIPCountry;
60 }
61
6257 if (ipAddr && PROXYCHECK_API_KEY) {
6358 const proxyCheck = await fetch(
6459 `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
......@@ -77,17 +72,19 @@ app.post("/q+a", async (c) => {
7772 proxyCheck[ipAddr].organisation ??
7873 proxyCheck[ipAddr].provider ?? "unknown";
7974 }
80 if (Number(proxyCheck[ipAddr].risk) > 72) {
75 if (Number(proxyCheck[ipAddr].risk) > 78) {
8176 return questionFailure(
8277 c,
8378 403,
84 "This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.",
79 "This IP address has been flagged as a high risk IP address. If " +
80 "you are using a VPN/Proxy, please disable it and try again.",
8581 text,
8682 );
8783 }
8884 }
8985 }
9086
87 view.regenerate("q+a inbox");
9188 const date = Question.create(
9289 QuestionType.pending,
9390 JSON.stringify(input),
......@@ -104,7 +101,7 @@ async function sendSuccess(c: Context, date: Date) {
104101 id: formatQuestionId(date),
105102 }, { status: 200 });
106103 }
107 c.res = await renderView(c, "q+a/success", {
104 c.res = await view.serve(c, "q+a/success", {
108105 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
109106 });
110107}
......@@ -130,7 +127,7 @@ app.get("/q+a/:id", async (c, next) => {
130127 if (image) {
131128 return getQuestionImage(question, c.req.method === "HEAD");
132129 }
133 return renderView(c, "q+a/permalink", { question });
130 return view.serve(c, "q+a/permalink", { question });
134131});
135132
136133// Admin
......@@ -138,7 +135,7 @@ app.get("/admin/q+a", async (c) => {
138135 return serveAsset(c, "/admin/q+a", 200);
139136});
140137app.get("/admin/q+a/inbox", async (c) => {
141 return renderView(c, "q+a/backend-inbox", {});
138 return view.serve(c, "q+a/backend-inbox", {});
142139});
143140app.delete("/admin/q+a/:id", async (c, next) => {
144141 const id = c.req.param("id");
......@@ -152,6 +149,7 @@ app.delete("/admin/q+a/:id", async (c, next) => {
152149 } else {
153150 Question.rejectByQmid(question.qmid);
154151 }
152 view.regenerate("q+a");
155153 return c.json({ success: true, message: "ok" });
156154});
157155app.patch("/admin/q+a/:id", async (c, next) => {
......@@ -165,6 +163,7 @@ app.patch("/admin/q+a/:id", async (c, next) => {
165163 return questionFailure(c, 400, "Bad Request");
166164 }
167165 Question.updateByQmid(question.qmid, form.text, form.type);
166 view.regenerate("q+a");
168167 return c.json({ success: true, message: "ok" });
169168});
170169app.get("/admin/q+a/:id", async (c, next) => {
......@@ -177,20 +176,22 @@ app.get("/admin/q+a/:id", async (c, next) => {
177176 let pendingInfo: null | PendingQuestionData = null;
178177 if (question.type === QuestionType.pending) {
179178 pendingInfo = JSON.parse(question.text) as PendingQuestionData;
180 question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
181 line.trim().length === 0 ? "" : `q: ${line.trim()}`
182 ).join("\n") + "\n\n";
179 question.text = pendingInfo.prompt
180 .trim()
181 .split("\n")
182 .map((line) => (line.trim().length === 0 ? "" : `q: ${line.trim()}`))
183 .join("\n") + "\n\n";
183184 question.type = QuestionType.normal;
184185 }
185186
186 return renderView(c, "q+a/editor", {
187 return view.serve(c, "q+a/editor", {
187188 pendingInfo,
188189 question,
189190 });
190191});
191192
192193app.get("/q+a/things/random", async (c) => {
193 c.res = await renderView(c, "q+a/things-random", {});
194 c.res = await view.serve(c, "q+a/things-random", {});
194195});
195196
196197async function questionFailure(
......@@ -202,7 +203,7 @@ async function questionFailure(
202203 if (c.req.header("Accept")?.includes("application/json")) {
203204 return c.json({ success: false, message, id: null }, { status });
204205 }
205 return await renderView(c, "q+a/fail", {
206 return await view.serve(c, "q+a/fail", {
206207 error: message,
207208 content,
208209 });
......@@ -218,11 +219,8 @@ import {
218219} from "unique-names-generator";
219220import { hasAdminToken } from "../admin.ts";
220221import { serveAsset } from "#sitegen/assets";
221import {
222 PendingQuestion,
223 PendingQuestionData,
224} from "./models/PendingQuestion.ts";
222import type { PendingQuestionData } from "./models/PendingQuestion.ts";
225223import { Question, QuestionType } from "./models/Question.ts";
226import { renderView } from "#sitegen/view";
224import * as view from "#sitegen/view";
227225import { getQuestionImage } from "./image.tsx";
228226import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
src/q+a/clover-markdown.tsx+1-3
......@@ -144,9 +144,7 @@ function ListRenderer(node: ASTNode, children: any[]) {
144144 const T = node.ordered ? "ol" : "ul";
145145 return (
146146 <T>
147 {children.map((child) => (
148 <li>{child}</li>
149 ))}
147 {children.map((child) => <li>{child}</li>)}
150148 </T>
151149 );
152150}
src/q+a/image.tsx+5-4
......@@ -11,7 +11,7 @@ const getBrowser = RefCountedExpirable(
1111);
1212
1313export async function renderQuestionImage(question: Question) {
14 const html = await renderViewToString("q+a/image-embed", { question });
14 const html = await view.renderToString("q+a/image-embed", { question });
1515
1616 // this browser session will be reused if multiple images are generated
1717 // either at the same time or within a 5-minute time span. the dispose
......@@ -44,14 +44,15 @@ export async function getQuestionImage(
4444 question: Question,
4545 headOnly: boolean,
4646): Promise<Response> {
47 const hash = crypto.createHash("sha1")
47 const hash = crypto
48 .createHash("sha1")
4849 .update(question.qmid + question.type + question.text)
4950 .digest("hex");
5051
5152 const headers = {
5253 "Content-Type": "image/png",
5354 "Cache-Control": "public, max-age=31536000",
54 "ETag": `"${hash}"`,
55 ETag: `"${hash}"`,
5556 "Last-Modified": question.date.toUTCString(),
5657 };
5758
......@@ -78,4 +79,4 @@ import * as path from "node:path";
7879import * as puppeteer from "puppeteer";
7980import { Question } from "@/q+a/models/Question.ts";
8081import { RefCountedExpirable } from "#sitegen/async";
81import { renderViewToString } from "#sitegen/view";
82import * as view from "#sitegen/view";
src/q+a/pages/admin.q+a.marko+1-1
......@@ -7,7 +7,7 @@ export const meta: Metadata = {
77 description: "ask clover a question",
88};
99export const regenerate = {
10 manual: true,
10 tags: ["q+a", "q+a inbox"]
1111};
1212
1313<const/inboxSize = PendingQuestion.getAll().length />
src/q+a/pages/q+a.marko+4-6
......@@ -1,11 +1,9 @@
1export * as layout from "../layout.tsx";
2export const regenerate = {
3 manual: true,
4};
5
61export interface Input {
72 admin?: boolean;
83}
4
5export * as layout from "../layout.tsx";
6export const regenerate = { tags: ["q+a"] };
97export const meta: Metadata = {
108 title: "paper clover q+a",
119 description: "ask clover a question",
......@@ -14,7 +12,7 @@ export const meta: Metadata = {
1412<const/{ admin = false } = input />
1513<const/questions = [...Question.getAll()] />
1614
17<if=true>
15<if=!admin>
1816 <question-form />
1917</>
2018<for|question| of=questions>
src/q+a/views/backend-inbox.marko+2-2
......@@ -19,8 +19,8 @@ export const meta = { title: 'question answer inbox' };
1919 </time>
2020 <div style="color: dodgerblue; margin-bottom: 0.25rem">
2121 ${q.sourceName}
22 ${q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : null}
23 ${q.sourceVPN ? `(VPN: ${q.sourceVPN})` : null}
22 ${q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : ''}
23 ${q.sourceVPN ? `(VPN: ${q.sourceVPN})` : ''}
2424 </div>
2525 <p style="white-space: pre-wrap">${q.prompt}</p>
2626 <p>
src/source-of-truth.ts+6-2
......@@ -33,8 +33,12 @@ const rawFileRoot = process.env.CLOVER_FILE_RAW ??
3333const derivedFileRoot = process.env.CLOVER_FILE_DERIVED ??
3434 path.join(nasRoot, "Documents/Config/paperclover/derived");
3535
36if (!fs.existsSync(rawFileRoot)) throw new Error(`${rawFileRoot} does not exist`);
37if (!fs.existsSync(derivedFileRoot)) throw new Error(`${derivedFileRoot} does not exist`);
36if (!fs.existsSync(rawFileRoot)) {
37 throw new Error(`${rawFileRoot} does not exist`);
38}
39if (!fs.existsSync(derivedFileRoot)) {
40 throw new Error(`${derivedFileRoot} does not exist`);
41}
3842
3943type Awaitable<T> = T | Promise<T>;
4044
tsconfig.json+3-2
......@@ -5,7 +5,7 @@
55 "incremental": true,
66 "jsx": "react-jsxdev",
77 "jsxImportSource": "#engine",
8 "lib": ["dom", "esnext", "esnext.iterator"],
8 "lib": ["esnext", "dom", "dom.iterable"],
99 "module": "nodenext",
1010 "noEmit": true,
1111 "outDir": ".clover/ts",
......@@ -14,7 +14,8 @@
1414 "skipLibCheck": true,
1515 "strict": true,
1616 "verbatimModuleSyntax": true,
17 "target": "es2022"
17 "target": "es2022",
18 "noUncheckedIndexedAccess": true
1819 },
1920 "include": ["framework/**/*", "src/**/*"]
2021}