authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-03 03:38:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:48-07:00
logdf4fa11a813a4697d6f36f728a19aa83c1b210bc
tree5f7db819e0b436e3d5072e7397773c2b9e49fa4f
parent165f4035df4689343605874887574a8c06d0a24b
signature Commit is signed but in an unrecognized format.

move more things into lib


86 files changed, 3218 insertions(+), 2775 deletions(-)

framework/backend/entry-node.ts-2
......@@ -1,5 +1,3 @@
1import "#debug";
2
31const app = require(globalThis.CLOVER_SERVER_ENTRY ?? process.argv[2]);
42
53const protocol = "http";
framework/backend/entry-passthru.ts deleted-3
......@@ -1,3 +0,0 @@
1export default app;
2
3import app from "#backend";
framework/bundle.ts+4-6
......@@ -65,7 +65,7 @@ export async function bundleClientJavaScript(
6565 format: "esm",
6666 jsx: "automatic",
6767 jsxDev: dev,
68 jsxImportSource: "#engine",
68 jsxImportSource: "#jsx",
6969 logLevel: "silent",
7070 metafile: true,
7171 minify: !dev,
......@@ -75,7 +75,6 @@ export async function bundleClientJavaScript(
7575 external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"],
7676 define: {
7777 ASSERT: "console.assert",
78 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
7978 },
8079 })
8180 .catch((err: any) => {
......@@ -296,10 +295,9 @@ export async function bundleServerJavaScript({
296295 write: false,
297296 metafile: true,
298297 jsx: "automatic",
299 jsxImportSource: "#engine",
298 jsxImportSource: "#jsx",
300299 jsxDev: false,
301300 define: {
302 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
303301 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
304302 },
305303 external: Object.keys(pkg.dependencies).filter(
......@@ -398,10 +396,10 @@ import {
398396} from "./esbuild-support.ts";
399397import { Io, toAbs } from "./incremental.ts";
400398import * as fs from "#sitegen/fs";
401import * as mime from "#sitegen/mime";
402399import * as incr from "./incremental.ts";
403400import * as sg from "#sitegen";
404401import type { PageOrView } from "./generate.ts";
405402import type * as view from "#sitegen/view";
406import * as meta from "#sitegen/meta";
403import * as meta from "lib/render/meta.ts";
407404import * as progress from "lib/log/progress.ts";
405import { ASSERT, UNWRAP } from "lib/assert.ts";
framework/css.ts+2-1
......@@ -107,7 +107,7 @@ export async function bundleCssFiles(
107107 throw new AggregateError(warnings, "CSS Build Failed");
108108 }
109109 if (outputFiles.length > 1) throw new Error("Too many output files");
110 return outputFiles[0].text;
110 return UNWRAP(outputFiles[0]).text;
111111}
112112
113113import * as esbuild from "esbuild";
......@@ -116,3 +116,4 @@ import * as hot from "./hot.ts";
116116import * as path from "node:path";
117117import { virtualFiles } from "./esbuild-support.ts";
118118import type { Io } from "./incremental.ts";
119import { UNWRAP } from "lib/assert.ts";
framework/debug.safe.ts deleted-18
......@@ -1,18 +0,0 @@
1process.on("uncaughtException", (error) => {
2 log.error(error);
3 process.exit(1);
4});
5import * as log from "lib/log.ts";
6
7globalThis.UNWRAP = (t, ...args) => {
8 if (t == null) {
9 throw new Error(
10 args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
11 );
12 }
13 return t;
14};
15globalThis.ASSERT = assert.ok;
16
17import * as util from "node:util";
18import * as assert from "node:assert";
framework/engine/jsx-runtime.ts deleted-54
......@@ -1,54 +0,0 @@
1export const Fragment = ({ children }: { children: render.Node[] }) => children;
2
3export function jsx(
4 type: string | render.Component,
5 props: Record<string, unknown>,
6): render.Element {
7 if (typeof type !== "function" && typeof type !== "string") {
8 throw new Error("Invalid component type: " + render.inspect(type));
9 }
10 return [render.kElement, type, props];
11}
12
13export function jsxDEV(
14 type: string | render.Component,
15 props: Record<string, unknown>,
16 // Unused with the clover engine
17 _key: string,
18 // Unused with the clover engine
19 _isStaticChildren: boolean,
20 source: render.SrcLoc,
21): render.Element {
22 const { fileName, lineNumber, columnNumber } = source;
23
24 // Assert the component type is valid to render.
25 if (typeof type !== "function" && typeof type !== "string") {
26 throw new Error(
27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
28 render.inspect(type) +
29 ". Clover SSR element must be a function or string",
30 );
31 }
32
33 // Construct an `ssr.Element`
34 return [render.kElement, type, props, "", source];
35}
36
37// jsxs
38export { jsx as jsxs };
39
40declare global {
41 namespace JSX {
42 interface IntrinsicElements {
43 [name: string]: Record<string, unknown>;
44 }
45 interface ElementChildrenAttribute {
46 children: Node;
47 }
48 type Element = render.Element;
49 type ElementType = keyof IntrinsicElements | render.Component;
50 type ElementClass = ReturnType<render.Component>;
51 }
52}
53
54import * as render from "#engine/render";
framework/engine/marko-runtime.ts+2-8
......@@ -2,11 +2,6 @@
22// To use, replace the "marko/html" import with this file.
33export * from "#marko/html";
44
5interface BodyContentObject {
6 [x: PropertyKey]: unknown;
7 content: ServerRenderer;
8}
9
105export const _template: typeof marko._template = (
116 templateId,
127 renderer,
......@@ -52,7 +47,7 @@ export const _dynamic_tag: typeof marko._dynamic_tag = (
5247 }
5348 const r = render.current ?? (marko.$global().clover as render.State);
5449 if (!r) throw new Error("No Clover Render Active");
55 const subRender = render.init(r.async !== -1, r.addon);
50 const subRender = render.init(r.async !== -1, r.context);
5651 const resolved = render.resolveNode(
5752 subRender,
5853 render.element(
......@@ -133,7 +128,6 @@ interface Async {
133128 isAsync: boolean;
134129}
135130
136import * as render from "#engine/render";
137import type { ServerRenderer } from "marko/html/template";
138131import type { Template } from "marko/common/types";
132import * as render from "lib/render.ts";
139133import * as marko from "#marko/html";
framework/engine/render.test.tsx deleted-41
......@@ -1,41 +0,0 @@
1import { test } from "node:test";
2import * as render from "#engine/render";
3
4test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3"));
5test("simple tree", (t) =>
6 t.assert.equal(
7 render.sync(
8 <main class={["a", "b"]}>
9 <h1 style="background-color:red">hello world</h1>
10 <p>haha</p>
11 {1}|{0}|{true}|{false}|{null}|{undefined}|
12 </main>,
13 ).text,
14 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
15 ));
16test("unescaped/escaped html", (t) =>
17 t.assert.equal(
18 render.sync(
19 <div>
20 {render.raw("<fuck>")}
21 {"\"&'`<>"}
22 </div>,
23 ).text,
24 "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
25 ));
26test("clsx built-in", (t) =>
27 t.assert.equal(
28 render.sync(
29 <>
30 <a class="a" />
31 <b class={null} />
32 <c class={undefined} />
33 <d class={["a", "b", null]} />
34 <e class={{ a: true, b: false }} />
35 <e
36 class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
37 />
38 </>,
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>',
41 ));
framework/engine/render.ts deleted-355
......@@ -1,355 +0,0 @@
1// Clover's Rendering Engine is the backbone of her website generator. It
2// converts objects and components (functions returning 'Node') into HTML. The
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.
5//
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
8// to track needed client scripts without introducing patches to the engine.
9//
10// This module is not production ready
11export let current: State | null = null;
12export function setCurrent(r: State | null) {
13 current = r ?? null;
14}
15
16/* Convert a UI description into a string synchronously. */
17export function sync<A extends Addons>(node: Node, addon: A = {} as A) {
18 const state = init(false, addon);
19 const resolved = resolveNode(state, node);
20 return { text: stringifyNode(resolved), addon };
21}
22
23/* Convert a UI description into a string asynchronously. */
24export function async<A extends Addons>(node: Node, addon: A = {} as A) {
25 const state = init(true, addon);
26 const resolved = resolveNode(state, node);
27 if (state.async === 0) {
28 return Promise.resolve({ text: stringifyNode(resolved), addon });
29 }
30 const { resolve, reject, promise } = Promise.withResolvers<Result<A>>();
31 state.asyncDone = () => {
32 const rejections = state.rejections;
33 if (!rejections) return resolve({ text: stringifyNode(resolved), addon });
34 if (rejections.length === 1) return reject(rejections[0]);
35 return reject(new AggregateError(rejections));
36 };
37 return promise;
38}
39
40export type Addons = Record<symbol, unknown>;
41export interface Result<A extends Addons = Addons> {
42 text: string;
43 addon: A;
44}
45
46export function userData<T>(def: () => T) {
47 const k: unique symbol = Symbol();
48 return {
49 key: k,
50 get: () => ((UNWRAP(current).addon[k] as T) ??= def() as T),
51 set: (value: T) => void (UNWRAP(current).addon[k] = value),
52 };
53}
54
55/** Inline HTML into a render without escaping it */
56export function raw(node: ResolvedNode): Raw {
57 return [kRaw, node];
58}
59export function element(type: Element[1], props: Element[2] = {}): Element {
60 return [kElement, type, props];
61}
62export function resolvedElement(
63 type: ResolvedElement[1],
64 props: ResolvedElement[2],
65 children: ResolvedElement[3],
66): ResolvedElement {
67 return [kElement, type, props, children];
68}
69
70export interface State {
71 /**
72 * Set to '-1' if rendering synchronously
73 * Number of async promises the render is waiting on.
74 */
75 async: number | -1;
76 asyncDone: null | (() => void);
77 /** When components reject, those are logged here */
78 rejections: unknown[] | null;
79 /** Add-ons to the rendering engine store state here */
80 addon: Addons;
81}
82
83export const kElement = Symbol("Element");
84export const kRaw = Symbol("Raw");
85
86/** Node represents a webpage that can be 'rendered' into HTML. */
87export type Node =
88 | number
89 | string // Escape HTML
90 | Node[] // Concat
91 | Element // Stringify
92 | Raw // Insert
93 | Promise<Node> // Await
94 // Ignore
95 | (undefined | null | boolean);
96export type Element = [
97 tag: typeof kElement,
98 type: string | Component,
99 props: Record<string, unknown>,
100 _?: "", // children
101 source?: SrcLoc,
102];
103export type Raw = [tag: typeof kRaw, html: ResolvedNode];
104/** Components must return a value; 'undefined' is prohibited here
105 * to avoid functions that are missing a return statement. */
106export type Component = (props: Record<any, any>) => Exclude<Node, undefined>;
107/** Emitted by JSX runtime */
108export interface SrcLoc {
109 fileName: string;
110 lineNumber: number;
111 columnNumber: number;
112}
113
114/**
115 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
116 * marked in 'State'. This operation performs everything besides the final
117 * string concatenation. This function is agnostic across async/sync modes.
118 */
119export function resolveNode(r: State, node: unknown): ResolvedNode {
120 if (!node && node !== 0) return ""; // falsy, non numeric
121 if (typeof node !== "object") {
122 if (node === true) return ""; // booleans are ignored
123 if (typeof node === "string") return escapeHtmlContent(node);
124 if (typeof node === "number") return String(node); // no escaping ever
125 if (typeof node === "symbol" && node.toString() === kElement.toString()) {
126 throw new Error(`There are two instances of Clover SSR loaded!`);
127 }
128 throw new Error(`Cannot render ${inspect(node)} to HTML`);
129 }
130 if (node instanceof Promise) {
131 if (r.async === -1) {
132 throw new Error(`Asynchronous rendering is not supported here.`);
133 }
134 const placeholder: InsertionPoint = [null];
135 r.async += 1;
136 node
137 .then((result) => void (placeholder[0] = resolveNode(r, result)))
138 // Intentionally catching errors in `resolveNode`
139 .catch((e) => (r.rejections ??= []).push(e))
140 .finally(() => {
141 if (--r.async == 0) {
142 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
143 r.asyncDone();
144 r.asyncDone = null;
145 }
146 });
147 // This lie is checked with an assertion in `renderNode`
148 return placeholder as [ResolvedNode];
149 }
150 if (!Array.isArray(node)) {
151 throw new Error(`Invalid node type: ${inspect(node)}`);
152 }
153 const type = node[0];
154 if (type === kElement) {
155 const { 1: tag, 2: props } = node;
156 if (typeof tag === "function") {
157 current = r;
158 try {
159 return resolveNode(r, tag(props));
160 } catch (e) {
161 const { 4: src } = node;
162 if (e && typeof e === "object") (e as { src?: string }).src ??= src;
163 throw e;
164 } finally {
165 current = null;
166 }
167 }
168 if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
169 const children = props?.children;
170 if (children) return [kElement, tag, props, resolveNode(r, children)];
171 return node;
172 }
173 if (type === kRaw) return node[1];
174 return node.map((elem) => resolveNode(r, elem));
175}
176
177export type ResolvedNode =
178 | ResolvedNode[] // Concat
179 | ResolvedElement // Render
180 | string; // Raw HTML
181export type ResolvedElement = [
182 tag: typeof kElement,
183 type: string,
184 props: Record<string, unknown>,
185 children: ResolvedNode,
186];
187/**
188 * Async rendering is done by creating an array of one item,
189 * which is already a valid 'Node', but the element is written
190 * once the data is available. The 'Render' contains a count
191 * of how many async jobs are left.
192 */
193export type InsertionPoint = [null | ResolvedNode];
194
195/**
196 * Convert 'ResolvedNode' into HTML text. This operation happens after all
197 * async work is settled. The HTML is emitted as concisely as possible.
198 */
199export function stringifyNode(node: ResolvedNode): string {
200 if (typeof node === "string") return node;
201 ASSERT(node, "Unresolved Render Node");
202 const type = node[0];
203 if (type === kElement) {
204 return stringifyElement(node as ResolvedElement);
205 }
206 node = node as ResolvedNode[]; // TS cannot infer.
207 let out = type ? stringifyNode(type) : "";
208 let len = node.length;
209 for (let i = 1; i < len; i++) {
210 const elem = node[i];
211 if (elem) out += stringifyNode(elem);
212 }
213 return out;
214}
215function stringifyElement(element: ResolvedElement) {
216 const { 1: tag, 2: props, 3: children } = element;
217 let out = "<" + tag + " ";
218 for (const prop in props) {
219 const value = props[prop];
220 if (!value || typeof value === "function") continue;
221 let attr;
222 switch (prop) {
223 default:
224 attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`;
225 break;
226 case "className": // Legacy React Compat
227 case "class": {
228 const str = clsx(value as ClsxInput);
229 if (!str) continue;
230 attr = `class=${quoteIfNeeded(escapeAttribute(str))}`;
231 break;
232 }
233 case "style": {
234 const str = typeof value === "string"
235 ? value
236 : Object.entries(value).map((kv) => kv.join(":")).join(";");
237 if (!str) continue;
238 attr = `style=${quoteIfNeeded(escapeAttribute(str))}`;
239 break;
240 }
241 case "htmlFor":
242 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
243 case "dangerouslySetInnerHTML":
244 throw new Error(
245 "Do not use the `dangerouslySetInnerHTML` attribute. Use `render.raw()`",
246 );
247 // Do not process these
248 case "children":
249 case "ref":
250 case "key":
251 continue;
252 }
253 if (!out.endsWith('"')) out += " ";
254 out += attr;
255 }
256 out = out.trimEnd() + ">";
257 if (children) out += stringifyNode(children);
258 if (
259 tag !== "br" &&
260 tag !== "img" &&
261 tag !== "input" &&
262 tag !== "meta" &&
263 tag !== "link" &&
264 tag !== "hr"
265 ) {
266 out += `</${tag}>`;
267 }
268 return out;
269}
270export function stringifyStyleAttribute(style: Record<string, string>) {
271 let out = ``;
272 for (const styleName in style) {
273 if (out) out += ";";
274 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
275 escapeAttribute(
276 String(style[styleName]),
277 )
278 }`;
279 }
280 return "style=" + quoteIfNeeded(out);
281}
282export function quoteIfNeeded(text: string) {
283 if (text.match(/["/> ]/)) return '"' + text + '"';
284 return text;
285}
286
287// -- utility functions --
288
289export function init(allowAsync: boolean, addon: Addons): State {
290 return {
291 async: allowAsync ? 0 : -1,
292 rejections: null,
293 asyncDone: null,
294 addon,
295 };
296}
297
298export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
299export function clsx(mix: ClsxInput) {
300 var k,
301 y,
302 str = "";
303 if (typeof mix === "string") {
304 return mix;
305 } else if (typeof mix === "object") {
306 if (Array.isArray(mix)) {
307 for (k = 0; k < mix.length; k++) {
308 if (mix[k] && (y = clsx(mix[k]))) {
309 str && (str += " ");
310 str += y;
311 }
312 }
313 } else {
314 for (k in mix) {
315 if (mix[k]) {
316 str && (str += " ");
317 str += k;
318 }
319 }
320 }
321 }
322 return str;
323}
324
325export const escapeHtmlContent = (unsafeText: string) =>
326 String(unsafeText)
327 .replace(/&/g, "&amp;")
328 .replace(/</g, "&lt;")
329 .replace(/>/g, "&gt;")
330 .replace(/"/g, "&quot;");
331// TODO: combine into one function which decides if an attribute needs quotes
332// and escapes it correctly depending on the context.
333const escapeAttribute = (unsafeText: string) =>
334 String(unsafeText)
335 .replace(/&/g, "&amp;")
336 .replace(/</g, "&lt;")
337 .replace(/>/g, "&gt;")
338 .replace(/"/g, "&quot;");
339/** @deprecated */
340export const escapeHtml = (unsafeText: string) =>
341 String(unsafeText)
342 .replace(/&/g, "&amp;")
343 .replace(/</g, "&lt;")
344 .replace(/>/g, "&gt;")
345 .replace(/"/g, "&quot;")
346 .replace(/'/g, "&#x27;")
347 .replace(/`/g, "&#x60;");
348
349export function inspect(object: unknown) {
350 try {
351 return require("node:util").inspect(object);
352 } catch {
353 return typeof object;
354 }
355}
framework/engine/suspense.test.tsx deleted-40
......@@ -1,40 +0,0 @@
1import { test } from "node:test";
2import { renderStreaming, Suspense } from "./suspense.ts";
3
4test("sanity", async (t) => {
5 let resolve: () => void = null!;
6
7 // @ts-expect-error
8 async function AsyncComponent() {
9 await new Promise<void>((done) => resolve = done);
10 return <button>wow!</button>;
11 }
12
13 const example = (
14 <main>
15 <h1>app shell</h1>
16 <Suspense fallback="loading...">
17 <AsyncComponent />
18 </Suspense>
19 <footer>(c) 2025</footer>
20 </main>
21 );
22
23 const iterator = renderStreaming(example);
24 const assertContinue = (actual: unknown, value: unknown) =>
25 t.assert.deepEqual(actual, { done: false, value });
26
27 assertContinue(
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>",
30 );
31 t.assert.ok(resolve !== null), resolve();
32 assertContinue(
33 await iterator.next(),
34 "<button slot=suspended_1>wow!</button>",
35 );
36 t.assert.deepEqual(
37 await iterator.next(),
38 { done: true, value: {} },
39 );
40});
framework/engine/suspense.ts deleted-102
......@@ -1,102 +0,0 @@
1// This file implements out-of-order HTML streaming, mimicking the React
2// Suspense API. To use, place Suspense around an expensive async component
3// and render the page with 'renderStreaming'.
4//
5// Implementation of this article:
6// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
7//
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.
10const userData = render.userData<null | State>(() => null);
11
12interface SuspenseProps {
13 children: render.Node;
14 fallback?: render.Node;
15}
16
17interface State {
18 nested: boolean;
19 nextId: number;
20 completed: number;
21 pushChunk(name: string, node: render.ResolvedNode): void;
22}
23
24export function Suspense({ children, fallback }: SuspenseProps): render.Node {
25 const state = userData.get();
26 if (!state) return children;
27 if (state.nested) throw new Error("<Suspense> cannot be nested");
28 const parent = UNWRAP(render.current);
29 const r = render.initRender(true, { [userData.key]: { nested: true } });
30 const resolved = render.resolveNode(r, children);
31 if (r.async == 0) return render.raw(resolved);
32 const name = "suspended_" + ++state.nextId;
33 state.nested = true;
34 const ip: [render.ResolvedNode] = [
35 render.resolvedElement(
36 "slot",
37 { name },
38 fallback ? render.resolveNode(parent, fallback) : "",
39 ),
40 ];
41 state.nested = false;
42 r.asyncDone = () => {
43 const rejections = r.rejections;
44 if (rejections && rejections.length > 0) throw new Error("TODO");
45 state.pushChunk?.(name, ip[0] = resolved);
46 };
47 return render.raw(ip);
48}
49
50// TODO: add a User-Agent parameter, which is used to determine if a
51// fallback path must be used.
52// - Before ~2024 needs to use a JS implementation.
53// - IE should probably bail out entirely.
54export async function* renderStreaming<
55 T extends render.Addons = Record<never, unknown>,
56>(node: render.Node, addon: T = {} as T) {
57 const {
58 text: begin,
59 addon: { [userData.key]: state, ...addonOutput },
60 } = await render.async(node, {
61 ...addon,
62 [userData.key]: {
63 nested: false,
64 nextId: 0,
65 completed: 0,
66 pushChunk: () => {},
67 } satisfies State as State,
68 });
69 if (state.nextId === 0) {
70 yield begin;
71 return addonOutput as unknown as T;
72 }
73 let resolve: (() => void) | null = null;
74 let chunks: string[] = [];
75 state.pushChunk = (slot, node) => {
76 while (node.length === 1 && Array.isArray(node)) node = node[0];
77 if (node[0] === render.kElement) {
78 (node as render.ResolvedElement)[2].slot = slot;
79 } else {
80 node = [
81 render.kElement,
82 "clover-suspense",
83 {
84 style: "display:contents",
85 slot,
86 },
87 node,
88 ];
89 }
90 chunks.push(render.stringifyNode(node));
91 resolve?.();
92 };
93 yield `<template shadowrootmode=open>${begin}</template>`;
94 do {
95 await new Promise<void>((done) => (resolve = done));
96 yield* chunks;
97 chunks = [];
98 } while (state.nextId < state.completed);
99 return addonOutput as unknown as T;
100}
101
102import * as render from "#engine/render";
framework/font.ts+3-3
......@@ -206,9 +206,9 @@ import * as fs from "#sitegen/fs";
206206import * as os from "node:os";
207207import * as path from "node:path";
208208import * as sg from "#sitegen";
209import * as subprocess from "#sitegen/subprocess";
209import * as subprocess from "lib/subprocess.ts";
210210import * as incr from "./incremental.ts";
211211import * as crypto from "node:crypto";
212import * as async from "#sitegen/async";
213import * as error from "#sitegen/error";
212import * as async from "lib/async.ts";
213import * as error from "lib/error.ts";
214214import * as progress from "../lib/log/progress.ts";
framework/generate.ts+8-7
......@@ -280,11 +280,11 @@ export async function preparePage(
280280 if (layout?.default) {
281281 page = render.element(layout.default, { children: page });
282282 }
283 const bodyPromise = render.async(page, {
284 [sg.userData.key]: sg.initRender(),
285 });
286283
287 const [{ text, addon }, renderedMeta] = await Promise.all([
284 const sitegen = sg.context.init();
285 const bodyPromise = render.async(page, [sitegen]);
286
287 const [{ text }, renderedMeta] = await Promise.all([
288288 bodyPromise,
289289 renderedMetaPromise,
290290 ]);
......@@ -305,7 +305,7 @@ export async function preparePage(
305305 cssImports,
306306 theme: theme ?? null,
307307 styleKey,
308 clientRefs: Array.from(UNWRAP(addon[sg.userData.key]).scripts),
308 clientRefs: Array.from(sitegen.scripts),
309309 } as const;
310310}
311311
......@@ -397,8 +397,8 @@ export type PageOrView = PreparedPage | PreparedView;
397397import * as path from "node:path";
398398
399399import * as fs from "#sitegen/fs";
400import * as meta from "#sitegen/meta";
401import * as render from "#engine/render";
400import * as meta from "lib/render/meta.ts";
401import * as render from "lib/render.ts";
402402import * as sg from "#sitegen";
403403import type { FileItem } from "#sitegen";
404404import * as progress from "../lib/log/progress.ts";
......@@ -409,3 +409,4 @@ import * as fonts from "./font.ts";
409409import * as hot from "./hot.ts";
410410import * as incr from "./incremental.ts";
411411import { Io } from "./incremental.ts";
412import { ASSERT, UNWRAP } from "lib/assert.ts";
framework/global.d.ts-3
......@@ -1,5 +1,2 @@
1declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
2declare function ASSERT(value: unknown, message?: string): asserts value;
3
41type Timer = ReturnType<typeof setTimeout>;
52type Awaitable<T> = T | Promise<T>;
framework/hot.ts+8-4
......@@ -149,7 +149,7 @@ export function loadEsbuildCode(
149149 format: "cjs",
150150 target: "esnext",
151151 jsx: "automatic",
152 jsxImportSource: "#engine",
152 jsxImportSource: "#jsx",
153153 jsxDev: true,
154154 sourcefile: filepath,
155155 sourcemap: "inline",
......@@ -174,7 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
174174
175175function loadMdx(module: NodeJS.Module, filepath: string) {
176176 const input = fs.readFileSync(filepath);
177 const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value;
177 const out = mdx.compileSync(input, { jsxImportSource: "#jsx" }).value;
178178 const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
179179 return loadEsbuildCode(module, filepath, src);
180180}
......@@ -241,7 +241,10 @@ export function resolveFrom(src: string, dest: string) {
241241 try {
242242 return createRequire(src).resolve(dest);
243243 } catch (err: any) {
244 if (err.code === "MODULE_NOT_FOUND" && err.requireStack.length <= 1) {
244 if (
245 err.code === "MODULE_NOT_FOUND" && err.requireStack &&
246 err.requireStack.length <= 1
247 ) {
245248 err.message = err.message.split("\n")[0] + " from '" + src + "'";
246249 }
247250 throw err;
......@@ -262,7 +265,7 @@ export function resolveClientRefs(
262265 // This match finds a call to 'import ... from "#sitegen"'
263266 const importMatch = code.match(importRegExp);
264267 if (!importMatch) return { code, refs: [] };
265 const items = importMatch[1];
268 const items = UNWRAP(importMatch[1]);
266269 let identifier = "";
267270 if (items.startsWith("{")) {
268271 const clauseMatch = items.match(getSitegenAddScriptRegExp);
......@@ -322,3 +325,4 @@ import { createRequire } from "node:module";
322325import * as mdx from "@mdx-js/mdx";
323326import * as self from "./hot.ts";
324327import { Buffer } from "node:buffer";
328import { UNWRAP } from "lib/assert.ts";
framework/incremental.ts+8-5
......@@ -495,11 +495,13 @@ export class Io {
495495 }
496496 return resolved;
497497 }
498 async readFile(file: string) {
499 return fs.readFile(await this.trackFile(file));
498 async readFile(file: string): Promise<Buffer>;
499 async readFile(file: string, encoding: "utf-8"): Promise<string>;
500 async readFile(file: string, encoding?: "utf-8") {
501 return fs.readFile(await this.trackFile(file), encoding);
500502 }
501503 async readJson<T>(file: string) {
502 return JSON.parse(await this.readFile(file)) as T;
504 return JSON.parse(await this.readFile(file, "utf-8")) as T;
503505 }
504506 async readDir(dir: string) {
505507 const { key, resolved } = this.#trackFs(dir);
......@@ -585,7 +587,7 @@ export class Io {
585587 const headers = new Headers(asset.headers ?? {});
586588 const hash = crypto.createHash("sha1").update(buffer).digest("hex");
587589 if (!headers.has("Content-Type")) {
588 headers.set("Content-Type", mime.contentTypeFor(asset.pathname));
590 headers.set("Content-Type", mime.for(asset.pathname));
589591 }
590592 headers.set("etag", JSON.stringify(hash));
591593 this.writes.assets.set(asset.pathname, {
......@@ -726,7 +728,7 @@ const zstdCompress = util.promisify(zlib.zstdCompress);
726728import * as hot from "./hot.ts";
727729
728730import * as fs from "#sitegen/fs";
729import * as mime from "#sitegen/mime";
731import * as mime from "lib/mime.ts";
730732import * as manifest from "#sitegen/assets";
731733
732734import * as path from "node:path";
......@@ -739,3 +741,4 @@ import * as stack from "lib/log/stack.ts";
739741import { formatSize } from "@/file-viewer/format.ts";
740742
741743import * as msgpackr from "msgpackr";
744import { ASSERT, UNWRAP } from "lib/assert.ts";
framework/lib/assets.ts+1
......@@ -286,3 +286,4 @@ import * as crypto from "node:crypto";
286286import * as zlib from "node:zlib";
287287import * as util from "node:util";
288288import * as log from "lib/log.ts";
289import { ASSERT, UNWRAP } from "lib/assert.ts";
framework/lib/async.ts deleted-218
......@@ -1,218 +0,0 @@
1const five_minutes = 5 * 60 * 1000;
2
3/** @deprecated use OnceMap2 */
4export class OnceMap<T> {
5 private ongoing = new Map<string, Promise<T>>();
6
7 get(key: string, compute: () => Promise<T>) {
8 if (this.ongoing.has(key)) {
9 return this.ongoing.get(key)!;
10 }
11
12 const result = compute();
13 this.ongoing.set(key, result);
14 return result;
15 }
16}
17
18interface ARCEValue<T> {
19 value: T;
20 [Symbol.dispose]: () => void;
21}
22
23export function RefCountedExpirable<T>(
24 init: () => Promise<T>,
25 deinit: (value: T) => void,
26 expire: number = five_minutes,
27): () => Promise<ARCEValue<T>> {
28 let refs = 0;
29 let item: ARCEValue<T> | null = null;
30 let loading: Promise<ARCEValue<T>> | null = null;
31 let timer: ReturnType<typeof setTimeout> | null = null;
32
33 function deref() {
34 ASSERT(item !== null);
35 if (--refs !== 0) return;
36 ASSERT(timer === null);
37 timer = setTimeout(() => {
38 ASSERT(refs === 0);
39 ASSERT(loading === null);
40 ASSERT(item !== null);
41 deinit(item.value);
42 item = null;
43 timer = null;
44 }, expire);
45 }
46
47 return async function () {
48 if (timer !== null) {
49 clearTimeout(timer);
50 timer = null;
51 }
52 if (item !== null) {
53 refs++;
54 return item;
55 }
56 if (loading !== null) {
57 refs++;
58 return loading;
59 }
60 const p = Promise.withResolvers<ARCEValue<T>>();
61 loading = p.promise;
62 try {
63 const value = await init();
64 item = { value, [Symbol.dispose]: deref };
65 refs++;
66 p.resolve(item);
67 return item;
68 } catch (e) {
69 p.reject(e);
70 throw e;
71 } finally {
72 loading = null;
73 }
74 };
75}
76
77export function once<T>(fn: () => Promise<T>): () => Promise<T> {
78 let result: T | Promise<T> | null = null;
79 return async () => {
80 if (result) return result;
81 try {
82 result = await (result = fn());
83 } catch (err) {
84 result = null;
85 throw err;
86 }
87 return result;
88 };
89}
90
91export class Watch<T> {
92 #value: T;
93 #observers = new Set<WatchCallback<T>>();
94 constructor(value: T) {
95 this.#value = value;
96 }
97
98 on(cb: WatchCallback<T>) {
99 this.#observers.add(cb);
100 return () => void this.#observers.delete(cb);
101 }
102 once(cb: WatchCallback<T>) {
103 const once = (n: T, p: T) => (cb(n, p), this.#observers.delete(once));
104 this.#observers.add(once);
105 }
106
107 next(): Promise<T> {
108 return new Promise((resolve) => this.once(resolve));
109 }
110
111 until(condition: (v: T) => boolean): Promise<T> {
112 if (condition(this.#value)) return Promise.resolve(this.#value);
113 return new Promise((resolve) => {
114 function check(next: T) {
115 if (condition(next)) resolve(next), release();
116 }
117 const release = this.on(check);
118 });
119 }
120
121 get value() {
122 return this.#value;
123 }
124 set value(next: T) {
125 const prev = this.#value;
126 if (prev !== next) {
127 this.#value = next;
128 this.#observers.forEach((cb) => cb(next, prev));
129 }
130 }
131}
132type WatchCallback<T> = (next: T, prev: T) => void;
133
134/**
135 * When two requests with the same args come in at the same time, the response
136 * from them is shared. After returning, the data is no longer stored.
137 */
138export class DedupeConcurrent<Args extends unknown[], Ret> {
139 pending = new Map<unknown, Promise<Ret>>();
140 constructor(
141 public fn: (...args: Args) => Promise<Ret>,
142 public keyFn: (args: Args) => unknown = JSON.stringify,
143 ) {}
144
145 isPending(...args: Args) {
146 return this.pending.has(this.keyFn(args));
147 }
148
149 /** Unbound to allow easily exporting this callback */
150 get = (...args: Args): Promise<Ret> => {
151 const k = this.keyFn(args);
152 let promise = this.pending.get(k);
153 if (promise) return promise;
154 promise = this.fn(...args);
155 this.pending.set(k, promise);
156 void promise.finally(() => void this.pending.delete(k));
157 return promise;
158 };
159}
160
161/**
162 * When two requests with the same args come, the result of the first is memoized.
163 */
164export class OnceMap2<Args extends unknown[], Ret> {
165 cache = new Map<unknown, Promise<Ret>>();
166 constructor(
167 public fn: (...args: Args) => Promise<Ret>,
168 public keyFn: (args: Args) => unknown = JSON.stringify,
169 ) {}
170
171 has(...args: Args) {
172 return this.cache.has(this.keyFn(args));
173 }
174
175 /** Unbound to allow easily exporting this callback */
176 getOrRun = (...args: Args): Promise<Ret> => {
177 const k = this.keyFn(args);
178 let promise = this.cache.get(k);
179 if (promise) return promise;
180 promise = this.fn(...args);
181 this.cache.set(k, promise);
182 return promise;
183 };
184}
185
186export class PromiseAggregator {
187 failures: unknown[] = [];
188 promises = new Set<Promise<unknown>>();
189
190 push(p: Promise<unknown>) {
191 p = p.then(
192 () => {
193 this.promises.delete(p);
194 },
195 (err) => {
196 this.failures.push(err);
197 },
198 );
199 this.promises.add(p);
200 }
201
202 async all() {
203 while (this.promises.size > 0) {
204 const wait = [...this.promises];
205 this.promises.clear();
206 await Promise.all(wait);
207 }
208 if (this.failures.length > 0) {
209 const agg = new AggregateError(this.failures);
210 this.failures = [];
211 throw agg;
212 }
213 }
214
215 [Symbol.asyncDispose]() {
216 return this.all();
217 }
218}
framework/lib/error.ts deleted-47
......@@ -1,47 +0,0 @@
1declare global {
2 interface Error {
3 /* Extra fields on errors are extremely helpful for debugging. */
4 [metadata: string]: unknown;
5 }
6}
7
8/** Retrieve an error message from any value */
9export function message(error: unknown): string {
10 const message = (error as { message: unknown })?.message ?? error;
11 try {
12 return typeof message === "string" ? message : JSON.stringify(message);
13 } catch {}
14 try {
15 return String(message);
16 } catch {}
17 return `Could not stringify error message ${typeof message}`;
18}
19
20/** Retrieve an error-like object from any value. Useful to check fields. */
21export function obj(error: unknown): { message: string } & Partial<Error> {
22 if (error instanceof Error) return error;
23 return {
24 message: message(error),
25 ...(typeof error === "object" && error),
26 };
27}
28
29/* Node.js error codes are strings */
30export function code(error: unknown): NodeErrorCode | null {
31 const code = (error as { code: unknown })?.code;
32 return typeof code === "string" ? code : null;
33}
34
35/** Attach extra fields and throw */
36export function rethrowWithMetadata(
37 err: unknown,
38 meta: Record<string, unknown>,
39): never {
40 const error = err && typeof err === "object" ? err : new Error(message(err)); // no stack trace :/
41 Object.assign(error, meta);
42 throw error;
43}
44
45export type NodeErrorCode =
46 | keyof typeof import("node:os").constants.errno
47 | (string & {});
framework/lib/lru.test.ts deleted-137
......@@ -1,137 +0,0 @@
1import { test } from "node:test";
2import { Lru } from "./lru.ts";
3
4test("lru basic", (t) => {
5 const lru = new Lru({ capacity: 3 });
6 lru.set("a", 1);
7 lru.set("b", 2);
8 lru.set("c", 3);
9 t.assert.deepEqual(lru.serialize(), [
10 ["c", 3, 1],
11 ["b", 2, 1],
12 ["a", 1, 1],
13 ]);
14 lru.set("d", 4);
15 t.assert.deepEqual(lru.serialize(), [
16 ["d", 4, 1],
17 ["c", 3, 1],
18 ["b", 2, 1],
19 ]);
20 lru.set("c", 5);
21 lru.get("b");
22 lru.set("e", 6);
23 t.assert.deepEqual(lru.serialize(), [
24 ["e", 6, 1],
25 ["b", 2, 1],
26 ["c", 5, 1],
27 ]);
28 lru.capacity = 10;
29 lru.set("a", 0);
30 lru.set("z", 1);
31 lru.set("x", 1);
32 lru.set("w", 1);
33 t.assert.deepEqual(lru.serialize(), [
34 ["w", 1, 1],
35 ["x", 1, 1],
36 ["z", 1, 1],
37 ["a", 0, 1],
38 ["e", 6, 1],
39 ["b", 2, 1],
40 ["c", 5, 1],
41 ]);
42 lru.delete("a");
43 t.assert.deepEqual(lru.serialize(), [
44 ["w", 1, 1],
45 ["x", 1, 1],
46 ["z", 1, 1],
47 ["e", 6, 1],
48 ["b", 2, 1],
49 ["c", 5, 1],
50 ]);
51 lru.capacity = 2;
52 t.assert.deepEqual(lru.serialize(), [
53 ["w", 1, 1],
54 ["x", 1, 1],
55 ]);
56 lru.ensureUnusedCapacity(1);
57 t.assert.deepEqual(lru.serialize(), [
58 ["w", 1, 1],
59 ]);
60 lru.ensureUnusedCapacity(1.2);
61 t.assert.deepEqual(lru.serialize(), []);
62 lru.ensureUnusedCapacity(2);
63});
64test("lru sized elements", (t) => {
65 const lru = new Lru<string, number>({ capacity: 10, sizeFn: (v) => v });
66 lru.set("a", 3);
67 lru.set("b", 3);
68 lru.set("c", 3);
69 t.assert.deepEqual(lru.serialize(), [
70 ["c", 3, 3],
71 ["b", 3, 3],
72 ["a", 3, 3],
73 ]);
74 // hitting exact capacity
75 lru.set("d", 1);
76 lru.set("d", 1);
77 t.assert.deepEqual(lru.serialize(), [
78 ["d", 1, 1],
79 ["c", 3, 3],
80 ["b", 3, 3],
81 ["a", 3, 3],
82 ]);
83 lru.set("e", 1);
84 t.assert.deepEqual(lru.serialize(), [
85 ["e", 1, 1],
86 ["d", 1, 1],
87 ["c", 3, 3],
88 ["b", 3, 3],
89 ]);
90 lru.clear();
91 lru.set("a", 3);
92 lru.set("b", 3);
93 lru.set("c", 3);
94 lru.set("d", 1);
95 lru.get("a");
96 lru.get("b");
97 lru.get("c");
98 lru.set("e", 1);
99 t.assert.deepEqual(lru.serialize(), [
100 ["e", 1, 1],
101 ["c", 3, 3],
102 ["b", 3, 3],
103 ["a", 3, 3],
104 ]);
105});
106test("lru throw on exceeding capacity", (t) => {
107 const lru = new Lru<string, number>({ capacity: 1, sizeFn: (v) => v });
108 lru.set("e", 1);
109 lru.set("e", 1);
110 t.assert.throws(() => lru.set("e", 2), RangeError);
111 t.assert.throws(() => lru.set("d", 2), RangeError);
112 lru.set("d", 1);
113});
114test("locked items cannot be evicted", (t) => {
115 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
116 lru.set("e", 1);
117 lru.set("d", 1);
118 lru.set("f", 1);
119
120 const unlock = lru.lock("e");
121 lru.ensureUnusedCapacity(2);
122 t.assert.deepEqual(lru.serialize(), [
123 ["e", 1, 1],
124 ]);
125
126 unlock();
127
128 lru.ensureUnusedCapacity(3);
129
130 t.assert.deepEqual(lru.serialize(), []);
131});
132test("throws on evicting a locked item", (t) => {
133 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
134 lru.set("e", 1);
135 void lru.lock("e");
136 t.assert.throws(() => lru.ensureUnusedCapacity(3), RangeError);
137});
framework/lib/lru.ts deleted-259
......@@ -1,259 +0,0 @@
1interface Options<K, V> {
2 capacity: number;
3 /**
4 * Decide how many units of size each entry takes.
5 * For example, accessing `.byteLength` of a Buffer.
6 */
7 sizeFn?: (value: V, key: K) => number;
8 /** Called when `set` or `ensureUnusedCapacity` removes items. */
9 evict?: (value: V, key: K) => void;
10 /** Called when anything removes items. */
11 delete?: (value: V, key: K) => void;
12}
13
14/** Trivial Least-recently-used cache data structure. */
15export class Lru<K, V> extends Map<K, V> {
16 #order = new Map<K, Order<K>>();
17 /** The most recently used */
18 #head: K | none = none;
19 /** The least recently used */
20 #tail: K | none = none;
21 #used: number = 0;
22 #locked: number = 0;
23 #capacity: number;
24 #sizeFn: (value: V, key: K) => number;
25 #deleteFn: null | ((value: V, key: K) => void);
26 #evictFn: null | ((value: V, key: K) => void);
27
28 constructor(opts: Options<K, V>) {
29 super();
30 const { capacity, sizeFn, delete: deleteFn, evict } = opts;
31 this.#capacity = capacity;
32 this.#sizeFn = sizeFn ?? (() => 1);
33 this.#deleteFn = deleteFn ?? null;
34 this.#evictFn = evict ?? null;
35 }
36
37 override clear() {
38 super.clear();
39 this.#order.clear();
40 this.#head = none;
41 this.#tail = none;
42 this.#used = 0;
43 }
44 override delete(key: K) {
45 const entry = this.#evict(key);
46 if (!entry) return false;
47 this.#used -= entry[0];
48 ASSERT(this.#order.delete(key));
49 ASSERT(super.delete(key));
50 return true;
51 }
52 override get(key: K): V | undefined {
53 if (this.#head !== key) {
54 const entry = this.#evict(key);
55 if (!entry) return undefined;
56 entry[1] = none, entry[2] = this.#head;
57 UNWRAP(this.#order.get(this.#head as K))[1] = key;
58 this.#head = key;
59 }
60 return super.get(key) as V;
61 }
62 override set(key: K, value: V) {
63 const size = this.#sizeFn(value, key);
64 {
65 const uc = this.unlockedCapacity;
66 if (size > uc) {
67 const label = uc === this.#capacity ? " " : " unlocked ";
68 const msg = `Object of size ${size} exceeds${label}capacity ${uc}`;
69 throw new RangeError(msg);
70 }
71 }
72
73 const entry = this.#evict(key);
74 if (entry) {
75 // relocate
76 this.#used -= entry[0];
77 const head = this.#head;
78 ASSERT(head !== key);
79 entry[0] = size, entry[1] = none, entry[2] = this.#head;
80 if (head !== none) UNWRAP(this.#order.get(this.#head as K))[1] = key;
81 if (this.#tail === none) this.#tail = key;
82 } else {
83 // insertion
84 const { size: mapSize } = this;
85 const order: Order<K> = [size, none, mapSize > 0 ? this.#head : none, 0];
86 this.#order.set(key, order);
87 if (mapSize === 0) this.#tail = key;
88 else if (this.#head !== none) {
89 UNWRAP(this.#order.get(this.#head as K))[1] = key;
90 }
91 }
92
93 this.#head = key;
94 this.#used += size;
95 try {
96 this.ensureUnusedCapacity(0);
97 } catch (err) {
98 console.error(err);
99 ASSERT(false, "capacity is already verified");
100 }
101 super.set(key, value);
102 return this;
103 }
104
105 /** Prevent an item from being automatically evicted */
106 lock(key: K): Lock {
107 const order = this.#order.get(key);
108 if (!order) throw new Error(`Key ${key} not in Lru`);
109 const count = order[3] += 1;
110 if (count === 1) this.#locked += order[0];
111 const unlock = () => {
112 const count = order![3] -= 1;
113 if (count === 0) this.#locked -= order[0];
114 };
115 unlock[Symbol.dispose] = unlock;
116 return unlock;
117 }
118
119 /** Evict items until there are `unused` capacity slots. */
120 ensureUnusedCapacity(unused: number) {
121 const uc = this.unlockedCapacity;
122 if (unused > uc) {
123 const label = uc === this.#capacity ? "capacity" : "unlocked capacity";
124 const msg = `Can't ensure ${unused} unused capacity with ${label} ${uc}`;
125 throw new RangeError(msg);
126 }
127 const needed = this.#used - this.#capacity + unused;
128 if (needed <= 0) return;
129 let remain = needed;
130 const evictFn = this.#evictFn;
131 const deleteFn = this.#deleteFn;
132 const order = this.#order;
133 let key = this.#tail;
134 loop: {
135 let hadLocks = false;
136 while (key !== none) {
137 const entry = UNWRAP(order.get(key));
138 if (entry[3] > 0) {
139 key = entry[1];
140 hadLocks = true;
141 continue;
142 }
143 ASSERT(this.#evict(key) === entry);
144 if (evictFn) evictFn(super.get(key) as V, key);
145 if (deleteFn) deleteFn(super.get(key) as V, key);
146 ASSERT(this.#order.delete(key));
147 ASSERT(super.delete(key));
148 remain -= entry[0];
149 if (remain <= 0) break loop;
150 key = entry[1];
151 }
152 ASSERT(hadLocks);
153 const msg = `Too many locked objects, cannot free ${unused} capacity`;
154 throw new RangeError(msg);
155 }
156 this.#used -= needed - remain;
157 }
158
159 /** Unlink an entry without removing it. */
160 #evict(key: K) {
161 const entry = this.#order.get(key);
162 if (!entry) return null;
163 const [, before, after] = entry;
164 if (before !== none) {
165 UNWRAP(this.#order.get(before as K))[2] = after;
166 } else this.#head = after;
167 if (after !== none) {
168 UNWRAP(this.#order.get(after as K))[1] = before;
169 } else this.#tail = before;
170 return entry;
171 }
172
173 get capacity() {
174 return this.#capacity;
175 }
176 set capacity(capacity: number) {
177 this.#capacity = capacity;
178 this.ensureUnusedCapacity(0);
179 }
180 get remaining() {
181 return this.#capacity - this.#used;
182 }
183 get unlockedCapacity() {
184 return this.#capacity - this.#locked;
185 }
186 get used() {
187 return this.#used;
188 }
189
190 serialize(): Serialized<K, V>;
191 serialize<OK, OV>(o: SerializeOptions<K, V, OK, OV>): Serialized<OK, OV>;
192 serialize<OK, OV>(
193 opts: SerializeOptions<K, V, OK, OV> = {},
194 ): Serialized<OK, OV> {
195 const { map = (k, v) => [k as unknown as OK, v as unknown as OV] } = opts;
196 const result: Serialized<OK, OV> = [];
197 const order = this.#order;
198 let head = this.#head;
199 while (head !== none) {
200 const entry = UNWRAP(order.get(head), head);
201 const kv = map(head, super.get(head) as V);
202 head = entry[2];
203 if (!kv) continue;
204 const [key, value] = kv;
205 result.push([key, value, entry[0]]);
206 }
207 return result;
208 }
209 revive<IK, IV>(
210 entries: Serialized<IK, IV>,
211 opts: SerializeOptions<IK, IV, K, V> = {},
212 ) {
213 const { map = (k, v) => [k as unknown as K, v as unknown as V] } = opts;
214 const order = this.#order;
215 let prev: Order<K> | null = null;
216 let pk!: K;
217 for (let i = entries.length - 1; i > 0; i -= 1) {
218 const [ik, iv, size] = UNWRAP(entries[i]);
219 const kv = map(ik, iv);
220 if (!kv) continue;
221 const [k, v] = kv;
222 super.set(k, v);
223 const current: Order<K> = [size, none, prev ? pk : none, 0];
224 if (prev) prev[1] = k;
225 order.set(k, prev = current);
226 pk = k;
227 }
228 }
229}
230
231/** The first item is the most recently used. */
232export type Serialized<K, V> = Array<[K, V, number]>;
233
234interface SerializeOptions<IK, IV, OK = IK, OV = IV> {
235 /** null = omit in serialization/serialization */
236 map?: (key: IK, value: IV) => [OK, OV] | null;
237}
238
239type Order<K> = [
240 /** Number of units computed at insertion time. */
241 size: number,
242 /** Towards most recently used */
243 before: K | none,
244 /** Towards least recently used */
245 after: K | none,
246 /** Locking prevents automatic eviction */
247 locks: number,
248];
249
250interface Lock extends Disposable {
251 (): void;
252}
253
254export declare namespace Lru {
255 export type { Lock, Serialized, SerializeOptions };
256}
257
258const none: unique symbol = Symbol();
259type none = typeof none;
framework/lib/meta.ts deleted-256
......@@ -1,256 +0,0 @@
1export { renderMetadata as render };
2export interface Meta {
3 /** Required for all pages. `<title>{content}</title>` */
4 title: string;
5 /** Recommended for all pages. `<meta name="description" content="{...}" />` */
6 description?: string | null;
7 /** Automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;
9 /** Add `<link rel="alternate" ... />`. Object keys are interpretted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string>;
12
13 /** Automatically generate both OpenGraph and Twitter meta tags */
14 embed?: AutoEmbed | null;
15 /** Add a robots tag for `noindex` and `nofollow` */
16 denyRobots?: boolean | null;
17 /** Add 'og:*' meta tags */
18 openGraph?: OpenGraph | null;
19 /** Add 'twitter:*' meta tags */
20 twitter?: Twitter | null;
21 /** * Refer to an oEmbed file. See https://oembed.com
22 * TODO: support passing the oEmbed file directly here. */
23 oEmbed?: string;
24 /**
25 * '#sitegen/meta' intentionally excludes a lot of exotic tags.
26 * Add these manually using JSX syntax:
27 *
28 * extra: [
29 * <meta name="site-verification" content="waffles" />,
30 * ],
31 */
32 extra?: render.Node;
33
34 /** Adds `<meta name="author" content="{...}" />` */
35 authors?: string[];
36 /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
37 generator?: string | true | null;
38 /** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
39 keywords?: string[];
40 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
41 manifest?: string | null;
42 /** Adds `<meta name="publisher" content="{...}" />` */
43 publisher?: string | null;
44 /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
45 referrer?: Referrer | null;
46 /** Adds `<meta name="theme-color" content="{...}" />` */
47 themeColor?: string | { dark: string; light: string } | null;
48 /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
49 viewport?: string;
50}
51export type Alternate = { type: string; url: string } | {
52 lang: string;
53 url: string;
54};
55export interface AutoEmbed {
56 /* Defaults to the page title. */
57 title?: string | null;
58 /* Defaults to the page description. */
59 description?: string | null;
60 /* Provide to add an embed image. */
61 thumbnail?: string | null;
62 /** @default "banner", which applies twitter:card = "summary_large_image" */
63 thumbnailSize?: "banner" | "icon";
64 /* Ignored if not passed */
65 siteTitle?: string | null;
66}
67/** See https://ogp.me for extra rules. */
68export interface OpenGraph {
69 /** The title of your object as it should appear within the graph */
70 title?: string;
71 /** A one to two sentence description of your object. */
72 description?: string | null;
73 /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
74 type?: string;
75 /** An image URL which should represent your object within the graph */
76 image?: OpenGraphField;
77 /** The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */
78 url?: string;
79 /** A URL to an audio file to accompany this object */
80 audio?: OpenGraphField;
81 /** The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */
82 determiner?: string;
83 /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
84 locale?: string;
85 /** An array of other locales this page is available in */
86 "locale:alternate"?: string[];
87 /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
88 site_name?: string;
89 /** A URL to a video file that complements this object */
90 video?: OpenGraphField;
91 [field: string]: OpenGraphField;
92}
93/**
94 * When passing an array, the property is duplicated.
95 * When passing an object, the fields are emitted as namespaced with ':'.
96 */
97type OpenGraphField =
98 | string
99 | { [field: string]: OpenGraphField }
100 | Array<OpenGraphField>
101 | (null | undefined);
102/** Twitter uses various OpenGraph fields if these are not specified. */
103export interface Twitter {
104 card: string;
105 title?: string;
106 description?: string | null;
107 url?: string;
108 image?: string;
109 player?: string;
110 /** Same logic as Open Graph */
111 [field: string]: OpenGraphField;
112}
113export interface Template
114 extends Omit<Meta, "title" | "description" | "canonical"> {
115 base: URL;
116 titleTemplate?: (title: string) => string;
117}
118export interface Alternates {
119 canonical: string;
120 types: { [mime: string]: AlternateType };
121}
122export interface AlternateType {
123 url: string;
124 title: string;
125}
126export type Referrer =
127 | "no-referrer"
128 | "origin"
129 | "no-referrer-when-downgrade"
130 | "origin-when-cross-origin"
131 | "same-origin"
132 | "strict-origin"
133 | "strict-origin-when-cross-origin";
134
135/* Convert a metadata definition into text. */
136function renderMetadata(meta: Meta, template: Template): string {
137 const { titleTemplate, base } = template;
138 const resolve = (str: string) => new URL(str, base).href;
139
140 const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
141 const description = meta.description ?? null;
142 const canonical = meta.canonical ? resolve(meta.canonical) : null;
143 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
144 const authors = meta.authors ?? template.authors ?? null;
145 let generator = meta.generator ?? template.generator ?? null;
146 const keywords = meta.keywords ?? template.keywords ?? null;
147 const manifest = meta.manifest ?? template.manifest ?? null;
148 const publisher = meta.publisher ?? template.publisher ?? null;
149 const referrer = meta.referrer ?? template.referrer ?? null;
150 const themeColor = meta.themeColor ?? template.themeColor ?? null;
151 const viewport = meta.viewport ?? template.viewport ??
152 "width=device-width, initial-scale=1.0";
153
154 const embed = meta.embed ?? template.embed ?? null;
155 let openGraph = meta.openGraph ?? template.openGraph ?? null;
156 let twitter = meta.twitter ?? template.twitter ?? null;
157 if (embed) {
158 const { thumbnail, thumbnailSize, siteTitle } = embed;
159 openGraph = {
160 type: "website",
161 title: embed.title ?? title,
162 description: embed.description ?? description,
163 ...openGraph,
164 };
165 twitter = {
166 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
167 ? "summary_large_image"
168 : "summary",
169 ...twitter,
170 };
171 if (thumbnail) {
172 openGraph.image = embed.thumbnail;
173 }
174 if (siteTitle) {
175 openGraph.site_name = siteTitle;
176 }
177 if (canonical) {
178 openGraph.url = canonical;
179 }
180 }
181
182 let out = `<title>${esc(title)}</title>`;
183 if (description) {
184 out += `<meta name=description content=${attr(description)}>`;
185 }
186 for (const author of authors ?? []) {
187 out += `<meta name=author content=${attr(author)}>`;
188 }
189 if (keywords) {
190 out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
191 }
192 if (generator) {
193 if (generator === true) {
194 generator = "clover sitegen 3; paperclover.net/sitegen";
195 }
196 out += `<meta name=generator content=${attr(generator)}>`;
197 }
198 if (publisher) {
199 out += `<meta name=publisher content=${attr(publisher)}>`;
200 }
201 if (referrer) {
202 out += `<meta name=referrer content=${attr(referrer)}>`;
203 }
204 if (themeColor) {
205 if (typeof themeColor === "string") {
206 out += `<meta name=theme-color content=${attr(themeColor)}>`;
207 } else {
208 out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
209 `content=${attr(themeColor.light)}>`;
210 out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
211 `content=${attr(themeColor.dark)}>`;
212 }
213 }
214 if (denyRobots) {
215 out += `<meta name=robots content=noindex,nofollow>`;
216 }
217 if (viewport) {
218 out += `<meta name=viewport content=${attr(viewport)}>`;
219 }
220 if (canonical) {
221 out += `<link rel=canonical href=${attr(canonical)}>`;
222 }
223 if (manifest) {
224 out += `<link rel=manifest href=${attr(manifest)}>`;
225 }
226
227 if (openGraph) out += renderOpenGraph("og:", openGraph);
228 if (twitter) out += renderOpenGraph("twitter:", twitter);
229
230 if (meta.extra) out += render.sync(meta.extra);
231 if (template.extra) out += render.sync(template.extra);
232
233 return out;
234}
235
236function renderOpenGraph(prefix: string, value: OpenGraphField): string {
237 if (!value) return "";
238 if (typeof value === "string") {
239 return `<meta name=${attr(prefix)} content=${attr(value)}>`;
240 }
241 if (Array.isArray(value)) {
242 return value
243 .map((item) => renderOpenGraph(prefix, item))
244 .join("");
245 }
246 return Object.entries(value)
247 .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
248 .join("");
249}
250
251function attr(value: string) {
252 return render.quoteIfNeeded(esc(value));
253}
254
255import { escapeHtmlContent as esc } from "#engine/render";
256import * as render from "#engine/render";
framework/lib/mime.ts deleted-32
......@@ -1,32 +0,0 @@
1declare const MIME_INLINE_DATA: never;
2const entries = typeof MIME_INLINE_DATA !== "undefined"
3 ? MIME_INLINE_DATA
4 : fs.readFileSync(path.join(import.meta.dirname, "mime.txt"), "utf8")
5 .split("\n")
6 .map((line) => line.trim())
7 .filter((line) => line && !line.startsWith("#"))
8 .map((line) => line.split(/\s+/, 2) as [string, string]);
9export const rawEntriesText = entries;
10const extensions = new Map(entries.filter((x) => x[0].startsWith(".")));
11const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));
12
13/**
14 * Accepts:
15 * - Full file path or basename
16 * - Extension (with or without dot)
17 */
18export function contentTypeFor(file: string) {
19 const slash = file.indexOf("/");
20 if (slash !== -1) file = file.slice(slash + 1);
21 const dot = file.lastIndexOf(".");
22 if (dot === -1) file = "." + file;
23 else if (dot > 0) {
24 let entry = fullNames.get(file);
25 if (entry) return entry;
26 file = file.slice(dot);
27 }
28 return extensions.get(file) ?? "application/octet-stream";
29}
30
31import * as fs from "#sitegen/fs";
32import * as path from "node:path";
framework/lib/mime.txt deleted-100
......@@ -1,100 +0,0 @@
1# media types
2# https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types
3.aac audio/x-aac
4.aif audio/x-aiff
5.aifc audio/x-aiff
6.aiff audio/x-aiff
7.asm text/x-asm
8.avi video/x-msvideo
9.bat application/x-msdownload
10.c text/x-c
11.chat text/x-clover-chatlog
12.class application/java-vm
13.cmd application/x-msdownload
14.com application/x-msdownload
15.conf text/plain
16.cpp text/x-c
17.css text/css
18.csv text/csv
19.cxx text/x-c
20.def text/plain
21.diff text/plain
22.dll application/x-msdownload
23.dmg application/octet-stream
24.doc application/msword
25.docx application/vnd.openxmlformats-officedocument.wordprocessingml.document
26.epub application/epub+zip
27.exe application/x-msdownload
28.flv video/x-flv
29.fbx application/fbx
30.gz application/x-gzip
31.h text/x-c
32.h264 video/h264
33.hh text/x-c
34.htm text/html;charset=utf-8
35.html text/html;charset=utf-8
36.ico image/x-icon
37.ics text/calendar
38.in text/plain
39.jar application/java-archive
40.java text/x-java-source
41.jpeg image/jpeg
42.jpg image/jpeg
43.jpgv video/jpeg
44.jxl image/jxl
45.js application/javascript;charset=utf-8
46.json application/json
47.latex application/x-latex
48.list text/plain
49.log text/plain
50.m4a audio/mp4
51.man text/troff
52.mid audio/midi
53.midi audio/midi
54.mov video/quicktime
55.mp3 audio/mpeg
56.mp4 video/mp4
57.msh model/mesh
58.msi application/x-msdownload
59.obj application/octet-stream
60.ogg audio/ogg
61.otf application/x-font-otf
62.pdf application/pdf
63.png image/png
64.ppt application/vnd.ms-powerpoint
65.pptx application/vnd.openxmlformats-officedocument.presentationml.presentation
66.psd image/vnd.adobe.photoshop
67.py text/x-python
68.rar application/x-rar-compressed
69.rss application/rss+xml
70.rtf application/rtf
71.rtx text/richtext
72.s text/x-asm
73.pem application/x-pem-file"
74.ser application/java-serialized-object
75.sh application/x-sh
76.sig application/pgp-signature
77.silo model/mesh
78.svg image/svg+xml
79.t text/troff
80.tar application/x-tar
81.text text/plain
82.tgz application/x-gzip
83.tif image/tiff
84.tiff image/tiff
85.torrent application/x-bittorrent
86.ttc application/x-font-ttf
87.ttf application/x-font-ttf
88.txt text/plain
89.urls text/uri-list
90.v text/x-v
91.wav audio/x-wav
92.wmv video/x-ms-wmv
93.woff2 font/woff2
94.xls application/vnd.ms-excel
95.xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
96.xml application/xml
97.xps application/vnd.ms-xpsdocument
98
99# special cased based on file name
100rss.xml application/rss+xml
framework/lib/path.ts+2-2
......@@ -207,6 +207,6 @@ export class Path {
207207import * as path from "node:path";
208208import * as fs from "./fs.ts";
209209import * as util from "node:util";
210import * as error from "./error.ts";
210import * as error from "lib/error.ts";
211211import type { RmOptions } from "node:fs";
212import type { Serialized } from "./lru.ts";
212import { ASSERT } from "lib/assert.ts";
framework/lib/sitegen.ts+7-12
......@@ -50,19 +50,13 @@ export type FontVars = Record<string, FontVariableAxis>;
5050export type FontVariableAxis = number | [min: number, max: number];
5151export type FontRange = string | number | { start: number; end: number };
5252
53export const userData = render.userData<SitegenRender>(() => initRender());
54
55export interface SitegenRender {
56 scripts: Set<string>;
57}
58
59export function initRender(): SitegenRender {
60 return { scripts: new Set() };
61}
53export const context = new render.Context(() => ({
54 scripts: new Set<string>(),
55}));
6256
6357/** Add a client-side script to the page. */
6458export function addScript(id: ScriptId | { value: ScriptId }) {
65 userData.get().scripts.add(typeof id === "string" ? id : id.value);
59 context.get().scripts.add(typeof id === "string" ? id : id.value);
6660}
6761
6862export function wrapDocument({
......@@ -91,6 +85,7 @@ export function joinScripts(scriptSources: string[]) {
9185 "].map(e=>e())";
9286}
9387
94import * as render from "#engine/render";
95import type * as meta from "./meta.ts";
88import * as render from "lib/render.ts";
89import type * as meta from "lib/render/meta.ts";
9690import type * as css from "../css.ts";
91import { UNWRAP } from "lib/assert.ts";
framework/lib/subprocess.ts deleted-82
......@@ -1,82 +0,0 @@
1// This file is expecting a full rewrite to abstract the Node API away
2// entirely, exposing web streams I/O.
3
4const execFileRaw = util.promisify(child_process.execFile);
5export const exec: typeof execFileRaw = ((
6 ...args: Parameters<typeof execFileRaw>
7) =>
8 execFileRaw(...args).catch((e: any) => {
9 if (e?.message?.startsWith?.("Command failed")) {
10 if (e.code > 2 ** 31) e.code |= 0;
11 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
12 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
13 }
14 throw e;
15 })) as any;
16
17// const activePids = new Set<number>();
18
19export interface SpawnOptions extends child_process.SpawnOptions {
20 cmd: string[];
21}
22export function spawn({ cmd, ...rest }: SpawnOptions) {
23 // rest.detached ??= true;
24 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
25 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
26 // if (pid) {
27 // activePids.add(pid);
28 // process.on("exit", () => void activePids.delete(pid));
29 // }
30 return process;
31}
32
33export interface ForkOptions extends child_process.ForkOptions {
34 cmd: string[];
35}
36
37export function fork({ cmd, ...rest }: ForkOptions) {
38 // rest.detached ??= true;
39 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);
40 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
41 // if (pid) {
42 // activePids.add(pid);
43 // process.on("exit", () => void activePids.delete(pid));
44 // }
45 return process;
46}
47
48export async function spawnAndWait(opts: SpawnOptions) {
49 opts.stdio ??= ["ignore", "inherit", "inherit"];
50 const process = spawn(opts);
51 const output = Promise.all([
52 process.stdout
53 ? Array.fromAsync(process.stdout).then(Buffer.concat)
54 : null,
55 process.stderr
56 ? Array.fromAsync(process.stderr).then(Buffer.concat)
57 : null,
58 ]);
59 let [code, signal] = (await events.EventEmitter.once(process, "exit")) as [
60 number | null,
61 string | null,
62 ];
63 if (code !== 0) {
64 if (code !== null && code > 2 ** 31) code |= 0;
65 const exit = signal ? `signal ${signal}` : `code ${code}`;
66 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
67 }
68 const [stdout, stderr] = await output;
69 return { stdout, stderr };
70}
71
72// process.on("exit", () => {
73// for (const pid of activePids) {
74// console.info(`Kill ${Math.abs(pid)}`);
75// process.kill(pid, 9);
76// }
77// });
78
79// import * as process from "node:process";
80import * as events from "node:events";
81import * as util from "node:util";
82import * as child_process from "node:child_process";
framework/lib/view.ts+6-7
......@@ -73,10 +73,8 @@ export async function renderToString<K extends Key>(
7373 // -- html --
7474 let page: render.Element = render.element(component, props);
7575 if (layout) page = render.element(layout, { children: page });
76 const {
77 text: body,
78 addon: { [sg.userData.key]: sitegen },
79 } = await render.async(page, { [sg.userData.key]: sg.initRender() });
76 const sitegen = sg.context.init();
77 const { text: body } = await render.async(page, [sitegen]);
8078
8179 // -- join document and send --
8280 return sg.wrapDocument({
......@@ -85,7 +83,7 @@ export async function renderToString<K extends Key>(
8583 inlineCss,
8684 scripts: sg.joinScripts(
8785 Array.from(
88 sitegen!.scripts,
86 sitegen.scripts,
8987 (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
9088 ),
9189 ),
......@@ -108,12 +106,13 @@ export function regenerate(tag: RegenKey) {
108106 }
109107}
110108
111import * as meta from "./meta.ts";
109import * as meta from "lib/render/meta.ts";
112110import type * as hono from "#hono";
113import * as render from "#engine/render";
111import * as render from "lib/render.ts";
114112import * as sg from "./sitegen.ts";
115113import * as asset from "./assets.ts";
116114import type {
117115 RegenKey,
118116 RegisteredViews as ViewMap,
119117} from "../../.clover/ts/view.d.ts";
118import { UNWRAP } from "lib/assert.ts";
framework/marko.ts+1-1
......@@ -35,7 +35,7 @@ export function loadMarko(module: NodeJS.Module, filepath: string) {
3535 // workaround an esbuild bug?
3636 (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/";
3737 compiled.map.sources[0] = filepath;
38 src = compiled.code.replace("marko/debug/html", "#engine/marko-runtime") +
38 src = compiled.code.replace("marko/debug/html", "#marko/runtime") +
3939 "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," +
4040 Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64");
4141 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
framework/watch.ts+1-1
......@@ -170,4 +170,4 @@ import * as generate from "./generate.ts";
170170import * as incr from "./incremental.ts";
171171import * as path from "node:path";
172172import * as util from "node:util";
173import * as subprocess from "#sitegen/subprocess";
173import * as subprocess from "lib/subprocess.ts";
lib/Lru.test.ts created+138
......@@ -0,0 +1,138 @@
1test("lru basic", () => {
2 const lru = new Lru({ capacity: 3 });
3 lru.set("a", 1);
4 lru.set("b", 2);
5 lru.set("c", 3);
6 assert.deepEqual(lru.serialize(), [
7 ["c", 3, 1],
8 ["b", 2, 1],
9 ["a", 1, 1],
10 ]);
11 lru.set("d", 4); // deletes `a`
12 assert.deepEqual(lru.serialize(), [
13 ["d", 4, 1],
14 ["c", 3, 1],
15 ["b", 2, 1],
16 ]);
17 lru.set("c", 5);
18 lru.get("b");
19 lru.set("e", 6);
20 assert.deepEqual(lru.serialize(), [
21 ["e", 6, 1],
22 ["b", 2, 1],
23 ["c", 5, 1],
24 ]);
25 lru.capacity = 10;
26 lru.set("a", 0);
27 lru.set("z", 1);
28 lru.set("x", 1);
29 lru.set("w", 1);
30 assert.deepEqual(lru.serialize(), [
31 ["w", 1, 1],
32 ["x", 1, 1],
33 ["z", 1, 1],
34 ["a", 0, 1],
35 ["e", 6, 1],
36 ["b", 2, 1],
37 ["c", 5, 1],
38 ]);
39 lru.delete("a");
40 assert.deepEqual(lru.serialize(), [
41 ["w", 1, 1],
42 ["x", 1, 1],
43 ["z", 1, 1],
44 ["e", 6, 1],
45 ["b", 2, 1],
46 ["c", 5, 1],
47 ]);
48 lru.capacity = 2;
49 assert.deepEqual(lru.serialize(), [
50 ["w", 1, 1],
51 ["x", 1, 1],
52 ]);
53 lru.ensureUnusedCapacity(1);
54 assert.deepEqual(lru.serialize(), [
55 ["w", 1, 1],
56 ]);
57 lru.ensureUnusedCapacity(1.2);
58 assert.deepEqual(lru.serialize(), []);
59 lru.ensureUnusedCapacity(2);
60});
61test("lru sized elements", () => {
62 const lru = new Lru<string, number>({ capacity: 10, sizeFn: (v) => v });
63 lru.set("a", 3);
64 lru.set("b", 3);
65 lru.set("c", 3);
66 assert.deepEqual(lru.serialize(), [
67 ["c", 3, 3],
68 ["b", 3, 3],
69 ["a", 3, 3],
70 ]);
71 // hitting exact capacity
72 lru.set("d", 1);
73 lru.set("d", 1);
74 assert.deepEqual(lru.serialize(), [
75 ["d", 1, 1],
76 ["c", 3, 3],
77 ["b", 3, 3],
78 ["a", 3, 3],
79 ]);
80 lru.set("e", 1);
81 assert.deepEqual(lru.serialize(), [
82 ["e", 1, 1],
83 ["d", 1, 1],
84 ["c", 3, 3],
85 ["b", 3, 3],
86 ]);
87 lru.clear();
88 lru.set("a", 3);
89 lru.set("b", 3);
90 lru.set("c", 3);
91 lru.set("d", 1);
92 lru.get("a");
93 lru.get("b");
94 lru.get("c");
95 lru.set("e", 1);
96 assert.deepEqual(lru.serialize(), [
97 ["e", 1, 1],
98 ["c", 3, 3],
99 ["b", 3, 3],
100 ["a", 3, 3],
101 ]);
102});
103test("lru throw on exceeding capacity", () => {
104 const lru = new Lru<string, number>({ capacity: 1, sizeFn: (v) => v });
105 lru.set("e", 1);
106 lru.set("e", 1);
107 assert.throws(() => lru.set("e", 2), RangeError);
108 assert.throws(() => lru.set("d", 2), RangeError);
109 lru.set("d", 1);
110});
111test("locked items cannot be evicted", () => {
112 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
113 lru.set("e", 1);
114 lru.set("d", 1);
115 lru.set("f", 1);
116
117 const unlock = lru.lock("e");
118 lru.ensureUnusedCapacity(2);
119 assert.deepEqual(lru.serialize(), [
120 ["e", 1, 1],
121 ]);
122
123 unlock();
124
125 lru.ensureUnusedCapacity(3);
126
127 assert.deepEqual(lru.serialize(), []);
128});
129test("throws on evicting a locked item", () => {
130 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
131 lru.set("e", 1);
132 void lru.lock("e");
133 assert.throws(() => lru.ensureUnusedCapacity(3), RangeError);
134});
135
136import assert from "node:assert";
137import { test } from "node:test";
138import { Lru } from "lib/Lru.ts";
lib/Lru.ts created+273
......@@ -0,0 +1,273 @@
1/**
2 * This module is intended to be imported via the main class.
3 *
4 * import { Lru } from '@clo/lib/Lru';
5 * // Extra types are in a `declare namespace`
6 * const opts: Lru.Options = { capacity: 2 };
7 * const lru = new Lru(opts);
8 *
9 * @module
10 */
11
12interface Options<K, V> {
13 capacity: number;
14 /**
15 * Decide how many units of size each entry takes.
16 * For example, accessing `.byteLength` of a Buffer.
17 */
18 sizeFn?: (value: V, key: K) => number;
19 /** Called when `set` or `ensureUnusedCapacity` removes items. */
20 evict?: (value: V, key: K) => void;
21 /** Called when anything removes items. */
22 delete?: (value: V, key: K) => void;
23}
24
25/**
26 * A Least-recently-used cache has a set capacity, removing the oldest item
27 * when trying to insert an item that would exceed such capacity. This
28 * implementation supports variable sized items, eviction callbacks, entry
29 * locking, and JSON-compatible serialization.
30 *
31 * https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU
32 */
33export class Lru<K, V> extends Map<K, V> {
34 #order = new Map<K, Order<K>>();
35 /** The most recently used */
36 #head: K | none = none;
37 /** The least recently used */
38 #tail: K | none = none;
39 #used: number = 0;
40 #locked: number = 0;
41 #capacity: number;
42 #sizeFn: (value: V, key: K) => number;
43 #deleteFn: null | ((value: V, key: K) => void);
44 #evictFn: null | ((value: V, key: K) => void);
45
46 constructor(opts: Options<K, V>) {
47 super();
48 const { capacity, sizeFn, delete: deleteFn, evict } = opts;
49 this.#capacity = capacity;
50 this.#sizeFn = sizeFn ?? (() => 1);
51 this.#deleteFn = deleteFn ?? null;
52 this.#evictFn = evict ?? null;
53 }
54
55 override clear() {
56 super.clear();
57 this.#order.clear();
58 this.#head = none;
59 this.#tail = none;
60 this.#used = 0;
61 }
62 override delete(key: K): boolean {
63 const entry = this.#evict(key);
64 if (!entry) return false;
65 this.#used -= entry[0];
66 ASSERT(this.#order.delete(key));
67 ASSERT(super.delete(key));
68 return true;
69 }
70 override get(key: K): V | undefined {
71 if (this.#head !== key) {
72 const entry = this.#evict(key);
73 if (!entry) return undefined;
74 entry[1] = none, entry[2] = this.#head;
75 UNWRAP(this.#order.get(this.#head as K))[1] = key;
76 this.#head = key;
77 }
78 return super.get(key) as V;
79 }
80 override set(key: K, value: V): this {
81 const size = this.#sizeFn(value, key);
82 {
83 const uc = this.unlockedCapacity;
84 if (size > uc) {
85 const label = uc === this.#capacity ? " " : " unlocked ";
86 const msg = `Object of size ${size} exceeds${label}capacity ${uc}`;
87 throw new RangeError(msg);
88 }
89 }
90
91 const entry = this.#evict(key);
92 if (entry) {
93 // relocate
94 this.#used -= entry[0];
95 const head = this.#head;
96 ASSERT(head !== key);
97 entry[0] = size, entry[1] = none, entry[2] = this.#head;
98 if (head !== none) UNWRAP(this.#order.get(this.#head as K))[1] = key;
99 if (this.#tail === none) this.#tail = key;
100 } else {
101 // insertion
102 const { size: mapSize } = this;
103 const order: Order<K> = [size, none, mapSize > 0 ? this.#head : none, 0];
104 this.#order.set(key, order);
105 if (mapSize === 0) this.#tail = key;
106 else if (this.#head !== none) {
107 UNWRAP(this.#order.get(this.#head as K))[1] = key;
108 }
109 }
110
111 this.#head = key;
112 this.#used += size;
113 try {
114 this.ensureUnusedCapacity(0);
115 } catch {
116 ASSERT(false, "capacity is already verified");
117 }
118 super.set(key, value);
119 return this;
120 }
121
122 /** Prevent an item from being automatically evicted */
123 lock(key: K): ts.Dispose {
124 const order = this.#order.get(key);
125 if (!order) throw new Error(`Key ${key} not in Lru`);
126 const count = order[3] += 1;
127 if (count === 1) this.#locked += order[0];
128 return ts.defer(() => {
129 const count = order![3] -= 1;
130 if (count === 0) this.#locked -= order[0];
131 });
132 }
133
134 /** Evict items until there are `unused` capacity slots. */
135 ensureUnusedCapacity(unused: number) {
136 const uc = this.unlockedCapacity;
137 if (unused > uc) {
138 const label = uc === this.#capacity ? "capacity" : "unlocked capacity";
139 const msg = `Can't ensure ${unused} unused capacity with ${label} ${uc}`;
140 throw new RangeError(msg);
141 }
142 const needed = this.#used - this.#capacity + unused;
143 if (needed <= 0) return;
144 let remain = needed;
145 const evictFn = this.#evictFn;
146 const deleteFn = this.#deleteFn;
147 const order = this.#order;
148 let key = this.#tail;
149 loop: {
150 let hadLocks = false;
151 while (key !== none) {
152 const entry = UNWRAP(order.get(key));
153 if (entry[3] > 0) {
154 key = entry[1];
155 hadLocks = true;
156 continue;
157 }
158 ASSERT(this.#evict(key) === entry);
159 if (evictFn) evictFn(super.get(key) as V, key);
160 if (deleteFn) deleteFn(super.get(key) as V, key);
161 ASSERT(this.#order.delete(key));
162 ASSERT(super.delete(key));
163 remain -= entry[0];
164 if (remain <= 0) break loop;
165 key = entry[1];
166 }
167 ASSERT(hadLocks);
168 const msg = `Too many locked objects, cannot free ${unused} capacity`;
169 throw new RangeError(msg);
170 }
171 this.#used -= needed - remain;
172 }
173
174 /** Unlink an entry without removing it. */
175 #evict(key: K) {
176 const entry = this.#order.get(key);
177 if (!entry) return null;
178 const [, before, after] = entry;
179 if (before !== none) {
180 UNWRAP(this.#order.get(before as K))[2] = after;
181 } else this.#head = after;
182 if (after !== none) {
183 UNWRAP(this.#order.get(after as K))[1] = before;
184 } else this.#tail = before;
185 return entry;
186 }
187
188 get capacity(): number {
189 return this.#capacity;
190 }
191 set capacity(capacity: number) {
192 this.#capacity = capacity;
193 this.ensureUnusedCapacity(0);
194 }
195 get remaining(): number {
196 return this.#capacity - this.#used;
197 }
198 get unlockedCapacity(): number {
199 return this.#capacity - this.#locked;
200 }
201 get used(): number {
202 return this.#used;
203 }
204
205 serialize(): Serialized<K, V>;
206 serialize<OK, OV>(o: SerializeOptions<K, V, OK, OV>): Serialized<OK, OV>;
207 serialize<OK, OV>(
208 opts: SerializeOptions<K, V, OK, OV> = {},
209 ): Serialized<OK, OV> {
210 const { map = (k, v) => [k as unknown as OK, v as unknown as OV] } = opts;
211 const result: Serialized<OK, OV> = [];
212 const order = this.#order;
213 let head = this.#head;
214 while (head !== none) {
215 const entry = UNWRAP(order.get(head), head);
216 const kv = map(head, super.get(head) as V);
217 head = entry[2];
218 if (!kv) continue;
219 const [key, value] = kv;
220 result.push([key, value, entry[0]]);
221 }
222 return result;
223 }
224 revive<IK, IV>(
225 entries: Serialized<IK, IV>,
226 opts: SerializeOptions<IK, IV, K, V> = {},
227 ) {
228 const { map = (k, v) => [k as unknown as K, v as unknown as V] } = opts;
229 const order = this.#order;
230 let prev: Order<K> | null = null;
231 let pk!: K;
232 for (let i = entries.length - 1; i > 0; i -= 1) {
233 const [ik, iv, size] = UNWRAP(entries[i]);
234 const kv = map(ik, iv);
235 if (!kv) continue;
236 const [k, v] = kv;
237 super.set(k, v);
238 const current: Order<K> = [size, none, prev ? pk : none, 0];
239 if (prev) prev[1] = k;
240 order.set(k, prev = current);
241 pk = k;
242 }
243 }
244}
245
246/** The first item is the most recently used. */
247type Serialized<K, V> = Array<[K, V, number]>;
248
249interface SerializeOptions<IK, IV, OK = IK, OV = IV> {
250 /** null = omit in serialization/serialization */
251 map?: (key: IK, value: IV) => [OK, OV] | null;
252}
253
254type Order<K> = [
255 /** Number of units computed at insertion time. */
256 size: number,
257 /** Towards most recently used */
258 before: K | none,
259 /** Towards least recently used */
260 after: K | none,
261 /** Locking prevents automatic eviction */
262 locks: number,
263];
264
265export declare namespace Lru {
266 export type { Serialized, SerializeOptions };
267}
268
269const none: unique symbol = Symbol();
270type none = typeof none;
271
272import { ASSERT, UNWRAP } from "lib/assert.ts";
273import * as ts from "lib/ts.ts";
lib/assert.ts+10-6
......@@ -1,13 +1,17 @@
1// This module is intended to be imported as
1/**
2 * assertions and type narrowing helpers.
3 * intended to be imported by symbol.
4 * @module
5 */
26
3/** Assert that `condition` is truthy */
4export function ASSERT(condition: boolean, msg?: Message): asserts condition {
7/** assert that `condition` is truthy. */
8export function ASSERT(condition: unknown, msg?: Message): asserts condition {
59 if (!condition) {
610 throw new Error(msg ? messageToString(msg) : "Assertion Error");
711 }
812}
913
10/** Assert that `nullable` is non-null, returning a non-nullable T */
14/** assert that `nullable` is non-null, returning a non-nullable T. */
1115export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {
1216 if (nullable == null) {
1317 throw new Error(
......@@ -17,7 +21,7 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {
1721 return nullable;
1822}
1923
20/** Assert that `number` is not NaN */
24/** assert that `number` is not NaN. */
2125export function FINITE(number: number, msg?: Message): number {
2226 if (Number.isNaN(number)) {
2327 throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`);
......@@ -25,7 +29,7 @@ export function FINITE(number: number, msg?: Message): number {
2529 return number;
2630}
2731
28/** Assert that a `default` case in a switch is never reached. */
32/** assert that a `default` case in a switch is never reached. */
2933export function unreachableCase(value: never): never {
3034 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);
3135}
lib/async.ts created+207
......@@ -0,0 +1,207 @@
1const five_minutes = 5 * 60 * 1000;
2
3/*** @deprecated */
4interface ARCEValue<T> {
5 value: T;
6 [Symbol.dispose]: () => void;
7}
8
9/*** @deprecated */
10export function RefCountedExpirable<T>(
11 init: () => Promise<T>,
12 deinit: (value: T) => void,
13 expire: number = five_minutes,
14): () => Promise<ARCEValue<T>> {
15 let refs = 0;
16 let item: ARCEValue<T> | null = null;
17 let loading: Promise<ARCEValue<T>> | null = null;
18 let timer: ReturnType<typeof setTimeout> | null = null;
19
20 function deref() {
21 ASSERT(item !== null);
22 if (--refs !== 0) return;
23 ASSERT(timer === null);
24 timer = setTimeout(() => {
25 ASSERT(refs === 0);
26 ASSERT(loading === null);
27 ASSERT(item !== null);
28 deinit(item.value);
29 item = null;
30 timer = null;
31 }, expire);
32 }
33
34 return async function (): Promise<ARCEValue<T>> {
35 if (timer !== null) {
36 clearTimeout(timer);
37 timer = null;
38 }
39 if (item !== null) {
40 refs++;
41 return item;
42 }
43 if (loading !== null) {
44 refs++;
45 return loading;
46 }
47 const p = Promise.withResolvers<ARCEValue<T>>();
48 loading = p.promise;
49 try {
50 const value = await init();
51 item = { value, [Symbol.dispose]: deref };
52 refs++;
53 p.resolve(item);
54 return item;
55 } catch (e) {
56 p.reject(e);
57 throw e;
58 } finally {
59 loading = null;
60 }
61 };
62}
63
64export function once<T>(fn: () => Promise<T>): () => Promise<T> {
65 let result: T | Promise<T> | null = null;
66 return async () => {
67 if (result) return result;
68 try {
69 result = await (result = fn());
70 } catch (err) {
71 result = null;
72 throw err;
73 }
74 return result;
75 };
76}
77
78export class Watch<T> {
79 #value: T;
80 #observers = new Set<WatchCallback<T>>();
81 constructor(value: T) {
82 this.#value = value;
83 }
84
85 on(cb: WatchCallback<T>): () => void {
86 this.#observers.add(cb);
87 return () => void this.#observers.delete(cb);
88 }
89 once(cb: WatchCallback<T>): void {
90 const once = (n: T, p: T) => (cb(n, p), this.#observers.delete(once));
91 this.#observers.add(once);
92 }
93
94 next(): Promise<T> {
95 return new Promise((resolve) => this.once(resolve));
96 }
97
98 until(condition: (v: T) => boolean): Promise<T> {
99 if (condition(this.#value)) return Promise.resolve(this.#value);
100 return new Promise((resolve) => {
101 function check(next: T) {
102 if (condition(next)) resolve(next), release();
103 }
104 const release = this.on(check);
105 });
106 }
107
108 get value(): T {
109 return this.#value;
110 }
111 set value(next: T) {
112 const prev = this.#value;
113 if (prev !== next) {
114 this.#value = next;
115 this.#observers.forEach((cb) => cb(next, prev));
116 }
117 }
118}
119type WatchCallback<T> = (next: T, prev: T) => void;
120
121/**
122 * when two requests with the same args come in at the same time, the response
123 * from them is shared. after returning, the data is no longer stored.
124 */
125export class DedupeConcurrent<Args extends unknown[], Ret> {
126 pending: Map<unknown, Promise<Ret>> = new Map();
127 constructor(
128 public fn: (...args: Args) => Promise<Ret>,
129 public keyFn: (args: Args) => unknown = JSON.stringify,
130 ) {}
131
132 isPending(...args: Args): boolean {
133 return this.pending.has(this.keyFn(args));
134 }
135
136 /** Unbound to allow easily exporting this callback */
137 get = (...args: Args): Promise<Ret> => {
138 const k = this.keyFn(args);
139 let promise = this.pending.get(k);
140 if (promise) return promise;
141 promise = this.fn(...args);
142 this.pending.set(k, promise);
143 void promise.finally(() => void this.pending.delete(k));
144 return promise;
145 };
146}
147
148/**
149 * when two requests with the same args come, the result of the first is memoized.
150 */
151export class OnceMap<Args extends unknown[], Ret> {
152 cache: Map<unknown, Promise<Ret>> = new Map();
153 constructor(
154 public fn: (...args: Args) => Promise<Ret>,
155 public keyFn: (args: Args) => unknown = JSON.stringify,
156 ) {}
157
158 has(...args: Args): boolean {
159 return this.cache.has(this.keyFn(args));
160 }
161
162 /** Unbound to allow easily exporting this callback */
163 getOrRun = (...args: Args): Promise<Ret> => {
164 const k = this.keyFn(args);
165 let promise = this.cache.get(k);
166 if (promise) return promise;
167 promise = this.fn(...args);
168 this.cache.set(k, promise);
169 return promise;
170 };
171}
172
173export class PromiseAggregator {
174 failures: unknown[] = [];
175 promises: Set<Promise<unknown>> = new Set();
176
177 push(p: Promise<unknown>) {
178 p = p.then(
179 () => {
180 this.promises.delete(p);
181 },
182 (err) => {
183 this.failures.push(err);
184 },
185 );
186 this.promises.add(p);
187 }
188
189 async all() {
190 while (this.promises.size > 0) {
191 const wait = [...this.promises];
192 this.promises.clear();
193 await Promise.all(wait);
194 }
195 if (this.failures.length > 0) {
196 const agg = new AggregateError(this.failures);
197 this.failures = [];
198 throw agg;
199 }
200 }
201
202 [Symbol.asyncDispose](): Promise<void> {
203 return this.all();
204 }
205}
206
207import { ASSERT } from "lib/assert.ts";
lib/deno.json+22-6
......@@ -1,24 +1,40 @@
11{
22 "name": "@clo/lib",
3 "description": "",
4 "version": "1.0.0",
3 "description": "high quality typescript libraries",
4 "version": "2.0.0",
55 "license": "MIT",
66 "exports": {
77 "./assert.ts": "./assert.ts",
8 "./async.ts": "./async.ts",
9 "./error.ts": "./error.ts",
810 "./log.ts": "./log.ts",
9 "./log/ansi.ts": "./log/ansi.ts",
1011 "./log/headless.ts": "./log/headless.ts",
1112 "./log/progress.ts": "./log/progress.ts",
1213 "./log/stack.ts": "./log/stack.ts",
13 "./packaging.ts": "./packaging.ts",
14 "./Lru.ts": "./Lru.ts",
15 "./mime.ts": "./mime.ts",
16 "./node.ts": "./node.ts",
1417 "./queue.ts": "./queue.ts",
18 "./render.ts": "./render.ts",
19 "./render/jsx-runtime.ts": "./render/jsx-runtime.ts",
20 "./render/meta.ts": "./render/meta.ts",
1521 "./string.ts": "./string.ts",
22 "./string/ansi.ts": "./string/ansi.ts",
23 "./subprocess.ts": "./subprocess.ts",
24 "./subprocess/ffmpeg.ts": "./subprocess/ffmpeg.ts",
1625 "./ts.ts": "./ts.ts"
1726 },
27 "compilerOptions": {
28 "jsx": "react-jsx",
29 "jsxImportSource": "lib/render"
30 },
1831 "imports": {
19 "lib/": "./"
32 "lib/": "./",
33 "lib/render/jsx-runtime": "./render/jsx-runtime.ts",
34 "lib/render/jsx-dev-runtime": "./render/jsx-runtime.ts"
2035 },
2136 "exclude": [
22 "readme.npm.md"
37 "readme.npm.md",
38 "deno.lock"
2339 ]
2440}
\ No newline at end of file
lib/deno.lock+5-159
......@@ -1,171 +1,17 @@
11{
22 "version": "5",
33 "specifiers": {
4 "npm:@types/node@*": "24.2.0",
5 "npm:ts-to-jsdoc@2.4.0": "2.4.0"
4 "npm:@types/node@*": "22.15.15"
65 },
76 "npm": {
8 "@nodelib/fs.scandir@2.1.5": {
9 "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
10 "dependencies": [
11 "@nodelib/fs.stat",
12 "run-parallel"
13 ]
14 },
15 "@nodelib/fs.stat@2.0.5": {
16 "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
17 },
18 "@nodelib/fs.walk@1.2.8": {
19 "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
20 "dependencies": [
21 "@nodelib/fs.scandir",
22 "fastq"
23 ]
24 },
25 "@ts-morph/common@0.24.0": {
26 "integrity": "sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==",
27 "dependencies": [
28 "fast-glob",
29 "minimatch",
30 "mkdirp",
31 "path-browserify"
32 ]
33 },
34 "@types/node@24.2.0": {
35 "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
7 "@types/node@22.15.15": {
8 "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
369 "dependencies": [
3710 "undici-types"
3811 ]
3912 },
40 "arg@5.0.2": {
41 "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="
42 },
43 "balanced-match@1.0.2": {
44 "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
45 },
46 "brace-expansion@2.0.2": {
47 "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
48 "dependencies": [
49 "balanced-match"
50 ]
51 },
52 "braces@3.0.3": {
53 "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
54 "dependencies": [
55 "fill-range"
56 ]
57 },
58 "code-block-writer@13.0.3": {
59 "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="
60 },
61 "fast-glob@3.3.3": {
62 "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
63 "dependencies": [
64 "@nodelib/fs.stat",
65 "@nodelib/fs.walk",
66 "glob-parent",
67 "merge2",
68 "micromatch"
69 ]
70 },
71 "fastq@1.19.1": {
72 "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
73 "dependencies": [
74 "reusify"
75 ]
76 },
77 "fill-range@7.1.1": {
78 "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
79 "dependencies": [
80 "to-regex-range"
81 ]
82 },
83 "glob-parent@5.1.2": {
84 "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
85 "dependencies": [
86 "is-glob"
87 ]
88 },
89 "is-extglob@2.1.1": {
90 "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
91 },
92 "is-glob@4.0.3": {
93 "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
94 "dependencies": [
95 "is-extglob"
96 ]
97 },
98 "is-number@7.0.0": {
99 "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
100 },
101 "merge2@1.4.1": {
102 "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
103 },
104 "micromatch@4.0.8": {
105 "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
106 "dependencies": [
107 "braces",
108 "picomatch"
109 ]
110 },
111 "minimatch@9.0.5": {
112 "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
113 "dependencies": [
114 "brace-expansion"
115 ]
116 },
117 "mkdirp@3.0.1": {
118 "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
119 "bin": true
120 },
121 "path-browserify@1.0.1": {
122 "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
123 },
124 "picomatch@2.3.1": {
125 "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
126 },
127 "queue-microtask@1.2.3": {
128 "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
129 },
130 "reusify@1.1.0": {
131 "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
132 },
133 "run-parallel@1.2.0": {
134 "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
135 "dependencies": [
136 "queue-microtask"
137 ]
138 },
139 "to-regex-range@5.0.1": {
140 "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
141 "dependencies": [
142 "is-number"
143 ]
144 },
145 "ts-morph@23.0.0": {
146 "integrity": "sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==",
147 "dependencies": [
148 "@ts-morph/common",
149 "code-block-writer"
150 ]
151 },
152 "ts-to-jsdoc@2.4.0": {
153 "integrity": "sha512-RiI9ipxkLjXESp5+YjXIQJ5GgtjwWTZU4uDahxsIdr1HKLFaC/nQOtgkGRJ+AFxqHrrhwrXtPh093r0nXQwKmw==",
154 "dependencies": [
155 "arg",
156 "ts-morph"
157 ],
158 "optionalDependencies": [
159 "typescript"
160 ],
161 "bin": true
162 },
163 "typescript@5.9.2": {
164 "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
165 "bin": true
166 },
167 "undici-types@7.10.0": {
168 "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
13 "undici-types@6.21.0": {
14 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
16915 }
17016 }
17117}
lib/error.ts created+50
......@@ -0,0 +1,50 @@
1/**
2 * Helper functions for dealing with the `unknown` type, mainly in `catch`
3 * blocks or promise rejection callbacks.
4 *
5 * @module
6 */
7
8/** extra fields on errors are extremely helpful for debugging. */
9export interface Error extends globalThis.Error {
10 [metadata: string]: unknown;
11}
12
13/** retrieve an error message from any value */
14export function message(error: unknown): string {
15 const message = (error as { message: unknown })?.message ?? error;
16 try {
17 return typeof message === "string" ? message : JSON.stringify(message);
18 } catch {}
19 try {
20 return String(message);
21 } catch {}
22 return `Could not stringify error message ${typeof message}`;
23}
24
25/** retrieve an error-like object from any value. Useful to check fields. */
26export function obj(error: unknown): { message: string } & Partial<Error> {
27 if (error instanceof Error) return error as Error;
28 return {
29 message: message(error),
30 ...(typeof error === "object" && error),
31 };
32}
33
34/** node.js error codes are string values on the `.code` property. */
35export function code(error: unknown): node.ErrorCode | null {
36 const code = (error as { code: unknown })?.code;
37 return typeof code === "string" ? code : null;
38}
39
40/** attach extra fields and rethrow a value. */
41export function rethrowWithMetadata(
42 err: unknown,
43 meta: Record<string, unknown>,
44): never {
45 const error = err && typeof err === "object" ? err : new Error(message(err)); // no stack trace :/
46 Object.assign(error, meta);
47 throw error;
48}
49
50import type * as node from "lib/node.ts";
lib/log.test.ts+59-59
......@@ -5,6 +5,64 @@ import * as log from "lib/log.ts";
55
66log satisfies log.Scope; // the namespace import is a valid Scope
77
8test("writeLine buffers", () => {
9 using host = new TestWidgetHost();
10 host.writeLine("hello world");
11 host.writeLine("and so on");
12 host.expectFrame(0, {
13 stdout: "hello world\nand so on\n",
14 });
15 host.writeLine("more logs");
16 host.expectFrame(0, {
17 stdout: "more logs\n",
18 });
19});
20
21test("widget with interweaving logs", () => {
22 const host = new TestWidgetHost();
23
24 const _ = host.startWidget({
25 format: (now) => `line one ${now}\nline two`,
26 });
27
28 host.expectFrame(0, {
29 stderr: sync([
30 ansi.startOfLine,
31 "line one 0\nline two\n",
32 ]),
33 });
34
35 host.writeLine("info: line of log");
36 host.expectFrame(0, {
37 out: sync([
38 ansi.startOfLine,
39 ansi.cursorUp(2),
40 ansi.clearFullLine,
41 "info: line of log\n",
42 "line one 0\nline two\n",
43 ]),
44 });
45
46 host.writeLine("warn: line of log");
47 host.writeLine("error: line of log");
48 host.expectFrame(0, {
49 out: sync([
50 ansi.startOfLine,
51 ansi.cursorUp(1),
52 ansi.clearFullLine,
53 ansi.cursorUp(1),
54 ansi.clearFullLine,
55 "warn: line of log\n",
56 "error: line of log\n",
57 "line one 0\nline two\n",
58 ]),
59 });
60});
61
62function sync(text: string[]) {
63 return ansi.syncStart + text.join("") + ansi.syncEnd;
64}
65
866class TestWidgetHost implements headless.WidgetHost {
967 columns = 80;
1068 rows = 33;
......@@ -103,62 +161,4 @@ class TestWidgetHost implements headless.WidgetHost {
103161 }
104162}
105163
106test("writeLine buffers", () => {
107 using host = new TestWidgetHost();
108 host.writeLine("hello world");
109 host.writeLine("and so on");
110 host.expectFrame(0, {
111 stdout: "hello world\nand so on\n",
112 });
113 host.writeLine("more logs");
114 host.expectFrame(0, {
115 stdout: "more logs\n",
116 });
117});
118
119test("widget with interweaving logs", () => {
120 const host = new TestWidgetHost();
121
122 const _ = host.startWidget({
123 format: (now) => `line one ${now}\nline two`,
124 });
125
126 host.expectFrame(0, {
127 stderr: sync([
128 ansi.startOfLine,
129 "line one 0\nline two\n",
130 ]),
131 });
132
133 host.writeLine("info: line of log");
134 host.expectFrame(0, {
135 out: sync([
136 ansi.startOfLine,
137 ansi.cursorUp(2),
138 ansi.clearFullLine,
139 "info: line of log\n",
140 "line one 0\nline two\n",
141 ]),
142 });
143
144 host.writeLine("warn: line of log");
145 host.writeLine("error: line of log");
146 host.expectFrame(0, {
147 out: sync([
148 ansi.startOfLine,
149 ansi.cursorUp(1),
150 ansi.clearFullLine,
151 ansi.cursorUp(1),
152 ansi.clearFullLine,
153 "warn: line of log\n",
154 "error: line of log\n",
155 "line one 0\nline two\n",
156 ]),
157 });
158});
159
160function sync(text: string[]) {
161 return ansi.syncStart + text.join("") + ansi.syncEnd;
162}
163
164import * as ansi from "lib/log/ansi.ts";
164import * as ansi from "lib/string/ansi.ts";
lib/log.ts+21-13
......@@ -1,7 +1,9 @@
11/**
22 * By using `lib/log.ts`, your application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/log/progress.ts`. The pattern
4 * for using this module is to shadow the global `console` with a local scope:
3 * integration with terminal widgets such as `lib/log/progress.ts`.
4 *
5 * The pattern for using this module is to shadow the global `console` with a
6 * local scope:
57 *
68 * import * as log from "@clo/lib/log";
79 * const console = log.scoped("http");
......@@ -36,6 +38,7 @@ export interface Scope {
3638 /**
3739 * emit a debugging message. this method is in place for compatibility with
3840 * the `console` API. prefer calling `.log` directly.
41 * @internal
3942 */
4043 debug(...args: unknown[]): void;
4144
......@@ -48,33 +51,38 @@ export function scoped(name: string): Scope {
4851 return globalLog.scoped(name);
4952}
5053
51// These functions implement `Scope`. If a function takes in `Scope`, the
54// these functions implement `Scope`. If a function takes in `Scope`, the
5255// namespace import of this file can be passed to it.
5356
54/** informational */
57/** emit an informational message on the global log scope */
5558export function info(...args: unknown[]) {
5659 globalLog.info(...args);
5760}
58/** advisory */
61/** emit an advisory message on the global log scope */
5962export function warn(...args: unknown[]) {
6063 globalLog.warn(...args);
6164}
62/** failure */
65/** emit an failure message on the global log scope */
6366export function error(...args: unknown[]) {
6467 globalLog.error(...args);
6568}
66/** debugging */
69/** emit an debug message on the global log scope */
6770export function log(...args: unknown[]) {
6871 globalLog.log(...args);
6972}
70/** debugging */
73/**
74 * emit an debug message on the global log scope. this method is in place for
75 * compatibility with the `console` API. prefer calling `.log` directly.
76 * @internal
77 */
7178export function debug(...args: unknown[]) {
7279 globalLog.debug(...args);
7380}
7481
7582/**
76 * no built-in prefix or formatting. ensures the text does not interweave.
77 * data will be flushed in the next frame or when drawing is unlocked.
83 * no built-in prefix or formatting. ensures the text does not interweave. data
84 * will be flushed in the next frame or when drawing is
85 * {@link getDrawLock|unlocked}.
7886 */
7987export function writeLine(text: string) {
8088 globalWidgetHost.writeLine(text);
......@@ -115,7 +123,7 @@ export interface Widget {
115123}
116124
117125const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => {
118 const { process } = globalThis;
126 const { process } = node;
119127 if (!process || !process.stderr.isTTY) {
120128 return {
121129 // TODO: allow passing a level here
......@@ -145,9 +153,9 @@ const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => {
145153})();
146154const globalLog = /* @__PURE__ */ headless.logger({
147155 log: globalWidgetHost,
148 colors: process.stderr.isTTY,
156 colors: node.process?.stderr.isTTY ?? false,
149157});
150158
151import process from "node:process";
159import * as node from "lib/node.ts";
152160import * as headless from "lib/log/headless.ts";
153161import * as ts from "lib/ts.ts";
lib/log/ansi.ts deleted-131
......@@ -1,131 +0,0 @@
1export const reset: string = `\x1b[0m`;
2
3export const bold: string = `\x1b[1m`;
4export const dim: string = `\x1b[2m`;
5export const resetWeight: string = `\x1b[22m`;
6
7export const fgReset: string = `\x1b[39m`;
8export const fgBlack: string = `\x1b[30m`;
9export const fgRed: string = `\x1b[31m`;
10export const fgGreen: string = `\x1b[32m`;
11export const fgYellow: string = `\x1b[33m`;
12export const fgBlue: string = `\x1b[34m`;
13export const fgMagenta: string = `\x1b[35m`;
14export const fgCyan: string = `\x1b[36m`;
15export const fgWhite: string = `\x1b[37m`;
16export const fgBrightBlack: string = `\x1b[90m`;
17export const fgBrightRed: string = `\x1b[91m`;
18export const fgBrightGreen: string = `\x1b[92m`;
19export const fgBrightYellow: string = `\x1b[93m`;
20export const fgBrightBlue: string = `\x1b[94m`;
21export const fgBrightMagenta: string = `\x1b[95m`;
22export const fgBrightCyan: string = `\x1b[96m`;
23export const fgBrightWhite: string = `\x1b[97m`;
24export function fg256(n: number): string {
25 return `\x1b[38;5;${n}m`;
26}
27export function fgRgb(r: number, g: number, b: number): string {
28 return `\x1b[38;2;${r};${g};${b}m`;
29}
30
31export const bgReset: string = `\x1b[49m`;
32export const bgBlack: string = `\x1b[40m`;
33export const bgRed: string = `\x1b[41m`;
34export const bgGreen: string = `\x1b[42m`;
35export const bgYellow: string = `\x1b[43m`;
36export const bgBlue: string = `\x1b[44m`;
37export const bgMagenta: string = `\x1b[45m`;
38export const bgCyan: string = `\x1b[46m`;
39export const bgWhite: string = `\x1b[47m`;
40export const bgBrightBlack: string = `\x1b[100m`;
41export const bgBrightRed: string = `\x1b[101m`;
42export const bgBrightGreen: string = `\x1b[102m`;
43export const bgBrightYellow: string = `\x1b[103m`;
44export const bgBrightBlue: string = `\x1b[104m`;
45export const bgBrightMagenta: string = `\x1b[105m`;
46export const bgBrightCyan: string = `\x1b[106m`;
47export const bgBrightWhite: string = `\x1b[107m`;
48export function bg256(n: number): string {
49 return `\x1b[48;5;${n}m`;
50}
51export function bgRgb(r: number, g: number, b: number): string {
52 return `\x1b[48;2;${r};${g};${b}m`;
53}
54
55// cursor management
56export function cursorUp(n: number): string {
57 return n ? `\x1b[${n}A` : "";
58}
59export const startOfLine: string = "\r";
60export const startOfNextLine: string = "\n";
61
62// erasure
63export const clearToEndOfLine: string = "\x1b[K";
64export const clearToStartOfLine: string = "\x1b[1K";
65export const clearFullLine: string = "\x1b[2K";
66
67// Synchronized Output
68// https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
69export const syncStart: string = "\x1b[?2026h";
70export const syncEnd: string = "\x1b[?2026l";
71
72export function style(code: string, text: string): string {
73 code = mergeStyles(code);
74 const fg = /[\[;][39]/.test(code);
75 const bg = /[\[;](?:4|10)/.test(code);
76 const bold = /[\[;](?:1)[;m]/.test(code);
77 const dim = /[\[;](?:2)[;m]/.test(code);
78 return code + text +
79 mergeStyles(
80 (fg ? fgReset : "") +
81 (bg ? bgReset : "") +
82 (bold ? resetWeight : "") +
83 (dim ? resetWeight : ""),
84 );
85}
86
87/**
88 * Given a string of multiple style ansi codes, combines all of them.
89 * Must be used on a string of only ansi style codes.
90 */
91export function mergeStyles(code: string): string {
92 let styles = [];
93 while (code.length) {
94 if (!code.startsWith("\x1b[")) {
95 throw new Error("invalid style string: " + JSON.stringify(code));
96 }
97 const end = code.indexOf("m");
98 if (end === -1) {
99 throw new Error("invalid style string: " + JSON.stringify(code));
100 }
101 styles.push(code.slice(2, end));
102 code = code.slice(end + 1);
103 }
104 return `\x1b[${styles.join(";")}m`;
105}
106
107export function debugAnsi(text: string): string {
108 // deno-fmt-ignore
109 const verbaitim: (keyof typeof self)[] = [
110 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",
111 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",
112 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",
113 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",
114 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",
115 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
116 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
117 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"
118 ];
119 for (const key of verbaitim) {
120 text = text.replaceAll(
121 String(self[key]),
122 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),
123 );
124 }
125 text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}"));
126 text = text.replace(/\n/g, style(fgRed, "\\n"));
127 text = text.replace(/\r/g, style(fgMagenta, "\\r"));
128 return text;
129}
130
131import * as self from "lib/log/ansi.ts";
lib/log/headless.ts+42-29
......@@ -1,37 +1,47 @@
1// This file is under heavy construction.
2// - I am happy with the overall API, likely no changes.
3// - There are certainly a lot of bugs.
4//
5// Rendering for 'lib/term' is done by specifying all system APIs up front in
6// an interface and creating an instance of the widget host. The main file
7// fufills this interface with `node:process` and `globalThis`.
8//
9// Some notes on widget rendering:
10// - Never flush immediately (exception for `getDrawLock`), always do it next tick.
11// - Maximum of one `wait` call at once. When the expected time suddenly
12// shrinks, the timer is rescheduled.
13// - When redrawing widget lines, three tricks are done to reduce flickering:
14// 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)
15// 2. A simple prefix-based diffing algorithm for skipping unchanged text
16// 3. Avoid clearing a line before redrawing it.
17// Points 2 and 3 are used for terminals that are slow or have
18//
1/**
2 * terminal widget rendering and logging is done by specifying all system APIs up front in an
3 * interface, creating an instance of the widget host. `lib/log.ts` fufills
4 * this interface with `node:process` and `globalThis`.
5 *
6 * this file is under heavy construction.
7 * - I am happy with the overall API, likely no changes.
8 * - There are certainly a lot of bugs.
9 *
10 * some notes on widget rendering:
11 * - Never flush immediately (exception for
12 * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick.
13 * - Maximum of one `wait` call at once. When the expected time suddenly
14 * shrinks, the timer is rescheduled.
15 * - When redrawing widget lines, three tricks are done to reduce flickering:
16 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)
17 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
18 * 3. Avoid clearing a line before redrawing it.
19 * Points 2 and 3 are used for terminals that are slow or do not support sync.
20 *
21 * @module
22 */
1923
24/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
2025export interface WidgetHostEnv {
26 /** Recieves ANSI escape sequences for interactive data. */
2127 writeInteractive(text: string): void;
28 /** Recieves log content (from `writeLine`). */
2229 writeOutput(text: string): void;
30 /** Monotonic. */
2331 now(): ReturnType<typeof performance.now>;
24 /** 0ms indicates "one frame" */
32 /** 0ms indicates "one frame". */
2533 wait(ms: number, cb: () => void): () => void;
34 /** Called often. */
2635 getSize(): { columns: number; rows: number };
2736 // onInput?(write: (bytes: Uint8Array | string) => void): () => void;
2837}
2938
30/** Not intended to be implemented manually. See `widgetHost` */
39/** Returned by {@linkcode widgetHost} */
3140export interface WidgetHost {
3241 /**
33 * no built-in prefix or formatting. ensures the text does not interweave.
34 * data will be flushed in the next frame or when drawing is unlocked.
42 * no built-in prefix or formatting. ensures the text does not interweave
43 * with widgets. data will be flushed in the next frame or when drawing is
44 * unlocked.
3545 */
3646 writeLine(text: string): void;
3747 /**
......@@ -55,7 +65,7 @@ interface WidgetState {
5565 unsub: (() => void) | null;
5666}
5767
58/** Implement a widget host by providing an environment. */
68/** Implement a widget host by providing an environment implementation. */
5969export function widgetHost(env: WidgetHostEnv): WidgetHost {
6070 const { writeOutput, writeInteractive, now, wait, getSize } = env;
6171 type Cancel = ReturnType<typeof wait>;
......@@ -97,7 +107,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
97107 if (rowsLeft === 1) break;
98108 const lines = outText.split("\n").slice(0, rowsLeft);
99109 newWidgetLines.push(
100 ...lines.map((line) => string.trimToWidth(line, columns - 1)),
110 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
101111 );
102112
103113 next = Math.min(next, UNWRAP(internals[w]).frameTime);
......@@ -157,8 +167,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
157167 : newLine) +
158168 // clear rest of line if needed
159169 (oldLines[i] &&
160 string.widthInTerminal(oldLines[i]) >
161 string.widthInTerminal(newLine)
170 ansi.widthInTerminal(oldLines[i]) >
171 ansi.widthInTerminal(newLine)
162172 ? ansi.clearToEndOfLine
163173 : "") +
164174 "\n"
......@@ -185,8 +195,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
185195 : newLine) +
186196 // clear rest of line if needed
187197 (lines[i] &&
188 string.widthInTerminal(lines[i]) >
189 string.widthInTerminal(newLine)
198 ansi.widthInTerminal(lines[i]) >
199 ansi.widthInTerminal(newLine)
190200 ? ansi.clearToEndOfLine
191201 : "") +
192202 "\n"
......@@ -264,6 +274,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost {
264274 };
265275}
266276
277/** Input interface for {@linkcode logger} */
267278export interface LoggerOptions {
268279 /** Provide either a line writer or a log writer. */
269280 log: {
......@@ -274,6 +285,7 @@ export interface LoggerOptions {
274285 colors: boolean;
275286}
276287
288/** Implement a logging scope by providing an environment interface. */
277289export function logger(env: LoggerOptions): log.Scope {
278290 const { colors, log } = env;
279291 const levels = colors
......@@ -314,6 +326,7 @@ export function logger(env: LoggerOptions): log.Scope {
314326 }
315327
316328 function createSubScope(this: log.Scope, name: string) {
329 // @ts-ignore
317330 const { name: parent } = this;
318331 return scoped(parent ? parent + "/" + name : name);
319332 }
......@@ -323,7 +336,7 @@ export function logger(env: LoggerOptions): log.Scope {
323336
324337type Level = 0 | 1 | 2;
325338
326import * as ansi from "lib/log/ansi.ts";
339import * as ansi from "lib/string/ansi.ts";
327340import * as string from "lib/string.ts";
328341import * as ts from "lib/ts.ts";
329342import * as stack from "lib/log/stack.ts";
lib/log/progress.ts+7-11
......@@ -5,12 +5,10 @@
55// core, but still leaving the top level `start` function.
66// - There are an overwhelming amount of bugs
77//
8// A progress tree based on my past uses with clover console v3 and the Zig
9// Progress API. Pass `Progress` to functions which should track progress,
10// calling `.start()` to create child items. Or, multiple top-level progress
11// bars can be created with the singleton's `start()` function.
12import * as self from "lib/log/progress.ts";
13self satisfies Ref; // prove you can use the namespace import as a `Progress`
8// a progress tree based on my past uses with clover console v3 and the Zig
9// `Progress` API. Pass `Ref` to functions which should track progress, calling
10// `.start()` to create child items. Or, multiple top-level progress bars can
11// be created with the singleton's `start()` function.
1412
1513/**
1614 * creates a new trackable unit of work as a child of this one.
......@@ -28,7 +26,7 @@ export interface Ref {
2826 start(text: string, opts?: StartOptions): Node;
2927}
3028
31export interface Node extends Ref {
29export interface Node extends Ref, Disposable {
3230 /** reactive */
3331 text: string;
3432 /** reactive */
......@@ -62,8 +60,6 @@ export interface Node extends Ref {
6260
6361 /** end this Node or RootNode. ending collapses children */
6462 end(): void;
65
66 [Symbol.dispose](): void;
6763}
6864
6965export interface StartOptions {
......@@ -415,6 +411,6 @@ const global = /** @__PURE__ */ new Headless(log);
415411import * as process from "node:process";
416412import * as log from "lib/log.ts";
417413import * as headless from "lib/log/headless.ts";
418import * as ansi from "lib/log/ansi.ts";
414import * as ansi from "lib/string/ansi.ts";
419415import * as string from "lib/string.ts";
420import { UNWRAP } from "lib/assert.ts";
416import { ASSERT, UNWRAP } from "lib/assert.ts";
lib/log/stack.ts+23-28
......@@ -2,7 +2,8 @@
22 * the default stack trace formatters in node.js, deno, and bun are all
33 * extremely low quality. they don't highlight the most important information
44 * and spend too much space on showing source code. this module provides tools
5 * to `parse` and `format` errors. see `format`'s comment for design decisions.
5 * to {@linkcode parse} and {@linkcode format} errors. see {@linkcode format}'s
6 * comment for design decisions.
67 *
78 * to get a taste of the formatter:
89 * ```ts
......@@ -39,7 +40,7 @@ export interface Frame {
3940/**
4041 * parse a error stack trace from either v8 (node, deno, chrome),
4142 * jsc (safari, bun), or spidermonkey (firefox).
42 *
43 *
4344 * this function is derived from clover's work on bun's DevServer, which she
4445 * derived from stacktracejs. this file is more refined than those two:
4546 * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts
......@@ -52,7 +53,9 @@ export interface Frame {
5253export function parse(error: Error | string): Frame[] | null {
5354 const stack = (error as Error)?.stack ?? error;
5455 if (typeof stack !== "string") return null;
55 return stack.match(regexV8Stack) ? parseV8OrIe(stack) : parseJscOrSpidermonkey(stack);
56 return stack.match(regexV8Stack)
57 ? parseV8OrIe(stack)
58 : parseJscOrSpidermonkey(stack);
5659}
5760
5861/**
......@@ -65,7 +68,7 @@ export function capture(trimStart = 2): Frame[] {
6568
6669/**
6770 * convert an error into a human readable and debuggable string.
68 *
71 *
6972 * this printer puts the error metadata at the top. each stack frame gets two
7073 * lines: the absolute path or url, and then one line of source code. having
7174 * five lines of context on just the top frame is useless, since five lines is
......@@ -238,8 +241,8 @@ export function formatFrame(frame: Frame, colors: boolean): string {
238241
239242const existCache = new Map<string, boolean>();
240243function getPackageRoot(absPath: string) {
241 const fs = nodeBuiltin("node:fs");
242 const path = nodeBuiltin("node:path");
244 const fs = node.builtin("fs");
245 const path = node.builtin("path");
243246 const process = globalThis.process;
244247 if (!fs || !path || !process) return null;
245248
......@@ -302,14 +305,12 @@ function getPackageRoot(absPath: string) {
302305
303306const codeCache = new Map<string, string[]>();
304307function getSourceCode(file: string) {
305 const fs = nodeBuiltin("node:fs");
306 const path = nodeBuiltin("node:path");
308 const fs = node.builtin("fs");
309 const path = node.builtin("path");
307310 if (!fs || !path) return null;
308311 if (file.startsWith("node:")) {
309312 // node.js has a secret binding for all the internal source codes
310 type Process = { binding(name: "natives"): Record<string, string> };
311 return (globalThis.process as unknown as Process)
312 ?.binding?.("natives")[file.slice(5)]?.split("\n") ?? null;
313 return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null;
313314 } else {
314315 // bun and deno do not offer a way to get source code of internal modules,
315316 // probably for good reason.
......@@ -341,16 +342,16 @@ function parseV8OrIe(stack: string): Frame[] {
341342
342343 // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in
343344 // case it has spaces in it, as the string is split on \s+ later on
344 let loc = sanitizedLine.match(/ (\(.+\)$)/);
345 const loc = sanitizedLine.match(/ (\(.+\)$)/);
345346
346347 // remove the parenthesized location from the line, if it was matched
347348 sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine;
348349
349350 // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine
350351 // because this line doesn't have function name
351 let locationParts = extractLocation(loc?.[1] ?? sanitizedLine);
352 let functionName = (loc && sanitizedLine) || undefined;
353 let fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1
352 const locationParts = extractLocation(loc?.[1] ?? sanitizedLine);
353 const functionName = (loc && sanitizedLine) || undefined;
354 const fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1
354355 ? undefined
355356 : locationParts[0];
356357
......@@ -377,11 +378,10 @@ function parseJscOrSpidermonkey(stack: string): Frame[] {
377378 // Safari eval frames only have function names and nothing else
378379 fn = source.endsWith("@") ? source.slice(0, -1) : source;
379380 } else {
380 var functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/;
381 var matches = source.match(functionNameRegex);
382 var functionName = matches && matches[1] ? matches[1] : undefined;
383 var locationParts = extractLocation(
384 source.replace(functionNameRegex, ""),
381 const matches = source.match(regexFunctionName);
382 const functionName = matches && matches[1] ? matches[1] : undefined;
383 const locationParts = extractLocation(
384 source.replace(regexFunctionName, ""),
385385 );
386386 fn = functionName!;
387387 file = locationParts[0];
......@@ -408,14 +408,9 @@ function extractLocation(urlLike: string) {
408408 return [parts[1], parts[2] || undefined, parts[3] || undefined];
409409}
410410
411// TODO: extract
412function nodeBuiltin(name: "node:path"): typeof import("node:path") | null;
413function nodeBuiltin(name: "node:fs"): typeof import("node:fs") | null;
414function nodeBuiltin(name: string) {
415 return globalThis.process?.getBuiltinModule(name) ?? null;
416}
417
411const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/;
418412const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m;
419413const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/;
420414
421import * as ansi from "lib/log/ansi.ts";
415import * as ansi from "lib/string/ansi.ts";
416import * as node from "lib/node.ts";
lib/mime.test.ts created+28
......@@ -0,0 +1,28 @@
1// # `mime.for`
2// Retrieves the 'Content-Type' header to use given a file extension.
3test("for", () => {
4 // accepts extension as string
5 assert.equal(mime.for("txt"), "text/plain");
6 // accepts
7 assert.equal(mime.for(".xml"), "application/xml");
8 // accepts file path
9 assert.equal(mime.for("filename.xml"), "application/xml");
10 assert.equal(mime.for("a.directory/.xml"), "application/xml");
11 // accepts absolute file path
12 assert.equal(mime.for("/path/to/filename.xml"), "application/xml");
13 assert.equal(mime.for("C:\\path\\to\\filename.xml"), "application/xml");
14
15 // rss is special-cased
16 assert.equal(mime.for("rss.xml"), "application/rss+xml");
17
18 // supports modern innovations
19 assert.equal(mime.for(".avif"), "image/avif");
20 assert.equal(mime.for(".jxl"), "image/jxl");
21
22 // fallback
23 assert.equal(mime.for(".unknown"), "application/octet-stream");
24});
25
26import { test } from "node:test";
27import assert from "node:assert";
28import * as mime from "lib/mime.ts";
lib/mime.ts created+127
......@@ -0,0 +1,127 @@
1/**
2 * retrieves the 'Content-Type' header to use given a file extension.
3 * accepts:
4 * - full file path or basename
5 * - extension (with or without dot)
6 */
7function contentTypeFor(file: string): string {
8 const slash = file.indexOf("/");
9 if (slash !== -1) file = file.slice(slash + 1);
10 const dot = file.lastIndexOf(".");
11 if (dot === -1) file = "." + file;
12 else if (dot > 0) {
13 let entry = fullNames.get(file);
14 if (entry) return entry;
15 file = file.slice(dot);
16 }
17 return extensions.get(file) ?? "application/octet-stream";
18}
19export { contentTypeFor as for };
20
21// special-cased file names
22const fullNames = new Map([
23 ["rss.xml", "application/rss+xml"],
24]);
25
26// this string literal, when minified, is shorter than key-value pairs.
27const extensions = new Map(
28 ( // deno-fmt-ignore
29 '.aac' + '#' + 'audio/x-aac' + '\n' +
30 '.aif' + '#' + 'audio/x-aiff' + '\n' +
31 '.aifc' + '#' + 'audio/x-aiff' + '\n' +
32 '.aiff' + '#' + 'audio/x-aiff' + '\n' +
33 '.asm' + '#' + 'text/x-asm' + '\n' +
34 '.avi' + '#' + 'video/x-msvideo' + '\n' +
35 '.avif' + '#' + 'image/avif' + '\n' +
36 '.bat' + '#' + 'application/x-msdownload' + '\n' +
37 '.c' + '#' + 'text/x-c' + '\n' +
38 '.chat' + '#' + 'text/x-clover-chatlog' + '\n' +
39 '.class' + '#' + 'application/java-vm' + '\n' +
40 '.cmd' + '#' + 'application/x-msdownload' + '\n' +
41 '.com' + '#' + 'application/x-msdownload' + '\n' +
42 '.conf' + '#' + 'text/plain' + '\n' +
43 '.cpp' + '#' + 'text/x-c' + '\n' +
44 '.css' + '#' + 'text/css' + '\n' +
45 '.csv' + '#' + 'text/csv' + '\n' +
46 '.cxx' + '#' + 'text/x-c' + '\n' +
47 '.def' + '#' + 'text/plain' + '\n' +
48 '.diff' + '#' + 'text/plain' + '\n' +
49 '.dll' + '#' + 'application/x-msdownload' + '\n' +
50 '.dmg' + '#' + 'application/octet-stream' + '\n' +
51 '.doc' + '#' + 'application/msword' + '\n' +
52 '.docx' + '#' + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + '\n' +
53 '.epub' + '#' + 'application/epub+zip' + '\n' +
54 '.exe' + '#' + 'application/x-msdownload' + '\n' +
55 '.flv' + '#' + 'video/x-flv' + '\n' +
56 '.fbx' + '#' + 'application/fbx' + '\n' +
57 '.gz' + '#' + 'application/x-gzip' + '\n' +
58 '.h' + '#' + 'text/x-c' + '\n' +
59 '.h264' + '#' + 'video/h264' + '\n' +
60 '.hh' + '#' + 'text/x-c' + '\n' +
61 '.htm' + '#' + 'text/html;charset=utf-8' + '\n' +
62 '.html' + '#' + 'text/html;charset=utf-8' + '\n' +
63 '.ico' + '#' + 'image/x-icon' + '\n' +
64 '.ics' + '#' + 'text/calendar' + '\n' +
65 '.in' + '#' + 'text/plain' + '\n' +
66 '.jar' + '#' + 'application/java-archive' + '\n' +
67 '.java' + '#' + 'text/x-java-source' + '\n' +
68 '.jpeg' + '#' + 'image/jpeg' + '\n' +
69 '.jpg' + '#' + 'image/jpeg' + '\n' +
70 '.jpgv' + '#' + 'video/jpeg' + '\n' +
71 '.jxl' + '#' + 'image/jxl' + '\n' +
72 '.js' + '#' + 'application/javascript;charset=utf-8' + '\n' +
73 '.json' + '#' + 'application/json' + '\n' +
74 '.latex' + '#' + 'application/x-latex' + '\n' +
75 '.list' + '#' + 'text/plain' + '\n' +
76 '.log' + '#' + 'text/plain' + '\n' +
77 '.m4a' + '#' + 'audio/mp4' + '\n' +
78 '.man' + '#' + 'text/troff' + '\n' +
79 '.mid' + '#' + 'audio/midi' + '\n' +
80 '.midi' + '#' + 'audio/midi' + '\n' +
81 '.mov' + '#' + 'video/quicktime' + '\n' +
82 '.mp3' + '#' + 'audio/mpeg' + '\n' +
83 '.mp4' + '#' + 'video/mp4' + '\n' +
84 '.msh' + '#' + 'model/mesh' + '\n' +
85 '.msi' + '#' + 'application/x-msdownload' + '\n' +
86 '.obj' + '#' + 'application/octet-stream' + '\n' +
87 '.ogg' + '#' + 'audio/ogg' + '\n' +
88 '.otf' + '#' + 'application/x-font-otf' + '\n' +
89 '.pdf' + '#' + 'application/pdf' + '\n' +
90 '.png' + '#' + 'image/png' + '\n' +
91 '.ppt' + '#' + 'application/vnd.ms-powerpoint' + '\n' +
92 '.pptx' + '#' + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + '\n' +
93 '.psd' + '#' + 'image/vnd.adobe.photoshop' + '\n' +
94 '.py' + '#' + 'text/x-python' + '\n' +
95 '.rar' + '#' + 'application/x-rar-compressed' + '\n' +
96 '.rss' + '#' + 'application/rss+xml' + '\n' +
97 '.rtf' + '#' + 'application/rtf' + '\n' +
98 '.rtx' + '#' + 'text/richtext' + '\n' +
99 '.s' + '#' + 'text/x-asm' + '\n' +
100 '.pem' + '#' + 'application/x-pem-file"' + '\n' +
101 '.ser' + '#' + 'application/java-serialized-object' + '\n' +
102 '.sh' + '#' + 'application/x-sh' + '\n' +
103 '.sig' + '#' + 'application/pgp-signature' + '\n' +
104 '.silo' + '#' + 'model/mesh' + '\n' +
105 '.svg' + '#' + 'image/svg+xml' + '\n' +
106 '.t' + '#' + 'text/troff' + '\n' +
107 '.tar' + '#' + 'application/x-tar' + '\n' +
108 '.text' + '#' + 'text/plain' + '\n' +
109 '.tgz' + '#' + 'application/x-gzip' + '\n' +
110 '.tif' + '#' + 'image/tiff' + '\n' +
111 '.tiff' + '#' + 'image/tiff' + '\n' +
112 '.torrent'+ '#' + 'application/x-bittorrent' + '\n' +
113 '.ttc' + '#' + 'application/x-font-ttf' + '\n' +
114 '.ttf' + '#' + 'application/x-font-ttf' + '\n' +
115 '.txt' + '#' + 'text/plain' + '\n' +
116 '.urls' + '#' + 'text/uri-list' + '\n' +
117 '.v' + '#' + 'text/x-v' + '\n' +
118 '.wav' + '#' + 'audio/x-wav' + '\n' +
119 '.wmv' + '#' + 'video/x-ms-wmv' + '\n' +
120 '.woff2' + '#' + 'font/woff2' + '\n' +
121 '.xls' + '#' + 'application/vnd.ms-excel' + '\n' +
122 '.xlsx' + '#' + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + '\n' +
123 '.xml' + '#' + 'application/xml' + '\n' +
124 '.xps' + '#' + 'application/vnd.ms-xpsdocument' + '\n' +
125 + ''
126 ).slice(0, -1).split("\n").map((line) => line.split("#") as [string, string]),
127);
lib/node.ts created+73
......@@ -0,0 +1,73 @@
1/**
2 * functions to load Node.js apis via `globalThis.process`. trivially bundlable
3 * for the browser. does not depend on `@types/node` and does not intend to
4 * define types for the entire api. Instead, this is used for other library
5 * modules like `lib/log.ts` to bind to the system.
6 * @module
7 */
8
9export type ErrorCode =
10 | keyof typeof import("node:os").constants.errno
11 | (string & {});
12
13export const process: Process | undefined =
14 (globalThis as typeof globalThis & { process?: Process }).process;
15
16interface Process {
17 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
18 binding<K extends keyof Bindings>(name: K): Bindings[K] | null;
19 addListener(event: string, callback: () => void): this;
20
21 stdout: Tty;
22 stderr: Tty;
23}
24
25interface Tty {
26 isTTY: boolean;
27 columns: number;
28 rows: number;
29 addListener(event: string, callback: () => void): this;
30 write(text: string | Uint8Array): void;
31}
32
33/**
34 * Subset of Node.js builtin types needed to not depend on the problematic
35 * "@types/node" package.
36 */
37interface Builtins {
38 "fs": {
39 existsSync(file: string): boolean;
40 readFileSync(file: string, encoding: "utf-8"): string;
41 readFileSync(file: string): Uint8Array;
42 };
43 "path": {
44 isAbsolute(file: string): boolean;
45 dirname(file: string): string;
46 basename(file: string): string;
47 join(...parts: string[]): string;
48 sep: string;
49 };
50}
51/**
52 * Subset of Node.js binding types
53 */
54interface Bindings {
55 /**
56 * key value mapping of internal module id to its source code.
57 *
58 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...
59 */
60 "natives": Partial<Record<string, string>>;
61}
62
63export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | null {
64 return process?.getBuiltinModule(name) ?? null;
65}
66
67export function binding<K extends keyof Bindings>(name: K): Bindings[K] | null {
68 try {
69 return process?.binding(name) ?? null;
70 } catch {
71 return null;
72 }
73}
lib/packaging.ts deleted-130
......@@ -1,130 +0,0 @@
1// Tools that `lib` uses to package itself for NPM.
2// This depends on an installation of `ts-to-jsdoc` available as a resolvable node module.
3
4export async function updateJsrPackageExportsToAll(
5 absPath: string,
6 p: progress.Ref,
7) {
8 using _ = p.start("update deno.json");
9 const manifestPath = path.join(absPath, "deno.json");
10 const deno = JSON.parse(
11 await fs.readFile(manifestPath, "utf-8"),
12 );
13 const newExports: Record<string, string> = {};
14 for (const subPath of await fs.readdir(absPath, { recursive: true })) {
15 if (!subPath.match(/\.tsx?$/)) continue;
16 if (subPath.match(/\.(test|sample)\.tsx?$/)) continue;
17 if (subPath.startsWith("dist")) continue;
18 newExports["./" + subPath] = "./" + subPath;
19 }
20 deno.exports = Object.fromEntries(
21 Object.entries(newExports).sort(([a], [b]) => a.localeCompare(b)),
22 ) as typeof deno.exports;
23 await fs.writeFile(manifestPath, JSON.stringify(deno, null, 2));
24}
25
26interface TsToJsdoc {
27 transpileFile: (opts: { code: string }) => string;
28}
29
30export async function transpileForNpm(
31 absPath: string,
32 p: progress.Ref,
33 packageJson: Record<string, unknown>,
34 tsToJsdoc?: TsToJsdoc,
35) {
36 const libName = await Promise.resolve("ts-to-jsdoc");
37 try {
38 tsToJsdoc ??= await import(libName) as TsToJsdoc;
39 } catch {
40 tsToJsdoc ??= await import("npm:" + libName + "@2.4.0") as TsToJsdoc;
41 }
42 const { transpileFile } = tsToJsdoc;
43
44 using node = p.start("transpile into jsdoc for npm");
45 const manifestPath = path.join(absPath, "deno.json");
46 const deno = JSON.parse(
47 await fs.readFile(manifestPath, "utf-8"),
48 );
49 const distPath = path.join(absPath, "dist-npm");
50 await fs.rm(distPath, { recursive: true, force: true });
51 await fs.mkdir(distPath, { recursive: true });
52
53 const files: string[] = [];
54 const exports: Record<string, string> = {};
55 for (const subPath of await fs.readdir(absPath, { recursive: true })) {
56 if (!subPath.match(/\.tsx?$/)) continue;
57 if (subPath.startsWith("dist")) continue;
58 files.push(subPath);
59 }
60 node.estimate = Object.keys(exports).length;
61 for (const subPath of files) {
62 using _ = node.start(subPath);
63 const targetSubPath = subPath.replace(/\.tsx?$/, ".js");
64 if (!subPath.match(/\.(test|sample)\.tsx?$/)) {
65 exports["./" + subPath] = "./" + targetSubPath;
66 }
67
68 let code = transpileFile({
69 code: await fs.readFile(path.join(absPath, subPath), "utf-8"),
70 });
71 code = code.replace(
72 / from "lib\/([^"]+)"/g,
73 (_, src) => {
74 return ' from "./' + path.relative(
75 path.dirname(subPath),
76 src.replace(/\.tsx?$/, ".js"),
77 ) + '"';
78 },
79 );
80 if (packageJson.homepage) {
81 code =
82 "// This file is transpiled from TypeScript from paper clover's sitegen\n" +
83 "// " + packageJson.homepage + "/" + subPath + "\n" +
84 code;
85 }
86 const out = path.join(distPath, targetSubPath);
87 await fs.mkdir(path.dirname(out), { recursive: true });
88 await fs.writeFile(out, code);
89 node.value += 1;
90 }
91 await fs.cp(
92 path.join(absPath, "readme.npm.md"),
93 path.join(distPath, "readme.md"),
94 ).catch((e) => {
95 if (e.code !== "ENOENT") throw e;
96 });
97 await fs.cp(
98 path.join(absPath, "readme.changes.md"),
99 path.join(distPath, "readme.changes.md"),
100 ).catch((e) => {
101 if (e.code !== "ENOENT") throw e;
102 });
103 const { name, description, ...rest } = packageJson;
104 await fs.writeFile(
105 path.join(distPath, "package.json"),
106 JSON.stringify({
107 name,
108 description,
109 version: deno.version,
110 type: "module",
111 ...rest,
112 }),
113 );
114}
115
116if (import.meta.main) {
117 updateJsrPackageExportsToAll(import.meta.dirname!, progress)
118 .then(() =>
119 transpileForNpm(import.meta.dirname!, progress, {
120 name: "@paperclover/lib",
121 description: "high quality typescript libraries",
122 homepage:
123 "https://git.paperclover.net/clo/sitegen/src/branch/master/lib",
124 })
125 );
126}
127
128import * as progress from "lib/log/progress.ts";
129import * as path from "node:path";
130import * as fs from "node:fs/promises";
lib/queue.ts+11-2
......@@ -1,5 +1,8 @@
1// Implements a priority queue. Jobs can be automatically cancelled via
2// AbortSignal rescheduled if higher priority tasks come in.
1/**
2 * implements a priority queue. jobs are be automatically cancelled via
3 * `AbortSignal` and rescheduled when higher priority tasks come in.
4 * @module
5 */
36
47/** schedule `item.run` to be called at some point on the global queue */
58export function run<T>(item: Job<T>): Promise<T> {
......@@ -28,10 +31,15 @@ export interface Job<out T> {
2831}
2932
3033export class PriorityQueue {
34 /** Read only */
3135 concurrency: number;
36 /** Read only */
3237 coresRemain: number;
38 /** Read only */
3339 jobs: Array<Internal> = [];
40 /** Read only */
3441 active: Array<Internal> = [];
42 /** Can be assigned to react to queue changes. */
3543 onChange: (() => void) | null = null;
3644
3745 constructor(concurrency: number) {
......@@ -165,6 +173,7 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) {
165173 arr.push(item);
166174}
167175
176declare var navigator: { hardwareConcurrency: number };
168177const global = new PriorityQueue(navigator.hardwareConcurrency);
169178
170179import { UNWRAP } from "lib/assert.ts";
lib/readme.changes.md+23-8
......@@ -2,18 +2,33 @@
22
33## v2
44
5- top level readme now explains general usage patterns.
5### breaking
6
67- rework `log.Scope`, remove self call signature so that the `log` module
78 satisfies `log.Scope`, while adding `log` and `debug` for explicit debugging
89 logs. documentation prescribes doing `const console = log.scope("module");`.
9- add `stack.capture()`
10- remove `packaging.ts`
11- move `log/ansi.ts` into `string/ansi.ts`
12- move ansi related `string.ts` functions into `string/ansi.ts`
13
14### features
15
16- top level readme now explains general usage patterns. more docs overall.
17- new library `render`
18- new library `async.ts`
19- new library `Lru.ts`
20- new library `mime.ts`
21- new library `render.ts`, `render/jsx-runtime.ts`, `render/meta.ts`
22- new library `node.ts`
23- new library `subprocess.ts`, `subprocess/ffmpeg.ts`
1024- add `string.escapeRegExp()`
25- add `stack.capture()`
1126
1227## v1
1328
14- new library `assert`
15- new library `log`
16- new library `packaging`
17- new library `queue`
18- new library `string`
19- new library `ts`
29- new library `assert.ts`
30- new library `log.ts`
31- new library `packaging.ts`
32- new library `queue.ts`
33- new library `string.ts`
34- new library `ts.ts`
lib/readme.md+14-9
......@@ -12,23 +12,26 @@ projects and at work.
1212all top-level namespaces stand alone as their own projects, and generally do not
1313depend on each other. there are no external dependencies; code is easy to audit.
1414
15<!-- - `async` -->
16<!-- - `error` - interop with the `unknown` type in error handlers -->
1715<!-- - `fs` - rebuilt api for `node:fs` -->
18<!-- - `lru` - least recently used cache -->
19<!-- - `mime` - trimmed database of mime types -->
20<!-- - `subprocess` - rebuilt api for `node:child_process` -->
21<!-- - `testing` -->
2216
2317- `assert.ts`
18- `async.ts`
19- `error.ts` - interop with the `unknown` type in error handlers
2420- `log.ts` - logging, terminal i/o, interactive widgets
25- `log/ansi.ts` - cursor and color constants
2621- `log/headless.ts` - use `log` and `log/progress` without a tty
2722- `log/progress.ts` - progress bar, spinner, and tree
2823- `log/stack.ts` - stack trace parse and formatter
29- `packaging.ts` - workflows used for `lib`'s publications
24- `Lru.ts` - least recently used cache
25- `mime.ts` - trimmed database of ext -> mime types
26- `node.ts` - the Node.js javascript runtime
3027- `queue.ts` - priority queue with rescheduling
28- `render.ts` - html generation with extensions.
29- `render/jsx-runtime.ts` - integration with JSX syntax
30- `render/meta.ts` - html meta tag generation
3131- `string.ts`
32- `string/ansi.ts` - cursor and color constants
33- `subprocess.ts` - rebuilt api for `node:child_process`
34- `subprocess/ffmpeg.ts` - spawn and parse progress output
3235- `ts.ts` typescript types and general purpose code
3336
3437there is currently no hosted documentation site. please refer to the source
......@@ -52,7 +55,9 @@ library separately:
5255
5356```ts
5457import * as log from "@clo/lib/log.ts";
55import * as progress from "@clo/lib/log/progress.ts";
58// since the namespace cannot be a constructor, capitalized
59// filenames indicate the single export to use.
60import { Lru } from "@clo/lib/Lru.ts";
5661```
5762
5863these patterns developed initially when using a text editor without a language
lib/readme.npm.md+1-1
......@@ -1,6 +1,6 @@
11```sh
22# alias install "@paperclover/lib" as "@clo/lib"
3npm install lib@npm:@paperclover/lib
3npm install @clo/lib@npm:@paperclover/lib
44```
55
66the distribution on npm is transpiled with JSDoc annotations instead of
lib/render.ts created+466
......@@ -0,0 +1,466 @@
1/**
2 * Clover's rendering engine is the backbone of her website generator. It
3 * converts objects and components (functions returning {@linkcode Node}) into
4 * HTML. the engine is simple, self-contained, and easily extendable.
5 *
6 * extensions to the rendering engine can made via {@linkcode Context}. for
7 * example, 'sitegen' uses a context to track needed client scripts without
8 * introducing patches to {@linkcode State}.
9 *
10 * the easiest way to start is with the JSX adapter `./jsx-runtime.ts` and the
11 * synchronous rendering function {@linkcode sync|render.sync}:
12 *
13 * ```tsx
14 * import * as render from "@clo/lib/render.ts";
15 * console.info(render.sync(<h1>good morning</h1>));
16 * // jsx is equivilent to:
17 * console.info(render.sync(render.element("h1", { children: "good morning" })));
18 * ```
19 *
20 * NOTE: This module is not production ready, but it is in use at https://paperclover.net
21 *
22 * @module
23 */
24
25/* convert a UI description into a string synchronously.
26 * optional `context` argument pre-populates render context. */
27export function sync(node: Node, contexts?: ContextValue[]): Result {
28 const state = init(false, contexts ?? []);
29 const resolved = resolveNode(state, node);
30 return { text: stringifyNode(resolved), context: state.context };
31}
32
33/* convert a UI description into a string asynchronously.
34 * optional `context` argument pre-populates render context. */
35export function async(node: Node, contexts?: ContextValue[]): Promise<Result> {
36 const state = init(true, contexts ?? []);
37 const resolved = resolveNode(state, node);
38 const { context } = state;
39 if (!state.async) {
40 return Promise.resolve({ text: stringifyNode(resolved), context });
41 }
42 const { resolve, reject, promise } = Promise.withResolvers<Result>();
43 state.asyncDone = () => {
44 const { rejections } = state;
45 if (!rejections) return resolve({ text: stringifyNode(resolved), context });
46 if (rejections.length === 1) return reject(rejections[0]);
47 return reject(new AggregateError(rejections));
48 };
49 return promise;
50}
51
52interface Result {
53 /** HTML/XML/RSS Text*/
54 text: string;
55 /** For use with {@linkcode Context}. */
56 context: Map<Context, object>;
57}
58
59/**
60 * mechanism for extending the rendering engine by providing the component tree
61 * with a shared context.
62 *
63 * ```tsx
64 * const exampleContext = new render.Context(() => ({ count: 0 }));
65 *
66 * function Write({ add }) {
67 * const example: { count: number } = exampleContext.get()
68 * example.count += add;
69 * return null;
70 * }
71 * function Read() {
72 * const { count } = exampleContext.get()
73 * return `Value is: ${count}`;
74 * }
75 *
76 * render.sync([
77 * <Write add={12} />
78 * <Write add={30} />
79 * <Read />
80 * ]); // -> "Value is: 42"
81 * ```
82 */
83export class Context<T extends object = object> {
84 #default: (() => T) | null;
85
86 constructor(init?: () => T) {
87 this.#default = init ?? null;
88 }
89
90 /**
91 * Allow pre-populating a render's context
92 *
93 * ```tsx
94 * const state = exampleContext.init({ count: 5 });
95 * void render.sync(<Write add={5} />, [state]);
96 * state.count; // 10
97 * ```
98 */
99 init(value: T | void): ContextValue<T> {
100 value ??= UNWRAP(this.#default)();
101 (value as ContextValue<T>)[kContext] = this;
102 return value as ContextValue<T>;
103 }
104
105 /**
106 * retrieve the context value from
107 * - passing `undefined`, retrieves from current render
108 * - passing a `Result`, typesafe access
109 *
110 * due to the implementation using simple global state, this function will
111 * not work within `async` components.
112 */
113 get(result: Result): T | null;
114 get(): T;
115 get(result?: Result): T | null {
116 if (result) return (result.context.get(this) ?? null) as T | null;
117 const { context } = UNWRAP(current, "Not in an active render.");
118 let value = (context.get(this) ?? null) as T | null;
119 if (!value) {
120 const init = this.#default;
121 context.set(this, value = UNWRAP(init, "No init function")());
122 }
123 return value;
124 }
125}
126
127export type ContextValue<T extends object = object> = T & {
128 [kContext]: Context<T>;
129};
130
131/** the global state */
132export let current: State | null = null;
133
134/** set the global state */
135export function setCurrent(r: State | null) {
136 current = r ?? null;
137}
138
139/** inline HTML into a render without escaping it */
140export function raw(node: ResolvedNode): Raw {
141 return [kRaw, node];
142}
143
144/** constructs {@linkcode Element} */
145export function element(
146 type: Element[1],
147 attrs: Element[2] = {},
148 source?: SrcLoc,
149): Element {
150 return source ? [kElement, type, attrs, "", source] : [kElement, type, attrs];
151}
152
153/** constructs {@linkcode ResolvedElement} */
154export function resolvedElement(
155 type: ResolvedElement[1],
156 attrs: ResolvedElement[2],
157 children: ResolvedElement[3],
158): ResolvedElement {
159 return [kElement, type, attrs, children];
160}
161
162/** internal global state while rendering */
163export interface State {
164 /**
165 * set to '-1' if rendering synchronously
166 * number of async promises the render is waiting on.
167 */
168 async: number | -1;
169 asyncDone: null | (() => void);
170 /** when components reject, those are collected here */
171 rejections: unknown[] | null;
172 context: Map<Context, object>;
173}
174
175/** `Node` represents a webpage that can be "rendered' into HTML. */
176export type Node =
177 | number
178 | string // Escape HTML
179 | Node[] // Concat
180 | Element // Stringify
181 | Raw // Insert
182 | Promise<Node> // Await
183 // Ignore
184 | (undefined | null | boolean);
185/** `Element` represents a component call or HTML element. */
186export type Element = [
187 tag: typeof kElement,
188 type: string | Component,
189 attrs: Record<string, unknown>,
190 _?: "", // contents
191 source?: SrcLoc,
192];
193export type Raw = [tag: typeof kRaw, html: ResolvedNode];
194/**
195 * components must return a value; 'undefined' is prohibited here
196 * to avoid functions that are missing a return statement.
197 */
198export type Component = (attrs: Record<any, any>) => Exclude<Node, undefined>;
199/** emitted by JSX runtime */
200export interface SrcLoc {
201 fileName: string;
202 lineNumber: number;
203 columnNumber: number;
204}
205
206/**
207 * resolution narrows the type {@linkcode Node} into {@linkcode ResolvedNode}.
208 * Async trees are marked in {@linkcode State}. This operation performs
209 * everything besides the final string concatenation. This function is agnostic
210 * across {@linkcode async}/{@linkcode sync} modes.
211 */
212export function resolveNode(r: State, node: unknown): ResolvedNode {
213 if (!node && node !== 0) return ""; // falsy, non numeric
214 if (typeof node !== "object") {
215 if (node === true) return ""; // booleans are ignored
216 if (typeof node === "string") return escapeHtmlContent(node);
217 if (typeof node === "number") return String(node); // no escaping ever
218 if (typeof node === "symbol" && node.toString() === kElement.toString()) {
219 throw new Error(`There are two instances of Clover SSR loaded!`);
220 }
221 throw new Error(`Cannot render ${inspect(node)} to HTML`);
222 }
223 if (node instanceof Promise) {
224 if (r.async === -1) {
225 throw new Error(`Asynchronous rendering is not supported here.`);
226 }
227 const placeholder: InsertionPoint = [null];
228 r.async += 1;
229 node
230 .then((result) => void (placeholder[0] = resolveNode(r, result)))
231 // Intentionally catching errors in `resolveNode`
232 .catch((e) => (r.rejections ??= []).push(e))
233 .finally(() => {
234 if (--r.async == 0) {
235 if (r.asyncDone == null) throw new Error("r.asyncDone == null");
236 r.asyncDone();
237 r.asyncDone = null;
238 }
239 });
240 // This lie is checked with an assertion in `renderNode`
241 return placeholder as [ResolvedNode];
242 }
243 if (!Array.isArray(node)) {
244 throw new Error(`Invalid node type: ${inspect(node)}`);
245 }
246 const type = node[0];
247 if (type === kElement) {
248 const { 1: tag, 2: attrs } = node;
249 if (typeof tag === "function") {
250 current = r;
251 try {
252 return resolveNode(r, tag(attrs));
253 } catch (e) {
254 const { 4: src } = node;
255 if (e && typeof e === "object") (e as { src?: string }).src ??= src;
256 throw e;
257 } finally {
258 current = null;
259 }
260 }
261 if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
262 const children = attrs?.children;
263 if (children) return [kElement, tag, attrs, resolveNode(r, children)];
264 return node;
265 }
266 if (type === kRaw) return node[1];
267 return node.map((elem) => resolveNode(r, elem));
268}
269
270export type ResolvedNode =
271 | ResolvedNode[] // Concat
272 | ResolvedElement // Render
273 | string; // Raw HTML
274export type ResolvedElement = [
275 tag: typeof kElement,
276 type: string,
277 attrs: Record<string, unknown>,
278 children: ResolvedNode,
279];
280/**
281 * async rendering is done by creating an array of one item, which is already a
282 * valid {@linkcode Node}, but the element is written once the data is
283 * available. {@linkcode State} contains a count of how many async jobs are
284 * left.
285 */
286export type InsertionPoint = [null | ResolvedNode];
287
288/**
289 * convert {@linkcode ResolvedNode} into HTML text. This operation happens
290 * after all async work is settled. markup is emitted as concisely as possible.
291 */
292export function stringifyNode(node: ResolvedNode): string {
293 ASSERT(node != null, "Unresolved Render Node");
294 if (typeof node === "string") return node;
295 const type = node[0];
296 if (type === kElement) {
297 return stringifyElement(node as ResolvedElement);
298 }
299 node = node as ResolvedNode[]; // TS cannot infer.
300 let out = type ? stringifyNode(type) : "";
301 const len = node.length;
302 for (let i = 1; i < len; i++) {
303 const elem = node[i];
304 if (elem) out += stringifyNode(elem);
305 }
306 return out;
307}
308
309function stringifyElement(element: ResolvedElement) {
310 const { 1: tag, 2: attrs, 3: children } = element;
311 let out = "<" + tag;
312 for (const prop in attrs) {
313 const value = attrs[prop];
314 if (!value || typeof value === "function") continue;
315 let attr;
316 switch (prop) {
317 default:
318 attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`;
319 break;
320 case "className": // Legacy React Compat
321 case "class": {
322 const str = clsx(value as ClassName);
323 if (!str) continue;
324 attr = `class=${quoteIfNeeded(escapeAttribute(str))}`;
325 break;
326 }
327 case "style": {
328 attr = typeof value === "string"
329 ? `style=${quoteIfNeeded(escapeAttribute(value))}`
330 : stringifyStyleAttribute(value as Record<string, string>);
331 break;
332 }
333 case "htmlFor":
334 throw new Error("Do not use the `htmlFor` attribute. Use `for`");
335 case "dangerouslySetInnerHTML":
336 throw new Error(
337 "Do not use the `dangerouslySetInnerHTML` attribute. Use `render.raw()`",
338 );
339 // Do not process these
340 case "children":
341 case "ref":
342 case "key":
343 continue;
344 }
345 if (!out.endsWith('"')) out += " ";
346 out += attr;
347 }
348 out = out.trimEnd() + ">";
349 if (children) out += stringifyNode(children);
350 if (
351 tag !== "br" &&
352 tag !== "img" &&
353 tag !== "input" &&
354 tag !== "meta" &&
355 tag !== "link" &&
356 tag !== "hr"
357 ) {
358 out += `</${tag}>`;
359 }
360 return out;
361}
362
363function stringifyStyleAttribute(style: Record<string, string>): string {
364 let out = ``;
365 for (const styleName in style) {
366 if (out) out += ";";
367 out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
368 escapeAttribute(
369 String(style[styleName]),
370 )
371 }`;
372 }
373 return "style=" + quoteIfNeeded(out);
374}
375
376export function quoteIfNeeded(text: string): string {
377 if (text.match(/["/> ]/)) return '"' + text + '"';
378 return text;
379}
380
381// -- utility functions --
382
383/** Construct {@link State} */
384export function init(
385 allowAsync: boolean,
386 context: ContextValue[] | Map<Context, object>,
387): State {
388 return {
389 async: allowAsync ? 0 : -1,
390 rejections: null,
391 asyncDone: null,
392 context: Array.isArray(context)
393 ? new Map(context.map((value) => [value[kContext], value]))
394 : context,
395 };
396}
397
398export type ClassName = string | Record<string, boolean | null> | ClassName[];
399export function clsx(mix: ClassName): string {
400 let k, y, str = "";
401 if (typeof mix === "string") {
402 return mix;
403 } else if (typeof mix === "object") {
404 if (Array.isArray(mix)) {
405 for (k = 0; k < mix.length; k++) {
406 if (mix[k] && (y = clsx(mix[k]))) {
407 str && (str += " ");
408 str += y;
409 }
410 }
411 } else {
412 for (k in mix) {
413 if (mix[k]) {
414 str && (str += " ");
415 str += k;
416 }
417 }
418 }
419 }
420 return str;
421}
422
423// TODO: combine into one function which decides if an attribute needs quotes
424// and escapes it correctly depending on the context.
425
426/** @deprecated */
427export const escapeHtmlContent = (unsafeText: string): string =>
428 String(unsafeText)
429 .replace(/&/g, "&amp;")
430 .replace(/</g, "&lt;")
431 .replace(/>/g, "&gt;")
432 .replace(/"/g, "&quot;");
433/** @deprecated */
434const escapeAttribute = (unsafeText: string): string =>
435 String(unsafeText)
436 .replace(/&/g, "&amp;")
437 .replace(/</g, "&lt;")
438 .replace(/>/g, "&gt;")
439 .replace(/"/g, "&quot;");
440/** @deprecated */
441export const escapeHtml = (unsafeText: string): string =>
442 String(unsafeText)
443 .replace(/&/g, "&amp;")
444 .replace(/</g, "&lt;")
445 .replace(/>/g, "&gt;")
446 .replace(/"/g, "&quot;")
447 .replace(/'/g, "&#x27;")
448 .replace(/`/g, "&#x60;");
449
450/** @deprecated */
451export function inspect(object: unknown): string {
452 try {
453 return require("node:util").inspect(object);
454 } catch {
455 return typeof object;
456 }
457}
458
459/** descriminator for {@linkcode Element} */
460const kElement = Symbol("Element");
461/** descriminator for {@linkcode Raw} */
462const kRaw = Symbol("Raw");
463/** secret key to facilitate {@linkcode init} with a `ContextValue[]` */
464const kContext: unique symbol = Symbol();
465
466import { ASSERT, UNWRAP } from "lib/assert.ts";
lib/render/jsx-runtime.ts created+79
......@@ -0,0 +1,79 @@
1/** @module
2 * provides JSX integration with `lib/render.ts`.
3 *
4 * because the jsr does not allow default global augmentations, type
5 * installation must be done manually by pasting this in your project:
6 *
7 * declare global {
8 * namespace JSX {
9 * export type IntrinsicElements = jsx.IntrinsicElements;
10 * export type ElementChildrenAttribute = jsx.ElementChildrenAttribute;
11 * export type Element = jsx.Element;
12 * export type ElementType = jsx.ElementType;
13 * export type ElementClass = jsx.ElementClass;
14 * }
15 * }
16 * import * as jsx from "@clo/lib/render/jsx-runtime.ts";
17 */
18
19/** this element returns just its contents, and is used by `<>` fragment syntax. */
20export function Fragment(
21 { children }: { children: render.Node[] },
22): render.Node[] {
23 return children;
24}
25
26/** construct a JSX element in production mode. */
27export function jsx(
28 type: string | render.Component,
29 attrs: Record<string, unknown>,
30): render.Element {
31 if (typeof type !== "function" && typeof type !== "string") {
32 throw new Error("Invalid component type: " + render.inspect(type));
33 }
34 return render.element(type, attrs);
35}
36
37/** construct a JSX element in development mode. */
38export function jsxDEV(
39 type: string | render.Component,
40 attrs: Record<string, unknown>,
41 // Unused with the clover engine
42 _key: string,
43 // Unused with the clover engine
44 _isStaticChildren: boolean,
45 source: render.SrcLoc,
46): render.Element {
47 ASSERT(
48 source != null,
49 `Build tool does not emit correct JSX debugging information.`,
50 );
51 const { fileName, lineNumber, columnNumber } = source;
52
53 // Assert the component type is valid to render.
54 if (typeof type !== "function" && typeof type !== "string") {
55 throw new Error(
56 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
57 render.inspect(type) +
58 ". Clover SSR element must be a function or string.",
59 );
60 }
61
62 return render.element(type, attrs, source);
63}
64
65// Clover SSR engine does not yet distinguish between static and non-static JSX.
66export { jsx as jsxs };
67
68export interface IntrinsicElements {
69 [name: string]: Record<string, unknown>;
70}
71export interface ElementChildrenAttribute {
72 children: render.Node;
73}
74export type Element = render.Element;
75export type ElementType = keyof IntrinsicElements | render.Component;
76export type ElementClass = ReturnType<render.Component>;
77
78import * as render from "lib/render.ts";
79import { ASSERT } from "lib/assert.ts";
lib/render/meta.test.tsx created+49
......@@ -0,0 +1,49 @@
1// every metadata render requires a base URL
2const base: meta.Template = {
3 base: new URL("https://paperclover.net"),
4};
5
6test("default", () =>
7 assert.equal(
8 meta.render({ title: "my website <3" }, base),
9 "<title>my website &lt;3</title>" +
10 '<meta name=viewport content="width=device-width, initial-scale=1.0">',
11 ));
12test("some other meta tags", () =>
13 assert.equal(
14 meta.render(
15 {
16 title: "hello",
17 description: "my website is a place to be",
18 canonical: "/",
19 },
20 base,
21 ),
22 "<title>hello</title>" +
23 '<meta name=description content="my website is a place to be">' +
24 '<meta name=viewport content="width=device-width, initial-scale=1.0">' +
25 '<link rel=canonical href="https://paperclover.net/">',
26 ));
27test("automatic embed with '{}'", () =>
28 assert.equal(
29 meta.render(
30 {
31 title: "main title",
32 description: "main description",
33 embed: {},
34 },
35 base,
36 ),
37 "<title>main title</title>" +
38 '<meta name=description content="main description">' +
39 '<meta name=viewport content="width=device-width, initial-scale=1.0">' +
40 "<meta name=og:type content=website>" +
41 '<meta name=og:title content="main title">' +
42 '<meta name=og:description content="main description">' +
43 "<meta name=twitter:card content=summary>" +
44 "",
45 ));
46
47import { test } from "node:test";
48import assert from "node:assert";
49import * as meta from "lib/render/meta.ts";
lib/render/meta.ts created+255
......@@ -0,0 +1,255 @@
1export { renderMetadata as render };
2export interface Meta {
3 /** Required for all pages. `<title>{content}</title>` */
4 title: string;
5 /** Recommended for all pages. `<meta name="description" content="{...}" />` */
6 description?: string | null;
7 /** Automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;
9 /** Add `<link rel="alternate" ... />`. Object keys are interpretted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string>;
12
13 /** Automatically generate both OpenGraph and Twitter meta tags */
14 embed?: AutoEmbed | null;
15 /** Add a robots tag for `noindex` and `nofollow` */
16 denyRobots?: boolean | null;
17 /** Add 'og:*' meta tags */
18 openGraph?: OpenGraph | null;
19 /** Add 'twitter:*' meta tags */
20 twitter?: Twitter | null;
21 /** Refer to an oEmbed file. See https://oembed.com */
22 oEmbed?: string;
23 /**
24 * '#sitegen/meta' intentionally excludes a lot of exotic tags.
25 * Add these manually using JSX syntax:
26 *
27 * extra: [
28 * <meta name="site-verification" content="waffles" />,
29 * ],
30 */
31 extra?: render.Node;
32
33 /** Adds `<meta name="author" content="{...}" />` */
34 authors?: string[];
35 /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
36 generator?: string | true | null;
37 /** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
38 keywords?: string[];
39 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
40 manifest?: string | null;
41 /** Adds `<meta name="publisher" content="{...}" />` */
42 publisher?: string | null;
43 /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
44 referrer?: Referrer | null;
45 /** Adds `<meta name="theme-color" content="{...}" />` */
46 themeColor?: string | { dark: string; light: string } | null;
47 /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
48 viewport?: string;
49}
50export type Alternate = { type: string; url: string } | {
51 lang: string;
52 url: string;
53};
54export interface AutoEmbed {
55 /* Defaults to the page title. */
56 title?: string | null;
57 /* Defaults to the page description. */
58 description?: string | null;
59 /* Provide to add an embed image. */
60 thumbnail?: string | null;
61 /** @default "banner", which applies twitter:card = "summary_large_image" */
62 thumbnailSize?: "banner" | "icon";
63 /* Ignored if not passed */
64 siteTitle?: string | null;
65}
66/** See https://ogp.me for extra rules. */
67export interface OpenGraph {
68 /** The title of your object as it should appear within the graph */
69 title?: string;
70 /** A one to two sentence description of your object. */
71 description?: string | null;
72 /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
73 type?: string;
74 /** An image URL which should represent your object within the graph */
75 image?: OpenGraphField;
76 /** The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */
77 url?: string;
78 /** A URL to an audio file to accompany this object */
79 audio?: OpenGraphField;
80 /** The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */
81 determiner?: string;
82 /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
83 locale?: string;
84 /** An array of other locales this page is available in */
85 "locale:alternate"?: string[];
86 /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
87 site_name?: string;
88 /** A URL to a video file that complements this object */
89 video?: OpenGraphField;
90 [field: string]: OpenGraphField;
91}
92/**
93 * When passing an array, the property is duplicated.
94 * When passing an object, the fields are emitted as namespaced with ':'.
95 */
96type OpenGraphField =
97 | string
98 | { [field: string]: OpenGraphField }
99 | Array<OpenGraphField>
100 | (null | undefined);
101/** Twitter uses various OpenGraph fields if these are not specified. */
102export interface Twitter {
103 card: string;
104 title?: string;
105 description?: string | null;
106 url?: string;
107 image?: string;
108 player?: string;
109 /** Same logic as Open Graph */
110 [field: string]: OpenGraphField;
111}
112export interface Template
113 extends Omit<Meta, "title" | "description" | "canonical"> {
114 base: URL;
115 titleTemplate?: (title: string) => string;
116}
117export interface Alternates {
118 canonical: string;
119 types: { [mime: string]: AlternateType };
120}
121export interface AlternateType {
122 url: string;
123 title: string;
124}
125export type Referrer =
126 | "no-referrer"
127 | "origin"
128 | "no-referrer-when-downgrade"
129 | "origin-when-cross-origin"
130 | "same-origin"
131 | "strict-origin"
132 | "strict-origin-when-cross-origin";
133
134/* Convert a metadata definition into text. */
135function renderMetadata(meta: Meta, template: Template): string {
136 const { titleTemplate, base } = template;
137 const resolve = (str: string) => new URL(str, base).href;
138
139 const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
140 const description = meta.description ?? null;
141 const canonical = meta.canonical ? resolve(meta.canonical) : null;
142 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
143 const authors = meta.authors ?? template.authors ?? null;
144 let generator = meta.generator ?? template.generator ?? null;
145 const keywords = meta.keywords ?? template.keywords ?? null;
146 const manifest = meta.manifest ?? template.manifest ?? null;
147 const publisher = meta.publisher ?? template.publisher ?? null;
148 const referrer = meta.referrer ?? template.referrer ?? null;
149 const themeColor = meta.themeColor ?? template.themeColor ?? null;
150 const viewport = meta.viewport ?? template.viewport ??
151 "width=device-width, initial-scale=1.0";
152
153 const embed = meta.embed ?? template.embed ?? null;
154 let openGraph = meta.openGraph ?? template.openGraph ?? null;
155 let twitter = meta.twitter ?? template.twitter ?? null;
156 if (embed) {
157 const { thumbnail, thumbnailSize, siteTitle } = embed;
158 openGraph = {
159 type: "website",
160 title: embed.title ?? title,
161 description: embed.description ?? description,
162 ...openGraph,
163 };
164 twitter = {
165 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
166 ? "summary_large_image"
167 : "summary",
168 ...twitter,
169 };
170 if (thumbnail) {
171 openGraph.image = embed.thumbnail;
172 }
173 if (siteTitle) {
174 openGraph.site_name = siteTitle;
175 }
176 if (canonical) {
177 openGraph.url = canonical;
178 }
179 }
180
181 let out = `<title>${esc(title)}</title>`;
182 if (description) {
183 out += `<meta name=description content=${attr(description)}>`;
184 }
185 for (const author of authors ?? []) {
186 out += `<meta name=author content=${attr(author)}>`;
187 }
188 if (keywords) {
189 out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
190 }
191 if (generator) {
192 if (generator === true) {
193 generator = "clover sitegen 3; paperclover.net/sitegen";
194 }
195 out += `<meta name=generator content=${attr(generator)}>`;
196 }
197 if (publisher) {
198 out += `<meta name=publisher content=${attr(publisher)}>`;
199 }
200 if (referrer) {
201 out += `<meta name=referrer content=${attr(referrer)}>`;
202 }
203 if (themeColor) {
204 if (typeof themeColor === "string") {
205 out += `<meta name=theme-color content=${attr(themeColor)}>`;
206 } else {
207 out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
208 `content=${attr(themeColor.light)}>`;
209 out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
210 `content=${attr(themeColor.dark)}>`;
211 }
212 }
213 if (denyRobots) {
214 out += `<meta name=robots content=noindex,nofollow>`;
215 }
216 if (viewport) {
217 out += `<meta name=viewport content=${attr(viewport)}>`;
218 }
219 if (canonical) {
220 out += `<link rel=canonical href=${attr(canonical)}>`;
221 }
222 if (manifest) {
223 out += `<link rel=manifest href=${attr(manifest)}>`;
224 }
225
226 if (openGraph) out += renderOpenGraph("og", openGraph);
227 if (twitter) out += renderOpenGraph("twitter", twitter);
228
229 if (meta.extra) out += render.sync(meta.extra);
230 if (template.extra) out += render.sync(template.extra);
231
232 return out;
233}
234
235function renderOpenGraph(prefix: string, value: OpenGraphField): string {
236 if (!value) return "";
237 if (typeof value === "string") {
238 return `<meta name=${attr(prefix)} content=${attr(value)}>`;
239 }
240 if (Array.isArray(value)) {
241 return value
242 .map((item) => renderOpenGraph(prefix, item))
243 .join("");
244 }
245 return Object.entries(value)
246 .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
247 .join("");
248}
249
250function attr(value: string) {
251 return render.quoteIfNeeded(esc(value));
252}
253
254import { escapeHtmlContent as esc } from "lib/render.ts";
255import * as render from "lib/render.ts";
lib/string.test.ts created+35
......@@ -0,0 +1,35 @@
1test("countNewlines", () => {
2 assert.equal(string.countNewlines("line 1\nline 2\nline 3"), 2);
3 assert.equal(string.countNewlines(""), 0);
4});
5test("escapeRegExp", () => {
6 assert.equal(
7 string.escapeRegExp("delete * from users where id = $id"),
8 "delete \\* from users where id = \\$id",
9 );
10});
11test("escapeShellArgument", () => {
12 // escapeShellArgument leaves it alone when the input is trivial
13 assert.equal(string.escapeShellArgument("test"), "test");
14 assert.equal(string.escapeShellArgument("abc/file.@.txt"), "abc/file.@.txt");
15
16 // it adds quotes when there are spaces and globs
17 assert.equal(
18 string.escapeShellArgument("hello world"),
19 "'hello world'",
20 );
21
22 // handles other cases
23 assert.equal(
24 string.escapeShellArgument(
25 'delete * from "users" where "username" = \'clover\'',
26 ),
27 "'delete * from \"users\" where \"username\" = '\\''clover'\\'",
28 );
29 assert.equal(string.escapeShellArgument("'string"), '"\'string"');
30 assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'");
31});
32
33import { test } from "node:test";
34import assert from "node:assert";
35import * as string from "lib/string.ts";
lib/string.ts+16-153
......@@ -1,8 +1,3 @@
1/** Escape a value for use in a regular expression. */
2export function escapeRegExp(unsafeText: string) {
3 return unsafeText.replace(/[\$\\]/g, "\\$&");
4}
5
61// TODO: this function is not benchmarked
72export function countNewlines(str: string): number {
83 let count = 0;
......@@ -12,160 +7,28 @@ export function countNewlines(str: string): number {
127 return count;
138}
149
15export function widthInTerminal(str: string): number {
16 let width = 0;
17 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");
18 for (let i = 0, { length } = str; i < length; i += 1) {
19 const code = str.charCodeAt(i);
20 if (code < 32) continue;
21 if (code < 127) {
22 width += 1;
23 continue;
24 }
25 for (const { segment } of segmenter.segment(str.slice(i))) {
26 if (segment.length === 1) {
27 if (code < 32) continue;
28 if (code < 127) {
29 width += 1;
30 continue;
31 }
32 }
33 width += segmentWidth(segment);
34 }
35 return width;
36 }
37 return width;
38}
39
40export function trimToWidth(str: string, columns: number): string {
41 let width = 0;
42 let ansi = false;
43 for (let i = 0, { length } = str; i < length; i += 1) {
44 const code = str.charCodeAt(i);
45 if (code < 32) {
46 if (code === 0x1b) {
47 const esc = str.slice().match(ansiEscapes);
48 if (esc) i += esc[0].length - 1, ansi = true;
49 }
50 continue;
51 }
52 if (code < 127) {
53 width += 1;
54 if (width >= columns) {
55 const result = str.slice(0, i + 1);
56 return ansi ? result + "\x1b[0m" : result;
57 }
58 continue;
59 }
60 // non-ascii path
61 let out = str.slice(0, i);
62 str = str.slice(i);
63 const segments = segmenter.segment(str);
64 {
65 const { segment } = UNWRAP(segments.containing(0));
66 width += segmentWidth(segment);
67 if (width > columns) return ansi ? out + "\x1b[0m" : out;
68 out += segment;
69 i = segment.length;
70 }
71 for ({ length } = str; i < length; i += 1) {
72 const code = str.charCodeAt(i);
73 if (code === 0x1b) {
74 const esc = str.slice(i).match(ansiEscapes);
75 if (esc) {
76 i += esc[0].length - 1, ansi = true;
77 out += esc[0];
78 continue;
79 }
80 }
81 const { segment } = UNWRAP(segments.containing(i));
82 width += segmentWidth(segment);
83 if (width > columns) return ansi ? out + "\x1b[0m" : out;
84 out += segment;
85 }
86 return out;
87 }
88 return str;
10/** Escape a string for use in a regular expression. */
11export function escapeRegExp(unsafeText: string): string {
12 // TODO: in May 2025, RegExp.escape exists
13 return unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8914}
9015
91// derived from sindresorhus's wonderful `string-width`
92// https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js
93// https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js
94// MIT license
95const segmenter = /* @__PURE__ */ new Intl.Segmenter();
96const ansiEscapes =
97 /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
98const zeroWidthClusterRegex =
99 /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
100const leadingNonPrintingRegex =
101 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
102const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
103function segmentWidth(segment: string) {
104 if (zeroWidthClusterRegex.test(segment)) return 0;
105 if (rgiEmojiRegex.test(segment)) return 2;
106
107 // deno-fmt-ignore
108 const list = [
109 0x3000, 0x231A, 0x231B, 0x2329, 0x232A, 0x23F0, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615,
110 0x267F, 0x2693, 0x26A1, 0x26AA, 0x26AB, 0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26D4,
111 0x26EA, 0x26F2, 0x26F3, 0x26F5, 0x26FA, 0x26FD, 0x2705, 0x270A, 0x270B, 0x2728, 0x274C,
112 0x274E, 0x2757, 0x27B0, 0x27BF, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55, 0x1AFFD, 0x1AFFE, 0x1B132,
113 0x1B155, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F250, 0x1F251, 0x1F3F4, 0x1F440, 0x1F57A, 0x1F595,
114 0x1F596, 0x1F5A4, 0x1F6CC, 0x1F6EB, 0x1F6EC, 0x1F7F0, 0x1FAC8,
115 ];
116 const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0;
117 return 1 + +(
118 // full
119 x >= 0xFF01 && x <= 0xFF60 || x >= 0xFFE0 && x <= 0xFFE6 ||
120 // wide
121 x >= 0x1100 && x <= 0x115F || x >= 0x23E9 && x <= 0x23EC ||
122 x >= 0x2630 && x <= 0x2637 || x >= 0x2648 && x <= 0x2653 ||
123 x >= 0x268A && x <= 0x268F || x >= 0x2753 && x <= 0x2755 ||
124 x >= 0x2795 && x <= 0x2797 || x >= 0x2E80 && x <= 0x2E99 ||
125 x >= 0x2E9B && x <= 0x2EF3 || x >= 0x2F00 && x <= 0x2FD5 ||
126 x >= 0x2FF0 && x <= 0x2FFF || x >= 0x3001 && x <= 0x303E ||
127 x >= 0x3041 && x <= 0x3096 || x >= 0x3099 && x <= 0x30FF ||
128 x >= 0x3105 && x <= 0x312F || x >= 0x3131 && x <= 0x318E ||
129 x >= 0x3190 && x <= 0x31E5 || x >= 0x31EF && x <= 0x321E ||
130 x >= 0x3220 && x <= 0x3247 || x >= 0x3250 && x <= 0xA48C ||
131 x >= 0xA490 && x <= 0xA4C6 || x >= 0xA960 && x <= 0xA97C ||
132 x >= 0xAC00 && x <= 0xD7A3 || x >= 0xF900 && x <= 0xFAFF ||
133 x >= 0xFE10 && x <= 0xFE19 || x >= 0xFE30 && x <= 0xFE52 ||
134 x >= 0xFE54 && x <= 0xFE66 || x >= 0xFE68 && x <= 0xFE6B ||
135 x >= 0x16FE0 && x <= 0x16FE4 || x >= 0x16FF0 && x <= 0x16FF6 ||
136 x >= 0x17000 && x <= 0x18CD5 || x >= 0x18CFF && x <= 0x18D1E ||
137 x >= 0x18D80 && x <= 0x18DF2 || x >= 0x1AFF0 && x <= 0x1AFF3 ||
138 x >= 0x1AFF5 && x <= 0x1AFFB || x >= 0x1B150 && x <= 0x1B152 ||
139 x >= 0x1B164 && x <= 0x1B167 || x >= 0x1B170 && x <= 0x1B2FB ||
140 x >= 0x1D300 && x <= 0x1D356 || x >= 0x1D360 && x <= 0x1D376 ||
141 x >= 0x1F191 && x <= 0x1F19A || x >= 0x1F200 && x <= 0x1F202 ||
142 x >= 0x1F210 && x <= 0x1F23B || x >= 0x1F240 && x <= 0x1F248 ||
143 x >= 0x1F260 && x <= 0x1F265 || x >= 0x1F300 && x <= 0x1F320 ||
144 x >= 0x1F32D && x <= 0x1F335 || x >= 0x1F337 && x <= 0x1F37C ||
145 x >= 0x1F37E && x <= 0x1F393 || x >= 0x1F3A0 && x <= 0x1F3CA ||
146 x >= 0x1F3CF && x <= 0x1F3D3 || x >= 0x1F3E0 && x <= 0x1F3F0 ||
147 x >= 0x1F3F8 && x <= 0x1F43E || x >= 0x1F442 && x <= 0x1F4FC ||
148 x >= 0x1F4FF && x <= 0x1F53D || x >= 0x1F54B && x <= 0x1F54E ||
149 x >= 0x1F550 && x <= 0x1F567 || x >= 0x1F5FB && x <= 0x1F64F ||
150 x >= 0x1F680 && x <= 0x1F6C5 || x >= 0x1F6D0 && x <= 0x1F6D2 ||
151 x >= 0x1F6D5 && x <= 0x1F6D8 || x >= 0x1F6DC && x <= 0x1F6DF ||
152 x >= 0x1F6F4 && x <= 0x1F6FC || x >= 0x1F7E0 && x <= 0x1F7EB ||
153 x >= 0x1F90C && x <= 0x1F93A || x >= 0x1F93C && x <= 0x1F945 ||
154 x >= 0x1F947 && x <= 0x1F9FF || x >= 0x1FA70 && x <= 0x1FA7C ||
155 x >= 0x1FA80 && x <= 0x1FA8A || x >= 0x1FA8E && x <= 0x1FAC6 ||
156 x >= 0x1FACD && x <= 0x1FADC || x >= 0x1FADF && x <= 0x1FAEA ||
157 x >= 0x1FAEF && x <= 0x1FAF8 || x >= 0x20000 && x <= 0x2FFFD ||
158 x >= 0x30000 && x <= 0x3FFFD || x >= 0x1B000 && x <= 0x1B122 ||
159 list.includes(x)
160 );
16/** Escape a string for use in a regular expression. */
17export function escapeRegExpReplacement(unsafeText: string): string {
18 return unsafeText.replace(/\$/g, "$$$$");
16119}
16220
21/**
22 * Escape a string for use in a POSIX shell. Prefers easier to read output vs
23 * simpler implementation.
24 */
16325export function escapeShellArgument(s: string): string {
16426 if (s.length === 0) return "''";
165 if (/^[a-zA-Z0-9_\-.\/@,+=%:]+$/.test(s) && s[0] !== ":") return s;
27 if (/^[a-zA-Z0-9_\-./@,+=%:]+$/.test(s) && s[0] !== ":") return s;
16628 if (!s.includes("'")) return `'${s}'`;
16729 if (!/["$`\\]/.test(s)) return `"${s}"`;
168 return `'${s.replace(/'/g, "'\\''")}'`;
30 let complex = `'${s.replace(/'/g, "'\\''")}'`;
31 if (s.endsWith("'")) complex = complex.slice(0, -2);
32 if (s.startsWith("'")) complex = complex.slice(2);
33 return complex;
16934}
170
171import { UNWRAP } from "lib/assert.ts";
lib/string/ansi.test.ts created+24
......@@ -0,0 +1,24 @@
1test("widthInTerminal", () => {
2 assert.equal(
3 ansi.widthInTerminal("snowmobile"),
4 10,
5 );
6 assert.equal(
7 ansi.widthInTerminal("🏳️‍⚧️ " + ansi.style(ansi.bold, "rights")),
8 9,
9 );
10});
11test("trimToWidth", () => {
12 assert.equal(
13 ansi.trimToWidth("snowmobile", 6),
14 "snowmo",
15 );
16 assert.equal(
17 ansi.trimToWidth("🏳️‍⚧️ " + ansi.style(ansi.bold, "rights"), 5),
18 "🏳️‍⚧️ " + ansi.bold + "ri" + ansi.reset,
19 );
20});
21
22import { test } from "node:test";
23import assert from "node:assert";
24import * as ansi from "lib/string/ansi.ts";
lib/string/ansi.ts created+382
......@@ -0,0 +1,382 @@
1/**
2 * ANSI escape sequences can be used to style text in a tty or perform other
3 * interactions. This file contains many constants for escape codes, as well as
4 * functions for ones that take an argument, such as `cursorUp`.
5 *
6 * Note that this file only produces escape sequences, and does not yet feature
7 * detection or fallback code.
8 *
9 * @module
10 */
11
12/** resets all ansi styles */
13export const reset: string = `\x1b[0m`;
14
15/**
16 * puts the text in bold. can be combined with `dim` and colors.
17 * reset with `resetWeight`.
18 */
19export const bold: string = `\x1b[1m`;
20/**
21 * puts the text a more faded color, dimming it. can be combined with `bold` to
22 * produce bold-dim text. dim can be combined with any color to make a dimmed
23 * version of that color. reset with `resetWeight`.
24 */
25export const dim: string = `\x1b[2m`;
26/** resets `bold` and `dim` */
27export const resetWeight: string = `\x1b[22m`;
28
29/** resets foreground color */
30export const fgReset: string = `\x1b[39m`;
31/** sets the foreground color to black. on light mode terminals, this color may
32 * be visible. */
33export const fgBlack: string = `\x1b[30m`;
34/** sets the foreground color to red */
35export const fgRed: string = `\x1b[31m`;
36/** sets the foreground color to green */
37export const fgGreen: string = `\x1b[32m`;
38/** sets the foreground color to yellow */
39export const fgYellow: string = `\x1b[33m`;
40/** sets the foreground color to blue */
41export const fgBlue: string = `\x1b[34m`;
42/** sets the foreground color to magenta */
43export const fgMagenta: string = `\x1b[35m`;
44/** sets the foreground color to cyan */
45export const fgCyan: string = `\x1b[36m`;
46/**
47 * sets the foreground color to (dark) white / grey / gray. this color is
48 * brighter than `fgBrightBlack`. consider `dim` for greyed out text and
49 * `fgReset` to set the default foreground color.
50 */
51export const fgWhite: string = `\x1b[37m`;
52/**
53 * sets the foreground color to bright black / grey / gray. consider `dim` for
54 * greyed out text.
55 */
56export const fgBrightBlack: string = `\x1b[90m`;
57/** sets the foreground color to bright red */
58export const fgBrightRed: string = `\x1b[91m`;
59/** sets the foreground color to bright green */
60export const fgBrightGreen: string = `\x1b[92m`;
61/** sets the foreground color to bright yellow */
62export const fgBrightYellow: string = `\x1b[93m`;
63/** sets the foreground color to bright blue */
64export const fgBrightBlue: string = `\x1b[94m`;
65/** sets the foreground color to bright magenta */
66export const fgBrightMagenta: string = `\x1b[95m`;
67/** sets the foreground color to bright cyan */
68export const fgBrightCyan: string = `\x1b[96m`;
69/**
70 * sets the foreground color to bright white. consider `dim` for greyed out
71 * text and `fgReset` to set the default foreground color.
72 */
73export const fgBrightWhite: string = `\x1b[97m`;
74/** sets the foreground color to a color in the 256 color palette. */
75export function fg256(n: number): string {
76 return `\x1b[38;5;${n}m`;
77}
78/** sets the foreground color to an rgb color. */
79export function fgRgb(r: number, g: number, b: number): string {
80 return `\x1b[38;2;${r};${g};${b}m`;
81}
82
83/** resets the cell background color. */
84export const bgReset: string = `\x1b[49m`;
85/** sets the cell background to black. consider `bgReset` to clear the background. */
86export const bgBlack: string = `\x1b[40m`;
87/** sets the cell background to red. */
88export const bgRed: string = `\x1b[41m`;
89/** sets the cell background to green. */
90export const bgGreen: string = `\x1b[42m`;
91/** sets the cell background to yellow. */
92export const bgYellow: string = `\x1b[43m`;
93/** sets the cell background to blue. */
94export const bgBlue: string = `\x1b[44m`;
95/** sets the cell background to magenta. */
96export const bgMagenta: string = `\x1b[45m`;
97/** sets the cell background to cyan. */
98export const bgCyan: string = `\x1b[46m`;
99/**
100 * sets the cell background to (dark) white / grey / gray. this color is
101 * brighter than `bgBrightBlack`.
102 */
103export const bgWhite: string = `\x1b[47m`;
104/**
105 * sets the cell background to bright black. consider `bgReset` to clear the
106 * background.
107 */
108export const bgBrightBlack: string = `\x1b[100m`;
109/** sets the cell background to bright red. */
110export const bgBrightRed: string = `\x1b[101m`;
111/** sets the cell background to bright green. */
112export const bgBrightGreen: string = `\x1b[102m`;
113/** sets the cell background to bright yellow. */
114export const bgBrightYellow: string = `\x1b[103m`;
115/** sets the cell background to bright blue. */
116export const bgBrightBlue: string = `\x1b[104m`;
117/** sets the cell background to bright magenta. */
118export const bgBrightMagenta: string = `\x1b[105m`;
119/** sets the cell background to bright cyan. */
120export const bgBrightCyan: string = `\x1b[106m`;
121/** sets the cell background to bright white. */
122export const bgBrightWhite: string = `\x1b[107m`;
123/** sets the background color to a color in the 256 color palette. */
124export function bg256(n: number): string {
125 return `\x1b[48;5;${n}m`;
126}
127/** sets the background color to an rgb color. */
128export function bgRgb(r: number, g: number, b: number): string {
129 return `\x1b[48;2;${r};${g};${b}m`;
130}
131
132// cursor management
133
134/** move the cursor up `n` units. */
135export function cursorUp(n: number): string {
136 return n ? `\x1b[${n}A` : "";
137}
138/** move the cursor to the start of it's current line. */
139export const startOfLine: string = "\r";
140/** move the cursor to the start of the next line. */
141export const startOfNextLine: string = "\n";
142
143// erasure
144/** clear from and including the current cell right to the end of the line. */
145export const clearToEndOfLine: string = "\x1b[K";
146/** clear from and including the current cell left to the start of the line. */
147export const clearToStartOfLine: string = "\x1b[1K";
148/** clear the entire line that the cursor is on without moving it. */
149export const clearFullLine: string = "\x1b[2K";
150
151/**
152 * Begin Synchronized Output
153 * https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
154 */
155export const syncStart: string = "\x1b[?2026h";
156/** End Synchronized Output */
157export const syncEnd: string = "\x1b[?2026l";
158
159/**
160 * Apply ANSI styles to text. `code` is a concatenation of zero or more styles,
161 * which will be cleared as required.
162 */
163export function style(code: string, text: string): string {
164 code = mergeStyles(code);
165 if (!code) return text;
166 const fg = /[\[;][39]/.test(code);
167 const bg = /[\[;](?:4|10)/.test(code);
168 const bold = /[\[;](?:1)[;m]/.test(code);
169 const dim = /[\[;](?:2)[;m]/.test(code);
170 return code + text +
171 mergeStyles(
172 (fg ? fgReset : "") +
173 (bg ? bgReset : "") +
174 (bold ? resetWeight : "") +
175 (dim ? resetWeight : ""),
176 );
177}
178
179/**
180 * Given a string of multiple style ansi codes, combines all of them.
181 * Must be used on a string of only ansi style codes.
182 */
183export function mergeStyles(code: string): string {
184 let styles = [];
185 while (code.length) {
186 if (!code.startsWith("\x1b[")) {
187 throw new Error("invalid style string: " + JSON.stringify(code));
188 }
189 const end = code.indexOf("m");
190 if (end === -1) {
191 throw new Error("invalid style string: " + JSON.stringify(code));
192 }
193 styles.push(code.slice(2, end));
194 code = code.slice(end + 1);
195 }
196 return `\x1b[${styles.join(";")}m`;
197}
198
199/**
200 * Convert a string with ansi text into a string where all the codes have been
201 * turned into their original names.
202 */
203export function debugAnsi(text: string): string {
204 // deno-fmt-ignore
205 const verbaitim: (keyof typeof self)[] = [
206 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",
207 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",
208 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",
209 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",
210 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",
211 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
212 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
213 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"
214 ];
215 for (const key of verbaitim) {
216 text = text.replaceAll(
217 String(self[key]),
218 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),
219 );
220 }
221 text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}"));
222 text = text.replace(/\n/g, style(fgRed, "\\n"));
223 text = text.replace(/\r/g, style(fgMagenta, "\\r"));
224 return text;
225}
226
227/** Measure the visible length of a string, ignoring ansi codes. */
228export function widthInTerminal(str: string): number {
229 let width = 0;
230 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");
231 for (let i = 0, { length } = str; i < length; i += 1) {
232 const code = str.charCodeAt(i);
233 if (code < 32) continue;
234 if (code < 127) {
235 width += 1;
236 continue;
237 }
238 for (const { segment } of segmenter.segment(str.slice(i))) {
239 if (segment.length === 1) {
240 if (code < 32) continue;
241 if (code < 127) {
242 width += 1;
243 continue;
244 }
245 }
246 width += segmentWidth(segment);
247 }
248 return width;
249 }
250 return width;
251}
252
253/**
254 * Like `str.slice(0, columns)`, but respecting width in a terminal.
255 * preserves ansi escape sequences.
256 */
257// TODO: rename to trimForTerminal
258export function trimToWidth(str: string, columns: number): string {
259 let width = 0;
260 let ansi = false;
261 for (let i = 0, { length } = str; i < length; i += 1) {
262 const code = str.charCodeAt(i);
263 if (code < 32) {
264 if (code === 0x1b) {
265 const esc = str.slice().match(ansiEscapes);
266 if (esc) i += esc[0].length - 1, ansi = true;
267 }
268 continue;
269 }
270 if (code < 127) {
271 width += 1;
272 if (width >= columns) {
273 const result = str.slice(0, i + 1);
274 return ansi ? result + "\x1b[0m" : result;
275 }
276 continue;
277 }
278 // non-ascii path
279 let out = str.slice(0, i);
280 str = str.slice(i);
281 const segments = segmenter.segment(str);
282 {
283 const { segment } = UNWRAP(segments.containing(0));
284 width += segmentWidth(segment);
285 if (width > columns) return ansi ? out + "\x1b[0m" : out;
286 out += segment;
287 i = segment.length;
288 }
289 for ({ length } = str; i < length; i += 1) {
290 const code = str.charCodeAt(i);
291 if (code === 0x1b) {
292 const esc = str.slice(i).match(ansiEscapes);
293 if (esc) {
294 i += esc[0].length - 1, ansi = true;
295 out += esc[0];
296 continue;
297 }
298 }
299 const { segment } = UNWRAP(segments.containing(i));
300 width += segmentWidth(segment);
301 if (width > columns) return ansi ? out + "\x1b[0m" : out;
302 out += segment;
303 }
304 return out;
305 }
306 return str;
307}
308
309// derived from sindresorhus's wonderful `string-width`
310// https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js
311// https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js
312// MIT license
313const segmenter = /* @__PURE__ */ new Intl.Segmenter();
314const ansiEscapes =
315 /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
316const zeroWidthClusterRegex =
317 /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
318const leadingNonPrintingRegex =
319 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
320const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
321function segmentWidth(segment: string) {
322 if (zeroWidthClusterRegex.test(segment)) return 0;
323 if (rgiEmojiRegex.test(segment)) return 2;
324
325 // deno-fmt-ignore
326 const list = [
327 0x3000, 0x231A, 0x231B, 0x2329, 0x232A, 0x23F0, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615,
328 0x267F, 0x2693, 0x26A1, 0x26AA, 0x26AB, 0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26D4,
329 0x26EA, 0x26F2, 0x26F3, 0x26F5, 0x26FA, 0x26FD, 0x2705, 0x270A, 0x270B, 0x2728, 0x274C,
330 0x274E, 0x2757, 0x27B0, 0x27BF, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55, 0x1AFFD, 0x1AFFE, 0x1B132,
331 0x1B155, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F250, 0x1F251, 0x1F3F4, 0x1F440, 0x1F57A, 0x1F595,
332 0x1F596, 0x1F5A4, 0x1F6CC, 0x1F6EB, 0x1F6EC, 0x1F7F0, 0x1FAC8,
333 ];
334 const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0;
335 return 1 + +(
336 // full
337 x >= 0xFF01 && x <= 0xFF60 || x >= 0xFFE0 && x <= 0xFFE6 ||
338 // wide
339 x >= 0x1100 && x <= 0x115F || x >= 0x23E9 && x <= 0x23EC ||
340 x >= 0x2630 && x <= 0x2637 || x >= 0x2648 && x <= 0x2653 ||
341 x >= 0x268A && x <= 0x268F || x >= 0x2753 && x <= 0x2755 ||
342 x >= 0x2795 && x <= 0x2797 || x >= 0x2E80 && x <= 0x2E99 ||
343 x >= 0x2E9B && x <= 0x2EF3 || x >= 0x2F00 && x <= 0x2FD5 ||
344 x >= 0x2FF0 && x <= 0x2FFF || x >= 0x3001 && x <= 0x303E ||
345 x >= 0x3041 && x <= 0x3096 || x >= 0x3099 && x <= 0x30FF ||
346 x >= 0x3105 && x <= 0x312F || x >= 0x3131 && x <= 0x318E ||
347 x >= 0x3190 && x <= 0x31E5 || x >= 0x31EF && x <= 0x321E ||
348 x >= 0x3220 && x <= 0x3247 || x >= 0x3250 && x <= 0xA48C ||
349 x >= 0xA490 && x <= 0xA4C6 || x >= 0xA960 && x <= 0xA97C ||
350 x >= 0xAC00 && x <= 0xD7A3 || x >= 0xF900 && x <= 0xFAFF ||
351 x >= 0xFE10 && x <= 0xFE19 || x >= 0xFE30 && x <= 0xFE52 ||
352 x >= 0xFE54 && x <= 0xFE66 || x >= 0xFE68 && x <= 0xFE6B ||
353 x >= 0x16FE0 && x <= 0x16FE4 || x >= 0x16FF0 && x <= 0x16FF6 ||
354 x >= 0x17000 && x <= 0x18CD5 || x >= 0x18CFF && x <= 0x18D1E ||
355 x >= 0x18D80 && x <= 0x18DF2 || x >= 0x1AFF0 && x <= 0x1AFF3 ||
356 x >= 0x1AFF5 && x <= 0x1AFFB || x >= 0x1B150 && x <= 0x1B152 ||
357 x >= 0x1B164 && x <= 0x1B167 || x >= 0x1B170 && x <= 0x1B2FB ||
358 x >= 0x1D300 && x <= 0x1D356 || x >= 0x1D360 && x <= 0x1D376 ||
359 x >= 0x1F191 && x <= 0x1F19A || x >= 0x1F200 && x <= 0x1F202 ||
360 x >= 0x1F210 && x <= 0x1F23B || x >= 0x1F240 && x <= 0x1F248 ||
361 x >= 0x1F260 && x <= 0x1F265 || x >= 0x1F300 && x <= 0x1F320 ||
362 x >= 0x1F32D && x <= 0x1F335 || x >= 0x1F337 && x <= 0x1F37C ||
363 x >= 0x1F37E && x <= 0x1F393 || x >= 0x1F3A0 && x <= 0x1F3CA ||
364 x >= 0x1F3CF && x <= 0x1F3D3 || x >= 0x1F3E0 && x <= 0x1F3F0 ||
365 x >= 0x1F3F8 && x <= 0x1F43E || x >= 0x1F442 && x <= 0x1F4FC ||
366 x >= 0x1F4FF && x <= 0x1F53D || x >= 0x1F54B && x <= 0x1F54E ||
367 x >= 0x1F550 && x <= 0x1F567 || x >= 0x1F5FB && x <= 0x1F64F ||
368 x >= 0x1F680 && x <= 0x1F6C5 || x >= 0x1F6D0 && x <= 0x1F6D2 ||
369 x >= 0x1F6D5 && x <= 0x1F6D8 || x >= 0x1F6DC && x <= 0x1F6DF ||
370 x >= 0x1F6F4 && x <= 0x1F6FC || x >= 0x1F7E0 && x <= 0x1F7EB ||
371 x >= 0x1F90C && x <= 0x1F93A || x >= 0x1F93C && x <= 0x1F945 ||
372 x >= 0x1F947 && x <= 0x1F9FF || x >= 0x1FA70 && x <= 0x1FA7C ||
373 x >= 0x1FA80 && x <= 0x1FA8A || x >= 0x1FA8E && x <= 0x1FAC6 ||
374 x >= 0x1FACD && x <= 0x1FADC || x >= 0x1FADF && x <= 0x1FAEA ||
375 x >= 0x1FAEF && x <= 0x1FAF8 || x >= 0x20000 && x <= 0x2FFFD ||
376 x >= 0x30000 && x <= 0x3FFFD || x >= 0x1B000 && x <= 0x1B122 ||
377 list.includes(x)
378 );
379}
380
381import * as self from "lib/string/ansi.ts";
382import { UNWRAP } from "lib/assert.ts";
lib/subprocess.ts created+93
......@@ -0,0 +1,93 @@
1// This file is expecting a full rewrite to abstract the Node API away
2// entirely, exposing web streams I/O.
3
4const execFileRaw: typeof child_process.execFile.__promisify__ = util.promisify(
5 child_process.execFile,
6);
7/*** @deprecated */
8export const exec: typeof execFileRaw = ((
9 ...args: Parameters<typeof execFileRaw>
10) =>
11 execFileRaw(...args).catch((e: any) => {
12 if (e?.message?.startsWith?.("Command failed")) {
13 if (e.code > 2 ** 31) e.code |= 0;
14 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
15 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
16 }
17 throw e;
18 })) as any;
19
20// const activePids = new Set<number>();
21
22/*** @deprecated */
23export interface SpawnOptions extends child_process.SpawnOptions {
24 cmd: string[];
25}
26/*** @deprecated */
27export function spawn(
28 { cmd, ...rest }: SpawnOptions,
29): child_process.ChildProcess {
30 // rest.detached ??= true;
31 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
32 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
33 // if (pid) {
34 // activePids.add(pid);
35 // process.on("exit", () => void activePids.delete(pid));
36 // }
37 return process;
38}
39
40/*** @deprecated */
41export interface ForkOptions extends child_process.ForkOptions {
42 cmd: string[];
43}
44
45/*** @deprecated */
46export function fork(
47 { cmd, ...rest }: ForkOptions,
48): child_process.ChildProcess {
49 // rest.detached ??= true;
50 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);
51 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
52 // if (pid) {
53 // activePids.add(pid);
54 // process.on("exit", () => void activePids.delete(pid));
55 // }
56 return process;
57}
58
59/*** @deprecated */
60export async function spawnAndWait(
61 opts: SpawnOptions,
62): Promise<{ stdout: Buffer | null; stderr: Buffer | null }> {
63 opts.stdio ??= ["ignore", "inherit", "inherit"];
64 const process = spawn(opts);
65 const output = Promise.all([
66 process.stdout ? Array.fromAsync(process.stdout).then(Buffer.concat) : null,
67 process.stderr ? Array.fromAsync(process.stderr).then(Buffer.concat) : null,
68 ]);
69 let [code, signal] = (await events.EventEmitter.once(process, "exit")) as [
70 number | null,
71 string | null,
72 ];
73 if (code !== 0) {
74 if (code !== null && code > 2 ** 31) code |= 0;
75 const exit = signal ? `signal ${signal}` : `code ${code}`;
76 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
77 }
78 const [stdout, stderr] = await output;
79 return { stdout, stderr };
80}
81
82// process.on("exit", () => {
83// for (const pid of activePids) {
84// console.info(`Kill ${Math.abs(pid)}`);
85// process.kill(pid, 9);
86// }
87// });
88
89// import * as process from "node:process";
90import * as events from "node:events";
91import * as util from "node:util";
92import * as child_process from "node:child_process";
93import { Buffer } from "node:buffer";
lib/subprocess/ffmpeg.ts created+198
......@@ -0,0 +1,198 @@
1export interface SpawnOptions {
2 args: string[];
3 ffmpeg?: string;
4 progress: progress.Node;
5 cwd?: string; // TODO: Path
6}
7
8/**
9 * Spawn an instance of `ffmpeg` and bind it's live status to the given
10 * `progress.Node`. Logs are routed through the progress node, and dismissed on
11 * exit. `lib/log/progress.ts` will buffer and colocate the logs when running
12 * parallel jobs.
13 *
14 * await ffmpeg.spawn({
15 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
16 * progress: progress.start("encode hello.mov"),
17 * });
18 */
19export async function spawn(options: SpawnOptions) {
20 const { ffmpeg = "ffmpeg", args, cwd } = options;
21 using node = options.progress;
22 const title = node.text;
23 const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {
24 stdio: ["ignore", "inherit", "pipe"],
25 env: { ...process.env, SVT_LOG: "2" },
26 cwd: cwd?.toString(),
27 });
28 const parser = new Parse();
29 let running = true;
30 const splitter = readline.createInterface({ input: proc.stderr });
31 splitter.on("line", (line) => {
32 const result = parser.onLine(line);
33 if (result.kind === "ignore") {
34 return;
35 } else if (result.kind === "log") {
36 node.log[result.level](result.message);
37 } else if (result.kind === "progress") {
38 if (!running) return;
39 const { frame, totalFrames, fps, speed } = result;
40 node.value = frame;
41 node.estimate = totalFrames;
42 const extras = [
43 `${fps} fps`,
44 speed,
45 parser.hlsFile,
46 ].filter(Boolean).join(", ");
47 node.text = `${title}${extras.length > 0 ? ` (${extras})` : ""}`;
48 } else result satisfies never;
49 });
50 const [code, signal] = await events.once(proc, "close");
51 running = false;
52 if (code !== 0) {
53 const fmt = code ? `code ${code}` : `signal ${signal}`;
54 const e: any = new Error(`ffmpeg failed with ${fmt}`);
55 e.args = [ffmpeg, ...args].map(string.escapeShellArgument).join(" ");
56 e.code = code;
57 e.signal = signal;
58 throw e;
59 }
60}
61
62export interface Streams {
63 audio: boolean;
64 video: boolean;
65}
66
67export async function probeStreams(path: string): Promise<Streams> {
68 const { stdout } = await subprocess.spawnAndWait({
69 cmd: [
70 "ffprobe",
71 "-v",
72 "error",
73 "-show_entries",
74 "stream=codec_type",
75 path.toString(),
76 "-of",
77 "default=noprint_wrappers=1:nokey=1",
78 ],
79 stdio: "pipe",
80 });
81
82 const lines = new TextDecoder().decode(stdout!).trim().split("\n");
83 return {
84 audio: lines.includes("audio"),
85 video: lines.includes("video"),
86 };
87}
88
89export type Line =
90 | { kind: "ignore" }
91 | { kind: "log"; level: "info" | "warn" | "error"; message: string }
92 | {
93 kind: "progress";
94 frame: number;
95 totalFrames: number;
96 speed: string | null;
97 fps: number | null;
98 rest: Record<string, string>;
99 };
100
101export const defaultExtraOptions = [
102 "-hide_banner",
103 "-stats",
104];
105
106export class Parse {
107 parsingStart = true;
108 inIndentedIgnore: null | "out" | "inp" | "other" = null;
109 durationTime = 0;
110 targetFps: number | null = null;
111
112 hlsFile: string | null = null;
113 durationFrames = 0;
114
115 onLine(line: string): Line {
116 line = line.trimEnd();
117 if (/^frame=/.test(line)) {
118 if (this.parsingStart) {
119 this.parsingStart = false;
120 this.durationFrames = Math.ceil(
121 (this.targetFps ?? 25) * this.durationTime,
122 );
123 }
124 const parts = Object.fromEntries(
125 [...line.matchAll(/\b([a-z0-9]+)=\s*([^ ]+)(?= |$)/ig)].map((
126 [, k, v],
127 ) => [k, v]),
128 );
129 const { frame, fps, speed, ...rest } = parts;
130 return {
131 kind: "progress",
132 frame: Number(frame),
133 totalFrames: this.durationFrames,
134 fps: Number(fps),
135 speed,
136 rest,
137 };
138 }
139
140 if (this.parsingStart) {
141 if (this.inIndentedIgnore) {
142 if (line.startsWith(" ") || line.startsWith("\t")) {
143 line = line.trimStart();
144 if (this.inIndentedIgnore === "inp") {
145 const match = line.match(/^Duration: (\d+):(\d+):(\d+\.\d+)/);
146 if (match) {
147 const [h, m, s] = match.slice(1).map((x) => Number(x));
148 this.durationTime = Math.max(
149 this.durationTime,
150 h * 60 * 60 + m * 60 + s,
151 );
152 }
153 if (!this.targetFps) {
154 const match = line.match(/^Stream.*, (\d+) fps/);
155 if (match) this.targetFps = Number(match[1]);
156 }
157 }
158 return { kind: "ignore" };
159 }
160 this.inIndentedIgnore = null;
161 }
162 if (line === "Press [q] to stop, [?] for help") {
163 return { kind: "ignore" };
164 }
165 if (line === "Stream mapping:") {
166 this.inIndentedIgnore = "other";
167 return { kind: "ignore" };
168 }
169 if (line.startsWith("Output #") || line.startsWith("Input #")) {
170 this.inIndentedIgnore = line.slice(0, 3).toLowerCase() as "inp" | "out";
171 return { kind: "ignore" };
172 }
173 }
174
175 const hlsMatch = line.match(/.*Opening '(.+)' for writing/);
176 if (hlsMatch) {
177 if (!hlsMatch[1].endsWith(".tmp")) {
178 this.hlsFile = path.basename(hlsMatch[1]);
179 }
180 return { kind: "ignore" };
181 }
182
183 let level: Extract<Line, { kind: "log" }>["level"] = "info";
184 if (line.toLowerCase().includes("err")) level = "error";
185 else if (line.toLowerCase().includes("warn")) level = "warn";
186
187 return { kind: "log", level, message: line };
188 }
189}
190
191import * as child_process from "node:child_process";
192import * as subprocess from "lib/subprocess.ts";
193import * as readline from "node:readline";
194import * as process from "node:process";
195import events from "node:events";
196import * as path from "node:path";
197import type * as progress from "lib/log/progress.ts";
198import * as string from "lib/string.ts";
lib/ts.ts+11-1
......@@ -1,9 +1,19 @@
11export type Timer = ReturnType<typeof setTimeout>;
22export type Interval = ReturnType<typeof setInterval>;
33
4export interface Dispose extends Disposable {
4export interface VoidFunction {
55 (): void;
66}
7
8/** Convenience type. Construct with `defer` */
9export interface Dispose extends Disposable, VoidFunction {}
10
11/**
12 * using _ = ts.defer(() => { ... });
13 *
14 * Additionally, this can be used to construct the convenience type
15 * `ts.Dispose`
16 */
717export function defer(fn: () => void): Dispose {
818 const f = fn as Dispose;
919 f[Symbol.dispose] = f;
package.json+3-4
......@@ -25,12 +25,11 @@
2525 "typescript": "5.8.3"
2626 },
2727 "imports": {
28 "#backend": "./src/backend.ts",
29 "#debug": "./framework/debug.safe.ts",
3028 "#sitegen": "./framework/lib/sitegen.ts",
3129 "#sitegen/*": "./framework/lib/*.ts",
32 "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
33 "#engine/*": "./framework/engine/*.ts",
30 "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts",
31 "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts",
32 "#marko/runtime": "./framework/engine/marko-runtime.ts",
3433 "#marko/html": {
3534 "types": "marko/html",
3635 "production": "marko/production",
run.js+2-4
......@@ -4,6 +4,7 @@ import * as zlib from "node:zlib";
44import * as url from "node:url";
55import * as fs from "node:fs";
66import * as os from "node:os";
7import assert from "node:assert";
78import process from "node:process";
89
910// Disable experimental warnings (Type Stripping, etc)
......@@ -16,11 +17,8 @@ process.emit = function (event, error) {
1617};
1718
1819try {
19 // fails in a runtime without ts support
20 await import("#debug");
21
2220 // required APIs
23 ASSERT(zlib.zstdCompress);
21 assert(zlib.zstdCompress);
2422
2523 // seems to fail in bun
2624 (await import("source-map-support")).install({ hookRequire: true });
src/deploy.ts+1-1
......@@ -46,6 +46,6 @@ export async function main() {
4646import * as incr from "../framework/incremental.ts";
4747import { generate } from "../framework/generate.ts";
4848import * as progress from "lib/log/progress.ts";
49import * as subprocess from "#sitegen/subprocess";
49import * as subprocess from "lib/subprocess.ts";
5050import * as rsync from "./file-viewer/rsync.ts";
5151import { Path } from "#sitegen/path";
src/file-viewer/backend.tsx+5-4
......@@ -93,7 +93,7 @@ app.get("/file/*", async (c, next) => {
9393
9494 const headers = new Headers({
9595 Vary: "Accept-Encoding, Accept",
96 "Content-Type": contentTypeFor(derivedKey || file.path),
96 "Content-Type": mime.for(derivedKey || file.path),
9797 "Content-Length": size.toString(),
9898 ETag: etag,
9999 "Last-Modified": date.toUTCString(),
......@@ -269,7 +269,7 @@ function getPartialPage(c: Context, rawFilePath: string) {
269269 return c.json({ error: "File not found" }, 404);
270270 }
271271
272 const r = render.init(false, {});
272 const r = render.init(false, []);
273273 let root = render.resolveNode(
274274 r,
275275 <MediaPanel
......@@ -287,10 +287,10 @@ function getPartialPage(c: Context, rawFilePath: string) {
287287
288288import { type Context, Hono } from "hono";
289289
290import * as render from "#engine/render";
290import * as render from "lib/render.ts";
291291import { etagMatches, serveAsset } from "#sitegen/assets";
292292import * as view from "#sitegen/view";
293import { contentTypeFor } from "#sitegen/mime";
293import * as mime from "lib/mime.ts";
294294
295295import { requireFriendAuth } from "@/friend-auth.ts";
296296import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
......@@ -299,3 +299,4 @@ import { MediaPanel } from "@/file-viewer/views/clofi.tsx";
299299import { Speedbump } from "@/file-viewer/cotyledon.tsx";
300300import * as cache from "@/file-viewer/cache.ts";
301301import * as derived from "@/file-viewer/models/derived.ts";
302import { UNWRAP } from "lib/assert.ts";
src/file-viewer/bin/scan3.ts+4-3
......@@ -981,9 +981,9 @@ async function scrubLocationMetadata(
981981
982982const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
983983
984import * as async from "#sitegen/async";
984import * as async from "lib/async.ts";
985985import * as fs from "#sitegen/fs";
986import * as subprocess from "#sitegen/subprocess";
986import * as subprocess from "lib/subprocess.ts";
987987import { Path } from "#sitegen/path";
988988import * as queue from "../../../lib/queue.ts";
989989import * as log from "../../../lib/log.ts";
......@@ -1006,9 +1006,10 @@ import {
10061006} from "@/file-viewer/format.ts";
10071007import * as rules from "@/file-viewer/rules.ts";
10081008import * as highlight from "@/file-viewer/highlight.ts";
1009import * as ffmpeg from "@/file-viewer/ffmpeg.ts";
1009import * as ffmpeg from "lib/subprocess/ffmpeg.ts";
10101010import * as rsync from "@/file-viewer/rsync.ts";
10111011import * as derived from "@/file-viewer/models/derived.ts";
10121012import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
10131013
10141014import { rawFileRoot as root } from "../paths.ts";
1015import { UNWRAP } from "lib/assert.ts";
src/file-viewer/cache.ts+4-3
......@@ -573,8 +573,8 @@ async function writeStream(
573573import * as util from "node:util";
574574import * as fsCallbacks from "node:fs";
575575import * as fs from "#sitegen/fs";
576import * as async from "#sitegen/async";
577import * as error from "#sitegen/error";
576import * as async from "lib/async.ts";
577import * as error from "lib/error.ts";
578578import * as stream from "node:stream";
579579import * as crypto from "node:crypto";
580580import * as http from "node:http";
......@@ -583,5 +583,6 @@ import * as paths from "./paths.ts";
583583import * as log from "lib/log.ts";
584584import { MediaFile } from "./models/MediaFile.ts";
585585import type { ReadableStream } from "node:stream/web";
586import { Lru } from "#sitegen/lru";
586import { Lru } from "lib/Lru.ts";
587587import { Path } from "#sitegen/path";
588import { ASSERT, UNWRAP } from "lib/assert.ts";
src/file-viewer/ffmpeg.ts deleted-186
......@@ -1,186 +0,0 @@
1// Utilities for spawning ffmpeg and consuming its output as a `Progress`
2// A headless parser is available with `Parse`
3
4export type Line =
5 | { kind: "ignore" }
6 | { kind: "log"; level: "info" | "warn" | "error"; message: string }
7 | {
8 kind: "progress";
9 frame: number;
10 totalFrames: number;
11 speed: string | null;
12 fps: number | null;
13 rest: Record<string, string>;
14 };
15
16export const defaultExtraOptions = [
17 "-hide_banner",
18 "-stats",
19];
20
21export interface SpawnOptions {
22 args: string[];
23 ffmpeg?: string;
24 progress: progress.Node;
25 cwd: Path;
26}
27
28export async function spawn(options: SpawnOptions) {
29 const { ffmpeg = "ffmpeg", args, cwd } = options;
30 using node = options.progress;
31 const title = node.text;
32 const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {
33 stdio: ["ignore", "inherit", "pipe"],
34 env: { ...process.env, SVT_LOG: "2" },
35 cwd: cwd.toString(),
36 });
37 const parser = new Parse();
38 let running = true;
39 const splitter = readline.createInterface({ input: proc.stderr });
40 splitter.on("line", (line) => {
41 const result = parser.onLine(line);
42 if (result.kind === "ignore") {
43 return;
44 } else if (result.kind === "log") {
45 node.log[result.level](result.message);
46 } else if (result.kind === "progress") {
47 if (!running) return;
48 const { frame, totalFrames, fps, speed } = result;
49 node.value = frame;
50 node.estimate = totalFrames;
51 const extras = [
52 `${fps} fps`,
53 speed,
54 parser.hlsFile,
55 ].filter(Boolean).join(", ");
56 node.text = `${title}${extras.length > 0 ? ` (${extras})` : ""}`;
57 } else result satisfies never;
58 });
59 const [code, signal] = await events.once(proc, "close");
60 running = false;
61 if (code !== 0) {
62 const fmt = code ? `code ${code}` : `signal ${signal}`;
63 const e: any = new Error(`ffmpeg failed with ${fmt}`);
64 e.args = [ffmpeg, ...args].map(string.escapeShellArgument).join(" ");
65 e.code = code;
66 e.signal = signal;
67 throw e;
68 }
69}
70
71export async function probeStreams(path: Path) {
72 const { stdout } = await subprocess.spawnAndWait({
73 cmd: [
74 "ffprobe",
75 "-v",
76 "error",
77 "-show_entries",
78 "stream=codec_type",
79 path.toString(),
80 "-of",
81 "default=noprint_wrappers=1:nokey=1",
82 ],
83 stdio: "pipe",
84 });
85
86 const lines = new TextDecoder().decode(stdout!).trim().split("\n");
87 return {
88 audio: lines.includes("audio"),
89 video: lines.includes("video"),
90 };
91}
92
93export class Parse {
94 parsingStart = true;
95 inIndentedIgnore: null | "out" | "inp" | "other" = null;
96 durationTime = 0;
97 targetFps: number | null = null;
98
99 hlsFile: string | null = null;
100 durationFrames = 0;
101
102 onLine(line: string): Line {
103 line = line.trimEnd();
104 if (/^frame=/.test(line)) {
105 if (this.parsingStart) {
106 this.parsingStart = false;
107 this.durationFrames = Math.ceil(
108 (this.targetFps ?? 25) * this.durationTime,
109 );
110 }
111 const parts = Object.fromEntries(
112 [...line.matchAll(/\b([a-z0-9]+)=\s*([^ ]+)(?= |$)/ig)].map((
113 [, k, v],
114 ) => [k, v]),
115 );
116 const { frame, fps, speed, ...rest } = parts;
117 return {
118 kind: "progress",
119 frame: Number(frame),
120 totalFrames: this.durationFrames,
121 fps: Number(fps),
122 speed,
123 rest,
124 };
125 }
126
127 if (this.parsingStart) {
128 if (this.inIndentedIgnore) {
129 if (line.startsWith(" ") || line.startsWith("\t")) {
130 line = line.trimStart();
131 if (this.inIndentedIgnore === "inp") {
132 const match = line.match(/^Duration: (\d+):(\d+):(\d+\.\d+)/);
133 if (match) {
134 const [h, m, s] = match.slice(1).map((x) => Number(x));
135 this.durationTime = Math.max(
136 this.durationTime,
137 h * 60 * 60 + m * 60 + s,
138 );
139 }
140 if (!this.targetFps) {
141 const match = line.match(/^Stream.*, (\d+) fps/);
142 if (match) this.targetFps = Number(match[1]);
143 }
144 }
145 return { kind: "ignore" };
146 }
147 this.inIndentedIgnore = null;
148 }
149 if (line === "Press [q] to stop, [?] for help") {
150 return { kind: "ignore" };
151 }
152 if (line === "Stream mapping:") {
153 this.inIndentedIgnore = "other";
154 return { kind: "ignore" };
155 }
156 if (line.startsWith("Output #") || line.startsWith("Input #")) {
157 this.inIndentedIgnore = line.slice(0, 3).toLowerCase() as "inp" | "out";
158 return { kind: "ignore" };
159 }
160 }
161
162 const hlsMatch = line.match(/.*Opening '(.+)' for writing/);
163 if (hlsMatch) {
164 if (!hlsMatch[1].endsWith(".tmp")) {
165 this.hlsFile = path.basename(hlsMatch[1]);
166 }
167 return { kind: "ignore" };
168 }
169
170 let level: Extract<Line, { kind: "log" }>["level"] = "info";
171 if (line.toLowerCase().includes("err")) level = "error";
172 else if (line.toLowerCase().includes("warn")) level = "warn";
173
174 return { kind: "log", level, message: line };
175 }
176}
177
178import * as child_process from "node:child_process";
179import * as subprocess from "#sitegen/subprocess";
180import * as readline from "node:readline";
181import * as process from "node:process";
182import events from "node:events";
183import * as path from "node:path";
184import * as progress from "../../lib/log/progress.ts";
185import type { Path } from "#sitegen/path";
186import * as string from "lib/string.ts";
src/file-viewer/format.ts+1-1
......@@ -288,4 +288,4 @@ export function formatDate(dateTime: Date) {
288288}
289289
290290import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
291import { escapeHtml } from "#engine/render";
291import { escapeHtml } from "lib/render.ts";
src/file-viewer/highlight.ts+2-2
......@@ -192,9 +192,9 @@ export async function main() {
192192 }
193193}
194194
195import * as async from "#sitegen/async";
195import * as async from "lib/async.ts";
196196import * as fs from "#sitegen/fs";
197197import * as path from "node:path";
198198import * as oniguruma from "vscode-oniguruma";
199199import * as textmate from "vscode-textmate";
200import * as render from "#engine/render";
200import * as render from "lib/render.ts";
src/file-viewer/models/MediaFile.ts+1
......@@ -461,3 +461,4 @@ const getDirectoriesToReindexQuery = db.prepare(`
461461import { getDb } from "#sitegen/sqlite";
462462import * as path from "node:path/posix";
463463import { FilePermissions } from "./FilePermissions.ts";
464import { ASSERT } from "lib/assert.ts";
src/file-viewer/transcode-rules.ts+1-1
......@@ -313,4 +313,4 @@ export function getH264HlsArgs(
313313}
314314
315315import type { Path } from "#sitegen/path";
316import * as ffmpeg from "./ffmpeg.ts";
316import * as ffmpeg from "lib/subprocess/ffmpeg.ts";
src/file-viewer/views/clofi.tsx+5-4
......@@ -89,7 +89,7 @@ export default function MediaList({
8989 <MediaPanel
9090 file={entry}
9191 isLast={isLast}
92 activeFilename={parts[i + 1]}
92 activeFilename={parts[i + 1] ?? null}
9393 hasCotyledonCookie={hasCotyledonCookie}
9494 />
9595 );
......@@ -163,7 +163,7 @@ export function MediaPanel({
163163 className="custom mobile-back"
164164 />
165165 );
166 const canvases: Record<string, string> = {
166 const canvases: Record<string, () => void> = {
167167 "/2017": () => addScript("../scripts/canvas_2017.client.ts"),
168168 "/2018": () => addScript("../scripts/canvas_2018.client.ts"),
169169 "/2019": () => addScript("../scripts/canvas_2019.client.ts"),
......@@ -923,8 +923,9 @@ import {
923923 highlightLinksInTextView,
924924} from "@/file-viewer/format.ts";
925925import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
926import * as mime from "#sitegen/mime";
926import * as mime from "lib/mime.ts";
927927import * as sort from "../sort.ts";
928928import CloverVideo from "@/tags/clover-video.marko";
929929import Image from "@/tags/image.tsx";
930import * as render from "#engine/render";
930import * as render from "lib/render.ts";
931import { UNWRAP } from "lib/assert.ts";
src/package-lib.ts created+226
......@@ -0,0 +1,226 @@
1export async function updateJsrPackageExportsToAll(
2 absPath: string,
3 p: progress.Ref,
4) {
5 using _ = p.start("update deno.json");
6 const manifestPath = path.join(absPath, "deno.json");
7 const deno = JSON.parse(
8 await fs.readFile(manifestPath, "utf-8"),
9 );
10 const newExports: Record<string, string> = {};
11 for (const subPath of await fs.readdir(absPath, { recursive: true })) {
12 if (!subPath.match(/\.tsx?$/)) continue;
13 if (subPath.match(/\.(test|sample)\.tsx?$/)) continue;
14 if (subPath.startsWith("dist")) continue;
15 newExports["./" + subPath] = "./" + subPath;
16 }
17 deno.exports = Object.fromEntries(
18 Object.entries(newExports).sort(([a], [b]) => a.localeCompare(b)),
19 ) as typeof deno.exports;
20 await fs.writeFile(manifestPath, JSON.stringify(deno, null, 2));
21}
22
23export async function transpileForNpm(
24 absPath: string,
25 p: progress.Ref,
26 packageJson: Record<string, unknown>,
27) {
28 using node = p.start("transpile into js for npm");
29 const manifestPath = path.join(absPath, "deno.json");
30 const deno = JSON.parse(
31 await fs.readFile(manifestPath, "utf-8"),
32 );
33 const distPath = path.join(absPath, "dist-npm");
34 await fs.rm(distPath, { recursive: true, force: true });
35 await fs.mkdir(distPath, { recursive: true });
36
37 const files: string[] = [];
38 const exports: Record<string, string> = {};
39 for (const subPath of await fs.readdir(absPath, { recursive: true })) {
40 if (!subPath.match(/\.tsx?$/)) continue;
41 if (subPath.startsWith("dist")) continue;
42 files.push(subPath);
43 }
44 const tsFiles: string[] = [];
45 for (const subPath of files) {
46 using _ = node.start(subPath);
47 const targetSubPath = subPath.replace(/\.tsx?$/, ".js");
48 if (!subPath.match(/\.(test|sample)\.tsx?$/)) {
49 exports["./" + subPath] = "./" + targetSubPath;
50 }
51 const out = path.join(distPath, subPath);
52 await fs.mkdir(path.dirname(out), { recursive: true });
53 tsFiles.push(out);
54
55 let code = await fs.readFile(path.join(absPath, subPath), "utf-8");
56 code = code.replace(
57 / from "lib\/([^"]+)"/g,
58 (_, src) => {
59 return ' from "./' + path.relative(
60 path.dirname(subPath),
61 src.replace(/\.tsx?$/, ".js"),
62 ) + '"';
63 },
64 );
65 await fs.writeFile(out, code);
66 }
67 await subprocess.spawnAndWait({
68 cmd: [
69 "npx",
70 "tsc",
71 "--outdir",
72 distPath,
73 "--sourceMap",
74 "--declarationMap",
75 "--declaration",
76 "--module",
77 "nodenext",
78 "--lib",
79 "esnext",
80 "--skipLibCheck",
81 "--jsx",
82 "react-jsx",
83 "--jsxImportSource",
84 "./lib/dist-npm/render",
85 "--target",
86 "esnext",
87 "--isolatedModules",
88 "--verbatimModuleSyntax",
89 "--allowSyntheticDefaultImports",
90 "--moduleResolution",
91 "nodenext",
92 ...tsFiles,
93 ],
94 });
95 await fs.writeFile(
96 path.join(distPath, "readme.md"),
97 (await fs.readFile(path.join(absPath, "readme.md"), "utf-8")).replace(
98 "<!--%npm%-->\n",
99 await fs.readFile(path.join(absPath, "readme.npm.md"), "utf-8"),
100 ),
101 ).catch((e) => {
102 if (e.code !== "ENOENT") throw e;
103 });
104 await fs.cp(
105 path.join(absPath, "readme.changes.md"),
106 path.join(distPath, "readme.changes.md"),
107 ).catch((e) => {
108 if (e.code !== "ENOENT") throw e;
109 });
110 const { name, description, ...rest } = packageJson;
111 await fs.writeFile(
112 path.join(distPath, "package.json"),
113 JSON.stringify(
114 {
115 name,
116 description,
117 version: deno.version,
118 type: "module",
119 exports,
120 ...rest,
121 },
122 null,
123 2,
124 ),
125 );
126}
127
128// TODO: failed experiment
129//
130// export async function transpileForNpm(
131// absPath: string,
132// p: progress.Ref,
133// packageJson: Record<string, unknown>,
134// ) {
135// const { transpileFile } = await import("ts-to-jsdoc") as TsToJsdoc;
136//
137// using node = p.start("transpile into jsdoc for npm");
138// const manifestPath = path.join(absPath, "deno.json");
139// const deno = JSON.parse(
140// await fs.readFile(manifestPath, "utf-8"),
141// );
142// const distPath = path.join(absPath, "dist-npm");
143// await fs.rm(distPath, { recursive: true, force: true });
144// await fs.mkdir(distPath, { recursive: true });
145//
146// const files: string[] = [];
147// const exports: Record<string, string> = {};
148// for (const subPath of await fs.readdir(absPath, { recursive: true })) {
149// if (!subPath.match(/\.tsx?$/)) continue;
150// if (subPath.startsWith("dist")) continue;
151// files.push(subPath);
152// }
153// node.estimate = Object.keys(exports).length;
154// for (const subPath of files) {
155// using _ = node.start(subPath);
156// const targetSubPath = subPath.replace(/\.tsx?$/, ".js");
157// if (!subPath.match(/\.(test|sample)\.tsx?$/)) {
158// exports["./" + subPath] = "./" + targetSubPath;
159// }
160//
161// let code = transpileFile({
162// code: await fs.readFile(path.join(absPath, subPath), "utf-8"),
163// });
164// code = code.replace(
165// / from "lib\/([^"]+)"/g,
166// (_, src) => {
167// return ' from "./' + path.relative(
168// path.dirname(subPath),
169// src.replace(/\.tsx?$/, ".js"),
170// ) + '"';
171// },
172// );
173// if (packageJson.homepage) {
174// code =
175// "// This file is transpiled from TypeScript from paper clover's sitegen\n" +
176// "// " + packageJson.homepage + "/" + subPath + "\n" +
177// code;
178// }
179// const out = path.join(distPath, targetSubPath);
180// await fs.mkdir(path.dirname(out), { recursive: true });
181// await fs.writeFile(out, code);
182// node.value += 1;
183// }
184// await fs.cp(
185// path.join(absPath, "readme.npm.md"),
186// path.join(distPath, "readme.md"),
187// ).catch((e) => {
188// if (e.code !== "ENOENT") throw e;
189// });
190// await fs.cp(
191// path.join(absPath, "readme.changes.md"),
192// path.join(distPath, "readme.changes.md"),
193// ).catch((e) => {
194// if (e.code !== "ENOENT") throw e;
195// });
196// const { name, description, ...rest } = packageJson;
197// await fs.writeFile(
198// path.join(distPath, "package.json"),
199// JSON.stringify({
200// name,
201// description,
202// version: deno.version,
203// type: "module",
204// ...rest,
205// }),
206// );
207// }
208//
209export function main() {
210 const main = path.resolve("lib");
211 updateJsrPackageExportsToAll(main, progress)
212 .then(() =>
213 transpileForNpm(main, progress, {
214 name: "@paperclover/lib",
215 description: "high quality typescript libraries",
216 homepage:
217 "https://git.paperclover.net/clo/sitegen/src/branch/master/lib",
218 })
219 );
220}
221
222import * as progress from "lib/log/progress.ts";
223import * as path from "node:path";
224import * as fs from "node:fs/promises";
225import * as subprocess from "lib/subprocess.ts";
226import { UNWRAP } from "lib/assert.ts";
src/pages/index.marko+1-1
......@@ -20,7 +20,7 @@ export const meta = {
2020 <p><a href="/q+a">questions and answers</a></p>
2121 <p><a href="/file">file browser</a></p>
2222 <p><a href="/subscribe">mailing list</a></p>
23 <p><a href="/donate">donate</a></p>
23 <p><a href="https://ko-fi.com/paper_clover">donate</a></p>
2424 <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p>
2525 <h1>paper clover</h1>
2626 </div>
src/q+a/clover-markdown.tsx+1-1
......@@ -268,4 +268,4 @@ import {
268268 parseCaptureInline,
269269 parseInline,
270270} from "./simple-markdown.ts";
271import * as render from "#engine/render";
271import * as render from "lib/render.ts";
src/q+a/image.tsx+1-1
......@@ -78,5 +78,5 @@ import * as fs from "#sitegen/fs";
7878import * as path from "node:path";
7979import * as puppeteer from "puppeteer";
8080import { Question } from "@/q+a/models/Question.ts";
81import { RefCountedExpirable } from "#sitegen/async";
81import { RefCountedExpirable } from "lib/async.ts";
8282import * as view from "#sitegen/view";
src/q+a/scripts/editor.client.tsx+1-1
......@@ -1,6 +1,6 @@
11import { EditorState } from "@codemirror/state";
22import { basicSetup, EditorView } from "codemirror";
3import * as render from "#engine/render";
3import * as render from "lib/render.ts";
44import type { ScriptPayload } from "@/q+a/views/editor.marko";
55import QuestionRender from "@/q+a/tags/question.marko";
66
src/site.ts+1-1
......@@ -113,6 +113,6 @@ export async function main() {
113113
114114import * as path from "node:path";
115115import * as font from "../framework/font.ts";
116import type { Template as MetaTemplate } from "#sitegen/meta";
116import type { Template as MetaTemplate } from "lib/render/meta.ts";
117117import type * as sg from "#sitegen";
118118import { nasRoot } from "./file-viewer/paths.ts";
src/source-of-truth.ts+3-2
......@@ -87,7 +87,7 @@ app.get("/file/*", async (c) => {
8787 read: fsCallbacks.read,
8888 },
8989 });
90 c.header("Content-Type", mime.contentTypeFor(fullPath));
90 c.header("Content-Type", mime.for(fullPath));
9191 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);
9292});
9393
......@@ -111,5 +111,6 @@ import * as fs from "#sitegen/fs";
111111import * as fsCallbacks from "node:fs";
112112import * as util from "node:util";
113113import * as stream from "node:stream";
114import * as mime from "#sitegen/mime";
114import * as mime from "lib/mime.ts";
115115import { derivedFileRoot, rawFileRoot } from "./file-viewer/paths.ts";
116import { ASSERT } from "lib/assert.ts";
src/tags/clover-video.marko+1-1
......@@ -57,4 +57,4 @@ export interface Input {
5757client import "./clover-video.client.ts";
5858
5959import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
60
60import { UNWRAP, ASSERT } from "lib/assert.ts";
src/tags/image.tsx+2-1
......@@ -5,7 +5,7 @@ interface Props {
55 objectFit?: string;
66
77 style?: string;
8 className?: string;
8 class?: string;
99}
1010
1111export default function Image(
......@@ -53,3 +53,4 @@ export default function Image(
5353import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
5454import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
5555import { escapeUri } from "@/file-viewer/format.ts";
56import { UNWRAP } from "lib/assert.ts";
suspense.test.tsx created+40
......@@ -0,0 +1,40 @@
1import { test } from "node:test";
2import { renderStreaming, Suspense } from "./suspense.ts";
3
4test("sanity", async (t) => {
5 let resolve: () => void = null!;
6
7 // @ts-expect-error
8 async function AsyncComponent() {
9 await new Promise<void>((done) => resolve = done);
10 return <button>wow!</button>;
11 }
12
13 const example = (
14 <main>
15 <h1>app shell</h1>
16 <Suspense fallback="loading...">
17 <AsyncComponent />
18 </Suspense>
19 <footer>(c) 2025</footer>
20 </main>
21 );
22
23 const iterator = renderStreaming(example);
24 const assertContinue = (actual: unknown, value: unknown) =>
25 t.assert.deepEqual(actual, { done: false, value });
26
27 assertContinue(
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>",
30 );
31 t.assert.ok(resolve !== null), resolve();
32 assertContinue(
33 await iterator.next(),
34 "<button slot=suspended_1>wow!</button>",
35 );
36 t.assert.deepEqual(
37 await iterator.next(),
38 { done: true, value: {} },
39 );
40});
suspense.ts created+116
......@@ -0,0 +1,116 @@
1// TODO: revive the suspense renderer
2/**
3 * This file implements out-of-order HTML streaming, mimicking the React
4 * Suspense API. To use, place Suspense around an expensive async component
5 * and render the page with 'renderStreaming'.
6 *
7 * Implementation of this article:
8 * https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
9 *
10 * I would link to an article from Next.js or React, but their examples
11 * are too verbose and not informative to what they actually do.
12 * @module
13 */
14const userData = render.userData<null | State>(() => null);
15
16export interface Input {
17 children: render.Node;
18 fallback?: render.Node;
19}
20
21/**
22 * When used in combination with `Suspense.renderStreaming`, this component
23 * displays `fallback` until the async `children` `Node` has resolved..
24 *
25 * return <div>
26 * <h1>page shell</h1>
27 * <Suspense fallback="Loading...">
28 * <ComponentWithAwait />
29 * </Suspense>
30 * </div>;
31 */
32export function Suspense({ children, fallback }: Input): render.Node {
33 const state = userData.get();
34 if (!state) return children;
35 if (state.nested) throw new Error("<Suspense> cannot be nested");
36 const parent = UNWRAP(render.current);
37 const r = render.init(true, { [userData.key]: { nested: true } });
38 const resolved = render.resolveNode(r, children);
39 if (r.async == 0) return render.raw(resolved);
40 const name = "suspended_" + ++state.nextId;
41 state.nested = true;
42 const ip: [render.ResolvedNode] = [
43 render.resolvedElement(
44 "slot",
45 { name },
46 fallback ? render.resolveNode(parent, fallback) : "",
47 ),
48 ];
49 state.nested = false;
50 r.asyncDone = () => {
51 const rejections = r.rejections;
52 if (rejections && rejections.length > 0) throw new Error("TODO");
53 state.pushChunk?.(name, ip[0] = resolved);
54 };
55 return render.raw(ip);
56}
57
58// TODO: add a User-Agent parameter, which is used to determine if a
59// fallback path must be used.
60// - Before ~2024 needs to use a JS implementation.
61// - IE should probably bail out entirely.
62export async function* renderStreaming<
63 T extends render.Addons = Record<never, unknown>,
64>(node: render.Node, addon: T = {} as T) {
65 const {
66 text: begin,
67 addon: { [userData.key]: state, ...addonOutput },
68 } = await render.async(node, {
69 ...addon,
70 [userData.key]: {
71 nested: false,
72 nextId: 0,
73 completed: 0,
74 pushChunk: () => {},
75 } satisfies State as State,
76 });
77 if (state.nextId === 0) {
78 yield begin;
79 return addonOutput as unknown as T;
80 }
81 let resolve: (() => void) | null = null;
82 let chunks: string[] = [];
83 state.pushChunk = (slot, node) => {
84 while (node.length === 1 && Array.isArray(node)) node = UNWRAP(node[0]);
85 if (node[0] === render.kElement) {
86 (node as render.ResolvedElement)[2].slot = slot;
87 } else {
88 node = render.resolvedElement("clover-suspense", {
89 style: "display:contents",
90 slot,
91 }, node);
92 }
93 chunks.push(render.stringifyNode(node));
94 resolve?.();
95 };
96 yield `<template shadowrootmode=open>${begin}</template>`;
97 do {
98 await new Promise<void>((done) => (resolve = done));
99 yield* chunks;
100 chunks = [];
101 } while (state!.nextId < state!.completed);
102 return addonOutput as unknown as T;
103}
104
105interface State {
106 nested: boolean;
107 nextId: number;
108 completed: number;
109 pushChunk(name: string, node: render.ResolvedNode): void;
110}
111
112export declare namespace Suspense {
113 export type { Input };
114}
115
116import * as render from "lib/render.ts";
tsconfig.json+1-1
......@@ -4,7 +4,7 @@
44 "baseUrl": ".",
55 "incremental": true,
66 "jsx": "react-jsxdev",
7 "jsxImportSource": "#engine",
7 "jsxImportSource": "#jsx",
88 "lib": ["esnext", "dom", "dom.iterable"],
99 "module": "nodenext",
1010 "noEmit": true,