authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-07-07 20:58:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
log84fe2100063fd3f75c749270095c416ef5c69804
tree3938afec0b730a6fece5af88eafcdcf578290745
parent43c83a513c406ddfbf3d46079672a0576f5d7cd3
signature Commit is signed but in an unrecognized format.

format


48 files changed, 5217 insertions(+), 5177 deletions(-)

flake.nix+3
...@@ -16,7 +16,10 @@...@@ -16,7 +16,10 @@
16 pkgs.nodejs_24 # runtime16 pkgs.nodejs_24 # runtime
17 pkgs.deno # formatter17 pkgs.deno # formatter
18 (pkgs.ffmpeg.override {18 (pkgs.ffmpeg.override {
19 withOpus = true;
19 withSvtav1 = true;20 withSvtav1 = true;
21 withJxl = true;
22 withWebp = true;
20 })23 })
21 ];24 ];
22 };25 };
framework/backend/entry-node.ts+35-35
...@@ -1,35 +1,35 @@...@@ -1,35 +1,35 @@
1import "@paperclover/console/inject"; 1import "@paperclover/console/inject";
2import "#debug"; 2import "#debug";
3 3
4const protocol = "http"; 4const protocol = "http";
5 5
6const server = serve({ 6const server = serve({
7 fetch: app.fetch, 7 fetch: app.fetch,
8}, ({ address, port }) => { 8}, ({ address, port }) => {
9 if (address === "::") address = "::1"; 9 if (address === "::") address = "::1";
10 console.info(url.format({ 10 console.info(url.format({
11 protocol, 11 protocol,
12 hostname: address, 12 hostname: address,
13 port, 13 port,
14 })); 14 }));
15}); 15});
16 16
17process.on("SIGINT", () => { 17process.on("SIGINT", () => {
18 server.close(); 18 server.close();
19 process.exit(0); 19 process.exit(0);
20}); 20});
21 21
22process.on("SIGTERM", () => { 22process.on("SIGTERM", () => {
23 server.close((err) => { 23 server.close((err) => {
24 if (err) { 24 if (err) {
25 console.error(err); 25 console.error(err);
26 process.exit(1); 26 process.exit(1);
27 } 27 }
28 process.exit(0); 28 process.exit(0);
29 }); 29 });
30}); 30});
31 31
32import app from "#backend"; 32import app from "#backend";
33import url from "node:url"; 33import url from "node:url";
34import { serve } from "@hono/node-server"; 34import { serve } from "@hono/node-server";
35import process from "node:process"; 35import process from "node:process";
framework/backend/entry-passthru.ts+4-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import "@paperclover/console/inject"; 1import "@paperclover/console/inject";
2export default app; 2export default app;
3 3
4import app from "#backend"; 4import app from "#backend";
framework/debug.safe.ts+17-17
...@@ -1,17 +1,17 @@...@@ -1,17 +1,17 @@
1globalThis.UNWRAP = (t, ...args) => { 1globalThis.UNWRAP = (t, ...args) => {
2 if (t == null) { 2 if (t == null) {
3 throw new Error( 3 throw new Error(
4 args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")", 4 args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
5 ); 5 );
6 } 6 }
7 return t; 7 return t;
8}; 8};
9globalThis.ASSERT = (t, ...args) => { 9globalThis.ASSERT = (t, ...args) => {
10 if (!t) { 10 if (!t) {
11 throw new Error( 11 throw new Error(
12 args.length > 0 ? util.format(...args) : "Assertion Failed", 12 args.length > 0 ? util.format(...args) : "Assertion Failed",
13 ); 13 );
14 } 14 }
15}; 15};
16 16
17import * as util from "node:util"; 17import * as util from "node:util";
framework/definitions.d.ts+4-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T; 1declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
2declare function ASSERT(value: unknown, ...log: unknown[]): asserts value; 2declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
3 3
4type Timer = ReturnType<typeof setTimeout>; 4type Timer = ReturnType<typeof setTimeout>;
framework/engine/jsx-runtime.ts+54-54
...@@ -1,54 +1,54 @@...@@ -1,54 +1,54 @@
1export const Fragment = ({ children }: { children: engine.Node[] }) => children; 1export const Fragment = ({ children }: { children: engine.Node[] }) => children;
2 2
3export function jsx( 3export function jsx(
4 type: string | engine.Component, 4 type: string | engine.Component,
5 props: Record<string, unknown>, 5 props: Record<string, unknown>,
6): engine.Element { 6): engine.Element {
7 if (typeof type !== "function" && typeof type !== "string") { 7 if (typeof type !== "function" && typeof type !== "string") {
8 throw new Error("Invalid component type: " + engine.inspect(type)); 8 throw new Error("Invalid component type: " + engine.inspect(type));
9 } 9 }
10 return [engine.kElement, type, props]; 10 return [engine.kElement, type, props];
11} 11}
12 12
13export function jsxDEV( 13export function jsxDEV(
14 type: string | engine.Component, 14 type: string | engine.Component,
15 props: Record<string, unknown>, 15 props: Record<string, unknown>,
16 // Unused with the clover engine 16 // Unused with the clover engine
17 _key: string, 17 _key: string,
18 // Unused with the clover engine 18 // Unused with the clover engine
19 _isStaticChildren: boolean, 19 _isStaticChildren: boolean,
20 source: engine.SrcLoc, 20 source: engine.SrcLoc,
21): engine.Element { 21): engine.Element {
22 const { fileName, lineNumber, columnNumber } = source; 22 const { fileName, lineNumber, columnNumber } = source;
23 23
24 // Assert the component type is valid to render. 24 // Assert the component type is valid to render.
25 if (typeof type !== "function" && typeof type !== "string") { 25 if (typeof type !== "function" && typeof type !== "string") {
26 throw new Error( 26 throw new Error(
27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` + 27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
28 engine.inspect(type) + 28 engine.inspect(type) +
29 ". Clover SSR element must be a function or string", 29 ". Clover SSR element must be a function or string",
30 ); 30 );
31 } 31 }
32 32
33 // Construct an `ssr.Element` 33 // Construct an `ssr.Element`
34 return [engine.kElement, type, props, "", source]; 34 return [engine.kElement, type, props, "", source];
35} 35}
36 36
37// jsxs 37// jsxs
38export { jsx as jsxs }; 38export { jsx as jsxs };
39 39
40declare global { 40declare global {
41 namespace JSX { 41 namespace JSX {
42 interface IntrinsicElements { 42 interface IntrinsicElements {
43 [name: string]: Record<string, unknown>; 43 [name: string]: Record<string, unknown>;
44 } 44 }
45 interface ElementChildrenAttribute { 45 interface ElementChildrenAttribute {
46 children: Node; 46 children: Node;
47 } 47 }
48 type Element = engine.Element; 48 type Element = engine.Element;
49 type ElementType = keyof IntrinsicElements | engine.Component; 49 type ElementType = keyof IntrinsicElements | engine.Component;
50 type ElementClass = ReturnType<engine.Component>; 50 type ElementClass = ReturnType<engine.Component>;
51 } 51 }
52} 52}
53 53
54import * as engine from "./ssr.ts"; 54import * as engine from "./ssr.ts";
framework/engine/marko-runtime.ts+147-147
...@@ -1,147 +1,147 @@...@@ -1,147 +1,147 @@
1// This file is used to integrate Marko into the Clover Engine and Sitegen 1// This file is used to integrate Marko into the Clover Engine and Sitegen
2// To use, replace the "marko/html" import with this file. 2// To use, replace the "marko/html" import with this file.
3export * from "#marko/html"; 3export * from "#marko/html";
4 4
5interface BodyContentObject { 5interface BodyContentObject {
6 [x: PropertyKey]: unknown; 6 [x: PropertyKey]: unknown;
7 content: ServerRenderer; 7 content: ServerRenderer;
8} 8}
9 9
10export const createTemplate = ( 10export const createTemplate = (
11 templateId: string, 11 templateId: string,
12 renderer: ServerRenderer, 12 renderer: ServerRenderer,
13) => { 13) => {
14 const { render } = marko.createTemplate(templateId, renderer); 14 const { render } = marko.createTemplate(templateId, renderer);
15 function wrap(props: Record<string, unknown>, n: number) { 15 function wrap(props: Record<string, unknown>, n: number) {
16 // Marko Custom Tags 16 // Marko Custom Tags
17 const cloverAsyncMarker = { isAsync: false }; 17 const cloverAsyncMarker = { isAsync: false };
18 let r: engine.Render | undefined = undefined; 18 let r: engine.Render | undefined = undefined;
19 try { 19 try {
20 r = engine.getCurrentRender(); 20 r = engine.getCurrentRender();
21 } catch {} 21 } catch {}
22 // Support using Marko outside of Clover SSR 22 // Support using Marko outside of Clover SSR
23 if (r) { 23 if (r) {
24 engine.setCurrentRender(null); 24 engine.setCurrentRender(null);
25 const markoResult = render.call(renderer, { 25 const markoResult = render.call(renderer, {
26 ...props, 26 ...props,
27 $global: { clover: r, cloverAsyncMarker }, 27 $global: { clover: r, cloverAsyncMarker },
28 }); 28 });
29 if (cloverAsyncMarker.isAsync) { 29 if (cloverAsyncMarker.isAsync) {
30 return markoResult.then(engine.html); 30 return markoResult.then(engine.html);
31 } 31 }
32 const rr = markoResult.toString(); 32 const rr = markoResult.toString();
33 return engine.html(rr); 33 return engine.html(rr);
34 } else { 34 } else {
35 return renderer(props, n); 35 return renderer(props, n);
36 } 36 }
37 } 37 }
38 wrap.render = render; 38 wrap.render = render;
39 wrap.unwrapped = renderer; 39 wrap.unwrapped = renderer;
40 return wrap; 40 return wrap;
41}; 41};
42 42
43export const dynamicTag = ( 43export const dynamicTag = (
44 scopeId: number, 44 scopeId: number,
45 accessor: Accessor, 45 accessor: Accessor,
46 tag: unknown | string | ServerRenderer | BodyContentObject, 46 tag: unknown | string | ServerRenderer | BodyContentObject,
47 inputOrArgs: unknown, 47 inputOrArgs: unknown,
48 content?: (() => void) | 0, 48 content?: (() => void) | 0,
49 inputIsArgs?: 1, 49 inputIsArgs?: 1,
50 serializeReason?: 1 | 0, 50 serializeReason?: 1 | 0,
51) => { 51) => {
52 if (typeof tag === "function") { 52 if (typeof tag === "function") {
53 clover: { 53 clover: {
54 const unwrapped = (tag as any).unwrapped; 54 const unwrapped = (tag as any).unwrapped;
55 if (unwrapped) { 55 if (unwrapped) {
56 tag = unwrapped; 56 tag = unwrapped;
57 break clover; 57 break clover;
58 } 58 }
59 let r: engine.Render; 59 let r: engine.Render;
60 try { 60 try {
61 r = engine.getCurrentRender(); 61 r = engine.getCurrentRender();
62 if (!r) throw 0; 62 if (!r) throw 0;
63 } catch { 63 } catch {
64 r = marko.$global().clover as engine.Render; 64 r = marko.$global().clover as engine.Render;
65 } 65 }
66 if (!r) throw new Error("No Clover Render Active"); 66 if (!r) throw new Error("No Clover Render Active");
67 const subRender = engine.initRender(r.async !== -1, r.addon); 67 const subRender = engine.initRender(r.async !== -1, r.addon);
68 const resolved = engine.resolveNode(subRender, [ 68 const resolved = engine.resolveNode(subRender, [
69 engine.kElement, 69 engine.kElement,
70 tag, 70 tag,
71 inputOrArgs, 71 inputOrArgs,
72 ]); 72 ]);
73 73
74 if (subRender.async > 0) { 74 if (subRender.async > 0) {
75 const marker = marko.$global().cloverAsyncMarker as Async; 75 const marker = marko.$global().cloverAsyncMarker as Async;
76 marker.isAsync = true; 76 marker.isAsync = true;
77 77
78 // Wait for async work to finish 78 // Wait for async work to finish
79 const { resolve, reject, promise } = Promise.withResolvers<string>(); 79 const { resolve, reject, promise } = Promise.withResolvers<string>();
80 subRender.asyncDone = () => { 80 subRender.asyncDone = () => {
81 const rejections = subRender.rejections; 81 const rejections = subRender.rejections;
82 if (!rejections) return resolve(engine.renderNode(resolved)); 82 if (!rejections) return resolve(engine.renderNode(resolved));
83 (r.rejections ??= []).push(...rejections); 83 (r.rejections ??= []).push(...rejections);
84 return reject(new Error("Render had errors")); 84 return reject(new Error("Render had errors"));
85 }; 85 };
86 marko.fork( 86 marko.fork(
87 scopeId, 87 scopeId,
88 accessor, 88 accessor,
89 promise, 89 promise,
90 (string: string) => marko.write(string), 90 (string: string) => marko.write(string),
91 0, 91 0,
92 ); 92 );
93 } else { 93 } else {
94 marko.write(engine.renderNode(resolved)); 94 marko.write(engine.renderNode(resolved));
95 } 95 }
96 return; 96 return;
97 } 97 }
98 } 98 }
99 return marko.dynamicTag( 99 return marko.dynamicTag(
100 scopeId, 100 scopeId,
101 accessor, 101 accessor,
102 tag, 102 tag,
103 inputOrArgs, 103 inputOrArgs,
104 content, 104 content,
105 inputIsArgs, 105 inputIsArgs,
106 serializeReason, 106 serializeReason,
107 ); 107 );
108}; 108};
109 109
110export function fork( 110export function fork(
111 scopeId: number, 111 scopeId: number,
112 accessor: Accessor, 112 accessor: Accessor,
113 promise: Promise<unknown>, 113 promise: Promise<unknown>,
114 callback: (data: unknown) => void, 114 callback: (data: unknown) => void,
115 serializeMarker?: 0 | 1, 115 serializeMarker?: 0 | 1,
116) { 116) {
117 const marker = marko.$global().cloverAsyncMarker as Async; 117 const marker = marko.$global().cloverAsyncMarker as Async;
118 marker.isAsync = true; 118 marker.isAsync = true;
119 marko.fork(scopeId, accessor, promise, callback, serializeMarker); 119 marko.fork(scopeId, accessor, promise, callback, serializeMarker);
120} 120}
121 121
122export function escapeXML(input: unknown) { 122export function escapeXML(input: unknown) {
123 // The rationale of this check is that the default toString method 123 // The rationale of this check is that the default toString method
124 // creating `[object Object]` is universally useless to any end user. 124 // creating `[object Object]` is universally useless to any end user.
125 if ( 125 if (
126 input == null || 126 input == null ||
127 (typeof input === "object" && input && 127 (typeof input === "object" && input &&
128 // only block this if it's the default `toString` 128 // only block this if it's the default `toString`
129 input.toString === Object.prototype.toString) 129 input.toString === Object.prototype.toString)
130 ) { 130 ) {
131 throw new Error( 131 throw new Error(
132 `Unexpected value in template placeholder: '` + 132 `Unexpected value in template placeholder: '` +
133 engine.inspect(input) + "'. " + 133 engine.inspect(input) + "'. " +
134 `To emit a literal '${input}', use \${String(value)}`, 134 `To emit a literal '${input}', use \${String(value)}`,
135 ); 135 );
136 } 136 }
137 return marko.escapeXML(input); 137 return marko.escapeXML(input);
138} 138}
139 139
140interface Async { 140interface Async {
141 isAsync: boolean; 141 isAsync: boolean;
142} 142}
143 143
144import * as engine from "./ssr.ts"; 144import * as engine from "./ssr.ts";
145import type { ServerRenderer } from "marko/html/template"; 145import type { ServerRenderer } from "marko/html/template";
146import { type Accessor } from "marko/common/types"; 146import { type Accessor } from "marko/common/types";
147import * as marko from "#marko/html"; 147import * as marko from "#marko/html";
framework/engine/ssr.test.tsx+41-41
...@@ -1,41 +1,41 @@...@@ -1,41 +1,41 @@
1import { test } from "node:test"; 1import { test } from "node:test";
2import * as engine from "./ssr.ts"; 2import * as engine from "./ssr.ts";
3 3
4test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3")); 4test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3"));
5test("simple tree", (t) => 5test("simple tree", (t) =>
6 t.assert.equal( 6 t.assert.equal(
7 engine.ssrSync( 7 engine.ssrSync(
8 <main class={["a", "b"]}> 8 <main class={["a", "b"]}>
9 <h1 style="background-color:red">hello world</h1> 9 <h1 style="background-color:red">hello world</h1>
10 <p>haha</p> 10 <p>haha</p>
11 {1}| 11 {1}|
12 {0}| 12 {0}|
13 {true}| 13 {true}|
14 {false}| 14 {false}|
15 {null}| 15 {null}|
16 {undefined}| 16 {undefined}|
17 </main>, 17 </main>,
18 ).text, 18 ).text,
19 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>', 19 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
20 )); 20 ));
21test("unescaped/escaped html", (t) => 21test("unescaped/escaped html", (t) =>
22 t.assert.equal( 22 t.assert.equal(
23 engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text, 23 engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
24 "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>", 24 "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
25 )); 25 ));
26test("clsx built-in", (t) => 26test("clsx built-in", (t) =>
27 t.assert.equal( 27 t.assert.equal(
28 engine.ssrSync( 28 engine.ssrSync(
29 <> 29 <>
30 <a class="a" /> 30 <a class="a" />
31 <b class={null} /> 31 <b class={null} />
32 <c class={undefined} /> 32 <c class={undefined} />
33 <d class={["a", "b", null]} /> 33 <d class={["a", "b", null]} />
34 <e class={{ a: true, b: false }} /> 34 <e class={{ a: true, b: false }} />
35 <e 35 <e
36 class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]} 36 class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
37 /> 37 />
38 </>, 38 </>,
39 ).text, 39 ).text,
40 '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>', 40 '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
41 )); 41 ));
framework/engine/ssr.ts+311-311
...@@ -1,311 +1,311 @@...@@ -1,311 +1,311 @@
1// Clover's Rendering Engine is the backbone of her website generator. It 1// Clover's Rendering Engine is the backbone of her website generator. It
2// converts objects and components (functions returning 'Node') into HTML. The 2// converts objects and components (functions returning 'Node') into HTML. The
3// engine is simple and self-contained, with integrations for JSX and Marko 3// engine is simple and self-contained, with integrations for JSX and Marko
4// (which can interop with each-other) are provided next to this file. 4// (which can interop with each-other) are provided next to this file.
5// 5//
6// Add-ons to the rendering engine can provide opaque data, And retrieve it 6// Add-ons to the rendering engine can provide opaque data, And retrieve it
7// within component calls with 'getAddonData'. For example, 'sitegen' uses this 7// within component calls with 'getAddonData'. For example, 'sitegen' uses this
8// to track needed client scripts without introducing patches to the engine. 8// to track needed client scripts without introducing patches to the engine.
9export type Addons = Record<string | symbol, unknown>; 9export type Addons = Record<string | symbol, unknown>;
10 10
11export function ssrSync(node: Node): Result; 11export function ssrSync(node: Node): Result;
12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>; 12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
13export function ssrSync(node: Node, addon: Addons = {}) { 13export function ssrSync(node: Node, addon: Addons = {}) {
14 const r = initRender(false, addon); 14 const r = initRender(false, addon);
15 const resolved = resolveNode(r, node); 15 const resolved = resolveNode(r, node);
16 return { text: renderNode(resolved), addon }; 16 return { text: renderNode(resolved), addon };
17} 17}
18 18
19export function ssrAsync(node: Node): Promise<Result>; 19export function ssrAsync(node: Node): Promise<Result>;
20export function ssrAsync<A extends Addons>( 20export function ssrAsync<A extends Addons>(
21 node: Node, 21 node: Node,
22 addon: A, 22 addon: A,
23): Promise<Result<A>>; 23): Promise<Result<A>>;
24export function ssrAsync(node: Node, addon: Addons = {}) { 24export function ssrAsync(node: Node, addon: Addons = {}) {
25 const r = initRender(true, addon); 25 const r = initRender(true, addon);
26 const resolved = resolveNode(r, node); 26 const resolved = resolveNode(r, node);
27 if (r.async === 0) { 27 if (r.async === 0) {
28 return Promise.resolve({ text: renderNode(resolved), addon }); 28 return Promise.resolve({ text: renderNode(resolved), addon });
29 } 29 }
30 const { resolve, reject, promise } = Promise.withResolvers<Result>(); 30 const { resolve, reject, promise } = Promise.withResolvers<Result>();
31 r.asyncDone = () => { 31 r.asyncDone = () => {
32 const rejections = r.rejections; 32 const rejections = r.rejections;
33 if (!rejections) return resolve({ text: renderNode(resolved), addon }); 33 if (!rejections) return resolve({ text: renderNode(resolved), addon });
34 if (rejections.length === 1) return reject(rejections[0]); 34 if (rejections.length === 1) return reject(rejections[0]);
35 return reject(new AggregateError(rejections)); 35 return reject(new AggregateError(rejections));
36 }; 36 };
37 return promise; 37 return promise;
38} 38}
39 39
40/** Inline HTML into a render without escaping it */ 40/** Inline HTML into a render without escaping it */
41export function html(rawText: ResolvedNode): DirectHtml { 41export function html(rawText: ResolvedNode): DirectHtml {
42 return [kDirectHtml, rawText]; 42 return [kDirectHtml, rawText];
43} 43}
44 44
45interface Result<A extends Addons = Addons> { 45interface Result<A extends Addons = Addons> {
46 text: string; 46 text: string;
47 addon: A; 47 addon: A;
48} 48}
49 49
50export interface Render { 50export interface Render {
51 /** 51 /**
52 * Set to '-1' if rendering synchronously 52 * Set to '-1' if rendering synchronously
53 * Number of async promises the render is waiting on. 53 * Number of async promises the render is waiting on.
54 */ 54 */
55 async: number | -1; 55 async: number | -1;
56 asyncDone: null | (() => void); 56 asyncDone: null | (() => void);
57 /** When components reject, those are logged here */ 57 /** When components reject, those are logged here */
58 rejections: unknown[] | null; 58 rejections: unknown[] | null;
59 /** Add-ons to the rendering engine store state here */ 59 /** Add-ons to the rendering engine store state here */
60 addon: Addons; 60 addon: Addons;
61} 61}
62 62
63export const kElement = Symbol("Element"); 63export const kElement = Symbol("Element");
64export const kDirectHtml = Symbol("DirectHtml"); 64export const kDirectHtml = Symbol("DirectHtml");
65 65
66/** Node represents a webpage that can be 'rendered' into HTML. */ 66/** Node represents a webpage that can be 'rendered' into HTML. */
67export type Node = 67export type Node =
68 | number 68 | number
69 | string // Escape HTML 69 | string // Escape HTML
70 | Node[] // Concat 70 | Node[] // Concat
71 | Element // Render 71 | Element // Render
72 | DirectHtml // Insert 72 | DirectHtml // Insert
73 | Promise<Node> // Await 73 | Promise<Node> // Await
74 // Ignore 74 // Ignore
75 | undefined 75 | undefined
76 | null 76 | null
77 | boolean; 77 | boolean;
78export type Element = [ 78export type Element = [
79 tag: typeof kElement, 79 tag: typeof kElement,
80 type: string | Component, 80 type: string | Component,
81 props: Record<string, unknown>, 81 props: Record<string, unknown>,
82 _?: "", 82 _?: "",
83 source?: SrcLoc, 83 source?: SrcLoc,
84]; 84];
85export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; 85export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
86/** 86/**
87 * Components must return a value; 'undefined' is prohibited here 87 * Components must return a value; 'undefined' is prohibited here
88 * to avoid functions that are missing a return statement. 88 * to avoid functions that are missing a return statement.
89 */ 89 */
90export type Component = ( 90export type Component = (
91 props: Record<any, any>, 91 props: Record<any, any>,
92) => Exclude<Node, undefined>; 92) => Exclude<Node, undefined>;
93/** Emitted by JSX runtime */ 93/** Emitted by JSX runtime */
94export interface SrcLoc { 94export interface SrcLoc {
95 fileName: string; 95 fileName: string;
96 lineNumber: number; 96 lineNumber: number;
97 columnNumber: number; 97 columnNumber: number;
98} 98}
99 99
100/** 100/**
101 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are 101 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
102 * marked in the 'Render'. This operation performs everything besides the final 102 * marked in the 'Render'. This operation performs everything besides the final
103 * string concatenation. This function is agnostic across async/sync modes. 103 * string concatenation. This function is agnostic across async/sync modes.
104 */ 104 */
105export function resolveNode(r: Render, node: unknown): ResolvedNode { 105export function resolveNode(r: Render, node: unknown): ResolvedNode {
106 if (!node && node !== 0) return ""; // falsy, non numeric 106 if (!node && node !== 0) return ""; // falsy, non numeric
107 if (typeof node !== "object") { 107 if (typeof node !== "object") {
108 if (node === true) return ""; // booleans are ignored 108 if (node === true) return ""; // booleans are ignored
109 if (typeof node === "string") return escapeHtml(node); 109 if (typeof node === "string") return escapeHtml(node);
110 if (typeof node === "number") return String(node); // no escaping ever 110 if (typeof node === "number") return String(node); // no escaping ever
111 throw new Error(`Cannot render ${inspect(node)} to HTML`); 111 throw new Error(`Cannot render ${inspect(node)} to HTML`);
112 } 112 }
113 if (node instanceof Promise) { 113 if (node instanceof Promise) {
114 if (r.async === -1) { 114 if (r.async === -1) {
115 throw new Error(`Asynchronous rendering is not supported here.`); 115 throw new Error(`Asynchronous rendering is not supported here.`);
116 } 116 }
117 const placeholder: InsertionPoint = [null]; 117 const placeholder: InsertionPoint = [null];
118 r.async += 1; 118 r.async += 1;
119 node 119 node
120 .then((result) => void (placeholder[0] = resolveNode(r, result))) 120 .then((result) => void (placeholder[0] = resolveNode(r, result)))
121 // Intentionally catching errors in `resolveNode` 121 // Intentionally catching errors in `resolveNode`
122 .catch((e) => (r.rejections ??= []).push(e)) 122 .catch((e) => (r.rejections ??= []).push(e))
123 .finally(() => { 123 .finally(() => {
124 if (--r.async == 0) { 124 if (--r.async == 0) {
125 if (r.asyncDone == null) throw new Error("r.asyncDone == null"); 125 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
126 r.asyncDone(); 126 r.asyncDone();
127 r.asyncDone = null; 127 r.asyncDone = null;
128 } 128 }
129 }); 129 });
130 // This lie is checked with an assertion in `renderNode` 130 // This lie is checked with an assertion in `renderNode`
131 return placeholder as [ResolvedNode]; 131 return placeholder as [ResolvedNode];
132 } 132 }
133 if (!Array.isArray(node)) { 133 if (!Array.isArray(node)) {
134 throw new Error(`Invalid node type: ${inspect(node)}`); 134 throw new Error(`Invalid node type: ${inspect(node)}`);
135 } 135 }
136 const type = node[0]; 136 const type = node[0];
137 if (type === kElement) { 137 if (type === kElement) {
138 const { 1: tag, 2: props } = node; 138 const { 1: tag, 2: props } = node;
139 if (typeof tag === "function") { 139 if (typeof tag === "function") {
140 currentRender = r; 140 currentRender = r;
141 try { 141 try {
142 return resolveNode(r, tag(props)); 142 return resolveNode(r, tag(props));
143 } catch (e) { 143 } catch (e) {
144 const { 4: src } = node; 144 const { 4: src } = node;
145 if (e && typeof e === "object") { 145 if (e && typeof e === "object") {
146 } 146 }
147 } finally { 147 } finally {
148 currentRender = null; 148 currentRender = null;
149 } 149 }
150 } 150 }
151 if (typeof tag !== "string") throw new Error("Unexpected " + typeof type); 151 if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
152 const children = props?.children; 152 const children = props?.children;
153 if (children) return [kElement, tag, props, resolveNode(r, children)]; 153 if (children) return [kElement, tag, props, resolveNode(r, children)];
154 return node; 154 return node;
155 } 155 }
156 if (type === kDirectHtml) return node[1]; 156 if (type === kDirectHtml) return node[1];
157 return node.map((elem) => resolveNode(r, elem)); 157 return node.map((elem) => resolveNode(r, elem));
158} 158}
159 159
160export type ResolvedNode = 160export type ResolvedNode =
161 | ResolvedNode[] // Concat 161 | ResolvedNode[] // Concat
162 | ResolvedElement // Render 162 | ResolvedElement // Render
163 | string; // Direct HTML 163 | string; // Direct HTML
164export type ResolvedElement = [ 164export type ResolvedElement = [
165 tag: typeof kElement, 165 tag: typeof kElement,
166 type: string, 166 type: string,
167 props: Record<string, unknown>, 167 props: Record<string, unknown>,
168 children: ResolvedNode, 168 children: ResolvedNode,
169]; 169];
170/** 170/**
171 * Async rendering is done by creating an array of one item, 171 * Async rendering is done by creating an array of one item,
172 * which is already a valid 'Node', but the element is written 172 * which is already a valid 'Node', but the element is written
173 * once the data is available. The 'Render' contains a count 173 * once the data is available. The 'Render' contains a count
174 * of how many async jobs are left. 174 * of how many async jobs are left.
175 */ 175 */
176export type InsertionPoint = [null | ResolvedNode]; 176export type InsertionPoint = [null | ResolvedNode];
177 177
178/** 178/**
179 * Convert 'ResolvedNode' into HTML text. This operation happens after all 179 * Convert 'ResolvedNode' into HTML text. This operation happens after all
180 * async work is settled. The HTML is emitted as concisely as possible. 180 * async work is settled. The HTML is emitted as concisely as possible.
181 */ 181 */
182export function renderNode(node: ResolvedNode): string { 182export function renderNode(node: ResolvedNode): string {
183 if (typeof node === "string") return node; 183 if (typeof node === "string") return node;
184 ASSERT(node, "Unresolved Render Node"); 184 ASSERT(node, "Unresolved Render Node");
185 const type = node[0]; 185 const type = node[0];
186 if (type === kElement) { 186 if (type === kElement) {
187 return renderElement(node as ResolvedElement); 187 return renderElement(node as ResolvedElement);
188 } 188 }
189 node = node as ResolvedNode[]; // TS cannot infer. 189 node = node as ResolvedNode[]; // TS cannot infer.
190 let out = type ? renderNode(type) : ""; 190 let out = type ? renderNode(type) : "";
191 let len = node.length; 191 let len = node.length;
192 for (let i = 1; i < len; i++) { 192 for (let i = 1; i < len; i++) {
193 const elem = node[i]; 193 const elem = node[i];
194 if (elem) out += renderNode(elem); 194 if (elem) out += renderNode(elem);
195 } 195 }
196 return out; 196 return out;
197} 197}
198function renderElement(element: ResolvedElement) { 198function renderElement(element: ResolvedElement) {
199 const { 1: tag, 2: props, 3: children } = element; 199 const { 1: tag, 2: props, 3: children } = element;
200 let out = "<" + tag; 200 let out = "<" + tag;
201 let needSpace = true; 201 let needSpace = true;
202 for (const prop in props) { 202 for (const prop in props) {
203 const value = props[prop]; 203 const value = props[prop];
204 if (!value || typeof value === "function") continue; 204 if (!value || typeof value === "function") continue;
205 let attr; 205 let attr;
206 switch (prop) { 206 switch (prop) {
207 default: 207 default:
208 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`; 208 attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
209 break; 209 break;
210 case "className": 210 case "className":
211 // Legacy React Compat 211 // Legacy React Compat
212 case "class": 212 case "class":
213 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`; 213 attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
214 break; 214 break;
215 case "htmlFor": 215 case "htmlFor":
216 throw new Error("Do not use the `htmlFor` attribute. Use `for`"); 216 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
217 // Do not process these 217 // Do not process these
218 case "children": 218 case "children":
219 case "ref": 219 case "ref":
220 case "dangerouslySetInnerHTML": 220 case "dangerouslySetInnerHTML":
221 case "key": 221 case "key":
222 continue; 222 continue;
223 } 223 }
224 if (needSpace) out += " ", needSpace = !attr.endsWith('"'); 224 if (needSpace) out += " ", needSpace = !attr.endsWith('"');
225 out += attr; 225 out += attr;
226 } 226 }
227 out += ">"; 227 out += ">";
228 if (children) out += renderNode(children); 228 if (children) out += renderNode(children);
229 if ( 229 if (
230 tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && 230 tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
231 tag !== "link" && tag !== "hr" 231 tag !== "link" && tag !== "hr"
232 ) { 232 ) {
233 out += `</${tag}>`; 233 out += `</${tag}>`;
234 } 234 }
235 return out; 235 return out;
236} 236}
237export function renderStyleAttribute(style: Record<string, string>) { 237export function renderStyleAttribute(style: Record<string, string>) {
238 let out = ``; 238 let out = ``;
239 for (const styleName in style) { 239 for (const styleName in style) {
240 if (out) out += ";"; 240 if (out) out += ";";
241 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ 241 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
242 escapeHtml(String(style[styleName])) 242 escapeHtml(String(style[styleName]))
243 }`; 243 }`;
244 } 244 }
245 return "style=" + quoteIfNeeded(out); 245 return "style=" + quoteIfNeeded(out);
246} 246}
247export function quoteIfNeeded(text: string) { 247export function quoteIfNeeded(text: string) {
248 if (text.includes(" ")) return '"' + text + '"'; 248 if (text.includes(" ")) return '"' + text + '"';
249 return text; 249 return text;
250} 250}
251 251
252// -- utility functions -- 252// -- utility functions --
253 253
254export function initRender(allowAsync: boolean, addon: Addons): Render { 254export function initRender(allowAsync: boolean, addon: Addons): Render {
255 return { 255 return {
256 async: allowAsync ? 0 : -1, 256 async: allowAsync ? 0 : -1,
257 rejections: null, 257 rejections: null,
258 asyncDone: null, 258 asyncDone: null,
259 addon, 259 addon,
260 }; 260 };
261} 261}
262 262
263let currentRender: Render | null = null; 263let currentRender: Render | null = null;
264export function getCurrentRender() { 264export function getCurrentRender() {
265 if (!currentRender) throw new Error("No Render Active"); 265 if (!currentRender) throw new Error("No Render Active");
266 return currentRender; 266 return currentRender;
267} 267}
268export function setCurrentRender(r?: Render | null) { 268export function setCurrentRender(r?: Render | null) {
269 currentRender = r ?? null; 269 currentRender = r ?? null;
270} 270}
271export function getUserData<T>(namespace: PropertyKey, def: () => T): T { 271export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
272 return (getCurrentRender().addon[namespace] ??= def()) as T; 272 return (getCurrentRender().addon[namespace] ??= def()) as T;
273} 273}
274 274
275export function inspect(object: unknown) { 275export function inspect(object: unknown) {
276 try { 276 try {
277 return require("node:util").inspect(object); 277 return require("node:util").inspect(object);
278 } catch { 278 } catch {
279 return typeof object; 279 return typeof object;
280 } 280 }
281} 281}
282 282
283export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; 283export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
284export function clsx(mix: ClsxInput) { 284export function clsx(mix: ClsxInput) {
285 var k, y, str = ""; 285 var k, y, str = "";
286 if (typeof mix === "string") { 286 if (typeof mix === "string") {
287 return mix; 287 return mix;
288 } else if (typeof mix === "object") { 288 } else if (typeof mix === "object") {
289 if (Array.isArray(mix)) { 289 if (Array.isArray(mix)) {
290 for (k = 0; k < mix.length; k++) { 290 for (k = 0; k < mix.length; k++) {
291 if (mix[k] && (y = clsx(mix[k]))) { 291 if (mix[k] && (y = clsx(mix[k]))) {
292 str && (str += " "); 292 str && (str += " ");
293 str += y; 293 str += y;
294 } 294 }
295 } 295 }
296 } else { 296 } else {
297 for (k in mix) { 297 for (k in mix) {
298 if (mix[k]) { 298 if (mix[k]) {
299 str && (str += " "); 299 str && (str += " ");
300 str += k; 300 str += k;
301 } 301 }
302 } 302 }
303 } 303 }
304 } 304 }
305 return str; 305 return str;
306} 306}
307 307
308export const escapeHtml = (unsafeText: string) => 308export const escapeHtml = (unsafeText: string) =>
309 String(unsafeText) 309 String(unsafeText)
310 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") 310 .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
311 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); 311 .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");
framework/engine/suspense.test.tsx+40-40
...@@ -1,40 +1,40 @@...@@ -1,40 +1,40 @@
1import { test } from "node:test"; 1import { test } from "node:test";
2import { renderStreaming, Suspense } from "./suspense.ts"; 2import { renderStreaming, Suspense } from "./suspense.ts";
3 3
4test("sanity", async (t) => { 4test("sanity", async (t) => {
5 let resolve: () => void = null!; 5 let resolve: () => void = null!;
6 6
7 // @ts-expect-error 7 // @ts-expect-error
8 async function AsyncComponent() { 8 async function AsyncComponent() {
9 await new Promise<void>((done) => resolve = done); 9 await new Promise<void>((done) => resolve = done);
10 return <button>wow!</button>; 10 return <button>wow!</button>;
11 } 11 }
12 12
13 const example = ( 13 const example = (
14 <main> 14 <main>
15 <h1>app shell</h1> 15 <h1>app shell</h1>
16 <Suspense fallback="loading..."> 16 <Suspense fallback="loading...">
17 <AsyncComponent /> 17 <AsyncComponent />
18 </Suspense> 18 </Suspense>
19 <footer>(c) 2025</footer> 19 <footer>(c) 2025</footer>
20 </main> 20 </main>
21 ); 21 );
22 22
23 const iterator = renderStreaming(example); 23 const iterator = renderStreaming(example);
24 const assertContinue = (actual: unknown, value: unknown) => 24 const assertContinue = (actual: unknown, value: unknown) =>
25 t.assert.deepEqual(actual, { done: false, value }); 25 t.assert.deepEqual(actual, { done: false, value });
26 26
27 assertContinue( 27 assertContinue(
28 await iterator.next(), 28 await iterator.next(),
29 "<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>", 29 "<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>",
30 ); 30 );
31 t.assert.ok(resolve !== null), resolve(); 31 t.assert.ok(resolve !== null), resolve();
32 assertContinue( 32 assertContinue(
33 await iterator.next(), 33 await iterator.next(),
34 "<button slot=suspended_1>wow!</button>", 34 "<button slot=suspended_1>wow!</button>",
35 ); 35 );
36 t.assert.deepEqual( 36 t.assert.deepEqual(
37 await iterator.next(), 37 await iterator.next(),
38 { done: true, value: {} }, 38 { done: true, value: {} },
39 ); 39 );
40}); 40});
framework/engine/suspense.ts+102-102
...@@ -1,102 +1,102 @@...@@ -1,102 +1,102 @@
1// This file implements out-of-order HTML streaming, mimicking the React 1// This file implements out-of-order HTML streaming, mimicking the React
2// Suspense API. To use, place Suspense around an expensive async component 2// Suspense API. To use, place Suspense around an expensive async component
3// and render the page with 'renderStreaming'. 3// and render the page with 'renderStreaming'.
4// 4//
5// Implementation of this article: 5// Implementation of this article:
6// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/ 6// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
7// 7//
8// I would link to an article from Next.js or React, but their examples 8// I would link to an article from Next.js or React, but their examples
9// are too verbose and not informative to what they actually do. 9// are too verbose and not informative to what they actually do.
10const kState = Symbol("SuspenseState"); 10const kState = Symbol("SuspenseState");
11 11
12interface SuspenseProps { 12interface SuspenseProps {
13 children: ssr.Node; 13 children: ssr.Node;
14 fallback?: ssr.Node; 14 fallback?: ssr.Node;
15} 15}
16 16
17interface State { 17interface State {
18 nested: boolean; 18 nested: boolean;
19 nextId: number; 19 nextId: number;
20 completed: number; 20 completed: number;
21 pushChunk(name: string, node: ssr.ResolvedNode): void; 21 pushChunk(name: string, node: ssr.ResolvedNode): void;
22} 22}
23 23
24export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { 24export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
25 const state = ssr.getUserData<State>(kState, () => { 25 const state = ssr.getUserData<State>(kState, () => {
26 throw new Error("Can only use <Suspense> with 'renderStreaming'"); 26 throw new Error("Can only use <Suspense> with 'renderStreaming'");
27 }); 27 });
28 if (state.nested) throw new Error("<Suspense> cannot be nested"); 28 if (state.nested) throw new Error("<Suspense> cannot be nested");
29 const parent = ssr.getCurrentRender()!; 29 const parent = ssr.getCurrentRender()!;
30 const r = ssr.initRender(true, { [kState]: { nested: true } }); 30 const r = ssr.initRender(true, { [kState]: { nested: true } });
31 const resolved = ssr.resolveNode(r, children); 31 const resolved = ssr.resolveNode(r, children);
32 if (r.async == 0) return ssr.html(resolved); 32 if (r.async == 0) return ssr.html(resolved);
33 const name = "suspended_" + (++state.nextId); 33 const name = "suspended_" + (++state.nextId);
34 state.nested = true; 34 state.nested = true;
35 const ip: [ssr.ResolvedNode] = [ 35 const ip: [ssr.ResolvedNode] = [
36 [ 36 [
37 ssr.kElement, 37 ssr.kElement,
38 "slot", 38 "slot",
39 { name }, 39 { name },
40 fallback ? ssr.resolveNode(parent, fallback) : "", 40 fallback ? ssr.resolveNode(parent, fallback) : "",
41 ], 41 ],
42 ]; 42 ];
43 state.nested = false; 43 state.nested = false;
44 r.asyncDone = () => { 44 r.asyncDone = () => {
45 const rejections = r.rejections; 45 const rejections = r.rejections;
46 if (rejections && rejections.length > 0) throw new Error("TODO"); 46 if (rejections && rejections.length > 0) throw new Error("TODO");
47 state.pushChunk?.(name, ip[0] = resolved); 47 state.pushChunk?.(name, ip[0] = resolved);
48 }; 48 };
49 return ssr.html(ip); 49 return ssr.html(ip);
50} 50}
51 51
52// TODO: add a User-Agent parameter, which is used to determine if a 52// TODO: add a User-Agent parameter, which is used to determine if a
53// fallback path must be used. 53// fallback path must be used.
54// - Before ~2024 needs to use a JS implementation. 54// - Before ~2024 needs to use a JS implementation.
55// - IE should probably bail out entirely. 55// - IE should probably bail out entirely.
56export async function* renderStreaming< 56export async function* renderStreaming<
57 T extends ssr.Addons = Record<never, unknown>, 57 T extends ssr.Addons = Record<never, unknown>,
58>( 58>(
59 node: ssr.Node, 59 node: ssr.Node,
60 addon: T = {} as T, 60 addon: T = {} as T,
61) { 61) {
62 const { 62 const {
63 text: begin, 63 text: begin,
64 addon: { [kState]: state, ...addonOutput }, 64 addon: { [kState]: state, ...addonOutput },
65 } = await ssr.ssrAsync(node, { 65 } = await ssr.ssrAsync(node, {
66 ...addon, 66 ...addon,
67 [kState]: { 67 [kState]: {
68 nested: false, 68 nested: false,
69 nextId: 0, 69 nextId: 0,
70 completed: 0, 70 completed: 0,
71 pushChunk: () => {}, 71 pushChunk: () => {},
72 } satisfies State as State, 72 } satisfies State as State,
73 }); 73 });
74 if (state.nextId === 0) { 74 if (state.nextId === 0) {
75 yield begin; 75 yield begin;
76 return addonOutput as unknown as T; 76 return addonOutput as unknown as T;
77 } 77 }
78 let resolve: (() => void) | null = null; 78 let resolve: (() => void) | null = null;
79 let chunks: string[] = []; 79 let chunks: string[] = [];
80 state.pushChunk = (slot, node) => { 80 state.pushChunk = (slot, node) => {
81 while (node.length === 1 && Array.isArray(node)) node = node[0]; 81 while (node.length === 1 && Array.isArray(node)) node = node[0];
82 if (node[0] === ssr.kElement) { 82 if (node[0] === ssr.kElement) {
83 (node as ssr.ResolvedElement)[2].slot = slot; 83 (node as ssr.ResolvedElement)[2].slot = slot;
84 } else { 84 } else {
85 node = [ssr.kElement, "clover-suspense", { 85 node = [ssr.kElement, "clover-suspense", {
86 style: "display:contents", 86 style: "display:contents",
87 slot, 87 slot,
88 }, node]; 88 }, node];
89 } 89 }
90 chunks.push(ssr.renderNode(node)); 90 chunks.push(ssr.renderNode(node));
91 resolve?.(); 91 resolve?.();
92 }; 92 };
93 yield `<template shadowrootmode=open>${begin}</template>`; 93 yield `<template shadowrootmode=open>${begin}</template>`;
94 do { 94 do {
95 await new Promise<void>((done) => resolve = done); 95 await new Promise<void>((done) => resolve = done);
96 yield* chunks; 96 yield* chunks;
97 chunks = []; 97 chunks = [];
98 } while (state.nextId < state.completed); 98 } while (state.nextId < state.completed);
99 return addonOutput as unknown as T; 99 return addonOutput as unknown as T;
100} 100}
101 101
102import * as ssr from "./ssr.ts"; 102import * as ssr from "./ssr.ts";
framework/esbuild-support.ts+79-79
...@@ -1,79 +1,79 @@...@@ -1,79 +1,79 @@
1export function virtualFiles( 1export function virtualFiles(
2 map: Record<string, string | esbuild.OnLoadResult>, 2 map: Record<string, string | esbuild.OnLoadResult>,
3) { 3) {
4 return { 4 return {
5 name: "clover vfs", 5 name: "clover vfs",
6 setup(b) { 6 setup(b) {
7 b.onResolve( 7 b.onResolve(
8 { 8 {
9 filter: new RegExp( 9 filter: new RegExp(
10 `^(?:${ 10 `^(?:${
11 Object.keys(map).map((file) => string.escapeRegExp(file)).join( 11 Object.keys(map).map((file) => string.escapeRegExp(file)).join(
12 "|", 12 "|",
13 ) 13 )
14 })\$`, 14 })\$`,
15 ), 15 ),
16 }, 16 },
17 ({ path }) => ({ path, namespace: "vfs" }), 17 ({ path }) => ({ path, namespace: "vfs" }),
18 ); 18 );
19 b.onLoad( 19 b.onLoad(
20 { filter: /./, namespace: "vfs" }, 20 { filter: /./, namespace: "vfs" },
21 ({ path }) => { 21 ({ path }) => {
22 const entry = map[path]; 22 const entry = map[path];
23 return ({ 23 return ({
24 resolveDir: ".", 24 resolveDir: ".",
25 loader: "ts", 25 loader: "ts",
26 ...typeof entry === "string" ? { contents: entry } : entry, 26 ...typeof entry === "string" ? { contents: entry } : entry,
27 }); 27 });
28 }, 28 },
29 ); 29 );
30 }, 30 },
31 } satisfies esbuild.Plugin; 31 } satisfies esbuild.Plugin;
32} 32}
33 33
34export function banFiles( 34export function banFiles(
35 files: string[], 35 files: string[],
36) { 36) {
37 return { 37 return {
38 name: "clover vfs", 38 name: "clover vfs",
39 setup(b) { 39 setup(b) {
40 b.onResolve( 40 b.onResolve(
41 { 41 {
42 filter: new RegExp( 42 filter: new RegExp(
43 `^(?:${ 43 `^(?:${
44 files.map((file) => string.escapeRegExp(file)).join("|") 44 files.map((file) => string.escapeRegExp(file)).join("|")
45 })\$`, 45 })\$`,
46 ), 46 ),
47 }, 47 },
48 ({ path, importer }) => { 48 ({ path, importer }) => {
49 throw new Error( 49 throw new Error(
50 `Loading ${path} (from ${importer}) is banned!`, 50 `Loading ${path} (from ${importer}) is banned!`,
51 ); 51 );
52 }, 52 },
53 ); 53 );
54 }, 54 },
55 } satisfies esbuild.Plugin; 55 } satisfies esbuild.Plugin;
56} 56}
57 57
58export function projectRelativeResolution(root = process.cwd() + "/src") { 58export function projectRelativeResolution(root = process.cwd() + "/src") {
59 return { 59 return {
60 name: "project relative resolution ('@/' prefix)", 60 name: "project relative resolution ('@/' prefix)",
61 setup(b) { 61 setup(b) {
62 b.onResolve({ filter: /^@\// }, ({ path: id }) => { 62 b.onResolve({ filter: /^@\// }, ({ path: id }) => {
63 return { 63 return {
64 path: path.resolve(root, id.slice(2)), 64 path: path.resolve(root, id.slice(2)),
65 }; 65 };
66 }); 66 });
67 b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => { 67 b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
68 return { 68 return {
69 path: hot.resolveFrom(importer, id), 69 path: hot.resolveFrom(importer, id),
70 }; 70 };
71 }); 71 });
72 }, 72 },
73 } satisfies esbuild.Plugin; 73 } satisfies esbuild.Plugin;
74} 74}
75 75
76import * as esbuild from "esbuild"; 76import * as esbuild from "esbuild";
77import * as string from "#sitegen/string"; 77import * as string from "#sitegen/string";
78import * as path from "node:path"; 78import * as path from "node:path";
79import * as hot from "./hot.ts"; 79import * as hot from "./hot.ts";
framework/hot.ts+3-1
...@@ -96,7 +96,9 @@ Module._resolveFilename = (...args) => {...@@ -96,7 +96,9 @@ Module._resolveFilename = (...args) => {
96 try {96 try {
97 return require.resolve(replacedPath, { paths: [projectSrc] });97 return require.resolve(replacedPath, { paths: [projectSrc] });
98 } catch (err: any) {98 } catch (err: any) {
99 if (err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1) {99 if (
100 err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1
101 ) {
100 err.message.replace(replacedPath, args[0]);102 err.message.replace(replacedPath, args[0]);
101 }103 }
102 }104 }
framework/lib/async.ts+299-297
...@@ -1,297 +1,299 @@...@@ -1,297 +1,299 @@
1const five_minutes = 5 * 60 * 1000; 1const five_minutes = 5 * 60 * 1000;
2 2
3interface QueueOptions<T, R> { 3interface QueueOptions<T, R> {
4 name: string; 4 name: string;
5 fn: (item: T, spin: Spinner) => Promise<R>; 5 fn: (item: T, spin: Spinner) => Promise<R>;
6 getItemText?: (item: T) => string; 6 getItemText?: (item: T) => string;
7 maxJobs?: number; 7 maxJobs?: number;
8 passive?: boolean; 8 passive?: boolean;
9} 9}
10 10
11// Process multiple items in parallel, queue up as many. 11// Process multiple items in parallel, queue up as many.
12export class Queue<T, R> { 12export class Queue<T, R> {
13 #name: string; 13 #name: string;
14 #fn: (item: T, spin: Spinner) => Promise<R>; 14 #fn: (item: T, spin: Spinner) => Promise<R>;
15 #maxJobs: number; 15 #maxJobs: number;
16 #getItemText: (item: T) => string; 16 #getItemText: (item: T) => string;
17 #passive: boolean; 17 #passive: boolean;
18 18
19 #active: Spinner[] = []; 19 #active: Spinner[] = [];
20 #queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = []; 20 #queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
21 21
22 #cachedProgress: Progress<{ active: Spinner[] }> | null = null; 22 #cachedProgress: Progress<{ active: Spinner[] }> | null = null;
23 #done: number = 0; 23 #done: number = 0;
24 #total: number = 0; 24 #total: number = 0;
25 #onComplete: (() => void) | null = null; 25 #onComplete: (() => void) | null = null;
26 #estimate: number | null = null; 26 #estimate: number | null = null;
27 #errors: unknown[] = []; 27 #errors: unknown[] = [];
28 28
29 constructor(options: QueueOptions<T, R>) { 29 constructor(options: QueueOptions<T, R>) {
30 this.#name = options.name; 30 this.#name = options.name;
31 this.#fn = options.fn; 31 this.#fn = options.fn;
32 this.#maxJobs = options.maxJobs ?? 5; 32 this.#maxJobs = options.maxJobs ?? 5;
33 this.#getItemText = options.getItemText ?? defaultGetItemText; 33 this.#getItemText = options.getItemText ?? defaultGetItemText;
34 this.#passive = options.passive ?? false; 34 this.#passive = options.passive ?? false;
35 } 35 }
36 36
37 cancel() { 37 cancel() {
38 const bar = this.#cachedProgress; 38 const bar = this.#cachedProgress;
39 bar?.stop(); 39 bar?.stop();
40 this.#queue = []; 40 this.#queue = [];
41 } 41 }
42 42
43 get bar() { 43 get bar() {
44 const cached = this.#cachedProgress; 44 const cached = this.#cachedProgress;
45 if (!cached) { 45 if (!cached) {
46 const bar = this.#cachedProgress = new Progress({ 46 const bar = this.#cachedProgress = new Progress({
47 spinner: null, 47 spinner: null,
48 text: ({ active }) => { 48 text: ({ active }) => {
49 const now = performance.now(); 49 const now = performance.now();
50 let text = `[${this.#done}/${this.#total}] ${this.#name}`; 50 let text = `[${this.#done}/${this.#total}] ${this.#name}`;
51 let n = 0; 51 let n = 0;
52 for (const item of active) { 52 for (const item of active) {
53 let itemText = "- " + item.format(now); 53 let itemText = "- " + item.format(now);
54 text += `\n` + 54 text += `\n` +
55 itemText.slice(0, Math.max(0, process.stdout.columns - 1)); 55 itemText.slice(0, Math.max(0, process.stdout.columns - 1));
56 if (n > 10) { 56 if (n > 10) {
57 text += `\n ... + ${active.length - n} more`; 57 text += `\n ... + ${active.length - n} more`;
58 break; 58 break;
59 } 59 }
60 n++; 60 n++;
61 } 61 }
62 return text; 62 return text;
63 }, 63 },
64 props: { 64 props: {
65 active: [] as Spinner[], 65 active: [] as Spinner[],
66 }, 66 },
67 }); 67 });
68 bar.value = 0; 68 bar.value = 0;
69 return bar; 69 return bar;
70 } 70 }
71 return cached; 71 return cached;
72 } 72 }
73 73
74 addReturn(args: T) { 74 addReturn(args: T) {
75 this.#total += 1; 75 this.#total += 1;
76 this.updateTotal(); 76 this.updateTotal();
77 if (this.#active.length >= this.#maxJobs) { 77 if (this.#active.length >= this.#maxJobs) {
78 const { promise, resolve, reject } = Promise.withResolvers<R>(); 78 const { promise, resolve, reject } = Promise.withResolvers<R>();
79 this.#queue.push([args, resolve, reject]); 79 this.#queue.push([args, resolve, reject]);
80 return promise; 80 return promise;
81 } 81 }
82 return this.#run(args); 82 return this.#run(args);
83 } 83 }
84 84
85 add(args: T) { 85 add(args: T) {
86 return this.addReturn(args).then(() => {}, () => {}); 86 return this.addReturn(args).then(() => {}, () => {});
87 } 87 }
88 88
89 addMany(items: T[]) { 89 addMany(items: T[]) {
90 this.#total += items.length; 90 this.#total += items.length;
91 this.updateTotal(); 91 this.updateTotal();
92 92
93 const runNowCount = this.#maxJobs - this.#active.length; 93 const runNowCount = this.#maxJobs - this.#active.length;
94 const runNow = items.slice(0, runNowCount); 94 const runNow = items.slice(0, runNowCount);
95 const runLater = items.slice(runNowCount); 95 const runLater = items.slice(runNowCount);
96 this.#queue.push(...runLater.reverse().map<[T]>((x) => [x])); 96 this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
97 runNow.map((item) => this.#run(item).catch(() => {})); 97 runNow.map((item) => this.#run(item).catch(() => {}));
98 } 98 }
99 99
100 async #run(args: T): Promise<R> { 100 async #run(args: T): Promise<R> {
101 const bar = this.bar; 101 const bar = this.bar;
102 const itemText = this.#getItemText(args); 102 const itemText = this.#getItemText(args);
103 const spinner = new Spinner(itemText); 103 const spinner = new Spinner(itemText);
104 spinner.stop(); 104 spinner.stop();
105 (spinner as any).redraw = () => (bar as any).redraw(); 105 (spinner as any).redraw = () => (bar as any).redraw();
106 const active = this.#active; 106 const active = this.#active;
107 try { 107 try {
108 active.unshift(spinner); 108 active.unshift(spinner);
109 bar.props = { active }; 109 bar.props = { active };
110 console.log(this.#name + ": " + itemText); 110 // console.log(this.#name + ": " + itemText);
111 const result = await this.#fn(args, spinner); 111 const result = await this.#fn(args, spinner);
112 this.#done++; 112 this.#done++;
113 return result; 113 return result;
114 } catch (err) { 114 } catch (err) {
115 if (err && typeof err === "object") { 115 if (err && typeof err === "object") {
116 (err as any).job = itemText; 116 (err as any).job = itemText;
117 } 117 }
118 this.#errors.push(err); 118 this.#errors.push(err);
119 throw err; 119 console.error(util.inspect(err, false, Infinity, true));
120 } finally { 120 throw err;
121 active.splice(active.indexOf(spinner), 1); 121 } finally {
122 bar.props = { active }; 122 active.splice(active.indexOf(spinner), 1);
123 bar.value = this.#done; 123 bar.props = { active };
124 124 bar.value = this.#done;
125 // Process next item 125
126 const next = this.#queue.shift(); 126 // Process next item
127 if (next) { 127 const next = this.#queue.shift();
128 const args = next[0]; 128 if (next) {
129 this.#run(args) 129 const args = next[0];
130 .then((result) => next[1]?.(result)) 130 this.#run(args)
131 .catch((err) => next[2]?.(err)); 131 .then((result) => next[1]?.(result))
132 } else if (this.#active.length === 0) { 132 .catch((err) => next[2]?.(err));
133 if (this.#passive) { 133 } else if (this.#active.length === 0) {
134 this.bar.stop(); 134 if (this.#passive) {
135 this.#cachedProgress = null; 135 this.bar.stop();
136 } 136 this.#cachedProgress = null;
137 this.#onComplete?.(); 137 }
138 } 138 this.#onComplete?.();
139 } 139 }
140 } 140 }
141 141 }
142 updateTotal() { 142
143 const bar = this.bar; 143 updateTotal() {
144 bar.total = Math.max(this.#total, this.#estimate ?? 0); 144 const bar = this.bar;
145 } 145 bar.total = Math.max(this.#total, this.#estimate ?? 0);
146 146 }
147 set estimate(e: number) { 147
148 this.#estimate = e; 148 set estimate(e: number) {
149 if (this.#cachedProgress) { 149 this.#estimate = e;
150 this.updateTotal(); 150 if (this.#cachedProgress) {
151 } 151 this.updateTotal();
152 } 152 }
153 153 }
154 async done(o?: { method: "success" | "stop" }) { 154
155 if (this.#active.length === 0) { 155 async done(o?: { method: "success" | "stop" }) {
156 this.#end(o); 156 if (this.#active.length === 0) {
157 return; 157 this.#end(o);
158 } 158 return;
159 159 }
160 const { promise, resolve } = Promise.withResolvers<void>(); 160
161 this.#onComplete = resolve; 161 const { promise, resolve } = Promise.withResolvers<void>();
162 await promise; 162 this.#onComplete = resolve;
163 this.#end(o); 163 await promise;
164 } 164 this.#end(o);
165 165 }
166 #end( 166
167 { method = this.#passive ? "stop" : "success" }: { 167 #end(
168 method?: "success" | "stop"; 168 { method = this.#passive ? "stop" : "success" }: {
169 } = {}, 169 method?: "success" | "stop";
170 ) { 170 } = {},
171 const bar = this.#cachedProgress; 171 ) {
172 if (this.#errors.length > 0) { 172 const bar = this.#cachedProgress;
173 if (bar) bar.stop(); 173 if (this.#errors.length > 0) {
174 throw new AggregateError( 174 if (bar) bar.stop();
175 this.#errors, 175 throw new AggregateError(
176 this.#errors.length + " jobs failed in '" + this.#name + "'", 176 this.#errors,
177 ); 177 this.#errors.length + " jobs failed in '" + this.#name + "'",
178 } 178 );
179 179 }
180 if (bar) bar[method](); 180
181 } 181 if (bar) bar[method]();
182 182 }
183 get active(): boolean { 183
184 return this.#active.length !== 0; 184 get active(): boolean {
185 } 185 return this.#active.length !== 0;
186 186 }
187 [Symbol.dispose]() { 187
188 if (this.active) { 188 [Symbol.dispose]() {
189 this.cancel(); 189 if (this.active) {
190 } 190 this.cancel();
191 } 191 }
192} 192 }
193 193}
194const cwd = process.cwd(); 194
195function defaultGetItemText(item: unknown) { 195const cwd = process.cwd();
196 let itemText = ""; 196function defaultGetItemText(item: unknown) {
197 if (typeof item === "string") { 197 let itemText = "";
198 itemText = item; 198 if (typeof item === "string") {
199 } else if (typeof item === "object" && item !== null) { 199 itemText = item;
200 const { path, label, id } = item as any; 200 } else if (typeof item === "object" && item !== null) {
201 itemText = label ?? path ?? id ?? JSON.stringify(item); 201 const { path, label, id } = item as any;
202 } else { 202 itemText = label ?? path ?? id ?? JSON.stringify(item);
203 itemText = JSON.stringify(item); 203 } else {
204 } 204 itemText = JSON.stringify(item);
205 205 }
206 if (itemText.startsWith(cwd)) { 206
207 itemText = path.relative(cwd, itemText); 207 if (itemText.startsWith(cwd)) {
208 } 208 itemText = path.relative(cwd, itemText);
209 return itemText; 209 }
210} 210 return itemText;
211 211}
212export class OnceMap<T> { 212
213 private ongoing = new Map<string, Promise<T>>(); 213export class OnceMap<T> {
214 214 private ongoing = new Map<string, Promise<T>>();
215 get(key: string, compute: () => Promise<T>) { 215
216 if (this.ongoing.has(key)) { 216 get(key: string, compute: () => Promise<T>) {
217 return this.ongoing.get(key)!; 217 if (this.ongoing.has(key)) {
218 } 218 return this.ongoing.get(key)!;
219 219 }
220 const result = compute(); 220
221 this.ongoing.set(key, result); 221 const result = compute();
222 return result; 222 this.ongoing.set(key, result);
223 } 223 return result;
224} 224 }
225 225}
226interface ARCEValue<T> { 226
227 value: T; 227interface ARCEValue<T> {
228 [Symbol.dispose]: () => void; 228 value: T;
229} 229 [Symbol.dispose]: () => void;
230 230}
231export function RefCountedExpirable<T>( 231
232 init: () => Promise<T>, 232export function RefCountedExpirable<T>(
233 deinit: (value: T) => void, 233 init: () => Promise<T>,
234 expire: number = five_minutes, 234 deinit: (value: T) => void,
235): () => Promise<ARCEValue<T>> { 235 expire: number = five_minutes,
236 let refs = 0; 236): () => Promise<ARCEValue<T>> {
237 let item: ARCEValue<T> | null = null; 237 let refs = 0;
238 let loading: Promise<ARCEValue<T>> | null = null; 238 let item: ARCEValue<T> | null = null;
239 let timer: ReturnType<typeof setTimeout> | null = null; 239 let loading: Promise<ARCEValue<T>> | null = null;
240 240 let timer: ReturnType<typeof setTimeout> | null = null;
241 function deref() { 241
242 ASSERT(item !== null); 242 function deref() {
243 if (--refs !== 0) return; 243 ASSERT(item !== null);
244 ASSERT(timer === null); 244 if (--refs !== 0) return;
245 timer = setTimeout(() => { 245 ASSERT(timer === null);
246 ASSERT(refs === 0); 246 timer = setTimeout(() => {
247 ASSERT(loading === null); 247 ASSERT(refs === 0);
248 ASSERT(item !== null); 248 ASSERT(loading === null);
249 deinit(item.value); 249 ASSERT(item !== null);
250 item = null; 250 deinit(item.value);
251 timer = null; 251 item = null;
252 }, expire); 252 timer = null;
253 } 253 }, expire);
254 254 }
255 return async function () { 255
256 if (timer !== null) { 256 return async function () {
257 clearTimeout(timer); 257 if (timer !== null) {
258 timer = null; 258 clearTimeout(timer);
259 } 259 timer = null;
260 if (item !== null) { 260 }
261 refs++; 261 if (item !== null) {
262 return item; 262 refs++;
263 } 263 return item;
264 if (loading !== null) { 264 }
265 refs++; 265 if (loading !== null) {
266 return loading; 266 refs++;
267 } 267 return loading;
268 const p = Promise.withResolvers<ARCEValue<T>>(); 268 }
269 loading = p.promise; 269 const p = Promise.withResolvers<ARCEValue<T>>();
270 try { 270 loading = p.promise;
271 const value = await init(); 271 try {
272 item = { value, [Symbol.dispose]: deref }; 272 const value = await init();
273 refs++; 273 item = { value, [Symbol.dispose]: deref };
274 p.resolve(item); 274 refs++;
275 return item; 275 p.resolve(item);
276 } catch (e) { 276 return item;
277 p.reject(e); 277 } catch (e) {
278 throw e; 278 p.reject(e);
279 } finally { 279 throw e;
280 loading = null; 280 } finally {
281 } 281 loading = null;
282 }; 282 }
283} 283 };
284 284}
285export function once<T>(fn: () => Promise<T>): () => Promise<T> { 285
286 let result: T | Promise<T> | null = null; 286export function once<T>(fn: () => Promise<T>): () => Promise<T> {
287 return async () => { 287 let result: T | Promise<T> | null = null;
288 if (result) return result; 288 return async () => {
289 result = await fn(); 289 if (result) return result;
290 return result; 290 result = await fn();
291 }; 291 return result;
292} 292 };
293 293}
294import { Progress } from "@paperclover/console/Progress"; 294
295import { Spinner } from "@paperclover/console/Spinner"; 295import { Progress } from "@paperclover/console/Progress";
296import * as path from "node:path"; 296import { Spinner } from "@paperclover/console/Spinner";
297import process from "node:process"; 297import * as path from "node:path";
298import process from "node:process";
299import * as util from "node:util";
framework/lib/meta.ts+24-24
...@@ -1,24 +1,24 @@...@@ -1,24 +1,24 @@
1export interface Meta { 1export interface Meta {
2 title: string; 2 title: string;
3 description?: string | undefined; 3 description?: string | undefined;
4 openGraph?: OpenGraph; 4 openGraph?: OpenGraph;
5 alternates?: Alternates; 5 alternates?: Alternates;
6} 6}
7export interface OpenGraph { 7export interface OpenGraph {
8 title?: string; 8 title?: string;
9 description?: string | undefined; 9 description?: string | undefined;
10 type: string; 10 type: string;
11 url: string; 11 url: string;
12} 12}
13export interface Alternates { 13export interface Alternates {
14 canonical: string; 14 canonical: string;
15 types: { [mime: string]: AlternateType }; 15 types: { [mime: string]: AlternateType };
16} 16}
17export interface AlternateType { 17export interface AlternateType {
18 url: string; 18 url: string;
19 title: string; 19 title: string;
20} 20}
21export function renderMeta({ title }: Meta): string { 21export function renderMeta({ title }: Meta): string {
22 return `<title>${esc(title)}</title>`; 22 return `<title>${esc(title)}</title>`;
23} 23}
24import { escapeHtml as esc } from "../engine/ssr.ts"; 24import { escapeHtml as esc } from "../engine/ssr.ts";
framework/lib/string.ts+3-3
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1export function escapeRegExp(source: string) { 1export function escapeRegExp(source: string) {
2 return source.replace(/[\$\\]/g, "\\$&"); 2 return source.replace(/[\$\\]/g, "\\$&");
3} 3}
framework/lib/view.ts+100-100
...@@ -1,100 +1,100 @@...@@ -1,100 +1,100 @@
1// This import is generated by code 'bundle.ts' 1// This import is generated by code 'bundle.ts'
2export interface View { 2export interface View {
3 component: engine.Component; 3 component: engine.Component;
4 meta: 4 meta:
5 | meta.Meta 5 | meta.Meta
6 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta); 6 | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
7 layout?: engine.Component; 7 layout?: engine.Component;
8 inlineCss: string; 8 inlineCss: string;
9 scripts: Record<string, string>; 9 scripts: Record<string, string>;
10} 10}
11 11
12let views: Record<string, View> = null!; 12let views: Record<string, View> = null!;
13let scripts: Record<string, string> = null!; 13let scripts: Record<string, string> = null!;
14 14
15// An older version of the Clover Engine supported streaming suspense 15// An older version of the Clover Engine supported streaming suspense
16// boundaries, but those were never used. Pages will wait until they 16// boundaries, but those were never used. Pages will wait until they
17// are fully rendered before sending. 17// are fully rendered before sending.
18export async function renderView( 18export async function renderView(
19 context: hono.Context, 19 context: hono.Context,
20 id: string, 20 id: string,
21 props: Record<string, unknown>, 21 props: Record<string, unknown>,
22) { 22) {
23 return context.html(await renderViewToString(id, { context, ...props })); 23 return context.html(await renderViewToString(id, { context, ...props }));
24} 24}
25 25
26export async function renderViewToString( 26export async function renderViewToString(
27 id: string, 27 id: string,
28 props: Record<string, unknown>, 28 props: Record<string, unknown>,
29) { 29) {
30 views ?? ({ views, scripts } = require("$views")); 30 views ?? ({ views, scripts } = require("$views"));
31 // The view contains pre-bundled CSS and scripts, but keeps the scripts 31 // The view contains pre-bundled CSS and scripts, but keeps the scripts
32 // separate for run-time dynamic scripts. For example, the file viewer 32 // separate for run-time dynamic scripts. For example, the file viewer
33 // includes the canvas for the current page, but only the current page. 33 // includes the canvas for the current page, but only the current page.
34 const { 34 const {
35 component, 35 component,
36 inlineCss, 36 inlineCss,
37 layout, 37 layout,
38 meta: metadata, 38 meta: metadata,
39 }: View = UNWRAP(views[id], `Missing view ${id}`); 39 }: View = UNWRAP(views[id], `Missing view ${id}`);
40 40
41 // -- metadata -- 41 // -- metadata --
42 const renderedMetaPromise = Promise.resolve( 42 const renderedMetaPromise = Promise.resolve(
43 typeof metadata === "function" ? metadata(props) : metadata, 43 typeof metadata === "function" ? metadata(props) : metadata,
44 ).then((m) => meta.renderMeta(m)); 44 ).then((m) => meta.renderMeta(m));
45 45
46 // -- html -- 46 // -- html --
47 let page: engine.Element = [engine.kElement, component, props]; 47 let page: engine.Element = [engine.kElement, component, props];
48 if (layout) page = [engine.kElement, layout, { children: page }]; 48 if (layout) page = [engine.kElement, layout, { children: page }];
49 const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { 49 const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
50 sitegen: sg.initRender(), 50 sitegen: sg.initRender(),
51 }); 51 });
52 52
53 // -- join document and send -- 53 // -- join document and send --
54 return wrapDocument({ 54 return wrapDocument({
55 body, 55 body,
56 head: await renderedMetaPromise, 56 head: await renderedMetaPromise,
57 inlineCss, 57 inlineCss,
58 scripts: joinScripts( 58 scripts: joinScripts(
59 Array.from( 59 Array.from(
60 sitegen.scripts, 60 sitegen.scripts,
61 (id) => UNWRAP(scripts[id], `Missing script ${id}`), 61 (id) => UNWRAP(scripts[id], `Missing script ${id}`),
62 ), 62 ),
63 ), 63 ),
64 }); 64 });
65} 65}
66 66
67export function provideViewData(v: typeof views, s: typeof scripts) { 67export function provideViewData(v: typeof views, s: typeof scripts) {
68 views = v; 68 views = v;
69 scripts = s; 69 scripts = s;
70} 70}
71 71
72export function joinScripts(scriptSources: string[]) { 72export function joinScripts(scriptSources: string[]) {
73 const { length } = scriptSources; 73 const { length } = scriptSources;
74 if (length === 0) return ""; 74 if (length === 0) return "";
75 if (length === 1) return scriptSources[0]; 75 if (length === 1) return scriptSources[0];
76 return scriptSources.map((source) => `{${source}}`).join(";"); 76 return scriptSources.map((source) => `{${source}}`).join(";");
77} 77}
78 78
79export function wrapDocument({ 79export function wrapDocument({
80 body, 80 body,
81 head, 81 head,
82 inlineCss, 82 inlineCss,
83 scripts, 83 scripts,
84}: { 84}: {
85 head: string; 85 head: string;
86 body: string; 86 body: string;
87 inlineCss: string; 87 inlineCss: string;
88 scripts: string; 88 scripts: string;
89}) { 89}) {
90 return `<!doctype html><html lang=en><head>${head}${ 90 return `<!doctype html><html lang=en><head>${head}${
91 inlineCss ? `<style>${inlineCss}</style>` : "" 91 inlineCss ? `<style>${inlineCss}</style>` : ""
92 }</head><body>${body}${ 92 }</head><body>${body}${
93 scripts ? `<script>${scripts}</script>` : "" 93 scripts ? `<script>${scripts}</script>` : ""
94 }</body></html>`; 94 }</body></html>`;
95} 95}
96 96
97import * as meta from "./meta.ts"; 97import * as meta from "./meta.ts";
98import type * as hono from "#hono"; 98import type * as hono from "#hono";
99import * as engine from "../engine/ssr.ts"; 99import * as engine from "../engine/ssr.ts";
100import * as sg from "./sitegen.ts"; 100import * as sg from "./sitegen.ts";
framework/watch.ts+198-198
...@@ -1,198 +1,198 @@...@@ -1,198 +1,198 @@
1// File watcher and live reloading site generator 1// File watcher and live reloading site generator
2 2
3const debounceMilliseconds = 25; 3const debounceMilliseconds = 25;
4 4
5export async function main() { 5export async function main() {
6 let subprocess: child_process.ChildProcess | null = null; 6 let subprocess: child_process.ChildProcess | null = null;
7 7
8 // Catch up state by running a main build. 8 // Catch up state by running a main build.
9 const { incr } = await generate.main(); 9 const { incr } = await generate.main();
10 // ...and watch the files that cause invals. 10 // ...and watch the files that cause invals.
11 const watch = new Watch(rebuild); 11 const watch = new Watch(rebuild);
12 watch.add(...incr.invals.keys()); 12 watch.add(...incr.invals.keys());
13 statusLine(); 13 statusLine();
14 // ... and then serve it! 14 // ... and then serve it!
15 serve(); 15 serve();
16 16
17 function serve() { 17 function serve() {
18 if (subprocess) { 18 if (subprocess) {
19 subprocess.removeListener("close", onSubprocessClose); 19 subprocess.removeListener("close", onSubprocessClose);
20 subprocess.kill(); 20 subprocess.kill();
21 } 21 }
22 subprocess = child_process.fork(".clover/out/server.js", [ 22 subprocess = child_process.fork(".clover/out/server.js", [
23 "--development", 23 "--development",
24 ], { 24 ], {
25 stdio: "inherit", 25 stdio: "inherit",
26 }); 26 });
27 subprocess.on("close", onSubprocessClose); 27 subprocess.on("close", onSubprocessClose);
28 } 28 }
29 29
30 function onSubprocessClose(code: number | null, signal: string | null) { 30 function onSubprocessClose(code: number | null, signal: string | null) {
31 subprocess = null; 31 subprocess = null;
32 const status = code != null ? `code ${code}` : `signal ${signal}`; 32 const status = code != null ? `code ${code}` : `signal ${signal}`;
33 console.error(`Backend process exited with ${status}`); 33 console.error(`Backend process exited with ${status}`);
34 } 34 }
35 35
36 process.on("beforeExit", () => { 36 process.on("beforeExit", () => {
37 subprocess?.removeListener("close", onSubprocessClose); 37 subprocess?.removeListener("close", onSubprocessClose);
38 }); 38 });
39 39
40 function rebuild(files: string[]) { 40 function rebuild(files: string[]) {
41 files = files.map((file) => path.relative(hot.projectRoot, file)); 41 files = files.map((file) => path.relative(hot.projectRoot, file));
42 const changed: string[] = []; 42 const changed: string[] = [];
43 for (const file of files) { 43 for (const file of files) {
44 let mtimeMs: number | null = null; 44 let mtimeMs: number | null = null;
45 try { 45 try {
46 mtimeMs = fs.statSync(file).mtimeMs; 46 mtimeMs = fs.statSync(file).mtimeMs;
47 } catch (err: any) { 47 } catch (err: any) {
48 if (err?.code !== "ENOENT") throw err; 48 if (err?.code !== "ENOENT") throw err;
49 } 49 }
50 if (incr.updateStat(file, mtimeMs)) changed.push(file); 50 if (incr.updateStat(file, mtimeMs)) changed.push(file);
51 } 51 }
52 if (changed.length === 0) { 52 if (changed.length === 0) {
53 console.warn("Files were modified but the 'modify' time did not change."); 53 console.warn("Files were modified but the 'modify' time did not change.");
54 return; 54 return;
55 } 55 }
56 withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({ 56 withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
57 text: "Rebuilding", 57 text: "Rebuilding",
58 successText: generate.successText, 58 successText: generate.successText,
59 failureText: () => "sitegen FAIL", 59 failureText: () => "sitegen FAIL",
60 }, async (spinner) => { 60 }, async (spinner) => {
61 console.info("---"); 61 console.info("---");
62 console.info( 62 console.info(
63 "Updated" + 63 "Updated" +
64 (changed.length === 1 64 (changed.length === 1
65 ? " " + changed[0] 65 ? " " + changed[0]
66 : changed.map((file) => "\n- " + file)), 66 : changed.map((file) => "\n- " + file)),
67 ); 67 );
68 const result = await generate.sitegen(spinner, incr); 68 const result = await generate.sitegen(spinner, incr);
69 incr.toDisk(); // Allows picking up this state again 69 incr.toDisk(); // Allows picking up this state again
70 for (const file of watch.files) { 70 for (const file of watch.files) {
71 const relative = path.relative(hot.projectRoot, file); 71 const relative = path.relative(hot.projectRoot, file);
72 if (!incr.invals.has(relative)) watch.remove(file); 72 if (!incr.invals.has(relative)) watch.remove(file);
73 } 73 }
74 return result; 74 return result;
75 }).then((result) => { 75 }).then((result) => {
76 // Restart the server if it was changed or not running. 76 // Restart the server if it was changed or not running.
77 if ( 77 if (
78 !subprocess || 78 !subprocess ||
79 result.inserted.some(({ kind }) => kind === "backendReplace") 79 result.inserted.some(({ kind }) => kind === "backendReplace")
80 ) { 80 ) {
81 serve(); 81 serve();
82 } else if ( 82 } else if (
83 subprocess && 83 subprocess &&
84 result.inserted.some(({ kind }) => kind === "asset") 84 result.inserted.some(({ kind }) => kind === "asset")
85 ) { 85 ) {
86 subprocess.send({ type: "clover.assets.reload" }); 86 subprocess.send({ type: "clover.assets.reload" });
87 } 87 }
88 return result; 88 return result;
89 }).catch((err) => { 89 }).catch((err) => {
90 console.error(util.inspect(err)); 90 console.error(util.inspect(err));
91 }).finally(statusLine); 91 }).finally(statusLine);
92 } 92 }
93 93
94 function statusLine() { 94 function statusLine() {
95 console.info( 95 console.info(
96 `Watching ${incr.invals.size} files \x1b[36m[last change: ${ 96 `Watching ${incr.invals.size} files \x1b[36m[last change: ${
97 new Date().toLocaleTimeString() 97 new Date().toLocaleTimeString()
98 }]\x1b[39m`, 98 }]\x1b[39m`,
99 ); 99 );
100 } 100 }
101} 101}
102 102
103class Watch { 103class Watch {
104 files = new Set<string>(); 104 files = new Set<string>();
105 stale = new Set<string>(); 105 stale = new Set<string>();
106 onChange: (files: string[]) => void; 106 onChange: (files: string[]) => void;
107 watchers: fs.FSWatcher[] = []; 107 watchers: fs.FSWatcher[] = [];
108 /** Has a trailing slash */ 108 /** Has a trailing slash */
109 roots: string[] = []; 109 roots: string[] = [];
110 debounce: ReturnType<typeof setTimeout> | null = null; 110 debounce: ReturnType<typeof setTimeout> | null = null;
111 111
112 constructor(onChange: Watch["onChange"]) { 112 constructor(onChange: Watch["onChange"]) {
113 this.onChange = onChange; 113 this.onChange = onChange;
114 } 114 }
115 115
116 add(...files: string[]) { 116 add(...files: string[]) {
117 const { roots, watchers } = this; 117 const { roots, watchers } = this;
118 let newRoots: string[] = []; 118 let newRoots: string[] = [];
119 for (let file of files) { 119 for (let file of files) {
120 file = path.resolve(file); 120 file = path.resolve(file);
121 if (this.files.has(file)) continue; 121 if (this.files.has(file)) continue;
122 this.files.add(file); 122 this.files.add(file);
123 // Find an existing watcher 123 // Find an existing watcher
124 if (roots.some((root) => file.startsWith(root))) continue; 124 if (roots.some((root) => file.startsWith(root))) continue;
125 if (newRoots.some((root) => file.startsWith(root))) continue; 125 if (newRoots.some((root) => file.startsWith(root))) continue;
126 newRoots.push(path.dirname(file) + path.sep); 126 newRoots.push(path.dirname(file) + path.sep);
127 } 127 }
128 if (newRoots.length === 0) return; 128 if (newRoots.length === 0) return;
129 // Filter out directories that are already specified 129 // Filter out directories that are already specified
130 newRoots = newRoots 130 newRoots = newRoots
131 .sort((a, b) => a.length - b.length) 131 .sort((a, b) => a.length - b.length)
132 .filter((dir, i, a) => { 132 .filter((dir, i, a) => {
133 for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false; 133 for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false;
134 return true; 134 return true;
135 }); 135 });
136 // Append Watches 136 // Append Watches
137 let i = roots.length; 137 let i = roots.length;
138 for (const root of newRoots) { 138 for (const root of newRoots) {
139 this.watchers.push(fs.watch( 139 this.watchers.push(fs.watch(
140 root, 140 root,
141 { recursive: true, encoding: "utf-8" }, 141 { recursive: true, encoding: "utf-8" },
142 this.#handleEvent.bind(this, root), 142 this.#handleEvent.bind(this, root),
143 )); 143 ));
144 this.roots.push(root); 144 this.roots.push(root);
145 } 145 }
146 // If any new roots shadow over and old one, delete it! 146 // If any new roots shadow over and old one, delete it!
147 while (i > 0) { 147 while (i > 0) {
148 i -= 1; 148 i -= 1;
149 const root = roots[i]; 149 const root = roots[i];
150 if (newRoots.some((newRoot) => root.startsWith(newRoot))) { 150 if (newRoots.some((newRoot) => root.startsWith(newRoot))) {
151 watchers.splice(i, 1)[0].close(); 151 watchers.splice(i, 1)[0].close();
152 roots.splice(i, 1); 152 roots.splice(i, 1);
153 } 153 }
154 } 154 }
155 } 155 }
156 156
157 remove(...files: string[]) { 157 remove(...files: string[]) {
158 for (const file of files) this.files.delete(path.resolve(file)); 158 for (const file of files) this.files.delete(path.resolve(file));
159 // Find watches that are covering no files 159 // Find watches that are covering no files
160 const { roots, watchers } = this; 160 const { roots, watchers } = this;
161 const existingFiles = Array.from(this.files); 161 const existingFiles = Array.from(this.files);
162 let i = roots.length; 162 let i = roots.length;
163 while (i > 0) { 163 while (i > 0) {
164 i -= 1; 164 i -= 1;
165 const root = roots[i]; 165 const root = roots[i];
166 if (!existingFiles.some((file) => file.startsWith(root))) { 166 if (!existingFiles.some((file) => file.startsWith(root))) {
167 watchers.splice(i, 1)[0].close(); 167 watchers.splice(i, 1)[0].close();
168 roots.splice(i, 1); 168 roots.splice(i, 1);
169 } 169 }
170 } 170 }
171 } 171 }
172 172
173 stop() { 173 stop() {
174 for (const w of this.watchers) w.close(); 174 for (const w of this.watchers) w.close();
175 } 175 }
176 176
177 #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) { 177 #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
178 if (!subPath) return; 178 if (!subPath) return;
179 const file = path.join(root, subPath); 179 const file = path.join(root, subPath);
180 if (!this.files.has(file)) return; 180 if (!this.files.has(file)) return;
181 this.stale.add(file); 181 this.stale.add(file);
182 const { debounce } = this; 182 const { debounce } = this;
183 if (debounce !== null) clearTimeout(debounce); 183 if (debounce !== null) clearTimeout(debounce);
184 this.debounce = setTimeout(() => { 184 this.debounce = setTimeout(() => {
185 this.debounce = null; 185 this.debounce = null;
186 this.onChange(Array.from(this.stale)); 186 this.onChange(Array.from(this.stale));
187 this.stale.clear(); 187 this.stale.clear();
188 }, debounceMilliseconds); 188 }, debounceMilliseconds);
189 } 189 }
190} 190}
191 191
192import * as fs from "node:fs"; 192import * as fs from "node:fs";
193import { withSpinner } from "@paperclover/console/Spinner"; 193import { withSpinner } from "@paperclover/console/Spinner";
194import * as generate from "./generate.ts"; 194import * as generate from "./generate.ts";
195import * as path from "node:path"; 195import * as path from "node:path";
196import * as util from "node:util"; 196import * as util from "node:util";
197import * as hot from "./hot.ts"; 197import * as hot from "./hot.ts";
198import * as child_process from "node:child_process"; 198import * as child_process from "node:child_process";
readme.md+20-20
...@@ -4,27 +4,27 @@ this repository contains clover's "sitegen" framework, which is a set of tools...@@ -4,27 +4,27 @@ this repository contains clover's "sitegen" framework, which is a set of tools
4that assist building websites. these tools power https://paperclover.net.4that assist building websites. these tools power https://paperclover.net.
55
6- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)6- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
7 - A more practical JSX runtime (`class` instead of `className`, built-in7 - A more practical JSX runtime (`class` instead of `className`, built-in
8 `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).8 `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
9 - Integration with [Marko][1] for concisely written components.9 - Integration with [Marko][1] for concisely written components.
10 - TODO: MDX-like compiler for content-heavy pages like blogs.10 - TODO: MDX-like compiler for content-heavy pages like blogs.
11 - Different languages can be used at the same time. Supports11 - Different languages can be used at the same time. Supports `async function`
12 `async function` components, `<Suspense />`, and custom extensions.12 components, `<Suspense />`, and custom extensions.
13- **Incremental static site generator and build system.**13- **Incremental static site generator and build system.**
14 - Build entire production site at start, incremental updates when pages14 - Build entire production site at start, incremental updates when pages
15 change; Build system state survives coding sessions.15 change; Build system state survives coding sessions.
16 - The only difference in development and production mode is hidden16 - The only difference in development and production mode is hidden source-maps
17 source-maps and stripped `console.debug` calls. The site you17 and stripped `console.debug` calls. The site you see locally is the same
18 see locally is the same site you see deployed.18 site you see deployed.
19 - (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs19 - (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs
20 checks when the files change. For example, changing a component re-tests20 checks when the files change. For example, changing a component re-tests
21 only pages that use that component and re-lints only the changed file.21 only pages that use that component and re-lints only the changed file.
22- **Integrated libraries for building complex, content heavy web sites.**22- **Integrated libraries for building complex, content heavy web sites.**
23 - Static asset serving with ETag and build-time compression.23 - Static asset serving with ETag and build-time compression.
24 - Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)24 - Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
25 - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)25 - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
26 - TODO: Meta and Open Graph generation. (`export const meta`)26 - TODO: Meta and Open Graph generation. (`export const meta`)
27 - TODO: Font subsetting tools to reduce bytes downloaded by fonts.27 - TODO: Font subsetting tools to reduce bytes downloaded by fonts.
28- **Built on the battle-tested Node.js runtime.**28- **Built on the battle-tested Node.js runtime.**
2929
30[1]: https://next.markojs.com30[1]: https://next.markojs.com
...@@ -42,6 +42,7 @@ Included is `src`, which contains `paperclover.net`. Website highlights:...@@ -42,6 +42,7 @@ Included is `src`, which contains `paperclover.net`. Website highlights:
42## Development42## Development
4343
44minimum system requirements:44minimum system requirements:
45
45- a cpu with at least 1 core.46- a cpu with at least 1 core.
46- random access memory.47- random access memory.
47- windows 7 or later, macos, or other operating system.48- windows 7 or later, macos, or other operating system.
...@@ -73,4 +74,3 @@ open a shell with all needed system dependencies....@@ -73,4 +74,3 @@ open a shell with all needed system dependencies.
73## Contributions74## Contributions
7475
75No contributions to `src` accepted, only `framework`.76No contributions to `src` accepted, only `framework`.
76
run.js+1-1
...@@ -12,7 +12,7 @@ if (!zlib.zstdCompress) {...@@ -12,7 +12,7 @@ if (!zlib.zstdCompress) {
12 : null;12 : null;
1313
14 globalThis.console.error(14 globalThis.console.error(
15 `sitegen depends on a node.js-compatibile runtime that supports zstd compression\n` +15 `sitegen depends on a node.js-compatibile runtime\n` +
16 `this is node.js version ${process.version}${16 `this is node.js version ${process.version}${
17 brand ? ` (${brand})` : ""17 brand ? ` (${brand})` : ""
18 }\n\n` +18 }\n\n` +
src/admin.ts+75-75
...@@ -1,75 +1,75 @@...@@ -1,75 +1,75 @@
1const cookieAge = 60 * 60 * 24 * 365; // 1 year 1const cookieAge = 60 * 60 * 24 * 365; // 1 year
2 2
3let lastKnownToken: string | null = null; 3let lastKnownToken: string | null = null;
4function compareToken(token: string) { 4function compareToken(token: string) {
5 if (token === lastKnownToken) return true; 5 if (token === lastKnownToken) return true;
6 lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim(); 6 lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
7 return token === lastKnownToken; 7 return token === lastKnownToken;
8} 8}
9 9
10export async function middleware(c: Context, next: Next) { 10export async function middleware(c: Context, next: Next) {
11 if (c.req.path.startsWith("/admin")) { 11 if (c.req.path.startsWith("/admin")) {
12 return adminInner(c, next); 12 return adminInner(c, next);
13 } 13 }
14 return next(); 14 return next();
15} 15}
16 16
17export function adminInner(c: Context, next: Next) { 17export function adminInner(c: Context, next: Next) {
18 const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1]; 18 const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
19 19
20 if (c.req.path === "/admin/login") { 20 if (c.req.path === "/admin/login") {
21 const key = c.req.query("key"); 21 const key = c.req.query("key");
22 if (key) { 22 if (key) {
23 if (compareToken(key)) { 23 if (compareToken(key)) {
24 return c.body(null, 303, { 24 return c.body(null, 303, {
25 "Location": "/admin", 25 "Location": "/admin",
26 "Set-Cookie": 26 "Set-Cookie":
27 `admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, 27 `admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
28 }); 28 });
29 } 29 }
30 return serveAsset(c, "/admin/login/fail", 403); 30 return serveAsset(c, "/admin/login/fail", 403);
31 } 31 }
32 if (token && compareToken(token)) { 32 if (token && compareToken(token)) {
33 return c.redirect("/admin", 303); 33 return c.redirect("/admin", 303);
34 } 34 }
35 if (c.req.method === "POST") { 35 if (c.req.method === "POST") {
36 return serveAsset(c, "/admin/login/fail", 403); 36 return serveAsset(c, "/admin/login/fail", 403);
37 } else { 37 } else {
38 return serveAsset(c, "/admin/login", 200); 38 return serveAsset(c, "/admin/login", 200);
39 } 39 }
40 } 40 }
41 41
42 if (c.req.path === "/admin/logout") { 42 if (c.req.path === "/admin/logout") {
43 return c.body(null, 303, { 43 return c.body(null, 303, {
44 "Location": "/admin/login", 44 "Location": "/admin/login",
45 "Set-Cookie": 45 "Set-Cookie":
46 `admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`, 46 `admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
47 }); 47 });
48 } 48 }
49 49
50 if (token && compareToken(token)) { 50 if (token && compareToken(token)) {
51 return next(); 51 return next();
52 } 52 }
53 53
54 return c.redirect("/admin/login", 303); 54 return c.redirect("/admin/login", 303);
55} 55}
56 56
57export function hasAdminToken(c: Context) { 57export function hasAdminToken(c: Context) {
58 const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1]; 58 const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
59 return token && compareToken(token); 59 return token && compareToken(token);
60} 60}
61 61
62export async function main() { 62export async function main() {
63 const key = crypto.randomUUID(); 63 const key = crypto.randomUUID();
64 await fs.writeMkdir(".clover/admin-token.txt", key); 64 await fs.writeMkdir(".clover/admin-token.txt", key);
65 const start = ({ 65 const start = ({
66 win32: "start", 66 win32: "start",
67 darwin: "open", 67 darwin: "open",
68 } as Record<string, string>)[process.platform] ?? "xdg-open"; 68 } as Record<string, string>)[process.platform] ?? "xdg-open";
69 child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`); 69 child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`);
70} 70}
71 71
72import * as fs from "#sitegen/fs"; 72import * as fs from "#sitegen/fs";
73import type { Context, Next } from "hono"; 73import type { Context, Next } from "hono";
74import { serveAsset } from "#sitegen/assets"; 74import { serveAsset } from "#sitegen/assets";
75import * as child_process from "node:child_process"; 75import * as child_process from "node:child_process";
src/backend.ts+53-53
...@@ -1,53 +1,53 @@...@@ -1,53 +1,53 @@
1// This is the main file for the backend 1// This is the main file for the backend
2const app = new Hono(); 2const app = new Hono();
3const logHttp = scoped("http", { color: "magenta" }); 3const logHttp = scoped("http", { color: "magenta" });
4 4
5// Middleware 5// Middleware
6app.use(trimTrailingSlash()); 6app.use(trimTrailingSlash());
7app.use(removeDuplicateSlashes); 7app.use(removeDuplicateSlashes);
8app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4)))); 8app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
9app.use(admin.middleware); 9app.use(admin.middleware);
10 10
11// Backends 11// Backends
12app.route("", require("./q+a/backend.ts").app); 12app.route("", require("./q+a/backend.ts").app);
13app.route("", require("./file-viewer/backend.tsx").app); 13app.route("", require("./file-viewer/backend.tsx").app);
14 14
15// Asset middleware has least precedence 15// Asset middleware has least precedence
16app.use(assets.middleware); 16app.use(assets.middleware);
17 17
18// Handlers 18// Handlers
19app.notFound(assets.notFound); 19app.notFound(assets.notFound);
20if (process.argv.includes("--development")) { 20if (process.argv.includes("--development")) {
21 app.onError((err, c) => { 21 app.onError((err, c) => {
22 if (err instanceof HTTPException) { 22 if (err instanceof HTTPException) {
23 // Get the custom response 23 // Get the custom response
24 return err.getResponse(); 24 return err.getResponse();
25 } 25 }
26 26
27 return c.text(util.inspect(err), 500); 27 return c.text(util.inspect(err), 500);
28 }); 28 });
29} 29}
30 30
31export default app; 31export default app;
32 32
33async function removeDuplicateSlashes(c: Context, next: Next) { 33async function removeDuplicateSlashes(c: Context, next: Next) {
34 const path = c.req.path; 34 const path = c.req.path;
35 if (/\/\/+/.test(path)) { 35 if (/\/\/+/.test(path)) {
36 const normalizedPath = path.replace(/\/\/+/g, "/"); 36 const normalizedPath = path.replace(/\/\/+/g, "/");
37 const query = c.req.query(); 37 const query = c.req.query();
38 const queryString = Object.keys(query).length > 0 38 const queryString = Object.keys(query).length > 0
39 ? "?" + new URLSearchParams(query).toString() 39 ? "?" + new URLSearchParams(query).toString()
40 : ""; 40 : "";
41 return c.redirect(normalizedPath + queryString, 301); 41 return c.redirect(normalizedPath + queryString, 301);
42 } 42 }
43 await next(); 43 await next();
44} 44}
45 45
46import { type Context, Hono, type Next } from "#hono"; 46import { type Context, Hono, type Next } from "#hono";
47import { HTTPException } from "hono/http-exception"; 47import { HTTPException } from "hono/http-exception";
48import { logger } from "hono/logger"; 48import { logger } from "hono/logger";
49import { trimTrailingSlash } from "hono/trailing-slash"; 49import { trimTrailingSlash } from "hono/trailing-slash";
50import * as assets from "#sitegen/assets"; 50import * as assets from "#sitegen/assets";
51import * as admin from "./admin.ts"; 51import * as admin from "./admin.ts";
52import { scoped } from "@paperclover/console"; 52import { scoped } from "@paperclover/console";
53import * as util from "node:util"; 53import * as util from "node:util";
src/file-viewer/bin/list.ts+8-8
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1export function main() { 1export function main() {
2 const meows = MediaFile.db.prepare(` 2 const meows = MediaFile.db.prepare(`
3 select * from media_files; 3 select * from media_files;
4 `).as(MediaFile).array(); 4 `).as(MediaFile).array();
5 console.log(meows); 5 console.log(meows);
6} 6}
7 7
8import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; 8import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
src/file-viewer/bin/scan3.ts+655-649
...@@ -1,649 +1,655 @@...@@ -1,649 +1,655 @@
1// The file scanner incrementally updates an sqlite database with file 1// The file scanner incrementally updates an sqlite database with file
2// stats. Additionally, it runs "processors" on files, which precompute 2// stats. Additionally, it runs "processors" on files, which precompute
3// expensive data such as running `ffprobe` on all media to get the 3// expensive data such as running `ffprobe` on all media to get the
4// duration. 4// duration.
5// 5//
6// Processors are also used to derive compressed and optimized assets, 6// Processors are also used to derive compressed and optimized assets,
7// which is how automatic JXL / AV1 encoding is done. Derived files are 7// which is how automatic JXL / AV1 encoding is done. Derived files are
8// uploaded to the clover NAS to be pulled by VPS instances for hosting. 8// uploaded to the clover NAS to be pulled by VPS instances for hosting.
9// 9//
10// This is the third iteration of the scanner, hence its name "scan3"; 10// This is the third iteration of the scanner, hence its name "scan3";
11// Remember that any software you want to be maintainable and high 11// Remember that any software you want to be maintainable and high
12// quality cannot be written with AI. 12// quality cannot be written with AI.
13const root = path.resolve("/Volumes/clover/Published"); 13const root = path.resolve("/Volumes/clover/Published");
14const workDir = path.resolve(".clover/file-assets"); 14const workDir = path.resolve(".clover/file-assets");
15 15
16export async function main() { 16export async function main() {
17 const start = performance.now(); 17 const start = performance.now();
18 const timerSpinner = new Spinner({ 18 const timerSpinner = new Spinner({
19 text: () => 19 text: () =>
20 `paper clover's scan3 [${ 20 `paper clover's scan3 [${
21 ((performance.now() - start) / 1000).toFixed(1) 21 ((performance.now() - start) / 1000).toFixed(1)
22 }s]`, 22 }s]`,
23 fps: 10, 23 fps: 10,
24 }); 24 });
25 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() }; 25 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
26 26
27 // Read a directory or file stat and queue up changed files. 27 // Read a directory or file stat and queue up changed files.
28 using qList = new async.Queue({ 28 using qList = new async.Queue({
29 name: "Discover Tree", 29 name: "Discover Tree",
30 async fn(absPath: string, spin) { 30 async fn(absPath: string, spin) {
31 const stat = await fs.stat(absPath); 31 const stat = await fs.stat(absPath);
32 32
33 const publicPath = toPublicPath(absPath); 33 const publicPath = toPublicPath(absPath);
34 const mediaFile = MediaFile.getByPath(publicPath); 34 const mediaFile = MediaFile.getByPath(publicPath);
35 35
36 if (stat.isDirectory()) { 36 if (stat.isDirectory()) {
37 const items = await fs.readdir(absPath); 37 const items = await fs.readdir(absPath);
38 qList.addMany(items.map((subPath) => path.join(absPath, subPath))); 38 qList.addMany(items.map((subPath) => path.join(absPath, subPath)));
39 39
40 if (mediaFile) { 40 if (mediaFile) {
41 const deleted = mediaFile.getChildren() 41 const deleted = mediaFile.getChildren()
42 .filter((child) => !items.includes(child.basename)) 42 .filter((child) => !items.includes(child.basename))
43 .flatMap((child) => 43 .flatMap((child) =>
44 child.kind === MediaFileKind.directory 44 child.kind === MediaFileKind.directory
45 ? child.getRecursiveFileChildren() 45 ? child.getRecursiveFileChildren()
46 : child 46 : child
47 ); 47 );
48 48
49 qMeta.addMany(deleted.map((mediaFile) => ({ 49 qMeta.addMany(deleted.map((mediaFile) => ({
50 absPath: path.join(root, mediaFile.path), 50 absPath: path.join(root, mediaFile.path),
51 publicPath: mediaFile.path, 51 publicPath: mediaFile.path,
52 stat: null, 52 stat: null,
53 mediaFile, 53 mediaFile,
54 }))); 54 })));
55 } 55 }
56 56
57 return; 57 return;
58 } 58 }
59 59
60 // All processes must be performed again if there is no file. 60 // All processes must be performed again if there is no file.
61 if ( 61 if (
62 !mediaFile || 62 !mediaFile ||
63 stat.size !== mediaFile.size || 63 stat.size !== mediaFile.size ||
64 stat.mtime.getTime() !== mediaFile.date.getTime() 64 stat.mtime.getTime() !== mediaFile.date.getTime()
65 ) { 65 ) {
66 qMeta.add({ absPath, publicPath, stat, mediaFile }); 66 qMeta.add({ absPath, publicPath, stat, mediaFile });
67 return; 67 return;
68 } 68 }
69 69
70 // If the scanners changed, it may mean more processes should be run. 70 // If the scanners changed, it may mean more processes should be run.
71 queueProcessors({ absPath, stat, mediaFile }); 71 queueProcessors({ absPath, stat, mediaFile });
72 }, 72 },
73 maxJobs: 24, 73 maxJobs: 24,
74 }); 74 });
75 using qMeta = new async.Queue({ 75 using qMeta = new async.Queue({
76 name: "Update Metadata", 76 name: "Update Metadata",
77 async fn({ absPath, publicPath, stat, mediaFile }: UpdateMetadataJob) { 77 async fn({ absPath, publicPath, stat, mediaFile }: UpdateMetadataJob) {
78 if (!stat) { 78 if (!stat) {
79 // File was deleted. 79 // File was deleted.
80 await runUndoProcessors(UNWRAP(mediaFile)); 80 await runUndoProcessors(UNWRAP(mediaFile));
81 return; 81 return;
82 } 82 }
83 // TODO: run scrubLocationMetadata first 83 // TODO: run scrubLocationMetadata first
84 84
85 const hash = await new Promise<string>((resolve, reject) => { 85 const hash = await new Promise<string>((resolve, reject) => {
86 const reader = fs.createReadStream(absPath); 86 const reader = fs.createReadStream(absPath);
87 reader.on("error", reject); 87 reader.on("error", reject);
88 88
89 const hasher = crypto.createHash("sha1").setEncoding("hex"); 89 const hasher = crypto.createHash("sha1").setEncoding("hex");
90 hasher.on("error", reject); 90 hasher.on("error", reject);
91 hasher.on("readable", () => resolve(hasher.read())); 91 hasher.on("readable", () => resolve(hasher.read()));
92 92
93 reader.pipe(hasher); 93 reader.pipe(hasher);
94 }); 94 });
95 let date = stat.mtime; 95 let date = stat.mtime;
96 if ( 96 if (
97 mediaFile && 97 mediaFile &&
98 mediaFile.date.getTime() < stat.mtime.getTime() && 98 mediaFile.date.getTime() < stat.mtime.getTime() &&
99 (Date.now() - stat.mtime.getTime()) < monthMilliseconds 99 (Date.now() - stat.mtime.getTime()) < monthMilliseconds
100 ) { 100 ) {
101 date = mediaFile.date; 101 date = mediaFile.date;
102 console.warn( 102 console.warn(
103 `M-time on ${publicPath} was likely corrupted. ${ 103 `M-time on ${publicPath} was likely corrupted. ${
104 formatDate(mediaFile.date) 104 formatDate(mediaFile.date)
105 } -> ${formatDate(stat.mtime)}`, 105 } -> ${formatDate(stat.mtime)}`,
106 ); 106 );
107 } 107 }
108 mediaFile = MediaFile.createFile({ 108 mediaFile = MediaFile.createFile({
109 path: publicPath, 109 path: publicPath,
110 date, 110 date,
111 hash, 111 hash,
112 size: stat.size, 112 size: stat.size,
113 duration: mediaFile?.duration ?? 0, 113 duration: mediaFile?.duration ?? 0,
114 dimensions: mediaFile?.dimensions ?? "", 114 dimensions: mediaFile?.dimensions ?? "",
115 contents: mediaFile?.contents ?? "", 115 contents: mediaFile?.contents ?? "",
116 }); 116 });
117 await queueProcessors({ absPath, stat, mediaFile }); 117 await queueProcessors({ absPath, stat, mediaFile });
118 }, 118 },
119 getItemText: (job) => 119 getItemText: (job) =>
120 job.publicPath.slice(1) + (job.stat ? "" : " (deleted)"), 120 job.publicPath.slice(1) + (job.stat ? "" : " (deleted)"),
121 maxJobs: 10, 121 maxJobs: 10,
122 }); 122 });
123 using qProcess = new async.Queue({ 123 using qProcess = new async.Queue({
124 name: "Process Contents", 124 name: "Process Contents",
125 async fn( 125 async fn(
126 { absPath, stat, mediaFile, processor, index, after }: ProcessJob, 126 { absPath, stat, mediaFile, processor, index, after }: ProcessJob,
127 spin, 127 spin,
128 ) { 128 ) {
129 await processor.run({ absPath, stat, mediaFile, spin }); 129 await processor.run({ absPath, stat, mediaFile, spin });
130 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index))); 130 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));
131 for (const dependantJob of after) { 131 for (const dependantJob of after) {
132 ASSERT(dependantJob.needs > 0); 132 ASSERT(dependantJob.needs > 0);
133 dependantJob.needs -= 1; 133 dependantJob.needs -= 1;
134 if (dependantJob.needs == 0) qProcess.add(dependantJob); 134 if (dependantJob.needs == 0) qProcess.add(dependantJob);
135 } 135 }
136 }, 136 },
137 getItemText: ({ mediaFile, processor }) => 137 getItemText: ({ mediaFile, processor }) =>
138 `${mediaFile.path.slice(1)} - ${processor.name}`, 138 `${mediaFile.path.slice(1)} - ${processor.name}`,
139 maxJobs: 4, 139 maxJobs: 4,
140 }); 140 });
141 141
142 function decodeProcessors(input: string) { 142 function decodeProcessors(input: string) {
143 return input 143 return input
144 .split(";") 144 .split(";")
145 .filter(Boolean) 145 .filter(Boolean)
146 .map(([a, b, c]) => ({ 146 .map(([a, b, c]) => ({
147 id: a, 147 id: a,
148 hash: (b.charCodeAt(0) << 8) + c.charCodeAt(0), 148 hash: (b.charCodeAt(0) << 8) + c.charCodeAt(0),
149 })); 149 }));
150 } 150 }
151 151
152 async function queueProcessors( 152 async function queueProcessors(
153 { absPath, stat, mediaFile }: Omit<ProcessFileArgs, "spin">, 153 { absPath, stat, mediaFile }: Omit<ProcessFileArgs, "spin">,
154 ) { 154 ) {
155 const ext = mediaFile.extension.toLowerCase(); 155 const ext = mediaFile.extension.toLowerCase();
156 let possible = processors.filter((p) => p.include.has(ext)); 156 let possible = processors.filter((p) => p.include.has(ext));
157 if (possible.length === 0) return; 157 if (possible.length === 0) return;
158 158
159 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1; 159 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;
160 ASSERT(hash <= 0xFFFF); 160 ASSERT(hash <= 0xFFFF);
161 let processed = mediaFile.processed; 161 let processed = mediaFile.processed;
162 162
163 // If the hash has changed, migrate the bitfield over. 163 // If the hash has changed, migrate the bitfield over.
164 // This also runs when the processor hash is in it's initial 0 state. 164 // This also runs when the processor hash is in it's initial 0 state.
165 const order = decodeProcessors(mediaFile.processors); 165 const order = decodeProcessors(mediaFile.processors);
166 if ((processed & 0xFFFF) !== hash) { 166 if ((processed & 0xFFFF) !== hash) {
167 const previous = order.filter((_, i) => 167 const previous = order.filter((_, i) =>
168 (processed & (1 << (16 + i))) !== 0 168 (processed & (1 << (16 + i))) !== 0
169 ); 169 );
170 processed = hash; 170 processed = hash;
171 for (const { id, hash } of previous) { 171 for (const { id, hash } of previous) {
172 const p = processors.find((p) => p.id === id); 172 const p = processors.find((p) => p.id === id);
173 if (!p) continue; 173 if (!p) continue;
174 const index = possible.indexOf(p); 174 const index = possible.indexOf(p);
175 if (index !== -1 && p.hash === hash) { 175 if (index !== -1 && p.hash === hash) {
176 processed |= 1 << (16 + index); 176 processed |= 1 << (16 + index);
177 } else { 177 } else {
178 if (p.undo) await p.undo(mediaFile); 178 if (p.undo) await p.undo(mediaFile);
179 } 179 }
180 } 180 }
181 mediaFile.setProcessors( 181 mediaFile.setProcessors(
182 processed, 182 processed,
183 possible.map((p) => 183 possible.map((p) =>
184 p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xFF) 184 p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xFF)
185 ).join(";"), 185 ).join(";"),
186 ); 186 );
187 } else { 187 } else {
188 possible = order.map(({ id }) => 188 possible = order.map(({ id }) =>
189 UNWRAP(possible.find((p) => p.id === id)) 189 UNWRAP(possible.find((p) => p.id === id))
190 ); 190 );
191 } 191 }
192 192
193 // Queue needed processors. 193 // Queue needed processors.
194 const jobs: ProcessJob[] = []; 194 const jobs: ProcessJob[] = [];
195 for (let i = 0, { length } = possible; i < length; i += 1) { 195 for (let i = 0, { length } = possible; i < length; i += 1) {
196 if ((processed & (1 << (16 + i))) === 0) { 196 if ((processed & (1 << (16 + i))) === 0) {
197 const job: ProcessJob = { 197 const job: ProcessJob = {
198 absPath, 198 absPath,
199 stat, 199 stat,
200 mediaFile, 200 mediaFile,
201 processor: possible[i], 201 processor: possible[i],
202 index: i, 202 index: i,
203 after: [], 203 after: [],
204 needs: possible[i].depends.length, 204 needs: possible[i].depends.length,
205 }; 205 };
206 jobs.push(job); 206 jobs.push(job);
207 if (job.needs === 0) qProcess.add(job); 207 if (job.needs === 0) qProcess.add(job);
208 } 208 }
209 } 209 }
210 for (const job of jobs) { 210 for (const job of jobs) {
211 for (const dependId of job.processor.depends) { 211 for (const dependId of job.processor.depends) {
212 const dependJob = jobs.find((j) => j.processor.id === dependId); 212 const dependJob = jobs.find((j) => j.processor.id === dependId);
213 if (dependJob) { 213 if (dependJob) {
214 dependJob.after.push(job); 214 dependJob.after.push(job);
215 } else { 215 } else {
216 ASSERT(job.needs > 0); 216 ASSERT(job.needs > 0);
217 job.needs -= 1; 217 job.needs -= 1;
218 if (job.needs === 0) qProcess.add(job); 218 if (job.needs === 0) qProcess.add(job);
219 } 219 }
220 } 220 }
221 } 221 }
222 } 222 }
223 223
224 async function runUndoProcessors(mediaFile: MediaFile) { 224 async function runUndoProcessors(mediaFile: MediaFile) {
225 const { processed } = mediaFile; 225 const { processed } = mediaFile;
226 const previous = decodeProcessors(mediaFile.processors) 226 const previous = decodeProcessors(mediaFile.processors)
227 .filter((_, i) => (processed & (1 << (16 + i))) !== 0); 227 .filter((_, i) => (processed & (1 << (16 + i))) !== 0);
228 for (const { id } of previous) { 228 for (const { id } of previous) {
229 const p = processors.find((p) => p.id === id); 229 const p = processors.find((p) => p.id === id);
230 if (!p) continue; 230 if (!p) continue;
231 if (p.undo) { 231 if (p.undo) {
232 await p.undo(mediaFile); 232 await p.undo(mediaFile);
233 } 233 }
234 } 234 }
235 mediaFile.delete(); 235 mediaFile.delete();
236 } 236 }
237 237
238 // Add the root & recursively iterate! 238 // Add the root & recursively iterate!
239 qList.add(root); 239 qList.add(root);
240 await qList.done(); 240 await qList.done();
241 await qMeta.done(); 241 await qMeta.done();
242 await qProcess.done(); 242 await qProcess.done();
243 243
244 console.info( 244 console.info(
245 "Updated file viewer index in " + 245 "Updated file viewer index in " +
246 ((performance.now() - start) / 1000).toFixed(1) + "s", 246 ((performance.now() - start) / 1000).toFixed(1) + "s",
247 ); 247 );
248} 248}
249 249
250interface Process { 250interface Process {
251 name: string; 251 name: string;
252 enable?: boolean; 252 enable?: boolean;
253 include: Set<string>; 253 include: Set<string>;
254 depends?: string[]; 254 depends?: string[];
255 /* Perform an action. */ 255 version?: number;
256 run(args: ProcessFileArgs): Promise<void>; 256 /* Perform an action. */
257 /* Should detect if `run` was never even run before before undoing state */ 257 run(args: ProcessFileArgs): Promise<void>;
258 undo?(mediaFile: MediaFile): Promise<void>; 258 /* Should detect if `run` was never even run before before undoing state */
259} 259 undo?(mediaFile: MediaFile): Promise<void>;
260 260}
261const execFileRaw = util.promisify(child_process.execFile); 261
262const execFile: typeof execFileRaw = (( 262const execFileRaw = util.promisify(child_process.execFile);
263 ...args: Parameters<typeof execFileRaw> 263const execFile: typeof execFileRaw = ((
264) => 264 ...args: Parameters<typeof execFileRaw>
265 execFileRaw(...args).catch((e: any) => { 265) =>
266 if (e?.message?.startsWith?.("Command failed")) { 266 execFileRaw(...args).catch((e: any) => {
267 if (e.code > (2 ** 31)) e.code |= 0; 267 if (e?.message?.startsWith?.("Command failed")) {
268 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`; 268 if (e.code > (2 ** 31)) e.code |= 0;
269 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`; 269 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
270 } 270 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
271 throw e; 271 }
272 })) as any; 272 throw e;
273const ffprobeBin = testProgram("ffprobe", "--help"); 273 })) as any;
274const ffmpegBin = testProgram("ffmpeg", "--help"); 274const ffprobeBin = testProgram("ffprobe", "--help");
275 275const ffmpegBin = testProgram("ffmpeg", "--help");
276const ffmpegOptions = [ 276
277 "-hide_banner", 277const ffmpegOptions = [
278 "-loglevel", 278 "-hide_banner",
279 "warning", 279 "-loglevel",
280]; 280 "warning",
281 281];
282const procDuration: Process = { 282
283 name: "calculate duration", 283const procDuration: Process = {
284 enable: ffprobeBin !== null, 284 name: "calculate duration",
285 include: rules.extsDuration, 285 enable: ffprobeBin !== null,
286 async run({ absPath, mediaFile }) { 286 include: rules.extsDuration,
287 const { stdout } = await execFile(ffprobeBin!, [ 287 async run({ absPath, mediaFile }) {
288 "-v", 288 const { stdout } = await execFile(ffprobeBin!, [
289 "error", 289 "-v",
290 "-show_entries", 290 "error",
291 "format=duration", 291 "-show_entries",
292 "-of", 292 "format=duration",
293 "default=noprint_wrappers=1:nokey=1", 293 "-of",
294 absPath, 294 "default=noprint_wrappers=1:nokey=1",
295 ]); 295 absPath,
296 296 ]);
297 const duration = parseFloat(stdout.trim()); 297
298 if (Number.isNaN(duration)) { 298 const duration = parseFloat(stdout.trim());
299 throw new Error("Could not extract duration from " + stdout); 299 if (Number.isNaN(duration)) {
300 } 300 throw new Error("Could not extract duration from " + stdout);
301 mediaFile.setDuration(Math.ceil(duration)); 301 }
302 }, 302 mediaFile.setDuration(Math.ceil(duration));
303}; 303 },
304 304};
305// NOTE: Never re-order the processors. Add new ones at the end. 305
306const procDimensions: Process = { 306// NOTE: Never re-order the processors. Add new ones at the end.
307 name: "calculate dimensions", 307const procDimensions: Process = {
308 enable: ffprobeBin != null, 308 name: "calculate dimensions",
309 include: rules.extsDimensions, 309 enable: ffprobeBin != null,
310 async run({ absPath, mediaFile }) { 310 include: rules.extsDimensions,
311 const ext = path.extname(absPath); 311 async run({ absPath, mediaFile }) {
312 312 const ext = path.extname(absPath);
313 let dimensions; 313
314 314 let dimensions;
315 if (ext === ".svg") { 315
316 // Parse out of text data 316 if (ext === ".svg") {
317 const content = await fs.readFile(absPath, "utf8"); 317 // Parse out of text data
318 const widthMatch = content.match(/width="(\d+)"/); 318 const content = await fs.readFile(absPath, "utf8");
319 const heightMatch = content.match(/height="(\d+)"/); 319 const widthMatch = content.match(/width="(\d+)"/);
320 320 const heightMatch = content.match(/height="(\d+)"/);
321 if (widthMatch && heightMatch) { 321
322 dimensions = `${widthMatch[1]}x${heightMatch[1]}`; 322 if (widthMatch && heightMatch) {
323 } 323 dimensions = `${widthMatch[1]}x${heightMatch[1]}`;
324 } else { 324 }
325 // Use ffprobe to observe streams 325 } else {
326 const { stdout } = await execFile("ffprobe", [ 326 // Use ffprobe to observe streams
327 "-v", 327 const { stdout } = await execFile("ffprobe", [
328 "error", 328 "-v",
329 "-select_streams", 329 "error",
330 "v:0", 330 "-select_streams",
331 "-show_entries", 331 "v:0",
332 "stream=width,height", 332 "-show_entries",
333 "-of", 333 "stream=width,height",
334 "csv=s=x:p=0", 334 "-of",
335 absPath, 335 "csv=s=x:p=0",
336 ]); 336 absPath,
337 if (stdout.includes("x")) { 337 ]);
338 dimensions = stdout.trim(); 338 if (stdout.includes("x")) {
339 } 339 dimensions = stdout.trim();
340 } 340 }
341 341 }
342 mediaFile.setDimensions(dimensions ?? ""); 342
343 }, 343 mediaFile.setDimensions(dimensions ?? "");
344}; 344 },
345 345};
346const procLoadTextContents: Process = { 346
347 name: "load text content", 347const procLoadTextContents: Process = {
348 include: rules.extsReadContents, 348 name: "load text content",
349 async run({ absPath, mediaFile, stat }) { 349 include: rules.extsReadContents,
350 if (stat.size > 1_000_000) return; 350 async run({ absPath, mediaFile, stat }) {
351 const text = await fs.readFile(absPath, "utf-8"); 351 if (stat.size > 1_000_000) return;
352 mediaFile.setContents(text); 352 const text = await fs.readFile(absPath, "utf-8");
353 }, 353 mediaFile.setContents(text);
354}; 354 },
355 355};
356const procHighlightCode: Process = { 356
357 name: "highlight source code", 357const procHighlightCode: Process = {
358 include: new Set(rules.extsCode.keys()), 358 name: "highlight source code",
359 async run({ absPath, mediaFile, stat }) { 359 include: new Set(rules.extsCode.keys()),
360 const language = UNWRAP( 360 async run({ absPath, mediaFile, stat }) {
361 rules.extsCode.get(path.extname(absPath).toLowerCase()), 361 const language = UNWRAP(
362 ); 362 rules.extsCode.get(path.extname(absPath).toLowerCase()),
363 // An issue is that .ts is an overloaded extension, shared between 363 );
364 // 'transport stream' and 'typescript'. 364 // An issue is that .ts is an overloaded extension, shared between
365 // 365 // 'transport stream' and 'typescript'.
366 // Filter used here is: 366 //
367 // - more than 1mb 367 // Filter used here is:
368 // - invalid UTF-8 368 // - more than 1mb
369 if (stat.size > 1_000_000) return; 369 // - invalid UTF-8
370 let code; 370 if (stat.size > 1_000_000) return;
371 const buf = await fs.readFile(absPath); 371 let code;
372 try { 372 const buf = await fs.readFile(absPath);
373 code = new TextDecoder("utf-8", { fatal: true }).decode(buf); 373 try {
374 } catch (error) { 374 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
375 mediaFile.setContents(""); 375 } catch (error) {
376 return; 376 mediaFile.setContents("");
377 } 377 return;
378 const content = await highlight.highlightCode(code, language); 378 }
379 mediaFile.setContents(content); 379 const content = await highlight.highlightCode(code, language);
380 }, 380 mediaFile.setContents(content);
381}; 381 },
382 382};
383const procImageSubsets: Process = { 383
384 name: "encode image subsets", 384const procImageSubsets: Process = {
385 include: rules.extsImage, 385 name: "encode image subsets",
386 depends: ["calculate dimensions"], 386 include: rules.extsImage,
387 async run({ absPath, mediaFile, spin }) { 387 depends: ["calculate dimensions"],
388 const { width, height } = UNWRAP(mediaFile.parseDimensions()); 388 version: 2,
389 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width); 389 async run({ absPath, mediaFile, spin }) {
390 const baseStatus = spin.text; 390 const { width, height } = UNWRAP(mediaFile.parseDimensions());
391 391 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
392 using stack = new DisposableStack(); 392 const baseStatus = spin.text;
393 for (const size of targetSizes) { 393
394 const { w, h } = resizeDimensions(width, height, size); 394 using stack = new DisposableStack();
395 for (const { ext, args } of transcodeRules.imagePresets) { 395 for (const size of targetSizes) {
396 spin.text = baseStatus + 396 const { w, h } = resizeDimensions(width, height, size);
397 ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`; 397 for (const { ext, args } of transcodeRules.imagePresets) {
398 398 spin.text = baseStatus +
399 stack.use( 399 ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
400 await produceAsset( 400
401 `${mediaFile.hash}/${size}${ext}`, 401 stack.use(
402 async (out) => { 402 await produceAsset(
403 await fs.mkdir(path.dirname(out)); 403 `${mediaFile.hash}/${size}${ext}`,
404 await fs.rm(out, { force: true }); 404 async (out) => {
405 await execFile(ffmpegBin!, [ 405 await fs.mkdir(path.dirname(out));
406 ...ffmpegOptions, 406 await fs.rm(out, { force: true });
407 "-i", 407 await execFile(ffmpegBin!, [
408 absPath, 408 ...ffmpegOptions,
409 "-vf", 409 "-i",
410 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`, 410 absPath,
411 ...args, 411 "-vf",
412 out, 412 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
413 ]); 413 ...args,
414 return [out]; 414 out,
415 }, 415 ]);
416 ), 416 return [out];
417 ); 417 },
418 } 418 ),
419 } 419 );
420 420 }
421 stack.move(); 421 }
422 }, 422
423 async undo(mediaFile) { 423 stack.move();
424 const { width } = UNWRAP(mediaFile.parseDimensions()); 424 },
425 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width); 425 async undo(mediaFile) {
426 for (const size of targetSizes) { 426 const { width } = UNWRAP(mediaFile.parseDimensions());
427 for (const { ext } of transcodeRules.imagePresets) { 427 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
428 unproduceAsset(`${mediaFile.hash}/${size}${ext}`); 428 for (const size of targetSizes) {
429 } 429 for (const { ext } of transcodeRules.imagePresets) {
430 } 430 unproduceAsset(`${mediaFile.hash}/${size}${ext}`);
431 }, 431 }
432}; 432 }
433const qualityMap: Record<string, string> = { 433 },
434 u: "ultra-high", 434};
435 h: "high", 435const qualityMap: Record<string, string> = {
436 m: "medium", 436 u: "ultra-high",
437 l: "low", 437 h: "high",
438 d: "data-saving", 438 m: "medium",
439}; 439 l: "low",
440const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({ 440 d: "data-saving",
441 name: `encode ${preset.codec} ${UNWRAP(qualityMap[preset.id[1]])}`, 441};
442 include: rules.extsVideo, 442const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
443 enable: ffmpegBin != null, 443 name: `encode ${preset.codec} ${UNWRAP(qualityMap[preset.id[1]])}`,
444 async run({ absPath, mediaFile, spin }) { 444 include: rules.extsVideo,
445 if ((mediaFile.duration ?? 0) < 10) return; 445 enable: ffmpegBin != null,
446 await produceAsset(`${mediaFile.hash}/${preset.id}`, async (base) => { 446 async run({ absPath, mediaFile, spin }) {
447 base = path.dirname(base); 447 if ((mediaFile.duration ?? 0) < 10) return;
448 await fs.mkdir(base); 448 await produceAsset(`${mediaFile.hash}/${preset.id}`, async (base) => {
449 449 base = path.dirname(base);
450 let inputArgs = ["-i", absPath]; 450 await fs.mkdir(base);
451 try { 451
452 const config = await fs.readJson<any>( 452 let inputArgs = ["-i", absPath];
453 path.join( 453 try {
454 path.dirname(absPath), 454 const config = await fs.readJson<any>(
455 path.basename(absPath, path.extname(absPath)) + ".json", 455 path.join(
456 ), 456 path.dirname(absPath),
457 ); 457 path.basename(absPath, path.extname(absPath)) + ".json",
458 if (config.encoder && typeof config.encoder.videoSrc === "string") { 458 ),
459 const { videoSrc, audioSrc, rate } = config.encoder; 459 );
460 inputArgs = [ 460 if (config.encoder && typeof config.encoder.videoSrc === "string") {
461 ...rate ? ["-r", String(rate)] : [], 461 const { videoSrc, audioSrc, rate } = config.encoder;
462 "-i", 462 inputArgs = [
463 videoSrc, 463 ...rate ? ["-r", String(rate)] : [],
464 ...audioSrc ? ["-i", audioSrc] : [], 464 "-i",
465 ]; 465 videoSrc,
466 } 466 ...audioSrc ? ["-i", audioSrc] : [],
467 } catch (err: any) { 467 ];
468 if (err?.code !== "ENOENT") throw err; 468 }
469 } 469 } catch (err: any) {
470 470 if (err?.code !== "ENOENT") throw err;
471 const args = transcodeRules.getVideoArgs( 471 }
472 preset, 472
473 base, 473 const args = transcodeRules.getVideoArgs(
474 inputArgs, 474 preset,
475 ); 475 base,
476 try { 476 inputArgs,
477 const fakeProgress = new Progress({ text: spin.text, spinner: null }); 477 );
478 fakeProgress.stop(); 478 try {
479 spin.format = (now: number) => fakeProgress.format(now); 479 const fakeProgress = new Progress({ text: spin.text, spinner: null });
480 // @ts-expect-error 480 fakeProgress.stop();
481 fakeProgress.redraw = () => spin.redraw(); 481 spin.format = (now: number) => fakeProgress.format(now);
482 482 // @ts-expect-error
483 await ffmpeg.spawn({ 483 fakeProgress.redraw = () => spin.redraw();
484 ffmpeg: ffmpegBin!, 484
485 title: fakeProgress.text, 485 await ffmpeg.spawn({
486 progress: fakeProgress, 486 ffmpeg: ffmpegBin!,
487 args, 487 title: fakeProgress.text,
488 }); 488 progress: fakeProgress,
489 return await collectFiles(); 489 args,
490 } catch (err) { 490 cwd: base,
491 for (const file of await collectFiles()) { 491 });
492 try { 492 return await collectFiles();
493 fs.rm(file); 493 } catch (err) {
494 } catch {} 494 for (const file of await collectFiles()) {
495 } 495 try {
496 throw err; 496 fs.rm(file);
497 } 497 } catch {}
498 498 }
499 async function collectFiles(): Promise<string[]> { 499 throw err;
500 return (await fs.readdir(base)) 500 }
501 .filter((basename) => basename.startsWith(preset.id)) 501
502 .map((basename) => path.join(base, basename)); 502 async function collectFiles(): Promise<string[]> {
503 } 503 return (await fs.readdir(base))
504 }); 504 .filter((basename) => basename.startsWith(preset.id))
505 }, 505 .map((basename) => path.join(base, basename));
506})); 506 }
507 507 });
508const processors = [ 508 },
509 procDimensions, 509}));
510 procDuration, 510
511 procLoadTextContents, 511const processors = [
512 procHighlightCode, 512 procDimensions,
513 procImageSubsets, 513 procDuration,
514 ...procVideos, 514 procLoadTextContents,
515] 515 procHighlightCode,
516 .map((process, id, all) => { 516 procImageSubsets,
517 const strIndex = (id: number) => 517 ...procVideos,
518 String.fromCharCode("a".charCodeAt(0) + id); 518]
519 return { 519 .map((process, id, all) => {
520 ...process as Process, 520 const strIndex = (id: number) =>
521 id: strIndex(id), 521 String.fromCharCode("a".charCodeAt(0) + id);
522 // Create a unique key. 522 return {
523 hash: new Uint16Array( 523 ...process as Process,
524 crypto.createHash("sha1") 524 id: strIndex(id),
525 .update(process.run.toString()) 525 // Create a unique key.
526 .digest().buffer, 526 hash: new Uint16Array(
527 ).reduce((a, b) => a ^ b), 527 crypto.createHash("sha1")
528 depends: (process.depends ?? []).map((depend) => { 528 .update(
529 const index = all.findIndex((p) => p.name === depend); 529 process.run.toString() +
530 if (index === -1) throw new Error(`Cannot find depend '${depend}'`); 530 (process.version ? String(process.version) : ""),
531 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`); 531 )
532 return strIndex(index); 532 .digest().buffer,
533 }), 533 ).reduce((a, b) => a ^ b),
534 }; 534 depends: (process.depends ?? []).map((depend) => {
535 }); 535 const index = all.findIndex((p) => p.name === depend);
536 536 if (index === -1) throw new Error(`Cannot find depend '${depend}'`);
537function resizeDimensions(w: number, h: number, desiredWidth: number) { 537 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`);
538 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`); 538 return strIndex(index);
539 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) }; 539 }),
540} 540 };
541 541 });
542async function produceAsset( 542
543 key: string, 543function resizeDimensions(w: number, h: number, desiredWidth: number) {
544 builder: (prefix: string) => Promise<string[]>, 544 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
545) { 545 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
546 const asset = AssetRef.putOrIncrement(key); 546}
547 try { 547
548 if (asset.refs === 1) { 548async function produceAsset(
549 const paths = await builder(path.join(workDir, key)); 549 key: string,
550 asset.addFiles( 550 builder: (prefix: string) => Promise<string[]>,
551 paths.map((file) => 551) {
552 path.relative(workDir, file) 552 const asset = AssetRef.putOrIncrement(key);
553 .replaceAll("\\", "/") 553 try {
554 ), 554 if (asset.refs === 1) {
555 ); 555 const paths = await builder(path.join(workDir, key));
556 } 556 asset.addFiles(
557 return { 557 paths.map((file) =>
558 [Symbol.dispose]: () => asset.unref(), 558 path.relative(workDir, file)
559 }; 559 .replaceAll("\\", "/")
560 } catch (err: any) { 560 ),
561 if (err && typeof err === "object") err.assetKey = key; 561 );
562 asset.unref(); 562 }
563 throw err; 563 return {
564 } 564 [Symbol.dispose]: () => asset.unref(),
565} 565 };
566 566 } catch (err: any) {
567async function unproduceAsset(key: string) { 567 if (err && typeof err === "object") err.assetKey = key;
568 const ref = AssetRef.get(key); 568 asset.unref();
569 if (ref) { 569 throw err;
570 ref.unref(); 570 }
571 console.log(`unref ${key}`); 571}
572 // TODO: remove associated files from target 572
573 } 573async function unproduceAsset(key: string) {
574} 574 const ref = AssetRef.get(key);
575 575 if (ref) {
576interface UpdateMetadataJob { 576 ref.unref();
577 absPath: string; 577 console.log(`unref ${key}`);
578 publicPath: string; 578 // TODO: remove associated files from target
579 stat: fs.Stats | null; 579 }
580 mediaFile: MediaFile | null; 580}
581} 581
582 582interface UpdateMetadataJob {
583interface ProcessFileArgs { 583 absPath: string;
584 absPath: string; 584 publicPath: string;
585 stat: fs.Stats; 585 stat: fs.Stats | null;
586 mediaFile: MediaFile; 586 mediaFile: MediaFile | null;
587 spin: Spinner; 587}
588} 588
589 589interface ProcessFileArgs {
590interface ProcessJob { 590 absPath: string;
591 absPath: string; 591 stat: fs.Stats;
592 stat: fs.Stats; 592 mediaFile: MediaFile;
593 mediaFile: MediaFile; 593 spin: Spinner;
594 processor: typeof processors[0]; 594}
595 index: number; 595
596 after: ProcessJob[]; 596interface ProcessJob {
597 needs: number; 597 absPath: string;
598} 598 stat: fs.Stats;
599 599 mediaFile: MediaFile;
600export function skipBasename(basename: string): boolean { 600 processor: typeof processors[0];
601 // dot files must be incrementally tracked 601 index: number;
602 if (basename === ".dirsort") return true; 602 after: ProcessJob[];
603 if (basename === ".friends") return true; 603 needs: number;
604 604}
605 return ( 605
606 basename.startsWith(".") || 606export function skipBasename(basename: string): boolean {
607 basename.startsWith("._") || 607 // dot files must be incrementally tracked
608 basename.startsWith(".tmp") || 608 if (basename === ".dirsort") return true;
609 basename === ".DS_Store" || 609 if (basename === ".friends") return true;
610 basename.toLowerCase() === "thumbs.db" || 610
611 basename.toLowerCase() === "desktop.ini" 611 return (
612 ); 612 basename.startsWith(".") ||
613} 613 basename.startsWith("._") ||
614 614 basename.startsWith(".tmp") ||
615export function toPublicPath(absPath: string) { 615 basename === ".DS_Store" ||
616 ASSERT(path.isAbsolute(absPath)); 616 basename.toLowerCase() === "thumbs.db" ||
617 if (absPath === root) return "/"; 617 basename.toLowerCase() === "desktop.ini"
618 return "/" + path.relative(root, absPath).replaceAll("\\", "/"); 618 );
619} 619}
620 620
621export function testProgram(name: string, helpArgument: string) { 621export function toPublicPath(absPath: string) {
622 try { 622 ASSERT(path.isAbsolute(absPath));
623 child_process.spawnSync(name, [helpArgument]); 623 if (absPath === root) return "/";
624 return name; 624 return "/" + path.relative(root, absPath).replaceAll("\\", "/");
625 } catch (err) { 625}
626 console.warn(`Missing or corrupt executable '${name}'`); 626
627 } 627export function testProgram(name: string, helpArgument: string) {
628 return null; 628 try {
629} 629 child_process.spawnSync(name, [helpArgument]);
630 630 return name;
631const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; 631 } catch (err) {
632 632 console.warn(`Missing or corrupt executable '${name}'`);
633import { Progress } from "@paperclover/console/Progress"; 633 }
634import { Spinner } from "@paperclover/console/Spinner"; 634 return null;
635import * as async from "#sitegen/async"; 635}
636import * as fs from "#sitegen/fs"; 636
637 637const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
638import * as path from "node:path"; 638
639import * as child_process from "node:child_process"; 639import { Progress } from "@paperclover/console/Progress";
640import * as util from "node:util"; 640import { Spinner } from "@paperclover/console/Spinner";
641import * as crypto from "node:crypto"; 641import * as async from "#sitegen/async";
642 642import * as fs from "#sitegen/fs";
643import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; 643
644import { AssetRef } from "@/file-viewer/models/AssetRef.ts"; 644import * as path from "node:path";
645import { formatDate } from "@/file-viewer/format.ts"; 645import * as child_process from "node:child_process";
646import * as rules from "@/file-viewer/rules.ts"; 646import * as util from "node:util";
647import * as highlight from "@/file-viewer/highlight.ts"; 647import * as crypto from "node:crypto";
648import * as ffmpeg from "@/file-viewer/ffmpeg.ts"; 648
649import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; 649import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
650import { AssetRef } from "@/file-viewer/models/AssetRef.ts";
651import { formatDate } from "@/file-viewer/format.ts";
652import * as rules from "@/file-viewer/rules.ts";
653import * as highlight from "@/file-viewer/highlight.ts";
654import * as ffmpeg from "@/file-viewer/ffmpeg.ts";
655import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
src/file-viewer/highlight.ts+1-4
...@@ -135,10 +135,7 @@ function highlightLines({...@@ -135,10 +135,7 @@ function highlightLines({
135135
136export const getRegistry = async.once(async () => {136export const getRegistry = async.once(async () => {
137 const wasmBin = await fs.readFile(137 const wasmBin = await fs.readFile(
138 path.join(138 require.resolve("vscode-oniguruma/release/onig.wasm"),
139 import.meta.dirname,
140 "../node_modules/vscode-oniguruma/release/onig.wasm",
141 ),
142 );139 );
143 await oniguruma.loadWASM(wasmBin);140 await oniguruma.loadWASM(wasmBin);
144141
src/file-viewer/models/AssetRef.ts+73-73
...@@ -1,73 +1,73 @@...@@ -1,73 +1,73 @@
1const db = getDb("cache.sqlite"); 1const db = getDb("cache.sqlite");
2db.table( 2db.table(
3 "asset_refs", 3 "asset_refs",
4 /* SQL */ ` 4 /* SQL */ `
5 create table if not exists asset_refs ( 5 create table if not exists asset_refs (
6 id integer primary key autoincrement, 6 id integer primary key autoincrement,
7 key text not null UNIQUE, 7 key text not null UNIQUE,
8 refs integer not null 8 refs integer not null
9 ); 9 );
10 create table if not exists asset_ref_files ( 10 create table if not exists asset_ref_files (
11 file text not null, 11 file text not null,
12 id integer not null, 12 id integer not null,
13 foreign key (id) references asset_refs(id) 13 foreign key (id) references asset_refs(id) ON DELETE CASCADE
14 ); 14 );
15 create index asset_ref_files_id on asset_ref_files(id); 15 create index asset_ref_files_id on asset_ref_files(id);
16`, 16`,
17); 17);
18 18
19/** 19/**
20 * Uncompressed files are read directly from the media store root. Derivied 20 * Uncompressed files are read directly from the media store root. Derivied
21 * assets like compressed files, optimized images, and streamable video are 21 * assets like compressed files, optimized images, and streamable video are
22 * stored in the `derived` folder. After scanning, the derived assets are 22 * stored in the `derived` folder. After scanning, the derived assets are
23 * uploaded into the store (storage1/clofi-derived dataset on NAS). Since 23 * uploaded into the store (storage1/clofi-derived dataset on NAS). Since
24 * multiple files can share the same hash, the number of references is 24 * multiple files can share the same hash, the number of references is
25 * tracked, and the derived content is only produced once. This means if a 25 * tracked, and the derived content is only produced once. This means if a
26 * file is deleted, it should only decrement a reference count; deleting it 26 * file is deleted, it should only decrement a reference count; deleting it
27 * once all references are removed. 27 * once all references are removed.
28 */ 28 */
29export class AssetRef { 29export class AssetRef {
30 /** Key which aws referenced */ 30 /** Key which aws referenced */
31 id!: number; 31 id!: number;
32 key!: string; 32 key!: string;
33 refs!: number; 33 refs!: number;
34 34
35 unref() { 35 unref() {
36 decrementQuery.run(this.key); 36 decrementQuery.run(this.key);
37 deleteUnreferencedQuery.run().changes > 0; 37 deleteUnreferencedQuery.run().changes > 0;
38 } 38 }
39 39
40 addFiles(files: string[]) { 40 addFiles(files: string[]) {
41 for (const file of files) { 41 for (const file of files) {
42 addFileQuery.run({ id: this.id, file }); 42 addFileQuery.run({ id: this.id, file });
43 } 43 }
44 } 44 }
45 45
46 static get(key: string) { 46 static get(key: string) {
47 return getQuery.get(key); 47 return getQuery.get(key);
48 } 48 }
49 49
50 static putOrIncrement(key: string) { 50 static putOrIncrement(key: string) {
51 putOrIncrementQuery.get(key); 51 putOrIncrementQuery.get(key);
52 return UNWRAP(AssetRef.get(key)); 52 return UNWRAP(AssetRef.get(key));
53 } 53 }
54} 54}
55 55
56const getQuery = db.prepare<[key: string]>(/* SQL */ ` 56const getQuery = db.prepare<[key: string]>(/* SQL */ `
57 select * from asset_refs where key = ?; 57 select * from asset_refs where key = ?;
58`).as(AssetRef); 58`).as(AssetRef);
59const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ ` 59const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
60 insert into asset_refs (key, refs) values (?, 1) 60 insert into asset_refs (key, refs) values (?, 1)
61 on conflict(key) do update set refs = refs + 1; 61 on conflict(key) do update set refs = refs + 1;
62`); 62`);
63const decrementQuery = db.prepare<[key: string]>(/* SQL */ ` 63const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
64 update asset_refs set refs = refs - 1 where key = ? and refs > 0; 64 update asset_refs set refs = refs - 1 where key = ? and refs > 0;
65`); 65`);
66const deleteUnreferencedQuery = db.prepare(/* SQL */ ` 66const deleteUnreferencedQuery = db.prepare(/* SQL */ `
67 delete from asset_refs where refs <= 0; 67 delete from asset_refs where refs <= 0;
68`); 68`);
69const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ ` 69const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
70 insert into asset_ref_files (id, file) values ($id, $file); 70 insert into asset_ref_files (id, file) values ($id, $file);
71`); 71`);
72 72
73import { getDb } from "#sitegen/sqlite"; 73import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/FilePermissions.ts+59-59
...@@ -1,59 +1,59 @@...@@ -1,59 +1,59 @@
1const db = getDb("cache.sqlite"); 1const db = getDb("cache.sqlite");
2 2
3db.table( 3db.table(
4 "permissions", 4 "permissions",
5 /* SQL */ ` 5 /* SQL */ `
6 CREATE TABLE IF NOT EXISTS permissions ( 6 CREATE TABLE IF NOT EXISTS permissions (
7 prefix TEXT PRIMARY KEY, 7 prefix TEXT PRIMARY KEY,
8 allow INTEGER NOT NULL 8 allow INTEGER NOT NULL
9 ); 9 );
10`, 10`,
11); 11);
12export class FilePermissions { 12export class FilePermissions {
13 prefix!: string; 13 prefix!: string;
14 /** Currently set to 1 always */ 14 /** Currently set to 1 always */
15 allow!: number; 15 allow!: number;
16 16
17 // -- static ops -- 17 // -- static ops --
18 static getByPrefix(filePath: string): number { 18 static getByPrefix(filePath: string): number {
19 return getByPrefixQuery.get(filePath)?.allow ?? 0; 19 return getByPrefixQuery.get(filePath)?.allow ?? 0;
20 } 20 }
21 21
22 static getExact(filePath: string): number { 22 static getExact(filePath: string): number {
23 return getExactQuery.get(filePath)?.allow ?? 0; 23 return getExactQuery.get(filePath)?.allow ?? 0;
24 } 24 }
25 25
26 static setPermissions(prefix: string, allow: number) { 26 static setPermissions(prefix: string, allow: number) {
27 if (allow) { 27 if (allow) {
28 insertQuery.run({ prefix, allow }); 28 insertQuery.run({ prefix, allow });
29 } else { 29 } else {
30 deleteQuery.run(prefix); 30 deleteQuery.run(prefix);
31 } 31 }
32 } 32 }
33} 33}
34 34
35const getByPrefixQuery = db.prepare< 35const getByPrefixQuery = db.prepare<
36 [prefix: string], 36 [prefix: string],
37 Pick<FilePermissions, "allow"> 37 Pick<FilePermissions, "allow">
38>(/* SQL */ ` 38>(/* SQL */ `
39 SELECT allow 39 SELECT allow
40 FROM permissions 40 FROM permissions
41 WHERE ? GLOB prefix || '*' 41 WHERE ? GLOB prefix || '*'
42 ORDER BY LENGTH(prefix) DESC 42 ORDER BY LENGTH(prefix) DESC
43 LIMIT 1; 43 LIMIT 1;
44`); 44`);
45const getExactQuery = db.prepare< 45const getExactQuery = db.prepare<
46 [file: string], 46 [file: string],
47 Pick<FilePermissions, "allow"> 47 Pick<FilePermissions, "allow">
48>(/* SQL */ ` 48>(/* SQL */ `
49 SELECT allow FROM permissions WHERE ? == prefix 49 SELECT allow FROM permissions WHERE ? == prefix
50`); 50`);
51 51
52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ ` 52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
53 REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow); 53 REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
54`); 54`);
55const deleteQuery = db.prepare<[file: string]>(/* SQL */ ` 55const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
56 DELETE FROM permissions WHERE prefix = ?; 56 DELETE FROM permissions WHERE prefix = ?;
57`); 57`);
58 58
59import { getDb } from "#sitegen/sqlite"; 59import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+436-436
...@@ -1,436 +1,436 @@...@@ -1,436 +1,436 @@
1const db = getDb("cache.sqlite"); 1const db = getDb("cache.sqlite");
2db.table( 2db.table(
3 "media_files", 3 "media_files",
4 /* SQL */ ` 4 /* SQL */ `
5 create table media_files ( 5 create table media_files (
6 id integer primary key autoincrement, 6 id integer primary key autoincrement,
7 parent_id integer, 7 parent_id integer,
8 path text unique, 8 path text unique,
9 kind integer not null, 9 kind integer not null,
10 timestamp integer not null, 10 timestamp integer not null,
11 timestamp_updated integer not null default current_timestamp, 11 timestamp_updated integer not null default current_timestamp,
12 hash text not null, 12 hash text not null,
13 size integer not null, 13 size integer not null,
14 duration integer not null default 0, 14 duration integer not null default 0,
15 dimensions text not null default "", 15 dimensions text not null default "",
16 contents text not null, 16 contents text not null,
17 dirsort text, 17 dirsort text,
18 processed integer not null, 18 processed integer not null,
19 processors text not null default "", 19 processors text not null default "",
20 foreign key (parent_id) references media_files(id) 20 foreign key (parent_id) references media_files(id)
21 ); 21 );
22 -- index for quickly looking up files by path 22 -- index for quickly looking up files by path
23 create index media_files_path on media_files (path); 23 create index media_files_path on media_files (path);
24 -- index for quickly looking up children 24 -- index for quickly looking up children
25 create index media_files_parent_id on media_files (parent_id); 25 create index media_files_parent_id on media_files (parent_id);
26 -- index for quickly looking up recursive file children 26 -- index for quickly looking up recursive file children
27 create index media_files_file_children on media_files (kind, path); 27 create index media_files_file_children on media_files (kind, path);
28 -- index for finding directories that need to be processed 28 -- index for finding directories that need to be processed
29 create index media_files_directory_processed on media_files (kind, processed); 29 create index media_files_directory_processed on media_files (kind, processed);
30`, 30`,
31); 31);
32 32
33export enum MediaFileKind { 33export enum MediaFileKind {
34 directory = 0, 34 directory = 0,
35 file = 1, 35 file = 1,
36} 36}
37export class MediaFile { 37export class MediaFile {
38 id!: number; 38 id!: number;
39 parent_id!: number | null; 39 parent_id!: number | null;
40 /** 40 /**
41 * Has leading slash, does not have `/file` prefix. 41 * Has leading slash, does not have `/file` prefix.
42 * @example "/2025/waterfalls/waterfalls.mp3" 42 * @example "/2025/waterfalls/waterfalls.mp3"
43 */ 43 */
44 path!: string; 44 path!: string;
45 kind!: MediaFileKind; 45 kind!: MediaFileKind;
46 private timestamp!: number; 46 private timestamp!: number;
47 private timestamp_updated!: number; 47 private timestamp_updated!: number;
48 /** for mp3/mp4 files, measured in seconds */ 48 /** for mp3/mp4 files, measured in seconds */
49 duration?: number; 49 duration?: number;
50 /** for images and videos, the dimensions. Two numbers split by `x` */ 50 /** for images and videos, the dimensions. Two numbers split by `x` */
51 dimensions?: string; 51 dimensions?: string;
52 /** 52 /**
53 * sha1 of 53 * sha1 of
54 * - files: the contents 54 * - files: the contents
55 * - directories: the JSON array of strings + the content of `readme.txt` 55 * - directories: the JSON array of strings + the content of `readme.txt`
56 * this is used 56 * this is used
57 * - to inform changes in caching mechanisms (etag, page render cache) 57 * - to inform changes in caching mechanisms (etag, page render cache)
58 * - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd}) 58 * - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
59 */ 59 */
60 hash!: string; 60 hash!: string;
61 /** 61 /**
62 * Depends on the file kind. 62 * Depends on the file kind.
63 * 63 *
64 * - For directories, this is the contents of `readme.txt`, if it exists. 64 * - For directories, this is the contents of `readme.txt`, if it exists.
65 * - Otherwise, it is an empty string. 65 * - Otherwise, it is an empty string.
66 */ 66 */
67 contents!: string; 67 contents!: string;
68 /** 68 /**
69 * For directories, if this is set, it is a JSON-encoded array of the explicit 69 * For directories, if this is set, it is a JSON-encoded array of the explicit
70 * sorting order. Derived off of `.dirsort` files. 70 * sorting order. Derived off of `.dirsort` files.
71 */ 71 */
72 dirsort!: string | null; 72 dirsort!: string | null;
73 /** in bytes */ 73 /** in bytes */
74 size!: number; 74 size!: number;
75 /** 75 /**
76 * 0 - not processed 76 * 0 - not processed
77 * non-zero - processed 77 * non-zero - processed
78 * 78 *
79 * file: a bit-field of the processors. 79 * file: a bit-field of the processors.
80 * directory: this is for re-indexing contents 80 * directory: this is for re-indexing contents
81 */ 81 */
82 processed!: number; 82 processed!: number;
83 processors!: string; 83 processors!: string;
84 84
85 // -- instance ops -- 85 // -- instance ops --
86 get date() { 86 get date() {
87 return new Date(this.timestamp); 87 return new Date(this.timestamp);
88 } 88 }
89 get lastUpdateDate() { 89 get lastUpdateDate() {
90 return new Date(this.timestamp_updated); 90 return new Date(this.timestamp_updated);
91 } 91 }
92 parseDimensions() { 92 parseDimensions() {
93 const dimensions = this.dimensions; 93 const dimensions = this.dimensions;
94 if (!dimensions) return null; 94 if (!dimensions) return null;
95 const [width, height] = dimensions.split("x").map(Number); 95 const [width, height] = dimensions.split("x").map(Number);
96 return { width, height }; 96 return { width, height };
97 } 97 }
98 get basename() { 98 get basename() {
99 return path.basename(this.path); 99 return path.basename(this.path);
100 } 100 }
101 get basenameWithoutExt() { 101 get basenameWithoutExt() {
102 return path.basename(this.path, path.extname(this.path)); 102 return path.basename(this.path, path.extname(this.path));
103 } 103 }
104 get extension() { 104 get extension() {
105 return path.extname(this.path); 105 return path.extname(this.path);
106 } 106 }
107 getChildren() { 107 getChildren() {
108 return MediaFile.getChildren(this.id) 108 return MediaFile.getChildren(this.id)
109 .filter((file) => !file.basename.startsWith(".")); 109 .filter((file) => !file.basename.startsWith("."));
110 } 110 }
111 getPublicChildren() { 111 getPublicChildren() {
112 const children = MediaFile.getChildren(this.id); 112 const children = MediaFile.getChildren(this.id);
113 if (FilePermissions.getByPrefix(this.path) == 0) { 113 if (FilePermissions.getByPrefix(this.path) == 0) {
114 return children.filter(({ path }) => FilePermissions.getExact(path) == 0); 114 return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
115 } 115 }
116 return children; 116 return children;
117 } 117 }
118 getParent() { 118 getParent() {
119 const dirPath = this.path; 119 const dirPath = this.path;
120 if (dirPath === "/") return null; 120 if (dirPath === "/") return null;
121 const parentPath = path.dirname(dirPath); 121 const parentPath = path.dirname(dirPath);
122 if (parentPath === dirPath) return null; 122 if (parentPath === dirPath) return null;
123 const result = MediaFile.getByPath(parentPath); 123 const result = MediaFile.getByPath(parentPath);
124 if (!result) return null; 124 if (!result) return null;
125 ASSERT(result.kind === MediaFileKind.directory); 125 ASSERT(result.kind === MediaFileKind.directory);
126 return result; 126 return result;
127 } 127 }
128 setProcessed(processed: number) { 128 setProcessed(processed: number) {
129 setProcessedQuery.run({ id: this.id, processed }); 129 setProcessedQuery.run({ id: this.id, processed });
130 this.processed = processed; 130 this.processed = processed;
131 } 131 }
132 setProcessors(processed: number, processors: string) { 132 setProcessors(processed: number, processors: string) {
133 setProcessorsQuery.run({ id: this.id, processed, processors }); 133 setProcessorsQuery.run({ id: this.id, processed, processors });
134 this.processed = processed; 134 this.processed = processed;
135 this.processors = processors; 135 this.processors = processors;
136 } 136 }
137 setDuration(duration: number) { 137 setDuration(duration: number) {
138 setDurationQuery.run({ id: this.id, duration }); 138 setDurationQuery.run({ id: this.id, duration });
139 this.duration = duration; 139 this.duration = duration;
140 } 140 }
141 setDimensions(dimensions: string) { 141 setDimensions(dimensions: string) {
142 setDimensionsQuery.run({ id: this.id, dimensions }); 142 setDimensionsQuery.run({ id: this.id, dimensions });
143 this.dimensions = dimensions; 143 this.dimensions = dimensions;
144 } 144 }
145 setContents(contents: string) { 145 setContents(contents: string) {
146 setContentsQuery.run({ id: this.id, contents }); 146 setContentsQuery.run({ id: this.id, contents });
147 this.contents = contents; 147 this.contents = contents;
148 } 148 }
149 getRecursiveFileChildren() { 149 getRecursiveFileChildren() {
150 if (this.kind !== MediaFileKind.directory) return []; 150 if (this.kind !== MediaFileKind.directory) return [];
151 return getChildrenFilesRecursiveQuery.array(this.path + "/"); 151 return getChildrenFilesRecursiveQuery.array(this.path + "/");
152 } 152 }
153 delete() { 153 delete() {
154 deleteCascadeQuery.run({ id: this.id }); 154 deleteCascadeQuery.run({ id: this.id });
155 } 155 }
156 156
157 // -- static ops -- 157 // -- static ops --
158 static getByPath(filePath: string): MediaFile | null { 158 static getByPath(filePath: string): MediaFile | null {
159 const result = getByPathQuery.get(filePath); 159 const result = getByPathQuery.get(filePath);
160 if (result) return result; 160 if (result) return result;
161 if (filePath === "/") { 161 if (filePath === "/") {
162 return Object.assign(new MediaFile(), { 162 return Object.assign(new MediaFile(), {
163 id: 0, 163 id: 0,
164 parent_id: null, 164 parent_id: null,
165 path: "/", 165 path: "/",
166 kind: MediaFileKind.directory, 166 kind: MediaFileKind.directory,
167 timestamp: 0, 167 timestamp: 0,
168 timestamp_updated: Date.now(), 168 timestamp_updated: Date.now(),
169 hash: "0".repeat(40), 169 hash: "0".repeat(40),
170 contents: "the file scanner has not been run yet", 170 contents: "the file scanner has not been run yet",
171 dirsort: null, 171 dirsort: null,
172 size: 0, 172 size: 0,
173 processed: 1, 173 processed: 1,
174 }); 174 });
175 } 175 }
176 return null; 176 return null;
177 } 177 }
178 static createFile({ 178 static createFile({
179 path: filePath, 179 path: filePath,
180 date, 180 date,
181 hash, 181 hash,
182 size, 182 size,
183 duration, 183 duration,
184 dimensions, 184 dimensions,
185 contents, 185 contents,
186 }: CreateFile) { 186 }: CreateFile) {
187 ASSERT( 187 ASSERT(
188 !filePath.includes("\\") && filePath.startsWith("/"), 188 !filePath.includes("\\") && filePath.startsWith("/"),
189 `Invalid path: ${filePath}`, 189 `Invalid path: ${filePath}`,
190 ); 190 );
191 return createFileQuery.getNonNull({ 191 return createFileQuery.getNonNull({
192 path: filePath, 192 path: filePath,
193 parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)), 193 parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
194 timestamp: date.getTime(), 194 timestamp: date.getTime(),
195 timestampUpdated: Date.now(), 195 timestampUpdated: Date.now(),
196 hash, 196 hash,
197 size, 197 size,
198 duration, 198 duration,
199 dimensions, 199 dimensions,
200 contents, 200 contents,
201 }); 201 });
202 } 202 }
203 static getOrPutDirectoryId(filePath: string) { 203 static getOrPutDirectoryId(filePath: string) {
204 ASSERT( 204 ASSERT(
205 !filePath.includes("\\") && filePath.startsWith("/"), 205 !filePath.includes("\\") && filePath.startsWith("/"),
206 `Invalid path: ${filePath}`, 206 `Invalid path: ${filePath}`,
207 ); 207 );
208 filePath = path.normalize(filePath); 208 filePath = path.normalize(filePath);
209 const row = getDirectoryIdQuery.get(filePath); 209 const row = getDirectoryIdQuery.get(filePath);
210 if (row) return row.id; 210 if (row) return row.id;
211 let current = filePath; 211 let current = filePath;
212 let parts = []; 212 let parts = [];
213 let parentId: null | number = null; 213 let parentId: null | number = null;
214 if (filePath === "/") { 214 if (filePath === "/") {
215 return createDirectoryQuery.getNonNull({ 215 return createDirectoryQuery.getNonNull({
216 path: filePath, 216 path: filePath,
217 parentId, 217 parentId,
218 }).id; 218 }).id;
219 } 219 }
220 // walk up the path until we find a directory that exists 220 // walk up the path until we find a directory that exists
221 do { 221 do {
222 parts.unshift(path.basename(current)); 222 parts.unshift(path.basename(current));
223 current = path.dirname(current); 223 current = path.dirname(current);
224 parentId = getDirectoryIdQuery.get(current)?.id ?? null; 224 parentId = getDirectoryIdQuery.get(current)?.id ?? null;
225 } while (parentId == undefined && current !== "/"); 225 } while (parentId == undefined && current !== "/");
226 if (parentId == undefined) { 226 if (parentId == undefined) {
227 parentId = createDirectoryQuery.getNonNull({ 227 parentId = createDirectoryQuery.getNonNull({
228 path: current, 228 path: current,
229 parentId, 229 parentId,
230 }).id; 230 }).id;
231 } 231 }
232 // walk back down the path, creating directories as needed 232 // walk back down the path, creating directories as needed
233 for (const part of parts) { 233 for (const part of parts) {
234 current = path.join(current, part); 234 current = path.join(current, part);
235 ASSERT(parentId != undefined); 235 ASSERT(parentId != undefined);
236 parentId = createDirectoryQuery.getNonNull({ 236 parentId = createDirectoryQuery.getNonNull({
237 path: current, 237 path: current,
238 parentId, 238 parentId,
239 }).id; 239 }).id;
240 } 240 }
241 return parentId; 241 return parentId;
242 } 242 }
243 static markDirectoryProcessed({ 243 static markDirectoryProcessed({
244 id, 244 id,
245 timestamp, 245 timestamp,
246 contents, 246 contents,
247 size, 247 size,
248 hash, 248 hash,
249 dirsort, 249 dirsort,
250 }: MarkDirectoryProcessed) { 250 }: MarkDirectoryProcessed) {
251 markDirectoryProcessedQuery.get({ 251 markDirectoryProcessedQuery.get({
252 id, 252 id,
253 timestamp: timestamp.getTime(), 253 timestamp: timestamp.getTime(),
254 contents, 254 contents,
255 dirsort: dirsort ? JSON.stringify(dirsort) : "", 255 dirsort: dirsort ? JSON.stringify(dirsort) : "",
256 hash, 256 hash,
257 size, 257 size,
258 }); 258 });
259 } 259 }
260 static setProcessed(id: number, processed: number) { 260 static setProcessed(id: number, processed: number) {
261 setProcessedQuery.run({ id, processed }); 261 setProcessedQuery.run({ id, processed });
262 } 262 }
263 static createOrUpdateDirectory(dirPath: string) { 263 static createOrUpdateDirectory(dirPath: string) {
264 const id = MediaFile.getOrPutDirectoryId(dirPath); 264 const id = MediaFile.getOrPutDirectoryId(dirPath);
265 return updateDirectoryQuery.get(id); 265 return updateDirectoryQuery.get(id);
266 } 266 }
267 static getChildren(id: number) { 267 static getChildren(id: number) {
268 return getChildrenQuery.array(id); 268 return getChildrenQuery.array(id);
269 } 269 }
270 static db = db; 270 static db = db;
271} 271}
272 272
273// Create a `file` entry with a given path, date, file hash, size, and duration 273// Create a `file` entry with a given path, date, file hash, size, and duration
274// If the file already exists, update the date and duration. 274// If the file already exists, update the date and duration.
275// If the file exists and the hash is different, sets `compress` to 0. 275// If the file exists and the hash is different, sets `compress` to 0.
276interface CreateFile { 276interface CreateFile {
277 path: string; 277 path: string;
278 date: Date; 278 date: Date;
279 hash: string; 279 hash: string;
280 size: number; 280 size: number;
281 duration: number; 281 duration: number;
282 dimensions: string; 282 dimensions: string;
283 contents: string; 283 contents: string;
284} 284}
285 285
286// Set the `processed` flag true and update the metadata for a directory 286// Set the `processed` flag true and update the metadata for a directory
287export interface MarkDirectoryProcessed { 287export interface MarkDirectoryProcessed {
288 id: number; 288 id: number;
289 timestamp: Date; 289 timestamp: Date;
290 contents: string; 290 contents: string;
291 size: number; 291 size: number;
292 hash: string; 292 hash: string;
293 dirsort: null | string[]; 293 dirsort: null | string[];
294} 294}
295 295
296export interface DirConfig { 296export interface DirConfig {
297 /** Overridden sorting */ 297 /** Overridden sorting */
298 sort: string[]; 298 sort: string[];
299} 299}
300 300
301// -- queries -- 301// -- queries --
302 302
303// Get a directory ID by path, creating it if it doesn't exist 303// Get a directory ID by path, creating it if it doesn't exist
304const createDirectoryQuery = db.prepare< 304const createDirectoryQuery = db.prepare<
305 [{ path: string; parentId: number | null }], 305 [{ path: string; parentId: number | null }],
306 { id: number } 306 { id: number }
307>( 307>(
308 /* SQL */ ` 308 /* SQL */ `
309 insert into media_files ( 309 insert into media_files (
310 path, parent_id, kind, timestamp, hash, size, 310 path, parent_id, kind, timestamp, hash, size,
311 duration, dimensions, contents, dirsort, processed) 311 duration, dimensions, contents, dirsort, processed)
312 values ( 312 values (
313 $path, $parentId, ${MediaFileKind.directory}, 0, '', 0, 313 $path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
314 0, '', '', '', 0) 314 0, '', '', '', 0)
315 returning id; 315 returning id;
316`, 316`,
317); 317);
318const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ ` 318const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
319 SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory}; 319 SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
320`); 320`);
321const createFileQuery = db.prepare<[{ 321const createFileQuery = db.prepare<[{
322 path: string; 322 path: string;
323 parentId: number; 323 parentId: number;
324 timestamp: number; 324 timestamp: number;
325 timestampUpdated: number; 325 timestampUpdated: number;
326 hash: string; 326 hash: string;
327 size: number; 327 size: number;
328 duration: number; 328 duration: number;
329 dimensions: string; 329 dimensions: string;
330 contents: string; 330 contents: string;
331}], void>(/* SQL */ ` 331}], void>(/* SQL */ `
332 insert into media_files ( 332 insert into media_files (
333 path, parent_id, kind, timestamp, timestamp_updated, hash, 333 path, parent_id, kind, timestamp, timestamp_updated, hash,
334 size, duration, dimensions, contents, processed) 334 size, duration, dimensions, contents, processed)
335 values ( 335 values (
336 $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated, 336 $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
337 $hash, $size, $duration, $dimensions, $contents, 0) 337 $hash, $size, $duration, $dimensions, $contents, 0)
338 on conflict(path) do update set 338 on conflict(path) do update set
339 timestamp = excluded.timestamp, 339 timestamp = excluded.timestamp,
340 timestamp_updated = excluded.timestamp_updated, 340 timestamp_updated = excluded.timestamp_updated,
341 duration = excluded.duration, 341 duration = excluded.duration,
342 size = excluded.size, 342 size = excluded.size,
343 contents = excluded.contents, 343 contents = excluded.contents,
344 processed = case 344 processed = case
345 when media_files.hash != excluded.hash then 0 345 when media_files.hash != excluded.hash then 0
346 else media_files.processed 346 else media_files.processed
347 end 347 end
348 returning *; 348 returning *;
349`).as(MediaFile); 349`).as(MediaFile);
350const setProcessedQuery = db.prepare<[{ 350const setProcessedQuery = db.prepare<[{
351 id: number; 351 id: number;
352 processed: number; 352 processed: number;
353}]>(/* SQL */ ` 353}]>(/* SQL */ `
354 update media_files set processed = $processed where id = $id; 354 update media_files set processed = $processed where id = $id;
355`); 355`);
356const setProcessorsQuery = db.prepare<[{ 356const setProcessorsQuery = db.prepare<[{
357 id: number; 357 id: number;
358 processed: number; 358 processed: number;
359 processors: string; 359 processors: string;
360}]>(/* SQL */ ` 360}]>(/* SQL */ `
361 update media_files set 361 update media_files set
362 processed = $processed, 362 processed = $processed,
363 processors = $processors 363 processors = $processors
364 where id = $id; 364 where id = $id;
365`); 365`);
366const setDurationQuery = db.prepare<[{ 366const setDurationQuery = db.prepare<[{
367 id: number; 367 id: number;
368 duration: number; 368 duration: number;
369}]>(/* SQL */ ` 369}]>(/* SQL */ `
370 update media_files set duration = $duration where id = $id; 370 update media_files set duration = $duration where id = $id;
371`); 371`);
372const setDimensionsQuery = db.prepare<[{ 372const setDimensionsQuery = db.prepare<[{
373 id: number; 373 id: number;
374 dimensions: string; 374 dimensions: string;
375}]>(/* SQL */ ` 375}]>(/* SQL */ `
376 update media_files set dimensions = $dimensions where id = $id; 376 update media_files set dimensions = $dimensions where id = $id;
377`); 377`);
378const setContentsQuery = db.prepare<[{ 378const setContentsQuery = db.prepare<[{
379 id: number; 379 id: number;
380 contents: string; 380 contents: string;
381}]>(/* SQL */ ` 381}]>(/* SQL */ `
382 update media_files set contents = $contents where id = $id; 382 update media_files set contents = $contents where id = $id;
383`); 383`);
384const getByPathQuery = db.prepare<[string]>(/* SQL */ ` 384const getByPathQuery = db.prepare<[string]>(/* SQL */ `
385 select * from media_files where path = ?; 385 select * from media_files where path = ?;
386`).as(MediaFile); 386`).as(MediaFile);
387const markDirectoryProcessedQuery = db.prepare<[{ 387const markDirectoryProcessedQuery = db.prepare<[{
388 timestamp: number; 388 timestamp: number;
389 contents: string; 389 contents: string;
390 dirsort: string; 390 dirsort: string;
391 hash: string; 391 hash: string;
392 size: number; 392 size: number;
393 id: number; 393 id: number;
394}]>(/* SQL */ ` 394}]>(/* SQL */ `
395 update media_files set 395 update media_files set
396 processed = 1, 396 processed = 1,
397 timestamp = $timestamp, 397 timestamp = $timestamp,
398 contents = $contents, 398 contents = $contents,
399 dirsort = $dirsort, 399 dirsort = $dirsort,
400 hash = $hash, 400 hash = $hash,
401 size = $size 401 size = $size
402 where id = $id; 402 where id = $id;
403`); 403`);
404const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ ` 404const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
405 update media_files set processed = 0 where id = ?; 405 update media_files set processed = 0 where id = ?;
406`); 406`);
407 407
408const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ ` 408const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
409 select * from media_files where parent_id = ?; 409 select * from media_files where parent_id = ?;
410`).as(MediaFile); 410`).as(MediaFile);
411const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ ` 411const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
412 select * from media_files 412 select * from media_files
413 where path like ? || '%' 413 where path like ? || '%'
414 and kind = ${MediaFileKind.file} 414 and kind = ${MediaFileKind.file}
415`).as(MediaFile); 415`).as(MediaFile);
416const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ ` 416const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
417 with recursive items as ( 417 with recursive items as (
418 select id, parent_id from media_files where id = $id 418 select id, parent_id from media_files where id = $id
419 union all 419 union all
420 select p.id, p.parent_id 420 select p.id, p.parent_id
421 from media_files p 421 from media_files p
422 join items c on p.id = c.parent_id 422 join items c on p.id = c.parent_id
423 where p.parent_id is not null 423 where p.parent_id is not null
424 and not exists ( 424 and not exists (
425 select 1 from media_files child 425 select 1 from media_files child
426 where child.parent_id = p.id 426 where child.parent_id = p.id
427 and child.id <> c.id 427 and child.id <> c.id
428 ) 428 )
429 ) 429 )
430 delete from media_files 430 delete from media_files
431 where id in (select id from items) 431 where id in (select id from items)
432`); 432`);
433 433
434import { getDb } from "#sitegen/sqlite"; 434import { getDb } from "#sitegen/sqlite";
435import * as path from "node:path/posix"; 435import * as path from "node:path/posix";
436import { FilePermissions } from "./FilePermissions.ts"; 436import { FilePermissions } from "./FilePermissions.ts";
src/file-viewer/pages/file.404.tsx+34-34
...@@ -1,34 +1,34 @@...@@ -1,34 +1,34 @@
1import { MediaFile } from "../models/MediaFile.ts"; 1import { MediaFile } from "../models/MediaFile.ts";
2import { MediaPanel } from "../views/clofi.tsx"; 2import { MediaPanel } from "../views/clofi.tsx";
3import { addScript } from "#sitegen"; 3import { addScript } from "#sitegen";
4 4
5export const theme = { 5export const theme = {
6 bg: "#312652", 6 bg: "#312652",
7 fg: "#f0f0ff", 7 fg: "#f0f0ff",
8 primary: "#fabe32", 8 primary: "#fabe32",
9}; 9};
10 10
11export const meta = { title: "file not found" }; 11export const meta = { title: "file not found" };
12 12
13export default function CotyledonPage() { 13export default function CotyledonPage() {
14 addScript("../scripts/canvas_cotyledon.client.ts"); 14 addScript("../scripts/canvas_cotyledon.client.ts");
15 return ( 15 return (
16 <div class="files ctld ctld-sb"> 16 <div class="files ctld ctld-sb">
17 <MediaPanel 17 <MediaPanel
18 file={MediaFile.getByPath("/")!} 18 file={MediaFile.getByPath("/")!}
19 isLast={false} 19 isLast={false}
20 activeFilename={null} 20 activeFilename={null}
21 hasCotyledonCookie={false} 21 hasCotyledonCookie={false}
22 /> 22 />
23 <div class="panel last"> 23 <div class="panel last">
24 <div className="header"></div> 24 <div className="header"></div>
25 <div className="content file-view notfound"> 25 <div className="content file-view notfound">
26 <p>this file does not exist ...</p> 26 <p>this file does not exist ...</p>
27 <p> 27 <p>
28 <a href="/file">return</a> 28 <a href="/file">return</a>
29 </p> 29 </p>
30 </div> 30 </div>
31 </div> 31 </div>
32 </div> 32 </div>
33 ); 33 );
34} 34}
src/file-viewer/rules.ts+147-143
...@@ -1,143 +1,147 @@...@@ -1,143 +1,147 @@
1// -- file extension rules -- 1// -- file extension rules --
2 2
3/** Extensions that must have EXIF/etc data stripped */ 3/** Extensions that must have EXIF/etc data stripped */
4export const extScrubExif = new Set([ 4export const extScrubExif = new Set([
5 ".jpg", 5 ".jpg",
6 ".jpeg", 6 ".jpeg",
7 ".png", 7 ".png",
8 ".mov", 8 ".mov",
9 ".mp4", 9 ".mp4",
10 ".m4a", 10 ".m4a",
11]); 11]);
12/** Extensions that rendered syntax-highlighted code */ 12/** Extensions that rendered syntax-highlighted code */
13export const extsCode = new Map<string, highlight.Language>(Object.entries({ 13export const extsCode = new Map<string, highlight.Language>(Object.entries({
14 ".json": "json", 14 ".json": "json",
15 ".toml": "toml", 15 ".toml": "toml",
16 ".ts": "ts", 16 ".ts": "ts",
17 ".js": "ts", 17 ".js": "ts",
18 ".tsx": "tsx", 18 ".tsx": "tsx",
19 ".jsx": "tsx", 19 ".jsx": "tsx",
20 ".css": "css", 20 ".css": "css",
21 ".py": "python", 21 ".py": "python",
22 ".lua": "lua", 22 ".lua": "lua",
23 ".sh": "shell", 23 ".sh": "shell",
24 ".bat": "dosbatch", 24 ".bat": "dosbatch",
25 ".ps1": "powershell", 25 ".ps1": "powershell",
26 ".cmd": "dosbatch", 26 ".cmd": "dosbatch",
27 ".yaml": "yaml", 27 ".yaml": "yaml",
28 ".yml": "yaml", 28 ".yml": "yaml",
29 ".zig": "zig", 29 ".zig": "zig",
30 ".astro": "astro", 30 ".astro": "astro",
31 ".mdx": "mdx", 31 ".mdx": "mdx",
32 ".xml": "xml", 32 ".xml": "xml",
33 ".jsonc": "json", 33 ".jsonc": "json",
34 ".php": "php", 34 ".php": "php",
35 ".patch": "diff", 35 ".patch": "diff",
36 ".diff": "diff", 36 ".diff": "diff",
37})); 37}));
38/** These files show an audio embed. */ 38/** These files show an audio embed. */
39export const extsAudio = new Set([ 39export const extsAudio = new Set([
40 ".mp3", 40 ".mp3",
41 ".flac", 41 ".flac",
42 ".wav", 42 ".wav",
43 ".ogg", 43 ".ogg",
44 ".m4a", 44 ".m4a",
45]); 45]);
46/** These files show a video embed. */ 46/** These files show a video embed. */
47export const extsVideo = new Set([ 47export const extsVideo = new Set([
48 ".mp4", 48 ".mp4",
49 ".mkv", 49 ".mkv",
50 ".webm", 50 ".webm",
51 ".avi", 51 ".avi",
52 ".mov", 52 ".mov",
53]); 53]);
54/** These files show an image embed */ 54/** These files show an image embed */
55export const extsImage = new Set([ 55export const extsImage = new Set([
56 ".jpg", 56 ".jpg",
57 ".jpeg", 57 ".jpeg",
58 ".png", 58 ".png",
59 ".gif", 59 ".webp",
60 ".webp", 60 ".avif",
61 ".avif", 61 ".heic",
62 ".heic", 62]);
63 ".svg", 63/** These files show an image embed, but aren't optimized */
64]); 64export const extsImageLike = new Set([
65 65 ...extsImage,
66/** These files populate `duration` using `ffprobe` */ 66 ".svg",
67export const extsDuration = new Set([...extsAudio, ...extsVideo]); 67 ".gif",
68/** These files populate `dimensions` using `ffprobe` */ 68]);
69export const extsDimensions = new Set([...extsImage, ...extsVideo]); 69
70 70/** These files populate `duration` using `ffprobe` */
71/** These files read file contents into `contents`, as-is */ 71export const extsDuration = new Set([...extsAudio, ...extsVideo]);
72export const extsReadContents = new Set([".txt", ".chat"]); 72/** These files populate `dimensions` using `ffprobe` */
73 73export const extsDimensions = new Set([...extsImage, ...extsVideo]);
74export const extsArchive = new Set([ 74
75 ".zip", 75/** These files read file contents into `contents`, as-is */
76 ".rar", 76export const extsReadContents = new Set([".txt", ".chat"]);
77 ".7z", 77
78 ".tar", 78export const extsArchive = new Set([
79 ".gz", 79 ".zip",
80 ".bz2", 80 ".rar",
81 ".xz", 81 ".7z",
82]); 82 ".tar",
83 83 ".gz",
84/** 84 ".bz2",
85 * Formats which are already compression formats, meaning a pass 85 ".xz",
86 * through zstd would offer little to negative benefits 86]);
87 */ 87
88export const extsPreCompressed = new Set([ 88/**
89 ...extsAudio, 89 * Formats which are already compression formats, meaning a pass
90 ...extsVideo, 90 * through zstd would offer little to negative benefits
91 ...extsImage, 91 */
92 ...extsArchive, 92export const extsPreCompressed = new Set([
93 // TODO: are any of these NOT good for compression 93 ...extsAudio,
94]); 94 ...extsVideo,
95 95 ...extsImage,
96export function fileIcon( 96 ...extsArchive,
97 file: Pick<MediaFile, "kind" | "basename" | "path">, 97 // TODO: are any of these NOT good for compression
98 dirOpen?: boolean, 98]);
99) { 99
100 const { kind, basename } = file; 100export function fileIcon(
101 if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir"; 101 file: Pick<MediaFile, "kind" | "basename" | "path">,
102 102 dirOpen?: boolean,
103 // -- special cases -- 103) {
104 if (file.path === "/2024/for everyone") return "snow"; 104 const { kind, basename } = file;
105 105 if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
106 // -- basename cases -- 106
107 if (basename === "readme.txt") return "readme"; 107 // -- special cases --
108 108 if (file.path === "/2024/for everyone") return "snow";
109 // -- extension cases -- 109
110 const ext = path.extname(file.basename).toLowerCase(); 110 // -- basename cases --
111 if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion"; 111 if (basename === "readme.txt") return "readme";
112 if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json"; 112
113 if (ext === ".blend") return "blend"; 113 // -- extension cases --
114 if (ext === ".chat") return "chat"; 114 const ext = path.extname(file.basename).toLowerCase();
115 if (ext === ".html") return "webpage"; 115 if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
116 if (ext === ".lnk") return "link"; 116 if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
117 if (ext === ".txt" || ext === ".md") return "text"; 117 if (ext === ".blend") return "blend";
118 118 if (ext === ".chat") return "chat";
119 // -- extension categories -- 119 if (ext === ".html") return "webpage";
120 if (extsVideo.has(ext)) return "video"; 120 if (ext === ".lnk") return "link";
121 if (extsAudio.has(ext)) return "audio"; 121 if (ext === ".txt" || ext === ".md") return "text";
122 if (extsImage.has(ext)) return "image"; 122
123 if (extsArchive.has(ext)) return "archive"; 123 // -- extension categories --
124 if (extsCode.has(ext)) return "code"; 124 if (extsVideo.has(ext)) return "video";
125 125 if (extsAudio.has(ext)) return "audio";
126 return "file"; 126 if (extsImage.has(ext)) return "image";
127} 127 if (extsArchive.has(ext)) return "archive";
128 128 if (extsCode.has(ext)) return "code";
129// -- viewer rules -- 129
130const pathToCanvas = new Map<string, string>(Object.entries({ 130 return "file";
131 "/2017": "2017", 131}
132 "/2018": "2018", 132
133 "/2019": "2019", 133// -- viewer rules --
134 "/2020": "2020", 134const pathToCanvas = new Map<string, string>(Object.entries({
135 "/2021": "2021", 135 "/2017": "2017",
136 "/2022": "2022", 136 "/2018": "2018",
137 "/2023": "2023", 137 "/2019": "2019",
138 "/2024": "2024", 138 "/2020": "2020",
139})); 139 "/2021": "2021",
140 140 "/2022": "2022",
141import type * as highlight from "./highlight.ts"; 141 "/2023": "2023",
142import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; 142 "/2024": "2024",
143import * as path from "node:path"; 143}));
144
145import type * as highlight from "./highlight.ts";
146import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
147import * as path from "node:path";
src/file-viewer/sort.ts+58-58
...@@ -1,58 +1,58 @@...@@ -1,58 +1,58 @@
1export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) { 1export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
2 const children = dir.getPublicChildren(); 2 const children = dir.getPublicChildren();
3 let readme: MediaFile | null = null; 3 let readme: MediaFile | null = null;
4 4
5 const groups = { 5 const groups = {
6 // years 2025 and onwards 6 // years 2025 and onwards
7 years: [] as MediaFile[], 7 years: [] as MediaFile[],
8 // named categories 8 // named categories
9 categories: [] as MediaFile[], 9 categories: [] as MediaFile[],
10 // years 2017 to 2024 10 // years 2017 to 2024
11 cotyledon: [] as MediaFile[], 11 cotyledon: [] as MediaFile[],
12 }; 12 };
13 const colorMap: Record<string, string> = { 13 const colorMap: Record<string, string> = {
14 years: "#a2ff91", 14 years: "#a2ff91",
15 categories: "#9c91ff", 15 categories: "#9c91ff",
16 cotyledon: "#ff91ca", 16 cotyledon: "#ff91ca",
17 }; 17 };
18 for (const child of children) { 18 for (const child of children) {
19 const basename = child.basename; 19 const basename = child.basename;
20 if (basename === "readme.txt") { 20 if (basename === "readme.txt") {
21 readme = child; 21 readme = child;
22 continue; 22 continue;
23 } 23 }
24 24
25 const year = basename.match(/^(\d{4})/); 25 const year = basename.match(/^(\d{4})/);
26 if (year) { 26 if (year) {
27 const n = parseInt(year[1]); 27 const n = parseInt(year[1]);
28 if (n >= 2025) { 28 if (n >= 2025) {
29 groups.years.push(child); 29 groups.years.push(child);
30 } else { 30 } else {
31 groups.cotyledon.push(child); 31 groups.cotyledon.push(child);
32 } 32 }
33 } else { 33 } else {
34 groups.categories.push(child); 34 groups.categories.push(child);
35 } 35 }
36 } 36 }
37 37
38 let sections = []; 38 let sections = [];
39 for (const [key, files] of Object.entries(groups)) { 39 for (const [key, files] of Object.entries(groups)) {
40 if (key === "cotyledon" && !hasCotyledonCookie) { 40 if (key === "cotyledon" && !hasCotyledonCookie) {
41 continue; 41 continue;
42 } 42 }
43 if (key === "years" || key === "cotyledon") { 43 if (key === "years" || key === "cotyledon") {
44 files.sort((a, b) => { 44 files.sort((a, b) => {
45 return b.basename.localeCompare(a.basename); 45 return b.basename.localeCompare(a.basename);
46 }); 46 });
47 } else { 47 } else {
48 files.sort((a, b) => { 48 files.sort((a, b) => {
49 return a.basename.localeCompare(b.basename); 49 return a.basename.localeCompare(b.basename);
50 }); 50 });
51 } 51 }
52 sections.push({ key, titleColor: colorMap[key], files }); 52 sections.push({ key, titleColor: colorMap[key], files });
53 } 53 }
54 54
55 return { readme, sections }; 55 return { readme, sections };
56} 56}
57 57
58import { MediaFile } from "./models/MediaFile.ts"; 58import { MediaFile } from "./models/MediaFile.ts";
src/file-viewer/transcode-rules.ts+27-3
...@@ -99,14 +99,38 @@ export const imagePresets = [...@@ -99,14 +99,38 @@ export const imagePresets = [
99 "6",99 "6",
100 ],100 ],
101 },101 },
102 // TODO: avif102 {
103 ext: ".avif",
104 args: [
105 "-c:v",
106 "libaom-av1",
107 "-crf",
108 "30",
109 "-pix_fmt",
110 "yuv420p10le",
111 ],
112 },
103 {113 {
104 ext: ".jxl",114 ext: ".jxl",
105 args: ["-c:v", "libjxl", "-distance", "0.8", "-effort", "9"],115 args: [
116 "-c:v",
117 "libjxl",
118 "-distance",
119 "0.8",
120 "-effort",
121 "9",
122 "-update",
123 "-frames:v",
124 "1",
125 ],
106 },126 },
107];127];
108128
109export function getVideoArgs(preset: VideoEncodePreset, outbase: string, input: string[]) {129export function getVideoArgs(
130 preset: VideoEncodePreset,
131 outbase: string,
132 input: string[],
133) {
110 const cmd = [...input];134 const cmd = [...input];
111135
112 if (preset.codec === "av1") {136 if (preset.codec === "av1") {
src/file-viewer/views/lofi.css+43-43
...@@ -1,43 +1,43 @@...@@ -1,43 +1,43 @@
1body { 1body {
2 margin: 0; 2 margin: 0;
3 padding: 0; 3 padding: 0;
4} 4}
5#lofi { 5#lofi {
6 padding: 32px; 6 padding: 32px;
7} 7}
8h1 { 8h1 {
9 margin-top: 0; 9 margin-top: 0;
10 font-size: 3em; 10 font-size: 3em;
11 color: var(--primary); 11 color: var(--primary);
12 font-family: monospace; 12 font-family: monospace;
13} 13}
14ul, li { 14ul, li {
15 margin: 0; 15 margin: 0;
16 padding: 0; 16 padding: 0;
17 list-style-type: none; 17 list-style-type: none;
18} 18}
19ul { 19ul {
20 padding-right: 4em; 20 padding-right: 4em;
21} 21}
22li a { 22li a {
23 display: block; 23 display: block;
24 color: white; 24 color: white;
25 line-height: 2em; 25 line-height: 2em;
26 padding: 0 1em; 26 padding: 0 1em;
27 border-radius: 4px; 27 border-radius: 4px;
28} 28}
29li a:hover { 29li a:hover {
30 background-color: rgba(255,255,255,0.2); 30 background-color: rgba(255, 255, 255, 0.2);
31 font-weight: bold; 31 font-weight: bold;
32 text-decoration: none!important; 32 text-decoration: none !important;
33} 33}
34.dir a { 34.dir a {
35 color: #99eeFF 35 color: #99eeff;
36} 36}
37.ext { 37.ext {
38 opacity: 0.5; 38 opacity: 0.5;
39} 39}
40.meta { 40.meta {
41 margin-left: 1em; 41 margin-left: 1em;
42 opacity: 0.75; 42 opacity: 0.75;
43} 43}
src/friend-auth.ts+75-75
...@@ -1,75 +1,75 @@...@@ -1,75 +1,75 @@
1let friendPassword = ""; 1let friendPassword = "";
2try { 2try {
3 friendPassword = require("./friends/hardcoded-password.ts").friendPassword; 3 friendPassword = require("./friends/hardcoded-password.ts").friendPassword;
4} catch {} 4} catch {}
5 5
6export const app = new Hono(); 6export const app = new Hono();
7 7
8const cookieAge = 60 * 60 * 24 * 30; // 1 month 8const cookieAge = 60 * 60 * 24 * 30; // 1 month
9 9
10function checkFriendsCookie(c: Context) { 10function checkFriendsCookie(c: Context) {
11 const cookie = c.req.header("Cookie"); 11 const cookie = c.req.header("Cookie");
12 if (!cookie) return false; 12 if (!cookie) return false;
13 const cookies = cookie.split("; ").map((x) => x.split("=")); 13 const cookies = cookie.split("; ").map((x) => x.split("="));
14 return cookies.some( 14 return cookies.some(
15 (kv) => 15 (kv) =>
16 kv[0].trim() === "friends_password" && 16 kv[0].trim() === "friends_password" &&
17 kv[1].trim() && 17 kv[1].trim() &&
18 kv[1].trim() === friendPassword, 18 kv[1].trim() === friendPassword,
19 ); 19 );
20} 20}
21 21
22export function requireFriendAuth(c: Context) { 22export function requireFriendAuth(c: Context) {
23 const k = c.req.query("password") || c.req.query("k"); 23 const k = c.req.query("password") || c.req.query("k");
24 if (k) { 24 if (k) {
25 if (k === friendPassword) { 25 if (k === friendPassword) {
26 return c.body(null, 303, { 26 return c.body(null, 303, {
27 Location: "/friends", 27 Location: "/friends",
28 "Set-Cookie": 28 "Set-Cookie":
29 `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, 29 `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
30 }); 30 });
31 } else { 31 } else {
32 return c.body(null, 303, { 32 return c.body(null, 303, {
33 Location: "/friends", 33 Location: "/friends",
34 }); 34 });
35 } 35 }
36 } 36 }
37 if (checkFriendsCookie(c)) { 37 if (checkFriendsCookie(c)) {
38 return undefined; 38 return undefined;
39 } else { 39 } else {
40 return serveAsset(c, "/friends/auth", 403); 40 return serveAsset(c, "/friends/auth", 403);
41 } 41 }
42} 42}
43 43
44app.get("/friends", (c) => { 44app.get("/friends", (c) => {
45 const friendAuthChallenge = requireFriendAuth(c); 45 const friendAuthChallenge = requireFriendAuth(c);
46 if (friendAuthChallenge) return friendAuthChallenge; 46 if (friendAuthChallenge) return friendAuthChallenge;
47 return serveAsset(c, "/friends", 200); 47 return serveAsset(c, "/friends", 200);
48}); 48});
49 49
50let incorrectMap: Record<string, boolean> = {}; 50let incorrectMap: Record<string, boolean> = {};
51app.post("/friends", async (c) => { 51app.post("/friends", async (c) => {
52 const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ?? 52 const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ??
53 "unknown"; 53 "unknown";
54 if (incorrectMap[ip]) { 54 if (incorrectMap[ip]) {
55 return serveAsset(c, "/friends/auth/fail", 403); 55 return serveAsset(c, "/friends/auth/fail", 403);
56 } 56 }
57 const data = await c.req.formData(); 57 const data = await c.req.formData();
58 const k = data.get("password"); 58 const k = data.get("password");
59 if (k === friendPassword) { 59 if (k === friendPassword) {
60 return c.body(null, 303, { 60 return c.body(null, 303, {
61 Location: "/friends", 61 Location: "/friends",
62 "Set-Cookie": 62 "Set-Cookie":
63 `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, 63 `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
64 }); 64 });
65 } 65 }
66 incorrectMap[ip] = true; 66 incorrectMap[ip] = true;
67 await setTimeout(2500); 67 await setTimeout(2500);
68 incorrectMap[ip] = false; 68 incorrectMap[ip] = false;
69 return serveAsset(c, "/friends/auth/fail", 403); 69 return serveAsset(c, "/friends/auth/fail", 403);
70}); 70});
71 71
72import { type Context, Hono } from "hono"; 72import { type Context, Hono } from "hono";
73import { serveAsset } from "#sitegen/assets"; 73import { serveAsset } from "#sitegen/assets";
74import { setTimeout } from "node:timers/promises"; 74import { setTimeout } from "node:timers/promises";
75import { getConnInfo } from "#hono/conninfo"; 75import { getConnInfo } from "#hono/conninfo";
src/global.css-1
...@@ -118,4 +118,3 @@ code {...@@ -118,4 +118,3 @@ code {
118 font-family: "rmo", monospace;118 font-family: "rmo", monospace;
119 font-size: inherit;119 font-size: inherit;
120}120}
121
src/pages/resume.css+47-47
...@@ -1,47 +1,47 @@...@@ -1,47 +1,47 @@
1body,html { 1body, html {
2 overflow: hidden; 2 overflow: hidden;
3} 3}
4h1 { 4h1 {
5 color: #f09; 5 color: #f09;
6 margin-bottom: 0; 6 margin-bottom: 0;
7} 7}
8.job { 8.job {
9 padding: 18px; 9 padding: 18px;
10 margin: 1em -18px; 10 margin: 1em -18px;
11 border: 1px solid black; 11 border: 1px solid black;
12} 12}
13.job *, footer * { 13.job *, footer * {
14 margin: 0; 14 margin: 0;
15 padding: 0; 15 padding: 0;
16} 16}
17.job ul { 17.job ul {
18 margin-left: 1em; 18 margin-left: 1em;
19} 19}
20.job li { 20.job li {
21 line-height: 1.5em; 21 line-height: 1.5em;
22} 22}
23.job header, footer { 23.job header, footer {
24 display: grid; 24 display: grid;
25 grid-template-columns: auto max-content; 25 grid-template-columns: auto max-content;
26 grid-template-rows: 1fr 1fr; 26 grid-template-rows: 1fr 1fr;
27} 27}
28footer { 28footer {
29 margin-top: 1.5em; 29 margin-top: 1.5em;
30} 30}
31footer h2 { 31footer h2 {
32 font-size: 1em; 32 font-size: 1em;
33 margin-bottom: 0.5em; 33 margin-bottom: 0.5em;
34} 34}
35 35
36.job header > em, footer > em { 36.job header > em, footer > em {
37 margin-top: 2px; 37 margin-top: 2px;
38 font-size: 1.25em; 38 font-size: 1.25em;
39} 39}
40 40
41header h2, header em, footer h2, footer em { 41header h2, header em, footer h2, footer em {
42 display: inline-block; 42 display: inline-block;
43} 43}
44 header em, footer em { 44header em, footer em {
45 margin-left: 16px!important; 45 margin-left: 16px !important;
46 text-align: right; 46 text-align: right;
47} 47}
src/pages/subscribe.client.ts+97-97
...@@ -1,97 +1,97 @@...@@ -1,97 +1,97 @@
1// @ts-nocheck 1// @ts-nocheck
2// manually obfuscated to make it very difficult to reverse engineer 2// manually obfuscated to make it very difficult to reverse engineer
3// if you want to decode what the email is, visit the page! 3// if you want to decode what the email is, visit the page!
4// stops people from automatically scraping the email address 4// stops people from automatically scraping the email address
5// 5//
6// Unfortunately this needs a rewrite to support Chrome without 6// Unfortunately this needs a rewrite to support Chrome without
7// hardware acceleration and some Linux stuff. I will probably 7// hardware acceleration and some Linux stuff. I will probably
8// go with a proof of work alternative. 8// go with a proof of work alternative.
9requestAnimationFrame(() => { 9requestAnimationFrame(() => {
10 const hash = "SHA"; 10 const hash = "SHA";
11 const a = [ 11 const a = [
12 { parentElement: document.getElementById("subscribe") }, 12 { parentElement: document.getElementById("subscribe") },
13 function (b) { 13 function (b) {
14 let c = 0, d = 0; 14 let c = 0, d = 0;
15 for (let i = 0; i < b.length; i++) { 15 for (let i = 0; i < b.length; i++) {
16 c = (c + b[i] ^ 0xF8) % 8; 16 c = (c + b[i] ^ 0xF8) % 8;
17 d = (c * b[i] ^ 0x82) % 193; 17 d = (c * b[i] ^ 0x82) % 193;
18 } 18 }
19 a[c + 1]()[c](d, b.buffer); 19 a[c + 1]()[c](d, b.buffer);
20 }, 20 },
21 function () { 21 function () {
22 const i = a[4](a[3]()); 22 const i = a[4](a[3]());
23 const b = i.innerText = a.pop(); 23 const b = i.innerText = a.pop();
24 if (a[b.indexOf("@") / 3]) { 24 if (a[b.indexOf("@") / 3]) {
25 i.href = "mailto:" + b; 25 i.href = "mailto:" + b;
26 } 26 }
27 }, 27 },
28 function () { 28 function () {
29 return a[a.length % 10]; 29 return a[a.length % 10];
30 }, 30 },
31 function (x) { 31 function (x) {
32 return x.parentElement; 32 return x.parentElement;
33 }, 33 },
34 function (b, c) { 34 function (b, c) {
35 throw new Uint8Array( 35 throw new Uint8Array(
36 c, 36 c,
37 0, 37 0,
38 64, 38 64,
39 c.parentElement = this[8].call(b.call(this)).location, 39 c.parentElement = this[8].call(b.call(this)).location,
40 ); 40 );
41 }, 41 },
42 function (b, c) { 42 function (b, c) {
43 this.width = 8; 43 this.width = 8;
44 this.height = 16; 44 this.height = 16;
45 b.clearColor(0.5, 0.7, 0.9, 1.0); 45 b.clearColor(0.5, 0.7, 0.9, 1.0);
46 b.clear(16408 ^ this.width ^ this.height); 46 b.clear(16408 ^ this.width ^ this.height);
47 const e = new Uint8Array(4 * this.width * this.height); 47 const e = new Uint8Array(4 * this.width * this.height);
48 b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e); 48 b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e);
49 let parent = a[this.width / 2](this); 49 let parent = a[this.width / 2](this);
50 while (parent.tagName !== "BODY") { 50 while (parent.tagName !== "BODY") {
51 parent = a[2 * this.height / this.width](parent); 51 parent = a[2 * this.height / this.width](parent);
52 } 52 }
53 try { 53 try {
54 let d = [hash, e.length].join("-"); 54 let d = [hash, e.length].join("-");
55 const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e); 55 const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
56 [, d] = a; 56 [, d] = a;
57 b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d); 57 b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
58 } catch (e) { 58 } catch (e) {
59 fetch(e).then(a[5]).catch(a[2]); 59 fetch(e).then(a[5]).catch(a[2]);
60 } 60 }
61 }, 61 },
62 function (b, c) { 62 function (b, c) {
63 const d = a.splice( 63 const d = a.splice(
64 9, 64 9,
65 1, 65 1,
66 [ 66 [
67 a[3]().parentElement.id, 67 a[3]().parentElement.id,
68 c.parentElement.hostname, 68 c.parentElement.hostname,
69 ].join(String.fromCharCode(b)), 69 ].join(String.fromCharCode(b)),
70 ); 70 );
71 var e = new Error(); 71 var e = new Error();
72 Object.defineProperty(e, "stack", { 72 Object.defineProperty(e, "stack", {
73 get() { 73 get() {
74 a[9] = d; 74 a[9] = d;
75 }, 75 },
76 }); 76 });
77 a[2].call(console.log(e)); 77 a[2].call(console.log(e));
78 }, 78 },
79 function () { 79 function () {
80 return this; 80 return this;
81 }, 81 },
82 "[failed to verify your browser]", 82 "[failed to verify your browser]",
83 function (a) { 83 function (a) {
84 a = a.parentElement.ownerDocument.defaultView; 84 a = a.parentElement.ownerDocument.defaultView;
85 return { parentElement: a.navigator.webdriver || a.crypto }; 85 return { parentElement: a.navigator.webdriver || a.crypto };
86 }, 86 },
87 ]; 87 ];
88 try { 88 try {
89 const c = document.querySelector("canvas"); 89 const c = document.querySelector("canvas");
90 const g = c.getContext("webgl2") || c.getContext("webgl"); 90 const g = c.getContext("webgl2") || c.getContext("webgl");
91 a[0].parentElement.innerText = "[...loading...]"; 91 a[0].parentElement.innerText = "[...loading...]";
92 g.field || requestAnimationFrame(a[6].bind(c, g, a[5])); 92 g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
93 } catch { 93 } catch {
94 a.pop(); 94 a.pop();
95 fetch(":").then(a[5]).catch(a[2]); 95 fetch(":").then(a[5]).catch(a[2]);
96 } 96 }
97}); 97});
src/q+a/artifacts.ts+89-89
...@@ -1,89 +1,89 @@...@@ -1,89 +1,89 @@
1// Artifacts used to be a central system in the old data-driven website. 1// Artifacts used to be a central system in the old data-driven website.
2// Now, it simply refers to one of these link presets. Every project has 2// Now, it simply refers to one of these link presets. Every project has
3// one canonical URL, which the questions page can refer to with `@id`. 3// one canonical URL, which the questions page can refer to with `@id`.
4type Artifact = [title: string, url: string, type: ArtifactType]; 4type Artifact = [title: string, url: string, type: ArtifactType];
5type ArtifactType = "music" | "game" | "project" | "video"; 5type ArtifactType = "music" | "game" | "project" | "video";
6export const artifactMap: Record<string, Artifact> = { 6export const artifactMap: Record<string, Artifact> = {
7 // 2025 7 // 2025
8 "in-the-summer": ["in the summer", "", "music"], 8 "in-the-summer": ["in the summer", "", "music"],
9 waterfalls: ["waterfalls", "/waterfalls", "music"], 9 waterfalls: ["waterfalls", "/waterfalls", "music"],
10 lolzip: ["lol.zip", "", "project"], 10 lolzip: ["lol.zip", "", "project"],
11 "g-is-missing": ["g is missing", "", "music"], 11 "g-is-missing": ["g is missing", "", "music"],
12 "im-18-now": ["i'm 18 now", "", "music"], 12 "im-18-now": ["i'm 18 now", "", "music"],
13 "programming-comparison": [ 13 "programming-comparison": [
14 "thursday programming language comparison", 14 "thursday programming language comparison",
15 "", 15 "",
16 "video", 16 "video",
17 ], 17 ],
18 aaaaaaaaa: ["aaaaaaaaa", "", "music"], 18 aaaaaaaaa: ["aaaaaaaaa", "", "music"],
19 "its-snowing": ["it's snowing", "", "video"], 19 "its-snowing": ["it's snowing", "", "video"],
20 // 2023 20 // 2023
21 "iphone-15-review": [ 21 "iphone-15-review": [
22 "iphone 15 review", 22 "iphone 15 review",
23 "/file/2023/iphone%2015%20review/iphone-15-review.mp4", 23 "/file/2023/iphone%2015%20review/iphone-15-review.mp4",
24 "video", 24 "video",
25 ], 25 ],
26 // 2022 26 // 2022
27 mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"], 27 mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "mystery-of-life": [ 28 "mystery-of-life": [
29 "mystery of life", 29 "mystery of life",
30 "/file/2022/mystery-of-life/mystery-of-life.mp4", 30 "/file/2022/mystery-of-life/mystery-of-life.mp4",
31 "music", 31 "music",
32 ], 32 ],
33 // 2021 33 // 2021
34 "top-10000-bread": [ 34 "top-10000-bread": [
35 "top 10000 bread", 35 "top 10000 bread",
36 "https://paperclover.net/file/2021/top-10000-bread/output.mp4", 36 "https://paperclover.net/file/2021/top-10000-bread/output.mp4",
37 "video", 37 "video",
38 ], 38 ],
39 "phoenix-write-soundtrack": [ 39 "phoenix-write-soundtrack": [
40 "Phoenix, WRITE! soundtrack", 40 "Phoenix, WRITE! soundtrack",
41 "/file/2021/phoenix-write/OST", 41 "/file/2021/phoenix-write/OST",
42 "music", 42 "music",
43 ], 43 ],
44 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"], 44 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
45 "money-visual-cover": [ 45 "money-visual-cover": [
46 "money visual cover", 46 "money visual cover",
47 "/file/2021/money-visual-cover/money-visual-cover.mp4", 47 "/file/2021/money-visual-cover/money-visual-cover.mp4",
48 "video", 48 "video",
49 ], 49 ],
50 "i-got-this-thing": [ 50 "i-got-this-thing": [
51 "i got this thing", 51 "i got this thing",
52 "/file/2021/i-got-this-thing/i-got-this-thing.mp4", 52 "/file/2021/i-got-this-thing/i-got-this-thing.mp4",
53 "video", 53 "video",
54 ], 54 ],
55 // 2020 55 // 2020
56 elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"], 56 elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
57 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"], 57 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
58 // 2019 58 // 2019
59 "throw-soundtrack": [ 59 "throw-soundtrack": [
60 "throw soundtrack", 60 "throw soundtrack",
61 "/file/2019/throw/soundtrack", 61 "/file/2019/throw/soundtrack",
62 "music", 62 "music",
63 ], 63 ],
64 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"], 64 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
65 volar: [ 65 volar: [
66 "volar visual cover", 66 "volar visual cover",
67 "/file/2019/volar-visual-cover/volar.mp4", 67 "/file/2019/volar-visual-cover/volar.mp4",
68 "video", 68 "video",
69 ], 69 ],
70 wpm: [ 70 wpm: [
71 "how to read 500 words per minute", 71 "how to read 500 words per minute",
72 "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4", 72 "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
73 "video", 73 "video",
74 ], 74 ],
75 "dice-roll": [ 75 "dice-roll": [
76 "thursday dice roll", 76 "thursday dice roll",
77 "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4", 77 "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
78 "video", 78 "video",
79 ], 79 ],
80 "math-problem": [ 80 "math-problem": [
81 "thursday math problem", 81 "thursday math problem",
82 "/file/2019/thursday-math-problem/thursday-math-problem.mp4", 82 "/file/2019/thursday-math-problem/thursday-math-problem.mp4",
83 "video", 83 "video",
84 ], 84 ],
85 // 2018 85 // 2018
86 // 2017 86 // 2017
87 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"], 87 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
88 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"], 88 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
89}; 89};
src/q+a/backend.ts+228-228
...@@ -1,228 +1,228 @@...@@ -1,228 +1,228 @@
1const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY; 1const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
2 2
3export const app = new Hono(); 3export const app = new Hono();
4 4
5// Main page 5// Main page
6app.get("/q+a", async (c) => { 6app.get("/q+a", async (c) => {
7 if (hasAdminToken(c)) { 7 if (hasAdminToken(c)) {
8 return serveAsset(c, "/admin/q+a", 200); 8 return serveAsset(c, "/admin/q+a", 200);
9 } 9 }
10 return serveAsset(c, "/q+a", 200); 10 return serveAsset(c, "/q+a", 200);
11}); 11});
12 12
13// Submit form 13// Submit form
14app.post("/q+a", async (c) => { 14app.post("/q+a", async (c) => {
15 const form = await c.req.formData(); 15 const form = await c.req.formData();
16 let text = form.get("text"); 16 let text = form.get("text");
17 if (typeof text !== "string") { 17 if (typeof text !== "string") {
18 return questionFailure(c, 400, "Bad Request"); 18 return questionFailure(c, 400, "Bad Request");
19 } 19 }
20 text = text.trim(); 20 text = text.trim();
21 const input = { 21 const input = {
22 date: new Date(), 22 date: new Date(),
23 prompt: text, 23 prompt: text,
24 sourceName: "unknown", 24 sourceName: "unknown",
25 sourceLocation: "unknown", 25 sourceLocation: "unknown",
26 sourceVPN: null, 26 sourceVPN: null,
27 }; 27 };
28 28
29 input.date.setMilliseconds(0); 29 input.date.setMilliseconds(0);
30 30
31 if (text.length <= 0) { 31 if (text.length <= 0) {
32 return questionFailure(c, 400, "Content is too short", text); 32 return questionFailure(c, 400, "Content is too short", text);
33 } 33 }
34 34
35 if (text.length > 16000) { 35 if (text.length > 16000) {
36 return questionFailure(c, 400, "Content is too long", text); 36 return questionFailure(c, 400, "Content is too long", text);
37 } 37 }
38 38
39 // Ban patterns 39 // Ban patterns
40 if ( 40 if (
41 text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN 41 text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
42 ) { 42 ) {
43 // To prevent known automatic spam-bots from noticing something automatic is 43 // To prevent known automatic spam-bots from noticing something automatic is
44 // happening, pretend that the question was successfully submitted. 44 // happening, pretend that the question was successfully submitted.
45 return sendSuccess(c, new Date()); 45 return sendSuccess(c, new Date());
46 } 46 }
47 47
48 const ipAddr = c.req.header("cf-connecting-ip"); 48 const ipAddr = c.req.header("cf-connecting-ip");
49 if (ipAddr) { 49 if (ipAddr) {
50 input.sourceName = uniqueNamesGenerator({ 50 input.sourceName = uniqueNamesGenerator({
51 dictionaries: [adjectives, colors, animals], 51 dictionaries: [adjectives, colors, animals],
52 separator: "-", 52 separator: "-",
53 seed: ipAddr + PROXYCHECK_API_KEY, 53 seed: ipAddr + PROXYCHECK_API_KEY,
54 }); 54 });
55 } 55 }
56 56
57 const cfIPCountry = c.req.header("cf-ipcountry"); 57 const cfIPCountry = c.req.header("cf-ipcountry");
58 if (cfIPCountry) { 58 if (cfIPCountry) {
59 input.sourceLocation = cfIPCountry; 59 input.sourceLocation = cfIPCountry;
60 } 60 }
61 61
62 if (ipAddr && PROXYCHECK_API_KEY) { 62 if (ipAddr && PROXYCHECK_API_KEY) {
63 const proxyCheck = await fetch( 63 const proxyCheck = await fetch(
64 `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`, 64 `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
65 { 65 {
66 method: "POST", 66 method: "POST",
67 body: "ips=" + ipAddr, 67 body: "ips=" + ipAddr,
68 headers: { 68 headers: {
69 "Content-Type": "application/x-www-form-urlencoded", 69 "Content-Type": "application/x-www-form-urlencoded",
70 }, 70 },
71 }, 71 },
72 ).then((res) => res.json()); 72 ).then((res) => res.json());
73 73
74 if (ipAddr && proxyCheck[ipAddr]) { 74 if (ipAddr && proxyCheck[ipAddr]) {
75 if (proxyCheck[ipAddr].proxy === "yes") { 75 if (proxyCheck[ipAddr].proxy === "yes") {
76 input.sourceVPN = proxyCheck[ipAddr].operator?.name ?? 76 input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
77 proxyCheck[ipAddr].organisation ?? 77 proxyCheck[ipAddr].organisation ??
78 proxyCheck[ipAddr].provider ?? "unknown"; 78 proxyCheck[ipAddr].provider ?? "unknown";
79 } 79 }
80 if (Number(proxyCheck[ipAddr].risk) > 72) { 80 if (Number(proxyCheck[ipAddr].risk) > 72) {
81 return questionFailure( 81 return questionFailure(
82 c, 82 c,
83 403, 83 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.", 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.",
85 text, 85 text,
86 ); 86 );
87 } 87 }
88 } 88 }
89 } 89 }
90 90
91 const date = Question.create( 91 const date = Question.create(
92 QuestionType.pending, 92 QuestionType.pending,
93 JSON.stringify(input), 93 JSON.stringify(input),
94 input.date, 94 input.date,
95 ); 95 );
96 await sendSuccess(c, date); 96 await sendSuccess(c, date);
97}); 97});
98async function sendSuccess(c: Context, date: Date) { 98async function sendSuccess(c: Context, date: Date) {
99 if (c.req.header("Accept")?.includes("application/json")) { 99 if (c.req.header("Accept")?.includes("application/json")) {
100 return c.json({ 100 return c.json({
101 success: true, 101 success: true,
102 message: "ok", 102 message: "ok",
103 date: date.getTime(), 103 date: date.getTime(),
104 id: formatQuestionId(date), 104 id: formatQuestionId(date),
105 }, { status: 200 }); 105 }, { status: 200 });
106 } 106 }
107 c.res = await renderView(c, "q+a/success", { 107 c.res = await renderView(c, "q+a/success", {
108 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`, 108 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
109 }); 109 });
110} 110}
111// Question Permalink 111// Question Permalink
112app.get("/q+a/:id", async (c, next) => { 112app.get("/q+a/:id", async (c, next) => {
113 // from deadname era, the seconds used to be in the url. 113 // from deadname era, the seconds used to be in the url.
114 // this was removed so that the url can be crafted by hand. 114 // this was removed so that the url can be crafted by hand.
115 let id = c.req.param("id"); 115 let id = c.req.param("id");
116 if (id.length === 12 && /^\d+$/.test(id)) { 116 if (id.length === 12 && /^\d+$/.test(id)) {
117 return c.redirect(`/q+a/${id.slice(0, 10)}`); 117 return c.redirect(`/q+a/${id.slice(0, 10)}`);
118 } 118 }
119 let image = false; 119 let image = false;
120 if (id.endsWith(".png")) { 120 if (id.endsWith(".png")) {
121 image = true; 121 image = true;
122 id = id.slice(0, -4); 122 id = id.slice(0, -4);
123 } 123 }
124 124
125 const timestamp = questionIdToTimestamp(id); 125 const timestamp = questionIdToTimestamp(id);
126 if (!timestamp) return next(); 126 if (!timestamp) return next();
127 const question = Question.getByDate(timestamp); 127 const question = Question.getByDate(timestamp);
128 if (!question) return next(); 128 if (!question) return next();
129 129
130 if (image) { 130 if (image) {
131 return getQuestionImage(question, c.req.method === "HEAD"); 131 return getQuestionImage(question, c.req.method === "HEAD");
132 } 132 }
133 return renderView(c, "q+a/permalink", { question }); 133 return renderView(c, "q+a/permalink", { question });
134}); 134});
135 135
136// Admin 136// Admin
137app.get("/admin/q+a", async (c) => { 137app.get("/admin/q+a", async (c) => {
138 return serveAsset(c, "/admin/q+a", 200); 138 return serveAsset(c, "/admin/q+a", 200);
139}); 139});
140app.get("/admin/q+a/inbox", async (c) => { 140app.get("/admin/q+a/inbox", async (c) => {
141 return renderView(c, "q+a/backend-inbox", {}); 141 return renderView(c, "q+a/backend-inbox", {});
142}); 142});
143app.delete("/admin/q+a/:id", async (c, next) => { 143app.delete("/admin/q+a/:id", async (c, next) => {
144 const id = c.req.param("id"); 144 const id = c.req.param("id");
145 const timestamp = questionIdToTimestamp(id); 145 const timestamp = questionIdToTimestamp(id);
146 if (!timestamp) return next(); 146 if (!timestamp) return next();
147 const question = Question.getByDate(timestamp); 147 const question = Question.getByDate(timestamp);
148 if (!question) return next(); 148 if (!question) return next();
149 const deleteFull = c.req.header("X-Delete-Full") === "true"; 149 const deleteFull = c.req.header("X-Delete-Full") === "true";
150 if (deleteFull) { 150 if (deleteFull) {
151 Question.deleteByQmid(question.qmid); 151 Question.deleteByQmid(question.qmid);
152 } else { 152 } else {
153 Question.rejectByQmid(question.qmid); 153 Question.rejectByQmid(question.qmid);
154 } 154 }
155 return c.json({ success: true, message: "ok" }); 155 return c.json({ success: true, message: "ok" });
156}); 156});
157app.patch("/admin/q+a/:id", async (c, next) => { 157app.patch("/admin/q+a/:id", async (c, next) => {
158 const id = c.req.param("id"); 158 const id = c.req.param("id");
159 const timestamp = questionIdToTimestamp(id); 159 const timestamp = questionIdToTimestamp(id);
160 if (!timestamp) return next(); 160 if (!timestamp) return next();
161 const question = Question.getByDate(timestamp); 161 const question = Question.getByDate(timestamp);
162 if (!question) return next(); 162 if (!question) return next();
163 const form = await c.req.raw.json(); 163 const form = await c.req.raw.json();
164 if (typeof form.text !== "string" || typeof form.type !== "number") { 164 if (typeof form.text !== "string" || typeof form.type !== "number") {
165 return questionFailure(c, 400, "Bad Request"); 165 return questionFailure(c, 400, "Bad Request");
166 } 166 }
167 Question.updateByQmid(question.qmid, form.text, form.type); 167 Question.updateByQmid(question.qmid, form.text, form.type);
168 return c.json({ success: true, message: "ok" }); 168 return c.json({ success: true, message: "ok" });
169}); 169});
170app.get("/admin/q+a/:id", async (c, next) => { 170app.get("/admin/q+a/:id", async (c, next) => {
171 const id = c.req.param("id"); 171 const id = c.req.param("id");
172 const timestamp = questionIdToTimestamp(id); 172 const timestamp = questionIdToTimestamp(id);
173 if (!timestamp) return next(); 173 if (!timestamp) return next();
174 const question = Question.getByDate(timestamp); 174 const question = Question.getByDate(timestamp);
175 if (!question) return next(); 175 if (!question) return next();
176 176
177 let pendingInfo: null | PendingQuestionData = null; 177 let pendingInfo: null | PendingQuestionData = null;
178 if (question.type === QuestionType.pending) { 178 if (question.type === QuestionType.pending) {
179 pendingInfo = JSON.parse(question.text) as PendingQuestionData; 179 pendingInfo = JSON.parse(question.text) as PendingQuestionData;
180 question.text = pendingInfo.prompt.trim().split("\n").map((line) => 180 question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
181 line.trim().length === 0 ? "" : `q: ${line.trim()}` 181 line.trim().length === 0 ? "" : `q: ${line.trim()}`
182 ).join("\n") + "\n\n"; 182 ).join("\n") + "\n\n";
183 question.type = QuestionType.normal; 183 question.type = QuestionType.normal;
184 } 184 }
185 185
186 return renderView(c, "q+a/editor", { 186 return renderView(c, "q+a/editor", {
187 pendingInfo, 187 pendingInfo,
188 question, 188 question,
189 }); 189 });
190}); 190});
191 191
192app.get("/q+a/things/random", async (c) => { 192app.get("/q+a/things/random", async (c) => {
193 c.res = await renderView(c, "q+a/things-random", {}); 193 c.res = await renderView(c, "q+a/things-random", {});
194}); 194});
195 195
196async function questionFailure( 196async function questionFailure(
197 c: Context, 197 c: Context,
198 status: ContentfulStatusCode, 198 status: ContentfulStatusCode,
199 message: string, 199 message: string,
200 content?: string, 200 content?: string,
201) { 201) {
202 if (c.req.header("Accept")?.includes("application/json")) { 202 if (c.req.header("Accept")?.includes("application/json")) {
203 return c.json({ success: false, message, id: null }, { status }); 203 return c.json({ success: false, message, id: null }, { status });
204 } 204 }
205 return await renderView(c, "q+a/fail", { 205 return await renderView(c, "q+a/fail", {
206 error: message, 206 error: message,
207 content, 207 content,
208 }); 208 });
209} 209}
210 210
211import { type Context, Hono } from "#hono"; 211import { type Context, Hono } from "#hono";
212import type { ContentfulStatusCode } from "hono/utils/http-status"; 212import type { ContentfulStatusCode } from "hono/utils/http-status";
213import { 213import {
214 adjectives, 214 adjectives,
215 animals, 215 animals,
216 colors, 216 colors,
217 uniqueNamesGenerator, 217 uniqueNamesGenerator,
218} from "unique-names-generator"; 218} from "unique-names-generator";
219import { hasAdminToken } from "../admin.ts"; 219import { hasAdminToken } from "../admin.ts";
220import { serveAsset } from "#sitegen/assets"; 220import { serveAsset } from "#sitegen/assets";
221import { 221import {
222 PendingQuestion, 222 PendingQuestion,
223 PendingQuestionData, 223 PendingQuestionData,
224} from "./models/PendingQuestion.ts"; 224} from "./models/PendingQuestion.ts";
225import { Question, QuestionType } from "./models/Question.ts"; 225import { Question, QuestionType } from "./models/Question.ts";
226import { renderView } from "#sitegen/view"; 226import { renderView } from "#sitegen/view";
227import { getQuestionImage } from "./image.tsx"; 227import { getQuestionImage } from "./image.tsx";
228import { formatQuestionId, questionIdToTimestamp } from "./format.ts"; 228import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
src/q+a/format.ts+39-39
...@@ -1,39 +1,39 @@...@@ -1,39 +1,39 @@
1const dateFormat = new Intl.DateTimeFormat("sv", { 1const dateFormat = new Intl.DateTimeFormat("sv", {
2 timeZone: "EST", 2 timeZone: "EST",
3 year: "numeric", 3 year: "numeric",
4 month: "2-digit", 4 month: "2-digit",
5 hour: "2-digit", 5 hour: "2-digit",
6 day: "2-digit", 6 day: "2-digit",
7 minute: "2-digit", 7 minute: "2-digit",
8}); 8});
9 9
10// YYYY-MM-DD HH:MM 10// YYYY-MM-DD HH:MM
11export function formatQuestionTimestamp(date: Date) { 11export function formatQuestionTimestamp(date: Date) {
12 return dateFormat.format(date); 12 return dateFormat.format(date);
13} 13}
14 14
15// YYYY-MM-DDTHH:MM:00Z 15// YYYY-MM-DDTHH:MM:00Z
16export function formatQuestionISOTimestamp(date: Date) { 16export function formatQuestionISOTimestamp(date: Date) {
17 const str = dateFormat.format(date); 17 const str = dateFormat.format(date);
18 return `${str.slice(0, 10)}T${str.slice(11)}-05:00`; 18 return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
19} 19}
20 20
21// YYMMDDHHMM 21// YYMMDDHHMM
22export function formatQuestionId(date: Date) { 22export function formatQuestionId(date: Date) {
23 return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12); 23 return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
24} 24}
25 25
26export function questionIdToTimestamp(id: string) { 26export function questionIdToTimestamp(id: string) {
27 if (id.length !== 10 || !/^\d+$/.test(id)) { 27 if (id.length !== 10 || !/^\d+$/.test(id)) {
28 return null; 28 return null;
29 } 29 }
30 const date = new Date( 30 const date = new Date(
31 `20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${ 31 `20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
32 id.slice(6, 8) 32 id.slice(6, 8)
33 }:${id.slice(8, 10)}:00 EST`, 33 }:${id.slice(8, 10)}:00 EST`,
34 ); 34 );
35 if (isNaN(date.getTime())) { 35 if (isNaN(date.getTime())) {
36 return null; 36 return null;
37 } 37 }
38 return date; 38 return date;
39} 39}
src/q+a/image.tsx+81-81
...@@ -1,81 +1,81 @@...@@ -1,81 +1,81 @@
1const width = 768; 1const width = 768;
2const cacheImageDir = path.resolve(".clover/question_images"); 2const cacheImageDir = path.resolve(".clover/question_images");
3 3
4// Cached browser session 4// Cached browser session
5const getBrowser = RefCountedExpirable( 5const getBrowser = RefCountedExpirable(
6 () => 6 () =>
7 puppeteer.launch({ 7 puppeteer.launch({
8 args: ["--no-sandbox", "--disable-setuid-sandbox"], 8 args: ["--no-sandbox", "--disable-setuid-sandbox"],
9 }), 9 }),
10 (b) => b.close(), 10 (b) => b.close(),
11); 11);
12 12
13export async function renderQuestionImage(question: Question) { 13export async function renderQuestionImage(question: Question) {
14 const html = await renderViewToString("q+a/image-embed", { question }); 14 const html = await renderViewToString("q+a/image-embed", { question });
15 15
16 // this browser session will be reused if multiple images are generated 16 // this browser session will be reused if multiple images are generated
17 // either at the same time or within a 5-minute time span. the dispose 17 // either at the same time or within a 5-minute time span. the dispose
18 // symbol 18 // symbol
19 using sharedBrowser = await getBrowser(); 19 using sharedBrowser = await getBrowser();
20 const b = sharedBrowser.value; 20 const b = sharedBrowser.value;
21 21
22 const p = await b.newPage(); 22 const p = await b.newPage();
23 await p.setViewport({ width, height: 400 }); 23 await p.setViewport({ width, height: 400 });
24 await p.setContent(html); 24 await p.setContent(html);
25 try { 25 try {
26 await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 }); 26 await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
27 } catch (e) {} 27 } catch (e) {}
28 const height = await p.evaluate(() => { 28 const height = await p.evaluate(() => {
29 const e = document.querySelector("main")!; 29 const e = document.querySelector("main")!;
30 return e.getBoundingClientRect().height; 30 return e.getBoundingClientRect().height;
31 }); 31 });
32 const buf = await p.screenshot({ 32 const buf = await p.screenshot({
33 path: "screenshot.png", 33 path: "screenshot.png",
34 type: "png", 34 type: "png",
35 captureBeyondViewport: true, 35 captureBeyondViewport: true,
36 clip: { x: 0, width, y: 0, height: height, scale: 1.5 }, 36 clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
37 }); 37 });
38 await p.close(); 38 await p.close();
39 39
40 return Buffer.from(buf); 40 return Buffer.from(buf);
41} 41}
42 42
43export async function getQuestionImage( 43export async function getQuestionImage(
44 question: Question, 44 question: Question,
45 headOnly: boolean, 45 headOnly: boolean,
46): Promise<Response> { 46): Promise<Response> {
47 const hash = crypto.createHash("sha1") 47 const hash = crypto.createHash("sha1")
48 .update(question.qmid + question.type + question.text) 48 .update(question.qmid + question.type + question.text)
49 .digest("hex"); 49 .digest("hex");
50 50
51 const headers = { 51 const headers = {
52 "Content-Type": "image/png", 52 "Content-Type": "image/png",
53 "Cache-Control": "public, max-age=31536000", 53 "Cache-Control": "public, max-age=31536000",
54 "ETag": `"${hash}"`, 54 "ETag": `"${hash}"`,
55 "Last-Modified": question.date.toUTCString(), 55 "Last-Modified": question.date.toUTCString(),
56 }; 56 };
57 57
58 if (headOnly) { 58 if (headOnly) {
59 return new Response(null, { headers }); 59 return new Response(null, { headers });
60 } 60 }
61 61
62 const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`); 62 const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
63 let buf: Buffer; 63 let buf: Buffer;
64 try { 64 try {
65 buf = await fs.readFile(cachedFilePath); 65 buf = await fs.readFile(cachedFilePath);
66 } catch (e: any) { 66 } catch (e: any) {
67 if (e.code !== "ENOENT") throw e; 67 if (e.code !== "ENOENT") throw e;
68 buf = await renderQuestionImage(question); 68 buf = await renderQuestionImage(question);
69 fs.writeMkdir(cachedFilePath, buf).catch(() => {}); 69 fs.writeMkdir(cachedFilePath, buf).catch(() => {});
70 } 70 }
71 71
72 return new Response(buf, { headers }); 72 return new Response(buf, { headers });
73} 73}
74 74
75import * as crypto from "node:crypto"; 75import * as crypto from "node:crypto";
76import * as fs from "#sitegen/fs"; 76import * as fs from "#sitegen/fs";
77import * as path from "node:path"; 77import * as path from "node:path";
78import * as puppeteer from "puppeteer"; 78import * as puppeteer from "puppeteer";
79import { Question } from "@/q+a/models/Question.ts"; 79import { Question } from "@/q+a/models/Question.ts";
80import { RefCountedExpirable } from "#sitegen/async"; 80import { RefCountedExpirable } from "#sitegen/async";
81import { renderViewToString } from "#sitegen/view"; 81import { renderViewToString } from "#sitegen/view";
src/q+a/scripts/editor.client.tsx+116-116
...@@ -1,116 +1,116 @@...@@ -1,116 +1,116 @@
1import { EditorState } from "@codemirror/state"; 1import { EditorState } from "@codemirror/state";
2import { basicSetup, EditorView } from "codemirror"; 2import { basicSetup, EditorView } from "codemirror";
3import { ssrSync } from "#ssr"; 3import { ssrSync } from "#ssr";
4import type { ScriptPayload } from "@/q+a/views/editor.marko"; 4import type { ScriptPayload } from "@/q+a/views/editor.marko";
5import QuestionRender from "@/q+a/tags/question.marko"; 5import QuestionRender from "@/q+a/tags/question.marko";
6 6
7declare const payload: ScriptPayload; 7declare const payload: ScriptPayload;
8const date = new Date(payload.date); 8const date = new Date(payload.date);
9 9
10const main = document.getElementById("edit-grid")! as HTMLDivElement; 10const main = document.getElementById("edit-grid")! as HTMLDivElement;
11const preview = document.getElementById("preview")! as HTMLDivElement; 11const preview = document.getElementById("preview")! as HTMLDivElement;
12 12
13function updatePreview(text: string) { 13function updatePreview(text: string) {
14 preview.innerHTML = ssrSync( 14 preview.innerHTML = ssrSync(
15 <QuestionRender 15 <QuestionRender
16 question={{ 16 question={{
17 id: payload.id, 17 id: payload.id,
18 qmid: payload.qmid, 18 qmid: payload.qmid,
19 text: text, 19 text: text,
20 type: payload.type, 20 type: payload.type,
21 date, 21 date,
22 }} 22 }}
23 editor 23 editor
24 />, 24 />,
25 ).text; 25 ).text;
26} 26}
27updatePreview(payload.text); 27updatePreview(payload.text);
28 28
29const startState = EditorState.create({ 29const startState = EditorState.create({
30 doc: payload.text, 30 doc: payload.text,
31 extensions: [ 31 extensions: [
32 basicSetup, 32 basicSetup,
33 EditorView.darkTheme.of(true), 33 EditorView.darkTheme.of(true),
34 EditorView.updateListener.of((update) => { 34 EditorView.updateListener.of((update) => {
35 if (update.docChanged) { 35 if (update.docChanged) {
36 updatePreview(update.state.doc.toString()); 36 updatePreview(update.state.doc.toString());
37 } 37 }
38 }), 38 }),
39 EditorView.lineWrapping, 39 EditorView.lineWrapping,
40 ], 40 ],
41 // selection: EditorSelection.create([ 41 // selection: EditorSelection.create([
42 // EditorSelection.cursor(0), 42 // EditorSelection.cursor(0),
43 // ], 0), 43 // ], 0),
44}); 44});
45 45
46const view = new EditorView({ 46const view = new EditorView({
47 state: startState, 47 state: startState,
48 parent: document.getElementById("editor")!, 48 parent: document.getElementById("editor")!,
49}); 49});
50view.focus(); 50view.focus();
51 51
52(globalThis as any).onCommitQuestion = wrapAction(async () => { 52(globalThis as any).onCommitQuestion = wrapAction(async () => {
53 const text = view.state.doc.toString(); 53 const text = view.state.doc.toString();
54 const res = await fetch(`/admin/q+a/${payload.id}`, { 54 const res = await fetch(`/admin/q+a/${payload.id}`, {
55 method: "PATCH", 55 method: "PATCH",
56 headers: { 56 headers: {
57 Accept: "application/json", 57 Accept: "application/json",
58 "Content-Type": "application/json", 58 "Content-Type": "application/json",
59 }, 59 },
60 body: JSON.stringify({ 60 body: JSON.stringify({
61 text, 61 text,
62 type: payload.type, 62 type: payload.type,
63 }), 63 }),
64 }); 64 });
65 if (!res.ok) { 65 if (!res.ok) {
66 throw new Error("Failed to update question, status: " + res.status); 66 throw new Error("Failed to update question, status: " + res.status);
67 } 67 }
68 if (location.search.includes("return=inbox")) { 68 if (location.search.includes("return=inbox")) {
69 location.href = "/admin/q+a/inbox"; 69 location.href = "/admin/q+a/inbox";
70 } else { 70 } else {
71 location.href = "/q+a#q" + payload.id; 71 location.href = "/q+a#q" + payload.id;
72 } 72 }
73}); 73});
74(globalThis as any).onDelete = wrapAction(async () => { 74(globalThis as any).onDelete = wrapAction(async () => {
75 if (confirm("Are you sure you want to delete this question?")) { 75 if (confirm("Are you sure you want to delete this question?")) {
76 const res = await fetch(`/admin/q+a/${payload.id}`, { 76 const res = await fetch(`/admin/q+a/${payload.id}`, {
77 method: "DELETE", 77 method: "DELETE",
78 headers: { 78 headers: {
79 Accept: "application/json", 79 Accept: "application/json",
80 }, 80 },
81 }); 81 });
82 if (!res.ok) { 82 if (!res.ok) {
83 throw new Error("Failed to delete question, status: " + res.status); 83 throw new Error("Failed to delete question, status: " + res.status);
84 } 84 }
85 location.href = document.referrer || "/admin/q+a"; 85 location.href = document.referrer || "/admin/q+a";
86 } 86 }
87}); 87});
88(globalThis as any).onTypeChange = () => { 88(globalThis as any).onTypeChange = () => {
89 payload.type = parseInt( 89 payload.type = parseInt(
90 (document.getElementById("type") as HTMLSelectElement).value, 90 (document.getElementById("type") as HTMLSelectElement).value,
91 ); 91 );
92 updatePreview(view.state.doc.toString()); 92 updatePreview(view.state.doc.toString());
93}; 93};
94 94
95function wrapAction(cb: () => Promise<void>) { 95function wrapAction(cb: () => Promise<void>) {
96 return async () => { 96 return async () => {
97 main.style.opacity = "0.5"; 97 main.style.opacity = "0.5";
98 main.style.pointerEvents = "none"; 98 main.style.pointerEvents = "none";
99 const inputs = main.querySelectorAll("button,select,input") as NodeListOf< 99 const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
100 HTMLButtonElement 100 HTMLButtonElement
101 >; 101 >;
102 inputs.forEach((b) => { 102 inputs.forEach((b) => {
103 b.disabled = true; 103 b.disabled = true;
104 }); 104 });
105 try { 105 try {
106 await cb(); 106 await cb();
107 } catch (e: any) { 107 } catch (e: any) {
108 main.style.opacity = "1"; 108 main.style.opacity = "1";
109 main.style.pointerEvents = "auto"; 109 main.style.pointerEvents = "auto";
110 inputs.forEach((b) => { 110 inputs.forEach((b) => {
111 b.disabled = false; 111 b.disabled = false;
112 }); 112 });
113 alert(e.message); 113 alert(e.message);
114 } 114 }
115 }; 115 };
116} 116}
src/q+a/simple-markdown.ts+1090-1090
...@@ -1,1090 +1,1090 @@...@@ -1,1090 +1,1090 @@
1// @ts-nocheck 1// @ts-nocheck
2// # Simple-Markdown Core 2// # Simple-Markdown Core
3// 3//
4// This is a fork of Khan-academy's Simple-Markdown[1], initially forked in 2022 4// This is a fork of Khan-academy's Simple-Markdown[1], initially forked in 2022
5// to add Svelte support[2], and used for paper clover's q+a markdown flavor. 5// to add Svelte support[2], and used for paper clover's q+a markdown flavor.
6// 6//
7// 1: https://github.com/Khan/perseus/tree/main/packages/simple-markdown/src 7// 1: https://github.com/Khan/perseus/tree/main/packages/simple-markdown/src
8// 2: https://github.com/paperclover/svelte-simple-markdown 8// 2: https://github.com/paperclover/svelte-simple-markdown
9export type Rules = Record<string, ParserRule>; 9export type Rules = Record<string, ParserRule>;
10export interface ParserRule { 10export interface ParserRule {
11 name: string; 11 name: string;
12 match: MatchFunction; 12 match: MatchFunction;
13 parse: ParseFunction; 13 parse: ParseFunction;
14 quality?: QualityFunction; 14 quality?: QualityFunction;
15} 15}
16 16
17export class RuleList extends Array<ParserRule> { 17export class RuleList extends Array<ParserRule> {
18 constructor(input?: ArrayLike<ParserRule>) { 18 constructor(input?: ArrayLike<ParserRule>) {
19 super(); 19 super();
20 if (input) { 20 if (input) {
21 this.push(...Array.from(input)); 21 this.push(...Array.from(input));
22 } 22 }
23 } 23 }
24 24
25 insertBefore(rule: string, newRule: ParserRule): void { 25 insertBefore(rule: string, newRule: ParserRule): void {
26 const index = this.findIndex((r) => r.name === rule); 26 const index = this.findIndex((r) => r.name === rule);
27 if (index === -1) { 27 if (index === -1) {
28 throw new Error(`Rule ${rule} not found`); 28 throw new Error(`Rule ${rule} not found`);
29 } 29 }
30 this.splice(index, 0, newRule); 30 this.splice(index, 0, newRule);
31 } 31 }
32 32
33 insertAfter(rule: string, newRule: ParserRule): void { 33 insertAfter(rule: string, newRule: ParserRule): void {
34 const index = this.findIndex((r) => r.name === rule); 34 const index = this.findIndex((r) => r.name === rule);
35 if (index === -1) { 35 if (index === -1) {
36 throw new Error(`Rule ${rule} not found`); 36 throw new Error(`Rule ${rule} not found`);
37 } 37 }
38 this.splice(index + 1, 0, newRule); 38 this.splice(index + 1, 0, newRule);
39 } 39 }
40 40
41 toRuleObject(): Record<string, ParserRule> { 41 toRuleObject(): Record<string, ParserRule> {
42 const result: Record<string, ParserRule> = {}; 42 const result: Record<string, ParserRule> = {};
43 this.forEach((rule) => { 43 this.forEach((rule) => {
44 result[rule.name] = rule; 44 result[rule.name] = rule;
45 }); 45 });
46 return result; 46 return result;
47 } 47 }
48 48
49 add(rule: ParserRule): void { 49 add(rule: ParserRule): void {
50 this.push(rule); 50 this.push(rule);
51 } 51 }
52 52
53 get(rule: string): ParserRule | undefined { 53 get(rule: string): ParserRule | undefined {
54 return this.find((r) => r.name === rule); 54 return this.find((r) => r.name === rule);
55 } 55 }
56 56
57 remove(rule: string): void { 57 remove(rule: string): void {
58 const index = this.findIndex((r) => r.name === rule); 58 const index = this.findIndex((r) => r.name === rule);
59 if (index === -1) { 59 if (index === -1) {
60 throw new Error(`Rule ${rule} not found`); 60 throw new Error(`Rule ${rule} not found`);
61 } 61 }
62 this.splice(index, 1); 62 this.splice(index, 1);
63 } 63 }
64 64
65 clone() { 65 clone() {
66 return new RuleList(this); 66 return new RuleList(this);
67 } 67 }
68} 68}
69 69
70/** 70/**
71 * Creates a parser for a given set of rules, with the precedence 71 * Creates a parser for a given set of rules, with the precedence
72 * specified as a list of rules. 72 * specified as a list of rules.
73 * 73 *
74 * @param rules 74 * @param rules
75 * an object containing 75 * an object containing
76 * rule type -> {match, order, parse} objects 76 * rule type -> {match, order, parse} objects
77 * (lower order is higher precedence) 77 * (lower order is higher precedence)
78 * @param [defaultState] 78 * @param [defaultState]
79 * 79 *
80 * @returns 80 * @returns
81 * The resulting parse function, with the following parameters: 81 * The resulting parse function, with the following parameters:
82 * @source: the input source string to be parsed 82 * @source: the input source string to be parsed
83 * @state: an optional object to be threaded through parse 83 * @state: an optional object to be threaded through parse
84 * calls. Allows clients to add stateful operations to 84 * calls. Allows clients to add stateful operations to
85 * parsing, such as keeping track of how many levels deep 85 * parsing, such as keeping track of how many levels deep
86 * some nesting is. For an example use-case, see passage-ref 86 * some nesting is. For an example use-case, see passage-ref
87 * parsing in src/widgets/passage/passage-markdown.jsx 87 * parsing in src/widgets/passage/passage-markdown.jsx
88 */ 88 */
89export function createParser( 89export function createParser(
90 ruleListInput: RuleList, 90 ruleListInput: RuleList,
91 defaultState: Partial<ParserState> = {}, 91 defaultState: Partial<ParserState> = {},
92) { 92) {
93 let rules = ruleListInput.toRuleObject(); 93 let rules = ruleListInput.toRuleObject();
94 let ruleList = Object.keys(rules); 94 let ruleList = Object.keys(rules);
95 95
96 let latestState: ParserState; 96 let latestState: ParserState;
97 97
98 let nestedParse = function (source: string, state?: ParserState) { 98 let nestedParse = function (source: string, state?: ParserState) {
99 let result: ASTNode[] = []; 99 let result: ASTNode[] = [];
100 state = state || latestState; 100 state = state || latestState;
101 latestState = state; 101 latestState = state;
102 102
103 while (source) { 103 while (source) {
104 // store the best match, it's rule, and quality: 104 // store the best match, it's rule, and quality:
105 let ruleType = null; 105 let ruleType = null;
106 let rule = null; 106 let rule = null;
107 let capture = null; 107 let capture = null;
108 let quality = NaN; // loop control variables: 108 let quality = NaN; // loop control variables:
109 109
110 let i = 0; 110 let i = 0;
111 let currRuleType = ruleList[0]; 111 let currRuleType = ruleList[0];
112 112
113 let currRule = rules[currRuleType]; 113 let currRule = rules[currRuleType];
114 114
115 do { 115 do {
116 let currCapture = currRule.match(source, state); 116 let currCapture = currRule.match(source, state);
117 117
118 if (currCapture) { 118 if (currCapture) {
119 let currQuality = currRule.quality 119 let currQuality = currRule.quality
120 ? currRule.quality(currCapture, state) 120 ? currRule.quality(currCapture, state)
121 : 0; 121 : 0;
122 122
123 // This should always be true the first time because 123 // This should always be true the first time because
124 // the initial quality is NaN (that's why there's the 124 // the initial quality is NaN (that's why there's the
125 // condition negation). 125 // condition negation).
126 if (!(currQuality <= quality)) { 126 if (!(currQuality <= quality)) {
127 ruleType = currRuleType; 127 ruleType = currRuleType;
128 rule = currRule; 128 rule = currRule;
129 capture = currCapture; 129 capture = currCapture;
130 quality = currQuality; 130 quality = currQuality;
131 } 131 }
132 } 132 }
133 133
134 // Move on to the next item. 134 // Move on to the next item.
135 // Note that this makes `currRule` be the next item 135 // Note that this makes `currRule` be the next item
136 i++; 136 i++;
137 currRuleType = ruleList[i]; 137 currRuleType = ruleList[i];
138 currRule = rules[currRuleType]; 138 currRule = rules[currRuleType];
139 } while ( 139 } while (
140 // keep looping while we're still within the ruleList 140 // keep looping while we're still within the ruleList
141 currRule && 141 currRule &&
142 // if we don't have a match yet, continue 142 // if we don't have a match yet, continue
143 (!capture || 143 (!capture ||
144 // or if we have a match, but the next rule is 144 // or if we have a match, but the next rule is
145 // at the same order, and has a quality measurement 145 // at the same order, and has a quality measurement
146 // functions, then this rule must have a quality 146 // functions, then this rule must have a quality
147 // measurement function (since they are sorted before 147 // measurement function (since they are sorted before
148 // those without), and we need to check if there is 148 // those without), and we need to check if there is
149 // a better quality match 149 // a better quality match
150 currRule.quality) 150 currRule.quality)
151 ); 151 );
152 152
153 if (!rule || !capture || !ruleType) { 153 if (!rule || !capture || !ruleType) {
154 throw new Error( 154 throw new Error(
155 "Could not find a matching rule for the below " + 155 "Could not find a matching rule for the below " +
156 "content. The rule with highest `order` should " + 156 "content. The rule with highest `order` should " +
157 "always match content provided to it. Check " + 157 "always match content provided to it. Check " +
158 "the definition of `match` for '" + 158 "the definition of `match` for '" +
159 ruleList[ruleList.length - 1] + 159 ruleList[ruleList.length - 1] +
160 "'. It seems to not match the following source:\n" + 160 "'. It seems to not match the following source:\n" +
161 source, 161 source,
162 ); 162 );
163 } 163 }
164 164
165 if (capture.index) { 165 if (capture.index) {
166 // If present and non-zero, i.e. a non-^ regexp result: 166 // If present and non-zero, i.e. a non-^ regexp result:
167 throw new Error( 167 throw new Error(
168 "`match` must return a capture starting at index 0 " + 168 "`match` must return a capture starting at index 0 " +
169 "(the current parse index). Did you forget a ^ at the " + 169 "(the current parse index). Did you forget a ^ at the " +
170 "start of the RegExp?", 170 "start of the RegExp?",
171 ); 171 );
172 } 172 }
173 173
174 let parsed = rule.parse(capture, nestedParse, state); 174 let parsed = rule.parse(capture, nestedParse, state);
175 175
176 // We maintain the same object here so that rules can 176 // We maintain the same object here so that rules can
177 // store references to the objects they return and 177 // store references to the objects they return and
178 // modify them later. (oops sorry! but this adds a lot 178 // modify them later. (oops sorry! but this adds a lot
179 // of power--see reflinks.) 179 // of power--see reflinks.)
180 180
181 // We also let rules override the default type of 181 // We also let rules override the default type of
182 // their parsed node if they would like to, so that 182 // their parsed node if they would like to, so that
183 // there can be a single output function for all links, 183 // there can be a single output function for all links,
184 // even if there are several rules to parse them. 184 // even if there are several rules to parse them.
185 if (!parsed.type) { 185 if (!parsed.type) {
186 parsed.type = ruleType; 186 parsed.type = ruleType;
187 } 187 }
188 188
189 // Collapse text nodes 189 // Collapse text nodes
190 if ( 190 if (
191 parsed.type === "text" && result[result.length - 1]?.type === "text" 191 parsed.type === "text" && result[result.length - 1]?.type === "text"
192 ) { 192 ) {
193 result[result.length - 1].content += parsed.content; 193 result[result.length - 1].content += parsed.content;
194 } else { 194 } else {
195 result.push(parsed as ASTNode); 195 result.push(parsed as ASTNode);
196 } 196 }
197 197
198 state.prevCapture = capture; 198 state.prevCapture = capture;
199 source = source.substring(state.prevCapture[0].length); 199 source = source.substring(state.prevCapture[0].length);
200 } 200 }
201 201
202 return result; 202 return result;
203 }; 203 };
204 204
205 let outerParse = function ( 205 let outerParse = function (
206 source: string, 206 source: string,
207 state: ParserState = { inline: false }, 207 state: ParserState = { inline: false },
208 ) { 208 ) {
209 latestState = populateInitialState(state, defaultState); 209 latestState = populateInitialState(state, defaultState);
210 210
211 if (!latestState.inline && !latestState.disableAutoBlockNewlines) { 211 if (!latestState.inline && !latestState.disableAutoBlockNewlines) {
212 source = source + "\n\n"; 212 source = source + "\n\n";
213 } 213 }
214 214
215 // We store the previous capture so that match functions can 215 // We store the previous capture so that match functions can
216 // use some limited amount of lookbehind. Lists use this to 216 // use some limited amount of lookbehind. Lists use this to
217 // ensure they don't match arbitrary '- ' or '* ' in inline 217 // ensure they don't match arbitrary '- ' or '* ' in inline
218 // text (see the list rule for more information). This stores 218 // text (see the list rule for more information). This stores
219 // the full regex capture object, if there is one. 219 // the full regex capture object, if there is one.
220 latestState.prevCapture = undefined; 220 latestState.prevCapture = undefined;
221 return nestedParse(preprocess(source), latestState); 221 return nestedParse(preprocess(source), latestState);
222 }; 222 };
223 223
224 return outerParse; 224 return outerParse;
225} 225}
226 226
227type Multiple<T> = T | T[]; 227type Multiple<T> = T | T[];
228type Nullable<T> = T | null | undefined; 228type Nullable<T> = T | null | undefined;
229 229
230export type MatchFunction = ( 230export type MatchFunction = (
231 source: string, 231 source: string,
232 state: ParserState, 232 state: ParserState,
233) => Nullable<RegExpMatchArray>; 233) => Nullable<RegExpMatchArray>;
234 234
235export type Parser = (source: string, state?: ParserState) => ASTNode[]; 235export type Parser = (source: string, state?: ParserState) => ASTNode[];
236 236
237export type ParseFunction = ( 237export type ParseFunction = (
238 source: RegExpMatchArray, 238 source: RegExpMatchArray,
239 nestedParse: Parser, 239 nestedParse: Parser,
240 state: ParserState, 240 state: ParserState,
241) => TypeOptionalASTNode; 241) => TypeOptionalASTNode;
242 242
243export type QualityFunction = ( 243export type QualityFunction = (
244 capture: RegExpMatchArray, 244 capture: RegExpMatchArray,
245 state: ParserState, 245 state: ParserState,
246) => number; 246) => number;
247 247
248export interface ParserState { 248export interface ParserState {
249 inline: boolean; 249 inline: boolean;
250 prevCapture?: RegExpMatchArray; 250 prevCapture?: RegExpMatchArray;
251 [key: string]: any; 251 [key: string]: any;
252} 252}
253 253
254export interface ASTNode { 254export interface ASTNode {
255 type: string; 255 type: string;
256 content?: ASTNode[] | string; 256 content?: ASTNode[] | string;
257 [key: string]: any; 257 [key: string]: any;
258} 258}
259 259
260export type TypeOptionalASTNode = Omit<ASTNode, "type"> & { type?: string }; 260export type TypeOptionalASTNode = Omit<ASTNode, "type"> & { type?: string };
261 261
262export interface RefNode { 262export interface RefNode {
263 type: string; 263 type: string;
264 content?: Multiple<ASTNode>; 264 content?: Multiple<ASTNode>;
265 target?: string; 265 target?: string;
266 title?: string; 266 title?: string;
267 alt?: string; 267 alt?: string;
268} 268}
269 269
270/** Creates a match function for an inline scoped element from a regex */ 270/** Creates a match function for an inline scoped element from a regex */
271export function inlineRegex(regex: RegExp): MatchFunction { 271export function inlineRegex(regex: RegExp): MatchFunction {
272 return (source, state) => { 272 return (source, state) => {
273 if (state.inline) { 273 if (state.inline) {
274 return regex.exec(source); 274 return regex.exec(source);
275 } else { 275 } else {
276 return null; 276 return null;
277 } 277 }
278 }; 278 };
279} 279}
280 280
281/** Creates a match function for a block scoped element from a regex */ 281/** Creates a match function for a block scoped element from a regex */
282export function blockRegex(regex: RegExp): MatchFunction { 282export function blockRegex(regex: RegExp): MatchFunction {
283 return (source, state) => { 283 return (source, state) => {
284 if (state.inline) { 284 if (state.inline) {
285 return null; 285 return null;
286 } else { 286 } else {
287 return regex.exec(source); 287 return regex.exec(source);
288 } 288 }
289 }; 289 };
290} 290}
291 291
292/** Creates a match function from a regex, ignoring block/inline scope */ 292/** Creates a match function from a regex, ignoring block/inline scope */
293export function anyScopeRegex(regex: RegExp): MatchFunction { 293export function anyScopeRegex(regex: RegExp): MatchFunction {
294 return (source) => { 294 return (source) => {
295 return regex.exec(source); 295 return regex.exec(source);
296 }; 296 };
297} 297}
298 298
299const UNESCAPE_URL_R = /\\([^0-9A-Za-z\s])/g; 299const UNESCAPE_URL_R = /\\([^0-9A-Za-z\s])/g;
300export function unescapeUrl(rawUrlString: string) { 300export function unescapeUrl(rawUrlString: string) {
301 return rawUrlString.replace(UNESCAPE_URL_R, "$1"); 301 return rawUrlString.replace(UNESCAPE_URL_R, "$1");
302} 302}
303 303
304/** 304/**
305 * Parse some content with the parser `parse`, with state.inline 305 * Parse some content with the parser `parse`, with state.inline
306 * set to true. Useful for block elements; not generally necessary 306 * set to true. Useful for block elements; not generally necessary
307 * to be used by inline elements (where state.inline is already true. 307 * to be used by inline elements (where state.inline is already true.
308 */ 308 */
309export function parseInline( 309export function parseInline(
310 parse: Parser, 310 parse: Parser,
311 content: string, 311 content: string,
312 state: ParserState, 312 state: ParserState,
313) { 313) {
314 const isCurrentlyInline = state.inline || false; 314 const isCurrentlyInline = state.inline || false;
315 state.inline = true; 315 state.inline = true;
316 const result = parse(content, state); 316 const result = parse(content, state);
317 state.inline = isCurrentlyInline; 317 state.inline = isCurrentlyInline;
318 return result; 318 return result;
319} 319}
320 320
321export function parseBlock(parse: Parser, content: string, state: ParserState) { 321export function parseBlock(parse: Parser, content: string, state: ParserState) {
322 const isCurrentlyInline = state.inline || false; 322 const isCurrentlyInline = state.inline || false;
323 state.inline = false; 323 state.inline = false;
324 const result = parse(content + "\n\n", state); 324 const result = parse(content + "\n\n", state);
325 state.inline = isCurrentlyInline; 325 state.inline = isCurrentlyInline;
326 return result; 326 return result;
327} 327}
328 328
329export function parseCaptureInline( 329export function parseCaptureInline(
330 capture: RegExpMatchArray, 330 capture: RegExpMatchArray,
331 parse: Parser, 331 parse: Parser,
332 state: ParserState, 332 state: ParserState,
333) { 333) {
334 return { 334 return {
335 content: parseInline(parse, capture[1], state), 335 content: parseInline(parse, capture[1], state),
336 }; 336 };
337} 337}
338 338
339export function ignoreCapture() { 339export function ignoreCapture() {
340 return {}; 340 return {};
341} 341}
342 342
343export function sanitizeUrl(url?: string) { 343export function sanitizeUrl(url?: string) {
344 if (url == null) { 344 if (url == null) {
345 return null; 345 return null;
346 } 346 }
347 try { 347 try {
348 const prot = new URL(url, "https://localhost").protocol; 348 const prot = new URL(url, "https://localhost").protocol;
349 if ( 349 if (
350 prot.indexOf("javascript:") === 0 || 350 prot.indexOf("javascript:") === 0 ||
351 prot.indexOf("vbscript:") === 0 || 351 prot.indexOf("vbscript:") === 0 ||
352 prot.indexOf("data:") === 0 352 prot.indexOf("data:") === 0
353 ) { 353 ) {
354 return null; 354 return null;
355 } 355 }
356 } catch (e) { 356 } catch (e) {
357 // invalid URLs should throw a TypeError 357 // invalid URLs should throw a TypeError
358 // see for instance: `new URL("");` 358 // see for instance: `new URL("");`
359 return null; 359 return null;
360 } 360 }
361 return url; 361 return url;
362} 362}
363 363
364const CR_NEWLINE_R = /\r\n?/g; 364const CR_NEWLINE_R = /\r\n?/g;
365const TAB_R = /\t/g; 365const TAB_R = /\t/g;
366const FORMFEED_R = /\f/g; 366const FORMFEED_R = /\f/g;
367 367
368/** 368/**
369 * Turn various whitespace into easy-to-process whitespace 369 * Turn various whitespace into easy-to-process whitespace
370 */ 370 */
371function preprocess(source: string) { 371function preprocess(source: string) {
372 return source.replace(CR_NEWLINE_R, "\n").replace(FORMFEED_R, "").replace( 372 return source.replace(CR_NEWLINE_R, "\n").replace(FORMFEED_R, "").replace(
373 TAB_R, 373 TAB_R,
374 " ", 374 " ",
375 ); 375 );
376} 376}
377 377
378function populateInitialState( 378function populateInitialState(
379 givenState: Partial<ParserState>, 379 givenState: Partial<ParserState>,
380 defaultState: Partial<ParserState>, 380 defaultState: Partial<ParserState>,
381) { 381) {
382 let state = givenState || {}; 382 let state = givenState || {};
383 383
384 for (let prop in defaultState) { 384 for (let prop in defaultState) {
385 if (Object.prototype.hasOwnProperty.call(defaultState, prop)) { 385 if (Object.prototype.hasOwnProperty.call(defaultState, prop)) {
386 state[prop] = defaultState[prop]; 386 state[prop] = defaultState[prop];
387 } 387 }
388 } 388 }
389 389
390 return state as ParserState; 390 return state as ParserState;
391} 391}
392 392
393// recognize a `*` `-`, `+`, `1.`, `2.`... list bullet 393// recognize a `*` `-`, `+`, `1.`, `2.`... list bullet
394const LIST_BULLET = "(?:[*+-]|\\d+\\.)"; 394const LIST_BULLET = "(?:[*+-]|\\d+\\.)";
395 395
396// recognize the start of a list item: 396// recognize the start of a list item:
397// leading space plus a bullet plus a space (` * `) 397// leading space plus a bullet plus a space (` * `)
398const LIST_ITEM_PREFIX = "( *)(" + LIST_BULLET + ") +"; 398const LIST_ITEM_PREFIX = "( *)(" + LIST_BULLET + ") +";
399const LIST_ITEM_PREFIX_R = new RegExp("^" + LIST_ITEM_PREFIX); 399const LIST_ITEM_PREFIX_R = new RegExp("^" + LIST_ITEM_PREFIX);
400 400
401// recognize an individual list item: 401// recognize an individual list item:
402// * hi 402// * hi
403// this is part of the same item 403// this is part of the same item
404// 404//
405// as is this, which is a new paragraph in the same item 405// as is this, which is a new paragraph in the same item
406// 406//
407// * but this is not part of the same item 407// * but this is not part of the same item
408const LIST_ITEM_R = new RegExp( 408const LIST_ITEM_R = new RegExp(
409 LIST_ITEM_PREFIX + "[^\\n]*(?:\\n" + "(?!\\1" + LIST_BULLET + 409 LIST_ITEM_PREFIX + "[^\\n]*(?:\\n" + "(?!\\1" + LIST_BULLET +
410 " )[^\\n]*)*(\n|$)", 410 " )[^\\n]*)*(\n|$)",
411 "gm", 411 "gm",
412); 412);
413const BLOCK_END_R = /\n{2,}$/; 413const BLOCK_END_R = /\n{2,}$/;
414const INLINE_CODE_ESCAPE_BACKTICKS_R = /^ (?= *`)|(` *) $/g; 414const INLINE_CODE_ESCAPE_BACKTICKS_R = /^ (?= *`)|(` *) $/g;
415 415
416// recognize the end of a paragraph block inside a list item: 416// recognize the end of a paragraph block inside a list item:
417// two or more newlines at end end of the item 417// two or more newlines at end end of the item
418const LIST_BLOCK_END_R = BLOCK_END_R; 418const LIST_BLOCK_END_R = BLOCK_END_R;
419const LIST_ITEM_END_R = / *\n+$/; 419const LIST_ITEM_END_R = / *\n+$/;
420 420
421// check whether a list item has paragraphs: if it does, 421// check whether a list item has paragraphs: if it does,
422// we leave the newlines at the end 422// we leave the newlines at the end
423const LIST_R = new RegExp( 423const LIST_R = new RegExp(
424 "^( *)(" + 424 "^( *)(" +
425 LIST_BULLET + 425 LIST_BULLET +
426 ") " + 426 ") " +
427 "[\\s\\S]+?(?:\n{2,}(?! )" + 427 "[\\s\\S]+?(?:\n{2,}(?! )" +
428 "(?!\\1" + 428 "(?!\\1" +
429 LIST_BULLET + 429 LIST_BULLET +
430 " )\\n*" + 430 " )\\n*" +
431 // the \\s*$ here is so that we can parse the inside of nested 431 // the \\s*$ here is so that we can parse the inside of nested
432 // lists, where our content might end before we receive two `\n`s 432 // lists, where our content might end before we receive two `\n`s
433 "|\\s*\n*$)", 433 "|\\s*\n*$)",
434); 434);
435const LIST_LOOKBEHIND_R = /(?:^|\n)( *)$/; 435const LIST_LOOKBEHIND_R = /(?:^|\n)( *)$/;
436 436
437const TABLES = (function () { 437const TABLES = (function () {
438 const TABLE_ROW_SEPARATOR_TRIM = /^ *\| *| *\| *$/g; 438 const TABLE_ROW_SEPARATOR_TRIM = /^ *\| *| *\| *$/g;
439 const TABLE_CELL_END_TRIM = / *$/; 439 const TABLE_CELL_END_TRIM = / *$/;
440 const TABLE_RIGHT_ALIGN = /^ *-+: *$/; 440 const TABLE_RIGHT_ALIGN = /^ *-+: *$/;
441 const TABLE_CENTER_ALIGN = /^ *:-+: *$/; 441 const TABLE_CENTER_ALIGN = /^ *:-+: *$/;
442 const TABLE_LEFT_ALIGN = /^ *:-+ *$/; // TODO: This needs a real type 442 const TABLE_LEFT_ALIGN = /^ *:-+ *$/; // TODO: This needs a real type
443 443
444 const parseTableAlignCapture = (alignCapture: string) => { 444 const parseTableAlignCapture = (alignCapture: string) => {
445 if (TABLE_RIGHT_ALIGN.test(alignCapture)) { 445 if (TABLE_RIGHT_ALIGN.test(alignCapture)) {
446 return "right"; 446 return "right";
447 } else if (TABLE_CENTER_ALIGN.test(alignCapture)) { 447 } else if (TABLE_CENTER_ALIGN.test(alignCapture)) {
448 return "center"; 448 return "center";
449 } else if (TABLE_LEFT_ALIGN.test(alignCapture)) { 449 } else if (TABLE_LEFT_ALIGN.test(alignCapture)) {
450 return "left"; 450 return "left";
451 } else { 451 } else {
452 return null; 452 return null;
453 } 453 }
454 }; 454 };
455 455
456 const parseTableAlign = (source: string, trimEndSeparators: boolean) => { 456 const parseTableAlign = (source: string, trimEndSeparators: boolean) => {
457 if (trimEndSeparators) { 457 if (trimEndSeparators) {
458 source = source.replace(TABLE_ROW_SEPARATOR_TRIM, ""); 458 source = source.replace(TABLE_ROW_SEPARATOR_TRIM, "");
459 } 459 }
460 460
461 const alignText = source.trim().split("|"); 461 const alignText = source.trim().split("|");
462 return alignText.map(parseTableAlignCapture); 462 return alignText.map(parseTableAlignCapture);
463 }; 463 };
464 464
465 const parseTableRow = ( 465 const parseTableRow = (
466 source: string, 466 source: string,
467 parse: Parser, 467 parse: Parser,
468 state: ParserState, 468 state: ParserState,
469 trimEndSeparators: boolean, 469 trimEndSeparators: boolean,
470 ) => { 470 ) => {
471 const prevInTable = state.inTable; 471 const prevInTable = state.inTable;
472 state.inTable = true; 472 state.inTable = true;
473 const tableRow = parse(source.trim(), state); 473 const tableRow = parse(source.trim(), state);
474 state.inTable = prevInTable; 474 state.inTable = prevInTable;
475 const cells: ASTNode[][] = [[]]; 475 const cells: ASTNode[][] = [[]];
476 tableRow.forEach(function (node, i) { 476 tableRow.forEach(function (node, i) {
477 if (node.type === "tableSeparator") { 477 if (node.type === "tableSeparator") {
478 // Filter out empty table separators at the start/end: 478 // Filter out empty table separators at the start/end:
479 if (!trimEndSeparators || (i !== 0 && i !== tableRow.length - 1)) { 479 if (!trimEndSeparators || (i !== 0 && i !== tableRow.length - 1)) {
480 // Split the current row: 480 // Split the current row:
481 cells.push([]); 481 cells.push([]);
482 } 482 }
483 } else { 483 } else {
484 if ( 484 if (
485 typeof node.content === "string" && 485 typeof node.content === "string" &&
486 (tableRow[i + 1] == null || tableRow[i + 1].type === "tableSeparator") 486 (tableRow[i + 1] == null || tableRow[i + 1].type === "tableSeparator")
487 ) { 487 ) {
488 node.content = node.content.replace(TABLE_CELL_END_TRIM, ""); 488 node.content = node.content.replace(TABLE_CELL_END_TRIM, "");
489 } 489 }
490 490
491 cells[cells.length - 1].push(node); 491 cells[cells.length - 1].push(node);
492 } 492 }
493 }); 493 });
494 return cells; 494 return cells;
495 }; 495 };
496 496
497 /** 497 /**
498 * @param {string} source 498 * @param {string} source
499 * @param {SimpleMarkdown.Parser} parse 499 * @param {SimpleMarkdown.Parser} parse
500 * @param {SimpleMarkdown.State} state 500 * @param {SimpleMarkdown.State} state
501 * @param {boolean} trimEndSeparators 501 * @param {boolean} trimEndSeparators
502 * @returns {SimpleMarkdown.ASTNode[][]} 502 * @returns {SimpleMarkdown.ASTNode[][]}
503 */ 503 */
504 const parseTableCells = function ( 504 const parseTableCells = function (
505 source: string, 505 source: string,
506 parse: Parser, 506 parse: Parser,
507 state: ParserState, 507 state: ParserState,
508 trimEndSeparators: boolean, 508 trimEndSeparators: boolean,
509 ) { 509 ) {
510 const rowsText = source.trim().split("\n"); 510 const rowsText = source.trim().split("\n");
511 return rowsText.map(function (rowText) { 511 return rowsText.map(function (rowText) {
512 return parseTableRow(rowText, parse, state, trimEndSeparators); 512 return parseTableRow(rowText, parse, state, trimEndSeparators);
513 }); 513 });
514 }; 514 };
515 515
516 /** 516 /**
517 * @param {boolean} trimEndSeparators 517 * @param {boolean} trimEndSeparators
518 * @returns {SimpleMarkdown.SingleNodeParseFunction} 518 * @returns {SimpleMarkdown.SingleNodeParseFunction}
519 */ 519 */
520 const parseTable = function (trimEndSeparators: boolean) { 520 const parseTable = function (trimEndSeparators: boolean) {
521 return function ( 521 return function (
522 capture: RegExpMatchArray, 522 capture: RegExpMatchArray,
523 parse: Parser, 523 parse: Parser,
524 state: ParserState, 524 state: ParserState,
525 ) { 525 ) {
526 state.inline = true; 526 state.inline = true;
527 const header = parseTableRow(capture[1], parse, state, trimEndSeparators); 527 const header = parseTableRow(capture[1], parse, state, trimEndSeparators);
528 const align = parseTableAlign(capture[2], trimEndSeparators); 528 const align = parseTableAlign(capture[2], trimEndSeparators);
529 const cells = parseTableCells( 529 const cells = parseTableCells(
530 capture[3], 530 capture[3],
531 parse, 531 parse,
532 state, 532 state,
533 trimEndSeparators, 533 trimEndSeparators,
534 ); 534 );
535 state.inline = false; 535 state.inline = false;
536 return { 536 return {
537 type: "table", 537 type: "table",
538 header: header, 538 header: header,
539 align: align, 539 align: align,
540 cells: cells, 540 cells: cells,
541 }; 541 };
542 }; 542 };
543 }; 543 };
544 544
545 return { 545 return {
546 parseTable: parseTable(true), 546 parseTable: parseTable(true),
547 parseNpTable: parseTable(false), 547 parseNpTable: parseTable(false),
548 TABLE_REGEX: /^ *(\|.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/, 548 TABLE_REGEX: /^ *(\|.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/,
549 NPTABLE_REGEX: 549 NPTABLE_REGEX:
550 /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/, 550 /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/,
551 }; 551 };
552})(); 552})();
553 553
554const LINK_INSIDE = "(?:\\[[^\\]]*\\]|[^\\[\\]]|\\](?=[^\\[]*\\]))*"; 554const LINK_INSIDE = "(?:\\[[^\\]]*\\]|[^\\[\\]]|\\](?=[^\\[]*\\]))*";
555const LINK_HREF_AND_TITLE = 555const LINK_HREF_AND_TITLE =
556 "\\s*<?((?:\\([^)]*\\)|[^\\s\\\\]|\\\\.)*?)>?(?:\\s+['\"]([\\s\\S]*?)['\"])?\\s*"; 556 "\\s*<?((?:\\([^)]*\\)|[^\\s\\\\]|\\\\.)*?)>?(?:\\s+['\"]([\\s\\S]*?)['\"])?\\s*";
557const AUTOLINK_MAILTO_CHECK_R = /mailto:/i; 557const AUTOLINK_MAILTO_CHECK_R = /mailto:/i;
558 558
559function parseRef( 559function parseRef(
560 capture: RegExpMatchArray, 560 capture: RegExpMatchArray,
561 state: ParserState, 561 state: ParserState,
562 refNode: RefNode, 562 refNode: RefNode,
563) { 563) {
564 const ref = (capture[2] || capture[1]).replace(/\s+/g, " ").toLowerCase(); 564 const ref = (capture[2] || capture[1]).replace(/\s+/g, " ").toLowerCase();
565 565
566 // We store information about previously seen defs on 566 // We store information about previously seen defs on
567 // state._defs (_ to deconflict with client-defined 567 // state._defs (_ to deconflict with client-defined
568 // state). If the def for this reflink/refimage has 568 // state). If the def for this reflink/refimage has
569 // already been seen, we can use its target/source 569 // already been seen, we can use its target/source
570 // and title here: 570 // and title here:
571 if (state._defs && state._defs[ref]) { 571 if (state._defs && state._defs[ref]) {
572 const def = state._defs[ref]; 572 const def = state._defs[ref];
573 573
574 // `refNode` can be a link or an image. Both use 574 // `refNode` can be a link or an image. Both use
575 // target and title properties. 575 // target and title properties.
576 refNode.target = def.target; 576 refNode.target = def.target;
577 refNode.title = def.title; 577 refNode.title = def.title;
578 } 578 }
579 579
580 // In case we haven't seen our def yet (or if someone 580 // In case we haven't seen our def yet (or if someone
581 // overwrites that def later on), we add this node 581 // overwrites that def later on), we add this node
582 // to the list of ref nodes for that def. Then, when 582 // to the list of ref nodes for that def. Then, when
583 // we find the def, we can modify this link/image AST 583 // we find the def, we can modify this link/image AST
584 // node :). 584 // node :).
585 // I'm sorry. 585 // I'm sorry.
586 state._refs = state._refs || {}; 586 state._refs = state._refs || {};
587 state._refs[ref] = state._refs[ref] || []; 587 state._refs[ref] = state._refs[ref] || [];
588 588
589 state._refs[ref].push(refNode); 589 state._refs[ref].push(refNode);
590 590
591 return refNode; 591 return refNode;
592} 592}
593 593
594export const defaultRules = new RuleList(); 594export const defaultRules = new RuleList();
595{ 595{
596 defaultRules.add({ 596 defaultRules.add({
597 name: "heading", 597 name: "heading",
598 match: blockRegex(/^ *(#{1,6})([^\n]+?)#* *(?:\n *)+\n/), 598 match: blockRegex(/^ *(#{1,6})([^\n]+?)#* *(?:\n *)+\n/),
599 parse: function (capture, parse, state) { 599 parse: function (capture, parse, state) {
600 return { 600 return {
601 level: capture[1].length, 601 level: capture[1].length,
602 content: parseInline(parse, capture[2].trim(), state), 602 content: parseInline(parse, capture[2].trim(), state),
603 }; 603 };
604 }, 604 },
605 }); 605 });
606 606
607 defaultRules.add({ 607 defaultRules.add({
608 name: "nptable", 608 name: "nptable",
609 match: blockRegex(TABLES.NPTABLE_REGEX), 609 match: blockRegex(TABLES.NPTABLE_REGEX),
610 parse: TABLES.parseNpTable, 610 parse: TABLES.parseNpTable,
611 }); 611 });
612 612
613 defaultRules.add({ 613 defaultRules.add({
614 name: "lheading", 614 name: "lheading",
615 match: blockRegex(/^([^\n]+)\n *(=|-){3,} *(?:\n *)+\n/), 615 match: blockRegex(/^([^\n]+)\n *(=|-){3,} *(?:\n *)+\n/),
616 parse(capture, parse, state) { 616 parse(capture, parse, state) {
617 return { 617 return {
618 type: "heading", 618 type: "heading",
619 level: capture[2] === "=" ? 1 : 2, 619 level: capture[2] === "=" ? 1 : 2,
620 content: parseInline(parse, capture[1], state), 620 content: parseInline(parse, capture[1], state),
621 }; 621 };
622 }, 622 },
623 }); 623 });
624 624
625 defaultRules.add({ 625 defaultRules.add({
626 name: "hr", 626 name: "hr",
627 match: blockRegex(/^( *[-*_]){3,} *(?:\n *)+\n/), 627 match: blockRegex(/^( *[-*_]){3,} *(?:\n *)+\n/),
628 parse: ignoreCapture, 628 parse: ignoreCapture,
629 }); 629 });
630 630
631 defaultRules.add({ 631 defaultRules.add({
632 name: "codeBlock", 632 name: "codeBlock",
633 match: blockRegex(/^(?: {4}[^\n]+\n*)+(?:\n *)+\n/), 633 match: blockRegex(/^(?: {4}[^\n]+\n*)+(?:\n *)+\n/),
634 parse(capture) { 634 parse(capture) {
635 const content = capture[0].replace(/^ {4}/gm, "").replace(/\n+$/, ""); 635 const content = capture[0].replace(/^ {4}/gm, "").replace(/\n+$/, "");
636 return { 636 return {
637 lang: undefined, 637 lang: undefined,
638 content: content, 638 content: content,
639 }; 639 };
640 }, 640 },
641 }); 641 });
642 642
643 defaultRules.add({ 643 defaultRules.add({
644 name: "fence", 644 name: "fence",
645 match: blockRegex( 645 match: blockRegex(
646 /^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)+\n/, 646 /^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)+\n/,
647 ), 647 ),
648 parse(capture) { 648 parse(capture) {
649 return { 649 return {
650 type: "codeBlock", 650 type: "codeBlock",
651 lang: capture[2] || undefined, 651 lang: capture[2] || undefined,
652 content: capture[3], 652 content: capture[3],
653 }; 653 };
654 }, 654 },
655 }); 655 });
656 656
657 defaultRules.add({ 657 defaultRules.add({
658 name: "blockQuote", 658 name: "blockQuote",
659 match: blockRegex(/^( *>[^\n]+(\n[^\n]+)*\n*)+\n{2,}/), 659 match: blockRegex(/^( *>[^\n]+(\n[^\n]+)*\n*)+\n{2,}/),
660 parse(capture, parse, state) { 660 parse(capture, parse, state) {
661 const content = capture[0].replace(/^ *> ?/gm, ""); 661 const content = capture[0].replace(/^ *> ?/gm, "");
662 return { 662 return {
663 content: parse(content, state), 663 content: parse(content, state),
664 }; 664 };
665 }, 665 },
666 }); 666 });
667 667
668 defaultRules.add({ 668 defaultRules.add({
669 name: "list", 669 name: "list",
670 670
671 match(source, state) { 671 match(source, state) {
672 // We only want to break into a list if we are at the start of a 672 // We only want to break into a list if we are at the start of a
673 // line. This is to avoid parsing "hi * there" with "* there" 673 // line. This is to avoid parsing "hi * there" with "* there"
674 // becoming a part of a list. 674 // becoming a part of a list.
675 // You might wonder, "but that's inline, so of course it wouldn't 675 // You might wonder, "but that's inline, so of course it wouldn't
676 // start a list?". You would be correct! Except that some of our 676 // start a list?". You would be correct! Except that some of our
677 // lists can be inline, because they might be inside another list, 677 // lists can be inline, because they might be inside another list,
678 // in which case we can parse with inline scope, but need to allow 678 // in which case we can parse with inline scope, but need to allow
679 // nested lists inside this inline scope. 679 // nested lists inside this inline scope.
680 const prevCaptureStr = state.prevCapture == null 680 const prevCaptureStr = state.prevCapture == null
681 ? "" 681 ? ""
682 : state.prevCapture[0]; 682 : state.prevCapture[0];
683 const isStartOfLineCapture = LIST_LOOKBEHIND_R.exec(prevCaptureStr); 683 const isStartOfLineCapture = LIST_LOOKBEHIND_R.exec(prevCaptureStr);
684 const isListBlock = state._list || !state.inline; 684 const isListBlock = state._list || !state.inline;
685 685
686 if (isStartOfLineCapture && isListBlock) { 686 if (isStartOfLineCapture && isListBlock) {
687 source = isStartOfLineCapture[1] + source; 687 source = isStartOfLineCapture[1] + source;
688 return LIST_R.exec(source); 688 return LIST_R.exec(source);
689 } else { 689 } else {
690 return null; 690 return null;
691 } 691 }
692 }, 692 },
693 parse(capture, parse, state) { 693 parse(capture, parse, state) {
694 const bullet = capture[2]; 694 const bullet = capture[2];
695 const ordered = bullet.length > 1; 695 const ordered = bullet.length > 1;
696 const start = ordered ? +bullet : undefined; 696 const start = ordered ? +bullet : undefined;
697 // We know this will match here, because of how the regexes are defined 697 // We know this will match here, because of how the regexes are defined
698 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 698 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
699 const items = capture[0].replace(LIST_BLOCK_END_R, "\n").match( 699 const items = capture[0].replace(LIST_BLOCK_END_R, "\n").match(
700 LIST_ITEM_R, 700 LIST_ITEM_R,
701 )!; 701 )!;
702 702
703 let lastItemWasAParagraph = false; 703 let lastItemWasAParagraph = false;
704 const itemContent = items.map(function (item, i) { 704 const itemContent = items.map(function (item, i) {
705 // We need to see how far indented this item is: 705 // We need to see how far indented this item is:
706 const prefixCapture = LIST_ITEM_PREFIX_R.exec(item); 706 const prefixCapture = LIST_ITEM_PREFIX_R.exec(item);
707 const space = prefixCapture ? prefixCapture[0].length : 0; // And then we construct a regex to "unindent" the subsequent 707 const space = prefixCapture ? prefixCapture[0].length : 0; // And then we construct a regex to "unindent" the subsequent
708 // lines of the items by that amount: 708 // lines of the items by that amount:
709 709
710 const spaceRegex = new RegExp("^ {1," + space + "}", "gm"); // Before processing the item, we need a couple things 710 const spaceRegex = new RegExp("^ {1," + space + "}", "gm"); // Before processing the item, we need a couple things
711 711
712 const content = item // remove indents on trailing lines: 712 const content = item // remove indents on trailing lines:
713 .replace(spaceRegex, "") // remove the bullet: 713 .replace(spaceRegex, "") // remove the bullet:
714 .replace(LIST_ITEM_PREFIX_R, ""); // I'm not sur4 why this is necessary again? 714 .replace(LIST_ITEM_PREFIX_R, ""); // I'm not sur4 why this is necessary again?
715 // Handling "loose" lists, like: 715 // Handling "loose" lists, like:
716 // 716 //
717 // * this is wrapped in a paragraph 717 // * this is wrapped in a paragraph
718 // 718 //
719 // * as is this 719 // * as is this
720 // 720 //
721 // * as is this 721 // * as is this
722 722
723 const isLastItem = i === items.length - 1; 723 const isLastItem = i === items.length - 1;
724 const containsBlocks = content.indexOf("\n\n") !== -1; // Any element in a list is a block if it contains multiple 724 const containsBlocks = content.indexOf("\n\n") !== -1; // Any element in a list is a block if it contains multiple
725 // newlines. The last element in the list can also be a block 725 // newlines. The last element in the list can also be a block
726 // if the previous item in the list was a block (this is 726 // if the previous item in the list was a block (this is
727 // because non-last items in the list can end with \n\n, but 727 // because non-last items in the list can end with \n\n, but
728 // the last item can't, so we just "inherit" this property 728 // the last item can't, so we just "inherit" this property
729 // from our previous element). 729 // from our previous element).
730 730
731 const thisItemIsAParagraph = containsBlocks || 731 const thisItemIsAParagraph = containsBlocks ||
732 (isLastItem && lastItemWasAParagraph); 732 (isLastItem && lastItemWasAParagraph);
733 lastItemWasAParagraph = thisItemIsAParagraph; // backup our state for restoration afterwards. We're going to 733 lastItemWasAParagraph = thisItemIsAParagraph; // backup our state for restoration afterwards. We're going to
734 // want to set state._list to true, and state.inline depending 734 // want to set state._list to true, and state.inline depending
735 // on our list's looseness. 735 // on our list's looseness.
736 736
737 const oldStateInline = state.inline; 737 const oldStateInline = state.inline;
738 const oldStateList = state._list; 738 const oldStateList = state._list;
739 state._list = true; // Parse inline if we're in a tight list, or block if we're in 739 state._list = true; // Parse inline if we're in a tight list, or block if we're in
740 // a loose list. 740 // a loose list.
741 741
742 let adjustedContent; 742 let adjustedContent;
743 743
744 if (thisItemIsAParagraph) { 744 if (thisItemIsAParagraph) {
745 state.inline = false; 745 state.inline = false;
746 adjustedContent = content.replace(LIST_ITEM_END_R, "\n\n"); 746 adjustedContent = content.replace(LIST_ITEM_END_R, "\n\n");
747 } else { 747 } else {
748 state.inline = true; 748 state.inline = true;
749 adjustedContent = content.replace(LIST_ITEM_END_R, ""); 749 adjustedContent = content.replace(LIST_ITEM_END_R, "");
750 } 750 }
751 751
752 const result = parse(adjustedContent, state); // Restore our state before returning 752 const result = parse(adjustedContent, state); // Restore our state before returning
753 753
754 state.inline = oldStateInline; 754 state.inline = oldStateInline;
755 state._list = oldStateList; 755 state._list = oldStateList;
756 return result; 756 return result;
757 }); 757 });
758 758
759 return { 759 return {
760 ordered: ordered, 760 ordered: ordered,
761 start: start, 761 start: start,
762 content: itemContent, 762 content: itemContent,
763 }; 763 };
764 }, 764 },
765 }); 765 });
766 766
767 defaultRules.add({ 767 defaultRules.add({
768 name: "def", 768 name: "def",
769 // TODO: This will match without a blank line before the next 769 // TODO: This will match without a blank line before the next
770 // block element, which is inconsistent with most of the rest of 770 // block element, which is inconsistent with most of the rest of
771 // simple-markdown. 771 // simple-markdown.
772 match: blockRegex( 772 match: blockRegex(
773 /^ *\[([^\]]+)\]: *<?([^\s>]*)>?(?: +["(]([^\n]+)[")])? *\n(?: *\n)*/, 773 /^ *\[([^\]]+)\]: *<?([^\s>]*)>?(?: +["(]([^\n]+)[")])? *\n(?: *\n)*/,
774 ), 774 ),
775 parse(capture, parse, state) { 775 parse(capture, parse, state) {
776 const def = capture[1].replace(/\s+/g, " ").toLowerCase(); 776 const def = capture[1].replace(/\s+/g, " ").toLowerCase();
777 const target = capture[2]; 777 const target = capture[2];
778 const title = capture[3]; 778 const title = capture[3];
779 779
780 // Look for previous links/images using this def 780 // Look for previous links/images using this def
781 // If any links/images using this def have already been declared, 781 // If any links/images using this def have already been declared,
782 // they will have added themselves to the state._refs[def] list 782 // they will have added themselves to the state._refs[def] list
783 // (_ to deconflict with client-defined state). We look through 783 // (_ to deconflict with client-defined state). We look through
784 // that list of reflinks for this def, and modify those AST nodes 784 // that list of reflinks for this def, and modify those AST nodes
785 // with our newly found information now. 785 // with our newly found information now.
786 // Sorry :(. 786 // Sorry :(.
787 if (state._refs && state._refs[def]) { 787 if (state._refs && state._refs[def]) {
788 // `refNode` can be a link or an image 788 // `refNode` can be a link or an image
789 state._refs[def].forEach((refNode: RefNode) => { 789 state._refs[def].forEach((refNode: RefNode) => {
790 refNode.target = target; 790 refNode.target = target;
791 refNode.title = title; 791 refNode.title = title;
792 }); 792 });
793 } 793 }
794 794
795 // Add this def to our map of defs for any future links/images 795 // Add this def to our map of defs for any future links/images
796 // In case we haven't found any or all of the refs referring to 796 // In case we haven't found any or all of the refs referring to
797 // this def yet, we add our def to the table of known defs, so 797 // this def yet, we add our def to the table of known defs, so
798 // that future reflinks can modify themselves appropriately with 798 // that future reflinks can modify themselves appropriately with
799 // this information. 799 // this information.
800 state._defs = state._defs || {}; 800 state._defs = state._defs || {};
801 state._defs[def] = { 801 state._defs[def] = {
802 target: target, 802 target: target,
803 title: title, 803 title: title,
804 }; 804 };
805 805
806 // return the relevant parsed information 806 // return the relevant parsed information
807 // for debugging only. 807 // for debugging only.
808 return { 808 return {
809 def: def, 809 def: def,
810 target: target, 810 target: target,
811 title: title, 811 title: title,
812 }; 812 };
813 }, 813 },
814 }); 814 });
815 815
816 defaultRules.add({ 816 defaultRules.add({
817 name: "table", 817 name: "table",
818 match: blockRegex(TABLES.TABLE_REGEX), 818 match: blockRegex(TABLES.TABLE_REGEX),
819 parse: TABLES.parseTable, 819 parse: TABLES.parseTable,
820 }); 820 });
821 821
822 defaultRules.add({ 822 defaultRules.add({
823 name: "newline", 823 name: "newline",
824 match: blockRegex(/^(?:\n *)*\n/), 824 match: blockRegex(/^(?:\n *)*\n/),
825 parse: ignoreCapture, 825 parse: ignoreCapture,
826 }); 826 });
827 827
828 defaultRules.add({ 828 defaultRules.add({
829 name: "paragraph", 829 name: "paragraph",
830 match: blockRegex(/^((?:[^\n]|\n(?! *\n))+)(?:\n *)+\n/), 830 match: blockRegex(/^((?:[^\n]|\n(?! *\n))+)(?:\n *)+\n/),
831 parse: parseCaptureInline, 831 parse: parseCaptureInline,
832 }); 832 });
833 833
834 defaultRules.add({ 834 defaultRules.add({
835 name: "escape", 835 name: "escape",
836 // We don't allow escaping numbers, letters, or spaces here so that 836 // We don't allow escaping numbers, letters, or spaces here so that
837 // backslashes used in plain text still get rendered. But allowing 837 // backslashes used in plain text still get rendered. But allowing
838 // escaping anything else provides a very flexible escape mechanism, 838 // escaping anything else provides a very flexible escape mechanism,
839 // regardless of how this grammar is extended. 839 // regardless of how this grammar is extended.
840 match: inlineRegex(/^\\([^0-9A-Za-z\s])/), 840 match: inlineRegex(/^\\([^0-9A-Za-z\s])/),
841 parse(capture) { 841 parse(capture) {
842 return { 842 return {
843 type: "text", 843 type: "text",
844 content: capture[1], 844 content: capture[1],
845 }; 845 };
846 }, 846 },
847 }); 847 });
848 848
849 defaultRules.add({ 849 defaultRules.add({
850 name: "tableSeparator", 850 name: "tableSeparator",
851 851
852 match(source, state) { 852 match(source, state) {
853 if (!state.inTable) { 853 if (!state.inTable) {
854 return null; 854 return null;
855 } 855 }
856 856
857 return /^ *\| */.exec(source); 857 return /^ *\| */.exec(source);
858 }, 858 },
859 parse() { 859 parse() {
860 return { 860 return {
861 type: "tableSeparator", 861 type: "tableSeparator",
862 }; 862 };
863 }, 863 },
864 }); 864 });
865 865
866 defaultRules.add({ 866 defaultRules.add({
867 name: "autolink", 867 name: "autolink",
868 match: inlineRegex(/^<([^: >]+:\/[^ >]+)>/), 868 match: inlineRegex(/^<([^: >]+:\/[^ >]+)>/),
869 parse(capture) { 869 parse(capture) {
870 return { 870 return {
871 type: "link", 871 type: "link",
872 content: [ 872 content: [
873 { 873 {
874 type: "text", 874 type: "text",
875 content: capture[1], 875 content: capture[1],
876 }, 876 },
877 ], 877 ],
878 target: capture[1], 878 target: capture[1],
879 }; 879 };
880 }, 880 },
881 }); 881 });
882 882
883 defaultRules.add({ 883 defaultRules.add({
884 name: "mailto", 884 name: "mailto",
885 match: inlineRegex(/^<([^ >]+@[^ >]+)>/), 885 match: inlineRegex(/^<([^ >]+@[^ >]+)>/),
886 parse(capture) { 886 parse(capture) {
887 const address = capture[1]; 887 const address = capture[1];
888 let target = capture[1]; // Check for a `mailto:` already existing in the link: 888 let target = capture[1]; // Check for a `mailto:` already existing in the link:
889 889
890 if (!AUTOLINK_MAILTO_CHECK_R.test(target)) { 890 if (!AUTOLINK_MAILTO_CHECK_R.test(target)) {
891 target = "mailto:" + target; 891 target = "mailto:" + target;
892 } 892 }
893 893
894 return { 894 return {
895 type: "link", 895 type: "link",
896 content: [ 896 content: [
897 { 897 {
898 type: "text", 898 type: "text",
899 content: address, 899 content: address,
900 }, 900 },
901 ], 901 ],
902 target: target, 902 target: target,
903 }; 903 };
904 }, 904 },
905 }); 905 });
906 906
907 defaultRules.add({ 907 defaultRules.add({
908 name: "url", 908 name: "url",
909 match: inlineRegex(/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/), 909 match: inlineRegex(/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/),
910 parse(capture) { 910 parse(capture) {
911 return { 911 return {
912 type: "link", 912 type: "link",
913 content: [ 913 content: [
914 { 914 {
915 type: "text", 915 type: "text",
916 content: capture[1], 916 content: capture[1],
917 }, 917 },
918 ], 918 ],
919 target: capture[1], 919 target: capture[1],
920 title: undefined, 920 title: undefined,
921 }; 921 };
922 }, 922 },
923 }); 923 });
924 924
925 defaultRules.add({ 925 defaultRules.add({
926 name: "link", 926 name: "link",
927 match: inlineRegex( 927 match: inlineRegex(
928 new RegExp( 928 new RegExp(
929 "^\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)", 929 "^\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)",
930 ), 930 ),
931 ), 931 ),
932 parse(capture, parse, state) { 932 parse(capture, parse, state) {
933 const link = { 933 const link = {
934 content: parse(capture[1], state), 934 content: parse(capture[1], state),
935 target: unescapeUrl(capture[2]), 935 target: unescapeUrl(capture[2]),
936 title: capture[3], 936 title: capture[3],
937 }; 937 };
938 return link; 938 return link;
939 }, 939 },
940 }); 940 });
941 941
942 defaultRules.add({ 942 defaultRules.add({
943 name: "image", 943 name: "image",
944 match: inlineRegex( 944 match: inlineRegex(
945 new RegExp( 945 new RegExp(
946 "^!\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)", 946 "^!\\[(" + LINK_INSIDE + ")\\]\\(" + LINK_HREF_AND_TITLE + "\\)",
947 ), 947 ),
948 ), 948 ),
949 parse: function (capture) { 949 parse: function (capture) {
950 const image = { 950 const image = {
951 alt: capture[1], 951 alt: capture[1],
952 target: unescapeUrl(capture[2]), 952 target: unescapeUrl(capture[2]),
953 title: capture[3], 953 title: capture[3],
954 }; 954 };
955 return image; 955 return image;
956 }, 956 },
957 }); 957 });
958 958
959 defaultRules.add({ 959 defaultRules.add({
960 name: "reflink", 960 name: "reflink",
961 match: inlineRegex( 961 match: inlineRegex(
962 new RegExp( 962 new RegExp(
963 // The first [part] of the link 963 // The first [part] of the link
964 "^\\[(" + 964 "^\\[(" +
965 LINK_INSIDE + 965 LINK_INSIDE +
966 ")\\]" + // The [ref] target of the link 966 ")\\]" + // The [ref] target of the link
967 "\\s*\\[([^\\]]*)\\]", 967 "\\s*\\[([^\\]]*)\\]",
968 ), 968 ),
969 ), 969 ),
970 parse(capture, parse, state) { 970 parse(capture, parse, state) {
971 return parseRef(capture, state, { 971 return parseRef(capture, state, {
972 type: "link", 972 type: "link",
973 content: parse(capture[1], state), 973 content: parse(capture[1], state),
974 }); 974 });
975 }, 975 },
976 }); 976 });
977 977
978 defaultRules.add({ 978 defaultRules.add({
979 name: "refimage", 979 name: "refimage",
980 match: inlineRegex( 980 match: inlineRegex(
981 new RegExp( 981 new RegExp(
982 // The first [part] of the link 982 // The first [part] of the link
983 "^!\\[(" + 983 "^!\\[(" +
984 LINK_INSIDE + 984 LINK_INSIDE +
985 ")\\]" + // The [ref] target of the link 985 ")\\]" + // The [ref] target of the link
986 "\\s*\\[([^\\]]*)\\]", 986 "\\s*\\[([^\\]]*)\\]",
987 ), 987 ),
988 ), 988 ),
989 parse(capture, parse, state) { 989 parse(capture, parse, state) {
990 return parseRef(capture, state, { 990 return parseRef(capture, state, {
991 type: "image", 991 type: "image",
992 alt: capture[1], 992 alt: capture[1],
993 }); 993 });
994 }, 994 },
995 }); 995 });
996 996
997 defaultRules.add({ 997 defaultRules.add({
998 name: "em", 998 name: "em",
999 /* same as strong/u */ 999 /* same as strong/u */
1000 match: inlineRegex( 1000 match: inlineRegex(
1001 new RegExp( 1001 new RegExp(
1002 // only match _s surrounding words. 1002 // only match _s surrounding words.
1003 "^\\b_" + 1003 "^\\b_" +
1004 "((?:__|\\\\[\\s\\S]|[^\\\\_])+?)_" + 1004 "((?:__|\\\\[\\s\\S]|[^\\\\_])+?)_" +
1005 "\\b" + // Or match *s: 1005 "\\b" + // Or match *s:
1006 "|" + // Only match *s that are followed by a non-space: 1006 "|" + // Only match *s that are followed by a non-space:
1007 "^\\*(?=\\S)(" + // Match at least one of: 1007 "^\\*(?=\\S)(" + // Match at least one of:
1008 "(?:" + // - `**`: so that bolds inside italics don't close the 1008 "(?:" + // - `**`: so that bolds inside italics don't close the
1009 // italics 1009 // italics
1010 "\\*\\*|" + // - escape sequence: so escaped *s don't close us 1010 "\\*\\*|" + // - escape sequence: so escaped *s don't close us
1011 "\\\\[\\s\\S]|" + // - whitespace: followed by a non-* (we don't 1011 "\\\\[\\s\\S]|" + // - whitespace: followed by a non-* (we don't
1012 // want ' *' to close an italics--it might 1012 // want ' *' to close an italics--it might
1013 // start a list) 1013 // start a list)
1014 "\\s+(?:\\\\[\\s\\S]|[^\\s\\*\\\\]|\\*\\*)|" + // - non-whitespace, non-*, non-backslash characters 1014 "\\s+(?:\\\\[\\s\\S]|[^\\s\\*\\\\]|\\*\\*)|" + // - non-whitespace, non-*, non-backslash characters
1015 "[^\\s\\*\\\\]" + 1015 "[^\\s\\*\\\\]" +
1016 ")+?" + // followed by a non-space, non-* then * 1016 ")+?" + // followed by a non-space, non-* then *
1017 ")\\*(?!\\*)", 1017 ")\\*(?!\\*)",
1018 ), 1018 ),
1019 ), 1019 ),
1020 quality(capture) { 1020 quality(capture) {
1021 // precedence by length, `em` wins ties: 1021 // precedence by length, `em` wins ties:
1022 return capture[0].length + 0.2; 1022 return capture[0].length + 0.2;
1023 }, 1023 },
1024 parse(capture, parse, state) { 1024 parse(capture, parse, state) {
1025 return { 1025 return {
1026 content: parse(capture[2] || capture[1], state), 1026 content: parse(capture[2] || capture[1], state),
1027 }; 1027 };
1028 }, 1028 },
1029 }); 1029 });
1030 1030
1031 defaultRules.add({ 1031 defaultRules.add({
1032 name: "strong", 1032 name: "strong",
1033 /* same as em */ 1033 /* same as em */
1034 match: inlineRegex(/^\*\*((?:\\[\s\S]|[^\\])+?)\*\*(?!\*)/), 1034 match: inlineRegex(/^\*\*((?:\\[\s\S]|[^\\])+?)\*\*(?!\*)/),
1035 quality(capture) { 1035 quality(capture) {
1036 // precedence by length, wins ties vs `u`: 1036 // precedence by length, wins ties vs `u`:
1037 return capture[0].length + 0.1; 1037 return capture[0].length + 0.1;
1038 }, 1038 },
1039 parse: parseCaptureInline, 1039 parse: parseCaptureInline,
1040 }); 1040 });
1041 1041
1042 defaultRules.add({ 1042 defaultRules.add({
1043 name: "u", 1043 name: "u",
1044 /* same as em&strong; increment for next rule */ 1044 /* same as em&strong; increment for next rule */
1045 match: inlineRegex(/^__((?:\\[\s\S]|[^\\])+?)__(?!_)/), 1045 match: inlineRegex(/^__((?:\\[\s\S]|[^\\])+?)__(?!_)/),
1046 quality(capture) { 1046 quality(capture) {
1047 // precedence by length, loses all ties 1047 // precedence by length, loses all ties
1048 return capture[0].length; 1048 return capture[0].length;
1049 }, 1049 },
1050 parse: parseCaptureInline, 1050 parse: parseCaptureInline,
1051 }); 1051 });
1052 1052
1053 defaultRules.add({ 1053 defaultRules.add({
1054 name: "del", 1054 name: "del",
1055 match: inlineRegex(/^~~(?=\S)((?:\\[\s\S]|~(?!~)|[^\s~\\]|\s(?!~~))+?)~~/), 1055 match: inlineRegex(/^~~(?=\S)((?:\\[\s\S]|~(?!~)|[^\s~\\]|\s(?!~~))+?)~~/),
1056 parse: parseCaptureInline, 1056 parse: parseCaptureInline,
1057 }); 1057 });
1058 1058
1059 defaultRules.add({ 1059 defaultRules.add({
1060 name: "inlineCode", 1060 name: "inlineCode",
1061 match: inlineRegex(/^(`+)([\s\S]*?[^`])\1(?!`)/), 1061 match: inlineRegex(/^(`+)([\s\S]*?[^`])\1(?!`)/),
1062 parse(capture) { 1062 parse(capture) {
1063 return { 1063 return {
1064 content: capture[2].replace(INLINE_CODE_ESCAPE_BACKTICKS_R, "$1"), 1064 content: capture[2].replace(INLINE_CODE_ESCAPE_BACKTICKS_R, "$1"),
1065 }; 1065 };
1066 }, 1066 },
1067 }); 1067 });
1068 1068
1069 defaultRules.add({ 1069 defaultRules.add({
1070 name: "br", 1070 name: "br",
1071 match: anyScopeRegex(/^ {2,}\n/), 1071 match: anyScopeRegex(/^ {2,}\n/),
1072 parse: ignoreCapture, 1072 parse: ignoreCapture,
1073 }); 1073 });
1074 1074
1075 defaultRules.add({ 1075 defaultRules.add({
1076 name: "text", 1076 name: "text",
1077 // Here we look for anything followed by non-symbols, 1077 // Here we look for anything followed by non-symbols,
1078 // double newlines, or double-space-newlines 1078 // double newlines, or double-space-newlines
1079 // We break on any symbol characters so that this grammar 1079 // We break on any symbol characters so that this grammar
1080 // is easy to extend without needing to modify this regex 1080 // is easy to extend without needing to modify this regex
1081 match: anyScopeRegex( 1081 match: anyScopeRegex(
1082 /^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]|\n\n| {2,}\n|\w+:\S|$)/, 1082 /^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]|\n\n| {2,}\n|\w+:\S|$)/,
1083 ), 1083 ),
1084 parse: function (capture) { 1084 parse: function (capture) {
1085 return { 1085 return {
1086 content: capture[0], 1086 content: capture[0],
1087 }; 1087 };
1088 }, 1088 },
1089 }); 1089 });
1090} 1090}
src/q+a/views/backend-inbox.client.ts+74-74
...@@ -1,74 +1,74 @@...@@ -1,74 +1,74 @@
1// @ts-ignore 1// @ts-ignore
2globalThis.onReply = (id: string) => { 2globalThis.onReply = (id: string) => {
3 location.href = `/admin/q+a/${id}?return=inbox`; 3 location.href = `/admin/q+a/${id}?return=inbox`;
4}; 4};
5// @ts-ignore 5// @ts-ignore
6globalThis.onDelete = async (id: string) => { 6globalThis.onDelete = async (id: string) => {
7 const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement; 7 const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
8 if (!div) return alert("Question not found"); 8 if (!div) return alert("Question not found");
9 9
10 // Pending State 10 // Pending State
11 div.style.opacity = "0.5"; 11 div.style.opacity = "0.5";
12 div.style.pointerEvents = "none"; 12 div.style.pointerEvents = "none";
13 div?.querySelectorAll("button").forEach((b) => { 13 div?.querySelectorAll("button").forEach((b) => {
14 b.disabled = true; 14 b.disabled = true;
15 }); 15 });
16 16
17 try { 17 try {
18 const resp = await fetch(`/admin/q+a/${id}`, { 18 const resp = await fetch(`/admin/q+a/${id}`, {
19 method: "DELETE", 19 method: "DELETE",
20 headers: { 20 headers: {
21 Accept: "application/json", 21 Accept: "application/json",
22 }, 22 },
23 }); 23 });
24 if (resp.status !== 200) { 24 if (resp.status !== 200) {
25 throw new Error("Failed to delete question, status: " + resp.status); 25 throw new Error("Failed to delete question, status: " + resp.status);
26 } 26 }
27 } catch (e: any) { 27 } catch (e: any) {
28 div.style.opacity = "1"; 28 div.style.opacity = "1";
29 div.style.pointerEvents = "auto"; 29 div.style.pointerEvents = "auto";
30 div?.querySelectorAll("button").forEach((b) => { 30 div?.querySelectorAll("button").forEach((b) => {
31 b.disabled = false; 31 b.disabled = false;
32 }); 32 });
33 return alert(e.message); 33 return alert(e.message);
34 } 34 }
35 35
36 div.remove(); 36 div.remove();
37}; 37};
38// @ts-ignore 38// @ts-ignore
39globalThis.onDeleteFull = async (id: string) => { 39globalThis.onDeleteFull = async (id: string) => {
40 const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement; 40 const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
41 if (!div) return alert("Question not found"); 41 if (!div) return alert("Question not found");
42 42
43 // Confirmation 43 // Confirmation
44 if (!confirm("Are you sure you want to delete this question?")) return; 44 if (!confirm("Are you sure you want to delete this question?")) return;
45 45
46 // Pending State 46 // Pending State
47 div.style.opacity = "0.5"; 47 div.style.opacity = "0.5";
48 div.style.pointerEvents = "none"; 48 div.style.pointerEvents = "none";
49 div?.querySelectorAll("button").forEach((b) => { 49 div?.querySelectorAll("button").forEach((b) => {
50 b.disabled = true; 50 b.disabled = true;
51 }); 51 });
52 52
53 try { 53 try {
54 const resp = await fetch(`/admin/q+a/${id}`, { 54 const resp = await fetch(`/admin/q+a/${id}`, {
55 method: "DELETE", 55 method: "DELETE",
56 headers: { 56 headers: {
57 Accept: "application/json", 57 Accept: "application/json",
58 "X-Delete-Full": "true", 58 "X-Delete-Full": "true",
59 }, 59 },
60 }); 60 });
61 if (resp.status !== 200) { 61 if (resp.status !== 200) {
62 throw new Error("Failed to delete question, status: " + resp.status); 62 throw new Error("Failed to delete question, status: " + resp.status);
63 } 63 }
64 } catch (e: any) { 64 } catch (e: any) {
65 div.style.opacity = "1"; 65 div.style.opacity = "1";
66 div.style.pointerEvents = "auto"; 66 div.style.pointerEvents = "auto";
67 div?.querySelectorAll("button").forEach((b) => { 67 div?.querySelectorAll("button").forEach((b) => {
68 b.disabled = false; 68 b.disabled = false;
69 }); 69 });
70 return alert(e.message); 70 return alert(e.message);
71 } 71 }
72 72
73 div.remove(); 73 div.remove();
74}; 74};
src/q+a/views/editor.css+39-39
...@@ -1,39 +1,39 @@...@@ -1,39 +1,39 @@
1#edit-grid { 1#edit-grid {
2 display: grid; 2 display: grid;
3 grid-template-columns: 1fr 80ch; 3 grid-template-columns: 1fr 80ch;
4 grid-template-rows: 3rem 1fr; 4 grid-template-rows: 3rem 1fr;
5 grid-gap: 1em; 5 grid-gap: 1em;
6 height: 100vh; 6 height: 100vh;
7 7
8 button { 8 button {
9 margin-right: 1rem; 9 margin-right: 1rem;
10 } 10 }
11 main { 11 main {
12 padding: 0; 12 padding: 0;
13 margin: 0; 13 margin: 0;
14 } 14 }
15} 15}
16#topleft, #topright { 16#topleft, #topright {
17 padding: 1rem; 17 padding: 1rem;
18} 18}
19#topleft { 19#topleft {
20 padding-right: 0rem; 20 padding-right: 0rem;
21} 21}
22#topright { 22#topright {
23 padding-left: 0rem; 23 padding-left: 0rem;
24} 24}
25#preview { 25#preview {
26 overflow-y: auto; 26 overflow-y: auto;
27 e- { 27 e- {
28 margin-top: 0; 28 margin-top: 0;
29 } 29 }
30} 30}
31#editor { 31#editor {
32 background-color: #303030; 32 background-color: #303030;
33 overflow-y: scroll; 33 overflow-y: scroll;
34 height: 100%; 34 height: 100%;
35} 35}
36.cm-scroller { 36.cm-scroller {
37 overflow-y: auto; 37 overflow-y: auto;
38 height: 100%; 38 height: 100%;
39} 39}
src/static/wurtzhome.html+6-2
...@@ -3,7 +3,9 @@...@@ -3,7 +3,9 @@
3 <title>paper clover</title>3 <title>paper clover</title>
4 </head>4 </head>
5 <body bgcolor="black" style="word-wrap: initial">5 <body bgcolor="black" style="word-wrap: initial">
6 <main style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh">6 <main
7 style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh"
8 >
7 <div>9 <div>
8 <p style="margin: 0.5rem 0">10 <p style="margin: 0.5rem 0">
9 <a11 <a
...@@ -56,7 +58,9 @@...@@ -56,7 +58,9 @@
56 <font color="#FF8147">feed</font>58 <font color="#FF8147">feed</font>
57 </a>59 </a>
58 </p>60 </p>
59 <h1 style="margin: -1.5rem 0 3rem 0; font-size: 7rem; font-weight: 400; font-family: times">61 <h1
62 style="margin: -1.5rem 0 3rem 0; font-size: 7rem; font-weight: 400; font-family: times"
63 >
60 <font color="#B8E1FF">paper</font>64 <font color="#B8E1FF">paper</font>
61 <font color="#E8F4FF">clover</font>65 <font color="#E8F4FF">clover</font>
62 </h1>66 </h1>
src/tags/Video.tsx+58-58
...@@ -1,58 +1,58 @@...@@ -1,58 +1,58 @@
1import "./video.css"; 1import "./video.css";
2import * as path from "node:path"; 2import * as path from "node:path";
3import { addScript } from "#sitegen"; 3import { addScript } from "#sitegen";
4import { PrecomputedBlurhash } from "./blurhash.tsx"; 4import { PrecomputedBlurhash } from "./blurhash.tsx";
5 5
6export namespace Video { 6export namespace Video {
7 export interface Props { 7 export interface Props {
8 title: string; 8 title: string;
9 width: number; 9 width: number;
10 height: number; 10 height: number;
11 sources: string[]; 11 sources: string[];
12 downloads: string[]; 12 downloads: string[];
13 poster?: string; 13 poster?: string;
14 posterHash?: string; 14 posterHash?: string;
15 borderless?: boolean; 15 borderless?: boolean;
16 } 16 }
17} 17}
18 18
19export function Video( 19export function Video(
20 { title, sources, height, poster, posterHash, width, borderless }: 20 { title, sources, height, poster, posterHash, width, borderless }:
21 Video.Props, 21 Video.Props,
22) { 22) {
23 addScript("./hls-polyfill.client.ts"); 23 addScript("./hls-polyfill.client.ts");
24 return ( 24 return (
25 <figure class={`video ${borderless ? "borderless" : ""}`}> 25 <figure class={`video ${borderless ? "borderless" : ""}`}>
26 <figcaption>{title}</figcaption> 26 <figcaption>{title}</figcaption>
27 {posterHash && <PrecomputedBlurhash hash={posterHash} />} 27 {posterHash && <PrecomputedBlurhash hash={posterHash} />}
28 {poster && <img src={poster} alt="waterfalls" />} 28 {poster && <img src={poster} alt="waterfalls" />}
29 <video 29 <video
30 controls 30 controls
31 preload="none" 31 preload="none"
32 style={`width:100%;background:transparent;aspect-ratio:${ 32 style={`width:100%;background:transparent;aspect-ratio:${
33 simplifyFraction(width, height) 33 simplifyFraction(width, height)
34 }`} 34 }`}
35 poster="data:null" 35 poster="data:null"
36 > 36 >
37 {sources.map((src) => ( 37 {sources.map((src) => (
38 <source 38 <source
39 src={src} 39 src={src}
40 type={contentTypeFromExt(src)} 40 type={contentTypeFromExt(src)}
41 /> 41 />
42 ))} 42 ))}
43 </video> 43 </video>
44 </figure> 44 </figure>
45 ); 45 );
46} 46}
47export function contentTypeFromExt(src: string) { 47export function contentTypeFromExt(src: string) {
48 if (src.endsWith(".m3u8")) return "application/x-mpegURL"; 48 if (src.endsWith(".m3u8")) return "application/x-mpegURL";
49 if (src.endsWith(".webm")) return "video/webm"; 49 if (src.endsWith(".webm")) return "video/webm";
50 if (src.endsWith(".mp4")) return "video/mp4"; 50 if (src.endsWith(".mp4")) return "video/mp4";
51 if (src.endsWith(".ogg")) return "video/ogg"; 51 if (src.endsWith(".ogg")) return "video/ogg";
52 throw new Error("Unknown video extension: " + path.extname(src)); 52 throw new Error("Unknown video extension: " + path.extname(src));
53} 53}
54const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a; 54const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a;
55function simplifyFraction(n: number, d: number) { 55function simplifyFraction(n: number, d: number) {
56 const divisor = gcd(n, d); 56 const divisor = gcd(n, d);
57 return `${n / divisor}/${d / divisor}`; 57 return `${n / divisor}/${d / divisor}`;
58} 58}
src/tags/video.css+24-25
...@@ -1,25 +1,24 @@...@@ -1,25 +1,24 @@
1.video { 1.video {
2 border: 4px solid var(--fg); 2 border: 4px solid var(--fg);
3 display: flex; 3 display: flex;
4 flex-direction: column; 4 flex-direction: column;
5 position: relative; 5 position: relative;
6} 6}
7 7
8.video > img, 8.video > img,
9.video > span { 9.video > span {
10 position: absolute; 10 position: absolute;
11 left: 0; 11 left: 0;
12 top: 0; 12 top: 0;
13 width: 100%; 13 width: 100%;
14 height: 100%; 14 height: 100%;
15 z-index: -1; 15 z-index: -1;
16} 16}
17 17
18.video figcaption { 18.video figcaption {
19 background-color: var(--fg); 19 background-color: var(--fg);
20 color: var(--bg); 20 color: var(--bg);
21 width: 100%; 21 width: 100%;
22 margin-top: -1px; 22 margin-top: -1px;
23 padding-bottom: 2px; 23 padding-bottom: 2px;
24} 24}
25