| author | |
| committer | |
| log | df4fa11a813a4697d6f36f728a19aa83c1b210bc |
| tree | 5f7db819e0b436e3d5072e7397773c2b9e49fa4f |
| parent | 165f4035df4689343605874887574a8c06d0a24b |
| signature | Commit is signed but in an unrecognized format. |
86 files changed, 3218 insertions(+), 2775 deletions(-)
framework/backend/entry-node.ts-2| ... | @@ -1,5 +1,3 @@ | ... | @@ -1,5 +1,3 @@ |
| 1 | import "#debug"; | ||
| 2 | |||
| 3 | const app = require(globalThis.CLOVER_SERVER_ENTRY ?? process.argv[2]); | 1 | const app = require(globalThis.CLOVER_SERVER_ENTRY ?? process.argv[2]); |
| 4 | 2 | ||
| 5 | const protocol = "http"; | 3 | const protocol = "http"; |
framework/backend/entry-passthru.ts deleted-3| ... | @@ -1,3 +0,0 @@ | ||
| 1 | export default app; | ||
| 2 | |||
| 3 | import app from "#backend"; | ||
framework/bundle.ts+4-6| ... | @@ -65,7 +65,7 @@ export async function bundleClientJavaScript( | ... | @@ -65,7 +65,7 @@ export async function bundleClientJavaScript( |
| 65 | format: "esm", | 65 | format: "esm", |
| 66 | jsx: "automatic", | 66 | jsx: "automatic", |
| 67 | jsxDev: dev, | 67 | jsxDev: dev, |
| 68 | jsxImportSource: "#engine", | 68 | jsxImportSource: "#jsx", |
| 69 | logLevel: "silent", | 69 | logLevel: "silent", |
| 70 | metafile: true, | 70 | metafile: true, |
| 71 | minify: !dev, | 71 | minify: !dev, |
| ... | @@ -75,7 +75,6 @@ export async function bundleClientJavaScript( | ... | @@ -75,7 +75,6 @@ export async function bundleClientJavaScript( |
| 75 | external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"], | 75 | external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"], |
| 76 | define: { | 76 | define: { |
| 77 | ASSERT: "console.assert", | 77 | ASSERT: "console.assert", |
| 78 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | ||
| 79 | }, | 78 | }, |
| 80 | }) | 79 | }) |
| 81 | .catch((err: any) => { | 80 | .catch((err: any) => { |
| ... | @@ -296,10 +295,9 @@ export async function bundleServerJavaScript({ | ... | @@ -296,10 +295,9 @@ export async function bundleServerJavaScript({ |
| 296 | write: false, | 295 | write: false, |
| 297 | metafile: true, | 296 | metafile: true, |
| 298 | jsx: "automatic", | 297 | jsx: "automatic", |
| 299 | jsxImportSource: "#engine", | 298 | jsxImportSource: "#jsx", |
| 300 | jsxDev: false, | 299 | jsxDev: false, |
| 301 | define: { | 300 | define: { |
| 302 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | ||
| 303 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), | 301 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), |
| 304 | }, | 302 | }, |
| 305 | external: Object.keys(pkg.dependencies).filter( | 303 | external: Object.keys(pkg.dependencies).filter( |
| ... | @@ -398,10 +396,10 @@ import { | ... | @@ -398,10 +396,10 @@ import { |
| 398 | } from "./esbuild-support.ts"; | 396 | } from "./esbuild-support.ts"; |
| 399 | import { Io, toAbs } from "./incremental.ts"; | 397 | import { Io, toAbs } from "./incremental.ts"; |
| 400 | import * as fs from "#sitegen/fs"; | 398 | import * as fs from "#sitegen/fs"; |
| 401 | import * as mime from "#sitegen/mime"; | ||
| 402 | import * as incr from "./incremental.ts"; | 399 | import * as incr from "./incremental.ts"; |
| 403 | import * as sg from "#sitegen"; | 400 | import * as sg from "#sitegen"; |
| 404 | import type { PageOrView } from "./generate.ts"; | 401 | import type { PageOrView } from "./generate.ts"; |
| 405 | import type * as view from "#sitegen/view"; | 402 | import type * as view from "#sitegen/view"; |
| 406 | import * as meta from "#sitegen/meta"; | 403 | import * as meta from "lib/render/meta.ts"; |
| 407 | import * as progress from "lib/log/progress.ts"; | 404 | import * as progress from "lib/log/progress.ts"; |
| 405 | import { ASSERT, UNWRAP } from "lib/assert.ts"; |
framework/css.ts+2-1| ... | @@ -107,7 +107,7 @@ export async function bundleCssFiles( | ... | @@ -107,7 +107,7 @@ export async function bundleCssFiles( |
| 107 | throw new AggregateError(warnings, "CSS Build Failed"); | 107 | throw new AggregateError(warnings, "CSS Build Failed"); |
| 108 | } | 108 | } |
| 109 | if (outputFiles.length > 1) throw new Error("Too many output files"); | 109 | if (outputFiles.length > 1) throw new Error("Too many output files"); |
| 110 | return outputFiles[0].text; | 110 | return UNWRAP(outputFiles[0]).text; |
| 111 | } | 111 | } |
| 112 | 112 | ||
| 113 | import * as esbuild from "esbuild"; | 113 | import * as esbuild from "esbuild"; |
| ... | @@ -116,3 +116,4 @@ import * as hot from "./hot.ts"; | ... | @@ -116,3 +116,4 @@ import * as hot from "./hot.ts"; |
| 116 | import * as path from "node:path"; | 116 | import * as path from "node:path"; |
| 117 | import { virtualFiles } from "./esbuild-support.ts"; | 117 | import { virtualFiles } from "./esbuild-support.ts"; |
| 118 | import type { Io } from "./incremental.ts"; | 118 | import type { Io } from "./incremental.ts"; |
| 119 | import { UNWRAP } from "lib/assert.ts"; |
framework/debug.safe.ts deleted-18| ... | @@ -1,18 +0,0 @@ | ||
| 1 | process.on("uncaughtException", (error) => { | ||
| 2 | log.error(error); | ||
| 3 | process.exit(1); | ||
| 4 | }); | ||
| 5 | import * as log from "lib/log.ts"; | ||
| 6 | |||
| 7 | globalThis.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 | }; | ||
| 15 | globalThis.ASSERT = assert.ok; | ||
| 16 | |||
| 17 | import * as util from "node:util"; | ||
| 18 | import * as assert from "node:assert"; | ||
framework/engine/jsx-runtime.ts deleted-54| ... | @@ -1,54 +0,0 @@ | ||
| 1 | export const Fragment = ({ children }: { children: render.Node[] }) => children; | ||
| 2 | |||
| 3 | export 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 | |||
| 13 | export 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 | ||
| 38 | export { jsx as jsxs }; | ||
| 39 | |||
| 40 | declare 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 | |||
| 54 | import * as render from "#engine/render"; | ||
framework/engine/marko-runtime.ts+2-8| ... | @@ -2,11 +2,6 @@ | ... | @@ -2,11 +2,6 @@ |
| 2 | // To use, replace the "marko/html" import with this file. | 2 | // To use, replace the "marko/html" import with this file. |
| 3 | export * from "#marko/html"; | 3 | export * from "#marko/html"; |
| 4 | 4 | ||
| 5 | interface BodyContentObject { | ||
| 6 | [x: PropertyKey]: unknown; | ||
| 7 | content: ServerRenderer; | ||
| 8 | } | ||
| 9 | |||
| 10 | export const _template: typeof marko._template = ( | 5 | export const _template: typeof marko._template = ( |
| 11 | templateId, | 6 | templateId, |
| 12 | renderer, | 7 | renderer, |
| ... | @@ -52,7 +47,7 @@ export const _dynamic_tag: typeof marko._dynamic_tag = ( | ... | @@ -52,7 +47,7 @@ export const _dynamic_tag: typeof marko._dynamic_tag = ( |
| 52 | } | 47 | } |
| 53 | const r = render.current ?? (marko.$global().clover as render.State); | 48 | const r = render.current ?? (marko.$global().clover as render.State); |
| 54 | if (!r) throw new Error("No Clover Render Active"); | 49 | 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); |
| 56 | const resolved = render.resolveNode( | 51 | const resolved = render.resolveNode( |
| 57 | subRender, | 52 | subRender, |
| 58 | render.element( | 53 | render.element( |
| ... | @@ -133,7 +128,6 @@ interface Async { | ... | @@ -133,7 +128,6 @@ interface Async { |
| 133 | isAsync: boolean; | 128 | isAsync: boolean; |
| 134 | } | 129 | } |
| 135 | 130 | ||
| 136 | import * as render from "#engine/render"; | ||
| 137 | import type { ServerRenderer } from "marko/html/template"; | ||
| 138 | import type { Template } from "marko/common/types"; | 131 | import type { Template } from "marko/common/types"; |
| 132 | import * as render from "lib/render.ts"; | ||
| 139 | import * as marko from "#marko/html"; | 133 | import * as marko from "#marko/html"; |
framework/engine/render.test.tsx deleted-41| ... | @@ -1,41 +0,0 @@ | ||
| 1 | import { test } from "node:test"; | ||
| 2 | import * as render from "#engine/render"; | ||
| 3 | |||
| 4 | test("sanity", (t) => t.assert.equal(render.sync("gm <3").text, "gm &lt;3")); | ||
| 5 | test("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 | )); | ||
| 16 | test("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 | )); | ||
| 26 | test("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 | ||
| 11 | export let current: State | null = null; | ||
| 12 | export function setCurrent(r: State | null) { | ||
| 13 | current = r ?? null; | ||
| 14 | } | ||
| 15 | |||
| 16 | /* Convert a UI description into a string synchronously. */ | ||
| 17 | export 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. */ | ||
| 24 | export 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 | |||
| 40 | export type Addons = Record<symbol, unknown>; | ||
| 41 | export interface Result<A extends Addons = Addons> { | ||
| 42 | text: string; | ||
| 43 | addon: A; | ||
| 44 | } | ||
| 45 | |||
| 46 | export 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 */ | ||
| 56 | export function raw(node: ResolvedNode): Raw { | ||
| 57 | return [kRaw, node]; | ||
| 58 | } | ||
| 59 | export function element(type: Element[1], props: Element[2] = {}): Element { | ||
| 60 | return [kElement, type, props]; | ||
| 61 | } | ||
| 62 | export function resolvedElement( | ||
| 63 | type: ResolvedElement[1], | ||
| 64 | props: ResolvedElement[2], | ||
| 65 | children: ResolvedElement[3], | ||
| 66 | ): ResolvedElement { | ||
| 67 | return [kElement, type, props, children]; | ||
| 68 | } | ||
| 69 | |||
| 70 | export 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 | |||
| 83 | export const kElement = Symbol("Element"); | ||
| 84 | export const kRaw = Symbol("Raw"); | ||
| 85 | |||
| 86 | /** Node represents a webpage that can be 'rendered' into HTML. */ | ||
| 87 | export 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); | ||
| 96 | export type Element = [ | ||
| 97 | tag: typeof kElement, | ||
| 98 | type: string | Component, | ||
| 99 | props: Record<string, unknown>, | ||
| 100 | _?: "", // children | ||
| 101 | source?: SrcLoc, | ||
| 102 | ]; | ||
| 103 | export 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. */ | ||
| 106 | export type Component = (props: Record<any, any>) => Exclude<Node, undefined>; | ||
| 107 | /** Emitted by JSX runtime */ | ||
| 108 | export 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 | */ | ||
| 119 | export 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 | |||
| 177 | export type ResolvedNode = | ||
| 178 | | ResolvedNode[] // Concat | ||
| 179 | | ResolvedElement // Render | ||
| 180 | | string; // Raw HTML | ||
| 181 | export 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 | */ | ||
| 193 | export 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 | */ | ||
| 199 | export 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 | } | ||
| 215 | function 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 | } | ||
| 270 | export 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 | } | ||
| 282 | export function quoteIfNeeded(text: string) { | ||
| 283 | if (text.match(/["/> ]/)) return '"' + text + '"'; | ||
| 284 | return text; | ||
| 285 | } | ||
| 286 | |||
| 287 | // -- utility functions -- | ||
| 288 | |||
| 289 | export 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 | |||
| 298 | export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; | ||
| 299 | export 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 | |||
| 325 | export 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. | ||
| 333 | const escapeAttribute = (unsafeText: string) => | ||
| 334 | String(unsafeText) | ||
| 335 | .replace(/&/g, "&amp;") | ||
| 336 | .replace(/</g, "&lt;") | ||
| 337 | .replace(/>/g, "&gt;") | ||
| 338 | .replace(/"/g, "&quot;"); | ||
| 339 | /** @deprecated */ | ||
| 340 | export 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 | |||
| 349 | export 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 @@ | ||
| 1 | import { test } from "node:test"; | ||
| 2 | import { renderStreaming, Suspense } from "./suspense.ts"; | ||
| 3 | |||
| 4 | test("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. | ||
| 10 | const userData = render.userData<null | State>(() => null); | ||
| 11 | |||
| 12 | interface SuspenseProps { | ||
| 13 | children: render.Node; | ||
| 14 | fallback?: render.Node; | ||
| 15 | } | ||
| 16 | |||
| 17 | interface State { | ||
| 18 | nested: boolean; | ||
| 19 | nextId: number; | ||
| 20 | completed: number; | ||
| 21 | pushChunk(name: string, node: render.ResolvedNode): void; | ||
| 22 | } | ||
| 23 | |||
| 24 | export 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. | ||
| 54 | export 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 | |||
| 102 | import * as render from "#engine/render"; | ||
framework/font.ts+3-3| ... | @@ -206,9 +206,9 @@ import * as fs from "#sitegen/fs"; | ... | @@ -206,9 +206,9 @@ import * as fs from "#sitegen/fs"; |
| 206 | import * as os from "node:os"; | 206 | import * as os from "node:os"; |
| 207 | import * as path from "node:path"; | 207 | import * as path from "node:path"; |
| 208 | import * as sg from "#sitegen"; | 208 | import * as sg from "#sitegen"; |
| 209 | import * as subprocess from "#sitegen/subprocess"; | 209 | import * as subprocess from "lib/subprocess.ts"; |
| 210 | import * as incr from "./incremental.ts"; | 210 | import * as incr from "./incremental.ts"; |
| 211 | import * as crypto from "node:crypto"; | 211 | import * as crypto from "node:crypto"; |
| 212 | import * as async from "#sitegen/async"; | 212 | import * as async from "lib/async.ts"; |
| 213 | import * as error from "#sitegen/error"; | 213 | import * as error from "lib/error.ts"; |
| 214 | import * as progress from "../lib/log/progress.ts"; | 214 | import * as progress from "../lib/log/progress.ts"; |
framework/generate.ts+8-7| ... | @@ -280,11 +280,11 @@ export async function preparePage( | ... | @@ -280,11 +280,11 @@ export async function preparePage( |
| 280 | if (layout?.default) { | 280 | if (layout?.default) { |
| 281 | page = render.element(layout.default, { children: page }); | 281 | page = render.element(layout.default, { children: page }); |
| 282 | } | 282 | } |
| 283 | const bodyPromise = render.async(page, { | ||
| 284 | [sg.userData.key]: sg.initRender(), | ||
| 285 | }); | ||
| 286 | 283 | ||
| 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([ | ||
| 288 | bodyPromise, | 288 | bodyPromise, |
| 289 | renderedMetaPromise, | 289 | renderedMetaPromise, |
| 290 | ]); | 290 | ]); |
| ... | @@ -305,7 +305,7 @@ export async function preparePage( | ... | @@ -305,7 +305,7 @@ export async function preparePage( |
| 305 | cssImports, | 305 | cssImports, |
| 306 | theme: theme ?? null, | 306 | theme: theme ?? null, |
| 307 | styleKey, | 307 | styleKey, |
| 308 | clientRefs: Array.from(UNWRAP(addon[sg.userData.key]).scripts), | 308 | clientRefs: Array.from(sitegen.scripts), |
| 309 | } as const; | 309 | } as const; |
| 310 | } | 310 | } |
| 311 | 311 | ||
| ... | @@ -397,8 +397,8 @@ export type PageOrView = PreparedPage | PreparedView; | ... | @@ -397,8 +397,8 @@ export type PageOrView = PreparedPage | PreparedView; |
| 397 | import * as path from "node:path"; | 397 | import * as path from "node:path"; |
| 398 | 398 | ||
| 399 | import * as fs from "#sitegen/fs"; | 399 | import * as fs from "#sitegen/fs"; |
| 400 | import * as meta from "#sitegen/meta"; | 400 | import * as meta from "lib/render/meta.ts"; |
| 401 | import * as render from "#engine/render"; | 401 | import * as render from "lib/render.ts"; |
| 402 | import * as sg from "#sitegen"; | 402 | import * as sg from "#sitegen"; |
| 403 | import type { FileItem } from "#sitegen"; | 403 | import type { FileItem } from "#sitegen"; |
| 404 | import * as progress from "../lib/log/progress.ts"; | 404 | import * as progress from "../lib/log/progress.ts"; |
| ... | @@ -409,3 +409,4 @@ import * as fonts from "./font.ts"; | ... | @@ -409,3 +409,4 @@ import * as fonts from "./font.ts"; |
| 409 | import * as hot from "./hot.ts"; | 409 | import * as hot from "./hot.ts"; |
| 410 | import * as incr from "./incremental.ts"; | 410 | import * as incr from "./incremental.ts"; |
| 411 | import { Io } from "./incremental.ts"; | 411 | import { Io } from "./incremental.ts"; |
| 412 | import { ASSERT, UNWRAP } from "lib/assert.ts"; |
framework/global.d.ts-3| ... | @@ -1,5 +1,2 @@ | ... | @@ -1,5 +1,2 @@ |
| 1 | declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T; | ||
| 2 | declare function ASSERT(value: unknown, message?: string): asserts value; | ||
| 3 | |||
| 4 | type Timer = ReturnType<typeof setTimeout>; | 1 | type Timer = ReturnType<typeof setTimeout>; |
| 5 | type Awaitable<T> = T | Promise<T>; | 2 | type Awaitable<T> = T | Promise<T>; |
framework/hot.ts+8-4| ... | @@ -149,7 +149,7 @@ export function loadEsbuildCode( | ... | @@ -149,7 +149,7 @@ export function loadEsbuildCode( |
| 149 | format: "cjs", | 149 | format: "cjs", |
| 150 | target: "esnext", | 150 | target: "esnext", |
| 151 | jsx: "automatic", | 151 | jsx: "automatic", |
| 152 | jsxImportSource: "#engine", | 152 | jsxImportSource: "#jsx", |
| 153 | jsxDev: true, | 153 | jsxDev: true, |
| 154 | sourcefile: filepath, | 154 | sourcefile: filepath, |
| 155 | sourcemap: "inline", | 155 | sourcemap: "inline", |
| ... | @@ -174,7 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { | ... | @@ -174,7 +174,7 @@ function loadMarko(module: NodeJS.Module, filepath: string) { |
| 174 | 174 | ||
| 175 | function loadMdx(module: NodeJS.Module, filepath: string) { | 175 | function loadMdx(module: NodeJS.Module, filepath: string) { |
| 176 | const input = fs.readFileSync(filepath); | 176 | const input = fs.readFileSync(filepath); |
| 177 | const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value; | 177 | const out = mdx.compileSync(input, { jsxImportSource: "#jsx" }).value; |
| 178 | const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8"); | 178 | const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8"); |
| 179 | return loadEsbuildCode(module, filepath, src); | 179 | return loadEsbuildCode(module, filepath, src); |
| 180 | } | 180 | } |
| ... | @@ -241,7 +241,10 @@ export function resolveFrom(src: string, dest: string) { | ... | @@ -241,7 +241,10 @@ export function resolveFrom(src: string, dest: string) { |
| 241 | try { | 241 | try { |
| 242 | return createRequire(src).resolve(dest); | 242 | return createRequire(src).resolve(dest); |
| 243 | } catch (err: any) { | 243 | } 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 | ) { | ||
| 245 | err.message = err.message.split("\n")[0] + " from '" + src + "'"; | 248 | err.message = err.message.split("\n")[0] + " from '" + src + "'"; |
| 246 | } | 249 | } |
| 247 | throw err; | 250 | throw err; |
| ... | @@ -262,7 +265,7 @@ export function resolveClientRefs( | ... | @@ -262,7 +265,7 @@ export function resolveClientRefs( |
| 262 | // This match finds a call to 'import ... from "#sitegen"' | 265 | // This match finds a call to 'import ... from "#sitegen"' |
| 263 | const importMatch = code.match(importRegExp); | 266 | const importMatch = code.match(importRegExp); |
| 264 | if (!importMatch) return { code, refs: [] }; | 267 | if (!importMatch) return { code, refs: [] }; |
| 265 | const items = importMatch[1]; | 268 | const items = UNWRAP(importMatch[1]); |
| 266 | let identifier = ""; | 269 | let identifier = ""; |
| 267 | if (items.startsWith("{")) { | 270 | if (items.startsWith("{")) { |
| 268 | const clauseMatch = items.match(getSitegenAddScriptRegExp); | 271 | const clauseMatch = items.match(getSitegenAddScriptRegExp); |
| ... | @@ -322,3 +325,4 @@ import { createRequire } from "node:module"; | ... | @@ -322,3 +325,4 @@ import { createRequire } from "node:module"; |
| 322 | import * as mdx from "@mdx-js/mdx"; | 325 | import * as mdx from "@mdx-js/mdx"; |
| 323 | import * as self from "./hot.ts"; | 326 | import * as self from "./hot.ts"; |
| 324 | import { Buffer } from "node:buffer"; | 327 | import { Buffer } from "node:buffer"; |
| 328 | import { UNWRAP } from "lib/assert.ts"; |
framework/incremental.ts+8-5| ... | @@ -495,11 +495,13 @@ export class Io { | ... | @@ -495,11 +495,13 @@ export class Io { |
| 495 | } | 495 | } |
| 496 | return resolved; | 496 | return resolved; |
| 497 | } | 497 | } |
| 498 | async readFile(file: string) { | 498 | async readFile(file: string): Promise<Buffer>; |
| 499 | return fs.readFile(await this.trackFile(file)); | 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); | ||
| 500 | } | 502 | } |
| 501 | async readJson<T>(file: string) { | 503 | 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; |
| 503 | } | 505 | } |
| 504 | async readDir(dir: string) { | 506 | async readDir(dir: string) { |
| 505 | const { key, resolved } = this.#trackFs(dir); | 507 | const { key, resolved } = this.#trackFs(dir); |
| ... | @@ -585,7 +587,7 @@ export class Io { | ... | @@ -585,7 +587,7 @@ export class Io { |
| 585 | const headers = new Headers(asset.headers ?? {}); | 587 | const headers = new Headers(asset.headers ?? {}); |
| 586 | const hash = crypto.createHash("sha1").update(buffer).digest("hex"); | 588 | const hash = crypto.createHash("sha1").update(buffer).digest("hex"); |
| 587 | if (!headers.has("Content-Type")) { | 589 | if (!headers.has("Content-Type")) { |
| 588 | headers.set("Content-Type", mime.contentTypeFor(asset.pathname)); | 590 | headers.set("Content-Type", mime.for(asset.pathname)); |
| 589 | } | 591 | } |
| 590 | headers.set("etag", JSON.stringify(hash)); | 592 | headers.set("etag", JSON.stringify(hash)); |
| 591 | this.writes.assets.set(asset.pathname, { | 593 | this.writes.assets.set(asset.pathname, { |
| ... | @@ -726,7 +728,7 @@ const zstdCompress = util.promisify(zlib.zstdCompress); | ... | @@ -726,7 +728,7 @@ const zstdCompress = util.promisify(zlib.zstdCompress); |
| 726 | import * as hot from "./hot.ts"; | 728 | import * as hot from "./hot.ts"; |
| 727 | 729 | ||
| 728 | import * as fs from "#sitegen/fs"; | 730 | import * as fs from "#sitegen/fs"; |
| 729 | import * as mime from "#sitegen/mime"; | 731 | import * as mime from "lib/mime.ts"; |
| 730 | import * as manifest from "#sitegen/assets"; | 732 | import * as manifest from "#sitegen/assets"; |
| 731 | 733 | ||
| 732 | import * as path from "node:path"; | 734 | import * as path from "node:path"; |
| ... | @@ -739,3 +741,4 @@ import * as stack from "lib/log/stack.ts"; | ... | @@ -739,3 +741,4 @@ import * as stack from "lib/log/stack.ts"; |
| 739 | import { formatSize } from "@/file-viewer/format.ts"; | 741 | import { formatSize } from "@/file-viewer/format.ts"; |
| 740 | 742 | ||
| 741 | import * as msgpackr from "msgpackr"; | 743 | import * as msgpackr from "msgpackr"; |
| 744 | import { ASSERT, UNWRAP } from "lib/assert.ts"; |
framework/lib/assets.ts+1| ... | @@ -286,3 +286,4 @@ import * as crypto from "node:crypto"; | ... | @@ -286,3 +286,4 @@ import * as crypto from "node:crypto"; |
| 286 | import * as zlib from "node:zlib"; | 286 | import * as zlib from "node:zlib"; |
| 287 | import * as util from "node:util"; | 287 | import * as util from "node:util"; |
| 288 | import * as log from "lib/log.ts"; | 288 | import * as log from "lib/log.ts"; |
| 289 | import { ASSERT, UNWRAP } from "lib/assert.ts"; |
framework/lib/async.ts deleted-218| ... | @@ -1,218 +0,0 @@ | ||
| 1 | const five_minutes = 5 * 60 * 1000; | ||
| 2 | |||
| 3 | /** @deprecated use OnceMap2 */ | ||
| 4 | export 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 | |||
| 18 | interface ARCEValue<T> { | ||
| 19 | value: T; | ||
| 20 | [Symbol.dispose]: () => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | export 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 | |||
| 77 | export 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 | |||
| 91 | export 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 | } | ||
| 132 | type 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 | */ | ||
| 138 | export 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 | */ | ||
| 164 | export 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 | |||
| 186 | export 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 @@ | ||
| 1 | declare 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 */ | ||
| 9 | export 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. */ | ||
| 21 | export 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 */ | ||
| 30 | export 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 */ | ||
| 36 | export 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 | |||
| 45 | export type NodeErrorCode = | ||
| 46 | | keyof typeof import("node:os").constants.errno | ||
| 47 | | (string & {}); | ||
framework/lib/lru.test.ts deleted-137| ... | @@ -1,137 +0,0 @@ | ||
| 1 | import { test } from "node:test"; | ||
| 2 | import { Lru } from "./lru.ts"; | ||
| 3 | |||
| 4 | test("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 | }); | ||
| 64 | test("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 | }); | ||
| 106 | test("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 | }); | ||
| 114 | test("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 | }); | ||
| 132 | test("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 @@ | ||
| 1 | interface 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. */ | ||
| 15 | export 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. */ | ||
| 232 | export type Serialized<K, V> = Array<[K, V, number]>; | ||
| 233 | |||
| 234 | interface SerializeOptions<IK, IV, OK = IK, OV = IV> { | ||
| 235 | /** null = omit in serialization/serialization */ | ||
| 236 | map?: (key: IK, value: IV) => [OK, OV] | null; | ||
| 237 | } | ||
| 238 | |||
| 239 | type 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 | |||
| 250 | interface Lock extends Disposable { | ||
| 251 | (): void; | ||
| 252 | } | ||
| 253 | |||
| 254 | export declare namespace Lru { | ||
| 255 | export type { Lock, Serialized, SerializeOptions }; | ||
| 256 | } | ||
| 257 | |||
| 258 | const none: unique symbol = Symbol(); | ||
| 259 | type none = typeof none; | ||
framework/lib/meta.ts deleted-256| ... | @@ -1,256 +0,0 @@ | ||
| 1 | export { renderMetadata as render }; | ||
| 2 | export 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 | } | ||
| 51 | export type Alternate = { type: string; url: string } | { | ||
| 52 | lang: string; | ||
| 53 | url: string; | ||
| 54 | }; | ||
| 55 | export 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. */ | ||
| 68 | export 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 | */ | ||
| 97 | type OpenGraphField = | ||
| 98 | | string | ||
| 99 | | { [field: string]: OpenGraphField } | ||
| 100 | | Array<OpenGraphField> | ||
| 101 | | (null | undefined); | ||
| 102 | /** Twitter uses various OpenGraph fields if these are not specified. */ | ||
| 103 | export 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 | } | ||
| 113 | export interface Template | ||
| 114 | extends Omit<Meta, "title" | "description" | "canonical"> { | ||
| 115 | base: URL; | ||
| 116 | titleTemplate?: (title: string) => string; | ||
| 117 | } | ||
| 118 | export interface Alternates { | ||
| 119 | canonical: string; | ||
| 120 | types: { [mime: string]: AlternateType }; | ||
| 121 | } | ||
| 122 | export interface AlternateType { | ||
| 123 | url: string; | ||
| 124 | title: string; | ||
| 125 | } | ||
| 126 | export 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. */ | ||
| 136 | function 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 | |||
| 236 | function 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 | |||
| 251 | function attr(value: string) { | ||
| 252 | return render.quoteIfNeeded(esc(value)); | ||
| 253 | } | ||
| 254 | |||
| 255 | import { escapeHtmlContent as esc } from "#engine/render"; | ||
| 256 | import * as render from "#engine/render"; | ||
framework/lib/mime.ts deleted-32| ... | @@ -1,32 +0,0 @@ | ||
| 1 | declare const MIME_INLINE_DATA: never; | ||
| 2 | const 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]); | ||
| 9 | export const rawEntriesText = entries; | ||
| 10 | const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); | ||
| 11 | const 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 | */ | ||
| 18 | export 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 | |||
| 31 | import * as fs from "#sitegen/fs"; | ||
| 32 | import * 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 | ||
| 100 | rss.xml application/rss+xml | ||
framework/lib/path.ts+2-2| ... | @@ -207,6 +207,6 @@ export class Path { | ... | @@ -207,6 +207,6 @@ export class Path { |
| 207 | import * as path from "node:path"; | 207 | import * as path from "node:path"; |
| 208 | import * as fs from "./fs.ts"; | 208 | import * as fs from "./fs.ts"; |
| 209 | import * as util from "node:util"; | 209 | import * as util from "node:util"; |
| 210 | import * as error from "./error.ts"; | 210 | import * as error from "lib/error.ts"; |
| 211 | import type { RmOptions } from "node:fs"; | 211 | import type { RmOptions } from "node:fs"; |
| 212 | import type { Serialized } from "./lru.ts"; | 212 | import { ASSERT } from "lib/assert.ts"; |
framework/lib/sitegen.ts+7-12| ... | @@ -50,19 +50,13 @@ export type FontVars = Record<string, FontVariableAxis>; | ... | @@ -50,19 +50,13 @@ export type FontVars = Record<string, FontVariableAxis>; |
| 50 | export type FontVariableAxis = number | [min: number, max: number]; | 50 | export type FontVariableAxis = number | [min: number, max: number]; |
| 51 | export type FontRange = string | number | { start: number; end: number }; | 51 | export type FontRange = string | number | { start: number; end: number }; |
| 52 | 52 | ||
| 53 | export const userData = render.userData<SitegenRender>(() => initRender()); | 53 | export const context = new render.Context(() => ({ |
| 54 | 54 | scripts: new Set<string>(), | |
| 55 | export interface SitegenRender { | 55 | })); |
| 56 | scripts: Set<string>; | ||
| 57 | } | ||
| 58 | |||
| 59 | export function initRender(): SitegenRender { | ||
| 60 | return { scripts: new Set() }; | ||
| 61 | } | ||
| 62 | 56 | ||
| 63 | /** Add a client-side script to the page. */ | 57 | /** Add a client-side script to the page. */ |
| 64 | export function addScript(id: ScriptId | { value: ScriptId }) { | 58 | export 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); |
| 66 | } | 60 | } |
| 67 | 61 | ||
| 68 | export function wrapDocument({ | 62 | export function wrapDocument({ |
| ... | @@ -91,6 +85,7 @@ export function joinScripts(scriptSources: string[]) { | ... | @@ -91,6 +85,7 @@ export function joinScripts(scriptSources: string[]) { |
| 91 | "].map(e=>e())"; | 85 | "].map(e=>e())"; |
| 92 | } | 86 | } |
| 93 | 87 | ||
| 94 | import * as render from "#engine/render"; | 88 | import * as render from "lib/render.ts"; |
| 95 | import type * as meta from "./meta.ts"; | 89 | import type * as meta from "lib/render/meta.ts"; |
| 96 | import type * as css from "../css.ts"; | 90 | import type * as css from "../css.ts"; |
| 91 | import { 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 | |||
| 4 | const execFileRaw = util.promisify(child_process.execFile); | ||
| 5 | export 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 | |||
| 19 | export interface SpawnOptions extends child_process.SpawnOptions { | ||
| 20 | cmd: string[]; | ||
| 21 | } | ||
| 22 | export 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 | |||
| 33 | export interface ForkOptions extends child_process.ForkOptions { | ||
| 34 | cmd: string[]; | ||
| 35 | } | ||
| 36 | |||
| 37 | export 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 | |||
| 48 | export 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"; | ||
| 80 | import * as events from "node:events"; | ||
| 81 | import * as util from "node:util"; | ||
| 82 | import * as child_process from "node:child_process"; | ||
framework/lib/view.ts+6-7| ... | @@ -73,10 +73,8 @@ export async function renderToString<K extends Key>( | ... | @@ -73,10 +73,8 @@ export async function renderToString<K extends Key>( |
| 73 | // -- html -- | 73 | // -- html -- |
| 74 | let page: render.Element = render.element(component, props); | 74 | let page: render.Element = render.element(component, props); |
| 75 | if (layout) page = render.element(layout, { children: page }); | 75 | if (layout) page = render.element(layout, { children: page }); |
| 76 | const { | 76 | const sitegen = sg.context.init(); |
| 77 | text: body, | 77 | const { text: body } = await render.async(page, [sitegen]); |
| 78 | addon: { [sg.userData.key]: sitegen }, | ||
| 79 | } = await render.async(page, { [sg.userData.key]: sg.initRender() }); | ||
| 80 | 78 | ||
| 81 | // -- join document and send -- | 79 | // -- join document and send -- |
| 82 | return sg.wrapDocument({ | 80 | return sg.wrapDocument({ |
| ... | @@ -85,7 +83,7 @@ export async function renderToString<K extends Key>( | ... | @@ -85,7 +83,7 @@ export async function renderToString<K extends Key>( |
| 85 | inlineCss, | 83 | inlineCss, |
| 86 | scripts: sg.joinScripts( | 84 | scripts: sg.joinScripts( |
| 87 | Array.from( | 85 | Array.from( |
| 88 | sitegen!.scripts, | 86 | sitegen.scripts, |
| 89 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), | 87 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), |
| 90 | ), | 88 | ), |
| 91 | ), | 89 | ), |
| ... | @@ -108,12 +106,13 @@ export function regenerate(tag: RegenKey) { | ... | @@ -108,12 +106,13 @@ export function regenerate(tag: RegenKey) { |
| 108 | } | 106 | } |
| 109 | } | 107 | } |
| 110 | 108 | ||
| 111 | import * as meta from "./meta.ts"; | 109 | import * as meta from "lib/render/meta.ts"; |
| 112 | import type * as hono from "#hono"; | 110 | import type * as hono from "#hono"; |
| 113 | import * as render from "#engine/render"; | 111 | import * as render from "lib/render.ts"; |
| 114 | import * as sg from "./sitegen.ts"; | 112 | import * as sg from "./sitegen.ts"; |
| 115 | import * as asset from "./assets.ts"; | 113 | import * as asset from "./assets.ts"; |
| 116 | import type { | 114 | import type { |
| 117 | RegenKey, | 115 | RegenKey, |
| 118 | RegisteredViews as ViewMap, | 116 | RegisteredViews as ViewMap, |
| 119 | } from "../../.clover/ts/view.d.ts"; | 117 | } from "../../.clover/ts/view.d.ts"; |
| 118 | import { UNWRAP } from "lib/assert.ts"; |
framework/marko.ts+1-1| ... | @@ -35,7 +35,7 @@ export function loadMarko(module: NodeJS.Module, filepath: string) { | ... | @@ -35,7 +35,7 @@ export function loadMarko(module: NodeJS.Module, filepath: string) { |
| 35 | // workaround an esbuild bug? | 35 | // workaround an esbuild bug? |
| 36 | (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/"; | 36 | (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/"; |
| 37 | compiled.map.sources[0] = filepath; | 37 | 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") + |
| 39 | "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," + | 39 | "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," + |
| 40 | Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64"); | 40 | Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64"); |
| 41 | cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; | 41 | cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; |
framework/watch.ts+1-1| ... | @@ -170,4 +170,4 @@ import * as generate from "./generate.ts"; | ... | @@ -170,4 +170,4 @@ import * as generate from "./generate.ts"; |
| 170 | import * as incr from "./incremental.ts"; | 170 | import * as incr from "./incremental.ts"; |
| 171 | import * as path from "node:path"; | 171 | import * as path from "node:path"; |
| 172 | import * as util from "node:util"; | 172 | import * as util from "node:util"; |
| 173 | import * as subprocess from "#sitegen/subprocess"; | 173 | import * as subprocess from "lib/subprocess.ts"; |
lib/Lru.test.ts created+138| ... | @@ -0,0 +1,138 @@ | ||
| 1 | test("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 | }); | ||
| 61 | test("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 | }); | ||
| 103 | test("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 | }); | ||
| 111 | test("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 | }); | ||
| 129 | test("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 | |||
| 136 | import assert from "node:assert"; | ||
| 137 | import { test } from "node:test"; | ||
| 138 | import { 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 | |||
| 12 | interface 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 | */ | ||
| 33 | export 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. */ | ||
| 247 | type Serialized<K, V> = Array<[K, V, number]>; | ||
| 248 | |||
| 249 | interface SerializeOptions<IK, IV, OK = IK, OV = IV> { | ||
| 250 | /** null = omit in serialization/serialization */ | ||
| 251 | map?: (key: IK, value: IV) => [OK, OV] | null; | ||
| 252 | } | ||
| 253 | |||
| 254 | type 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 | |||
| 265 | export declare namespace Lru { | ||
| 266 | export type { Serialized, SerializeOptions }; | ||
| 267 | } | ||
| 268 | |||
| 269 | const none: unique symbol = Symbol(); | ||
| 270 | type none = typeof none; | ||
| 271 | |||
| 272 | import { ASSERT, UNWRAP } from "lib/assert.ts"; | ||
| 273 | import * as ts from "lib/ts.ts"; | ||
lib/assert.ts+10-6| ... | @@ -1,13 +1,17 @@ | ... | @@ -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 | */ | ||
| 2 | 6 | ||
| 3 | /** Assert that `condition` is truthy */ | 7 | /** assert that `condition` is truthy. */ |
| 4 | export function ASSERT(condition: boolean, msg?: Message): asserts condition { | 8 | export function ASSERT(condition: unknown, msg?: Message): asserts condition { |
| 5 | if (!condition) { | 9 | if (!condition) { |
| 6 | throw new Error(msg ? messageToString(msg) : "Assertion Error"); | 10 | throw new Error(msg ? messageToString(msg) : "Assertion Error"); |
| 7 | } | 11 | } |
| 8 | } | 12 | } |
| 9 | 13 | ||
| 10 | /** Assert that `nullable` is non-null, returning a non-nullable T */ | 14 | /** assert that `nullable` is non-null, returning a non-nullable T. */ |
| 11 | export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { | 15 | export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { |
| 12 | if (nullable == null) { | 16 | if (nullable == null) { |
| 13 | throw new Error( | 17 | throw new Error( |
| ... | @@ -17,7 +21,7 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { | ... | @@ -17,7 +21,7 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { |
| 17 | return nullable; | 21 | return nullable; |
| 18 | } | 22 | } |
| 19 | 23 | ||
| 20 | /** Assert that `number` is not NaN */ | 24 | /** assert that `number` is not NaN. */ |
| 21 | export function FINITE(number: number, msg?: Message): number { | 25 | export function FINITE(number: number, msg?: Message): number { |
| 22 | if (Number.isNaN(number)) { | 26 | if (Number.isNaN(number)) { |
| 23 | throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`); | 27 | throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`); |
| ... | @@ -25,7 +29,7 @@ export function FINITE(number: number, msg?: Message): number { | ... | @@ -25,7 +29,7 @@ export function FINITE(number: number, msg?: Message): number { |
| 25 | return number; | 29 | return number; |
| 26 | } | 30 | } |
| 27 | 31 | ||
| 28 | /** Assert that a `default` case in a switch is never reached. */ | 32 | /** assert that a `default` case in a switch is never reached. */ |
| 29 | export function unreachableCase(value: never): never { | 33 | export function unreachableCase(value: never): never { |
| 30 | throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); | 34 | throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); |
| 31 | } | 35 | } |
lib/async.ts created+207| ... | @@ -0,0 +1,207 @@ | ||
| 1 | const five_minutes = 5 * 60 * 1000; | ||
| 2 | |||
| 3 | /*** @deprecated */ | ||
| 4 | interface ARCEValue<T> { | ||
| 5 | value: T; | ||
| 6 | [Symbol.dispose]: () => void; | ||
| 7 | } | ||
| 8 | |||
| 9 | /*** @deprecated */ | ||
| 10 | export 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 | |||
| 64 | export 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 | |||
| 78 | export 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 | } | ||
| 119 | type 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 | */ | ||
| 125 | export 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 | */ | ||
| 151 | export 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 | |||
| 173 | export 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 | |||
| 207 | import { ASSERT } from "lib/assert.ts"; | ||
lib/deno.json+22-6| ... | @@ -1,24 +1,40 @@ | ... | @@ -1,24 +1,40 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/lib", | 2 | "name": "@clo/lib", |
| 3 | "description": "", | 3 | "description": "high quality typescript libraries", |
| 4 | "version": "1.0.0", | 4 | "version": "2.0.0", |
| 5 | "license": "MIT", | 5 | "license": "MIT", |
| 6 | "exports": { | 6 | "exports": { |
| 7 | "./assert.ts": "./assert.ts", | 7 | "./assert.ts": "./assert.ts", |
| 8 | "./async.ts": "./async.ts", | ||
| 9 | "./error.ts": "./error.ts", | ||
| 8 | "./log.ts": "./log.ts", | 10 | "./log.ts": "./log.ts", |
| 9 | "./log/ansi.ts": "./log/ansi.ts", | ||
| 10 | "./log/headless.ts": "./log/headless.ts", | 11 | "./log/headless.ts": "./log/headless.ts", |
| 11 | "./log/progress.ts": "./log/progress.ts", | 12 | "./log/progress.ts": "./log/progress.ts", |
| 12 | "./log/stack.ts": "./log/stack.ts", | 13 | "./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", | ||
| 14 | "./queue.ts": "./queue.ts", | 17 | "./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", | ||
| 15 | "./string.ts": "./string.ts", | 21 | "./string.ts": "./string.ts", |
| 22 | "./string/ansi.ts": "./string/ansi.ts", | ||
| 23 | "./subprocess.ts": "./subprocess.ts", | ||
| 24 | "./subprocess/ffmpeg.ts": "./subprocess/ffmpeg.ts", | ||
| 16 | "./ts.ts": "./ts.ts" | 25 | "./ts.ts": "./ts.ts" |
| 17 | }, | 26 | }, |
| 27 | "compilerOptions": { | ||
| 28 | "jsx": "react-jsx", | ||
| 29 | "jsxImportSource": "lib/render" | ||
| 30 | }, | ||
| 18 | "imports": { | 31 | "imports": { |
| 19 | "lib/": "./" | 32 | "lib/": "./", |
| 33 | "lib/render/jsx-runtime": "./render/jsx-runtime.ts", | ||
| 34 | "lib/render/jsx-dev-runtime": "./render/jsx-runtime.ts" | ||
| 20 | }, | 35 | }, |
| 21 | "exclude": [ | 36 | "exclude": [ |
| 22 | "readme.npm.md" | 37 | "readme.npm.md", |
| 38 | "deno.lock" | ||
| 23 | ] | 39 | ] |
| 24 | } | 40 | } |
| \ No newline at end of file | |||
lib/deno.lock+5-159| ... | @@ -1,171 +1,17 @@ | ... | @@ -1,171 +1,17 @@ |
| 1 | { | 1 | { |
| 2 | "version": "5", | 2 | "version": "5", |
| 3 | "specifiers": { | 3 | "specifiers": { |
| 4 | "npm:@types/node@*": "24.2.0", | 4 | "npm:@types/node@*": "22.15.15" |
| 5 | "npm:ts-to-jsdoc@2.4.0": "2.4.0" | ||
| 6 | }, | 5 | }, |
| 7 | "npm": { | 6 | "npm": { |
| 8 | "@nodelib/fs.scandir@2.1.5": { | 7 | "@types/node@22.15.15": { |
| 9 | "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", | 8 | "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", |
| 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==", | ||
| 36 | "dependencies": [ | 9 | "dependencies": [ |
| 37 | "undici-types" | 10 | "undici-types" |
| 38 | ] | 11 | ] |
| 39 | }, | 12 | }, |
| 40 | "arg@5.0.2": { | 13 | "undici-types@6.21.0": { |
| 41 | "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" | 14 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" |
| 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==" | ||
| 169 | } | 15 | } |
| 170 | } | 16 | } |
| 171 | } | 17 | } |
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. */ | ||
| 9 | export interface Error extends globalThis.Error { | ||
| 10 | [metadata: string]: unknown; | ||
| 11 | } | ||
| 12 | |||
| 13 | /** retrieve an error message from any value */ | ||
| 14 | export 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. */ | ||
| 26 | export 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. */ | ||
| 35 | export 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. */ | ||
| 41 | export 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 | |||
| 50 | import type * as node from "lib/node.ts"; | ||
lib/log.test.ts+59-59| ... | @@ -5,6 +5,64 @@ import * as log from "lib/log.ts"; | ... | @@ -5,6 +5,64 @@ import * as log from "lib/log.ts"; |
| 5 | 5 | ||
| 6 | log satisfies log.Scope; // the namespace import is a valid Scope | 6 | log satisfies log.Scope; // the namespace import is a valid Scope |
| 7 | 7 | ||
| 8 | test("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 | |||
| 21 | test("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 | |||
| 62 | function sync(text: string[]) { | ||
| 63 | return ansi.syncStart + text.join("") + ansi.syncEnd; | ||
| 64 | } | ||
| 65 | |||
| 8 | class TestWidgetHost implements headless.WidgetHost { | 66 | class TestWidgetHost implements headless.WidgetHost { |
| 9 | columns = 80; | 67 | columns = 80; |
| 10 | rows = 33; | 68 | rows = 33; |
| ... | @@ -103,62 +161,4 @@ class TestWidgetHost implements headless.WidgetHost { | ... | @@ -103,62 +161,4 @@ class TestWidgetHost implements headless.WidgetHost { |
| 103 | } | 161 | } |
| 104 | } | 162 | } |
| 105 | 163 | ||
| 106 | test("writeLine buffers", () => { | 164 | import * as ansi from "lib/string/ansi.ts"; |
| 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 | |||
| 119 | test("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 | |||
| 160 | function sync(text: string[]) { | ||
| 161 | return ansi.syncStart + text.join("") + ansi.syncEnd; | ||
| 162 | } | ||
| 163 | |||
| 164 | import * as ansi from "lib/log/ansi.ts"; |
lib/log.ts+21-13| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | /** | 1 | /** |
| 2 | * By using `lib/log.ts`, your application gets easy scoped logging as well as | 2 | * 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 | 3 | * integration with terminal widgets such as `lib/log/progress.ts`. |
| 4 | * for using this module is to shadow the global `console` with a local scope: | 4 | * |
| 5 | * The pattern for using this module is to shadow the global `console` with a | ||
| 6 | * local scope: | ||
| 5 | * | 7 | * |
| 6 | * import * as log from "@clo/lib/log"; | 8 | * import * as log from "@clo/lib/log"; |
| 7 | * const console = log.scoped("http"); | 9 | * const console = log.scoped("http"); |
| ... | @@ -36,6 +38,7 @@ export interface Scope { | ... | @@ -36,6 +38,7 @@ export interface Scope { |
| 36 | /** | 38 | /** |
| 37 | * emit a debugging message. this method is in place for compatibility with | 39 | * emit a debugging message. this method is in place for compatibility with |
| 38 | * the `console` API. prefer calling `.log` directly. | 40 | * the `console` API. prefer calling `.log` directly. |
| 41 | * @internal | ||
| 39 | */ | 42 | */ |
| 40 | debug(...args: unknown[]): void; | 43 | debug(...args: unknown[]): void; |
| 41 | 44 | ||
| ... | @@ -48,33 +51,38 @@ export function scoped(name: string): Scope { | ... | @@ -48,33 +51,38 @@ export function scoped(name: string): Scope { |
| 48 | return globalLog.scoped(name); | 51 | return globalLog.scoped(name); |
| 49 | } | 52 | } |
| 50 | 53 | ||
| 51 | // These functions implement `Scope`. If a function takes in `Scope`, the | 54 | // these functions implement `Scope`. If a function takes in `Scope`, the |
| 52 | // namespace import of this file can be passed to it. | 55 | // namespace import of this file can be passed to it. |
| 53 | 56 | ||
| 54 | /** informational */ | 57 | /** emit an informational message on the global log scope */ |
| 55 | export function info(...args: unknown[]) { | 58 | export function info(...args: unknown[]) { |
| 56 | globalLog.info(...args); | 59 | globalLog.info(...args); |
| 57 | } | 60 | } |
| 58 | /** advisory */ | 61 | /** emit an advisory message on the global log scope */ |
| 59 | export function warn(...args: unknown[]) { | 62 | export function warn(...args: unknown[]) { |
| 60 | globalLog.warn(...args); | 63 | globalLog.warn(...args); |
| 61 | } | 64 | } |
| 62 | /** failure */ | 65 | /** emit an failure message on the global log scope */ |
| 63 | export function error(...args: unknown[]) { | 66 | export function error(...args: unknown[]) { |
| 64 | globalLog.error(...args); | 67 | globalLog.error(...args); |
| 65 | } | 68 | } |
| 66 | /** debugging */ | 69 | /** emit an debug message on the global log scope */ |
| 67 | export function log(...args: unknown[]) { | 70 | export function log(...args: unknown[]) { |
| 68 | globalLog.log(...args); | 71 | globalLog.log(...args); |
| 69 | } | 72 | } |
| 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 | */ | ||
| 71 | export function debug(...args: unknown[]) { | 78 | export function debug(...args: unknown[]) { |
| 72 | globalLog.debug(...args); | 79 | globalLog.debug(...args); |
| 73 | } | 80 | } |
| 74 | 81 | ||
| 75 | /** | 82 | /** |
| 76 | * no built-in prefix or formatting. ensures the text does not interweave. | 83 | * no built-in prefix or formatting. ensures the text does not interweave. data |
| 77 | * data will be flushed in the next frame or when drawing is unlocked. | 84 | * will be flushed in the next frame or when drawing is |
| 85 | * {@link getDrawLock|unlocked}. | ||
| 78 | */ | 86 | */ |
| 79 | export function writeLine(text: string) { | 87 | export function writeLine(text: string) { |
| 80 | globalWidgetHost.writeLine(text); | 88 | globalWidgetHost.writeLine(text); |
| ... | @@ -115,7 +123,7 @@ export interface Widget { | ... | @@ -115,7 +123,7 @@ export interface Widget { |
| 115 | } | 123 | } |
| 116 | 124 | ||
| 117 | const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => { | 125 | const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => { |
| 118 | const { process } = globalThis; | 126 | const { process } = node; |
| 119 | if (!process || !process.stderr.isTTY) { | 127 | if (!process || !process.stderr.isTTY) { |
| 120 | return { | 128 | return { |
| 121 | // TODO: allow passing a level here | 129 | // TODO: allow passing a level here |
| ... | @@ -145,9 +153,9 @@ const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => { | ... | @@ -145,9 +153,9 @@ const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => { |
| 145 | })(); | 153 | })(); |
| 146 | const globalLog = /* @__PURE__ */ headless.logger({ | 154 | const globalLog = /* @__PURE__ */ headless.logger({ |
| 147 | log: globalWidgetHost, | 155 | log: globalWidgetHost, |
| 148 | colors: process.stderr.isTTY, | 156 | colors: node.process?.stderr.isTTY ?? false, |
| 149 | }); | 157 | }); |
| 150 | 158 | ||
| 151 | import process from "node:process"; | 159 | import * as node from "lib/node.ts"; |
| 152 | import * as headless from "lib/log/headless.ts"; | 160 | import * as headless from "lib/log/headless.ts"; |
| 153 | import * as ts from "lib/ts.ts"; | 161 | import * as ts from "lib/ts.ts"; |
lib/log/ansi.ts deleted-131| ... | @@ -1,131 +0,0 @@ | ||
| 1 | export const reset: string = `\x1b[0m`; | ||
| 2 | |||
| 3 | export const bold: string = `\x1b[1m`; | ||
| 4 | export const dim: string = `\x1b[2m`; | ||
| 5 | export const resetWeight: string = `\x1b[22m`; | ||
| 6 | |||
| 7 | export const fgReset: string = `\x1b[39m`; | ||
| 8 | export const fgBlack: string = `\x1b[30m`; | ||
| 9 | export const fgRed: string = `\x1b[31m`; | ||
| 10 | export const fgGreen: string = `\x1b[32m`; | ||
| 11 | export const fgYellow: string = `\x1b[33m`; | ||
| 12 | export const fgBlue: string = `\x1b[34m`; | ||
| 13 | export const fgMagenta: string = `\x1b[35m`; | ||
| 14 | export const fgCyan: string = `\x1b[36m`; | ||
| 15 | export const fgWhite: string = `\x1b[37m`; | ||
| 16 | export const fgBrightBlack: string = `\x1b[90m`; | ||
| 17 | export const fgBrightRed: string = `\x1b[91m`; | ||
| 18 | export const fgBrightGreen: string = `\x1b[92m`; | ||
| 19 | export const fgBrightYellow: string = `\x1b[93m`; | ||
| 20 | export const fgBrightBlue: string = `\x1b[94m`; | ||
| 21 | export const fgBrightMagenta: string = `\x1b[95m`; | ||
| 22 | export const fgBrightCyan: string = `\x1b[96m`; | ||
| 23 | export const fgBrightWhite: string = `\x1b[97m`; | ||
| 24 | export function fg256(n: number): string { | ||
| 25 | return `\x1b[38;5;${n}m`; | ||
| 26 | } | ||
| 27 | export function fgRgb(r: number, g: number, b: number): string { | ||
| 28 | return `\x1b[38;2;${r};${g};${b}m`; | ||
| 29 | } | ||
| 30 | |||
| 31 | export const bgReset: string = `\x1b[49m`; | ||
| 32 | export const bgBlack: string = `\x1b[40m`; | ||
| 33 | export const bgRed: string = `\x1b[41m`; | ||
| 34 | export const bgGreen: string = `\x1b[42m`; | ||
| 35 | export const bgYellow: string = `\x1b[43m`; | ||
| 36 | export const bgBlue: string = `\x1b[44m`; | ||
| 37 | export const bgMagenta: string = `\x1b[45m`; | ||
| 38 | export const bgCyan: string = `\x1b[46m`; | ||
| 39 | export const bgWhite: string = `\x1b[47m`; | ||
| 40 | export const bgBrightBlack: string = `\x1b[100m`; | ||
| 41 | export const bgBrightRed: string = `\x1b[101m`; | ||
| 42 | export const bgBrightGreen: string = `\x1b[102m`; | ||
| 43 | export const bgBrightYellow: string = `\x1b[103m`; | ||
| 44 | export const bgBrightBlue: string = `\x1b[104m`; | ||
| 45 | export const bgBrightMagenta: string = `\x1b[105m`; | ||
| 46 | export const bgBrightCyan: string = `\x1b[106m`; | ||
| 47 | export const bgBrightWhite: string = `\x1b[107m`; | ||
| 48 | export function bg256(n: number): string { | ||
| 49 | return `\x1b[48;5;${n}m`; | ||
| 50 | } | ||
| 51 | export function bgRgb(r: number, g: number, b: number): string { | ||
| 52 | return `\x1b[48;2;${r};${g};${b}m`; | ||
| 53 | } | ||
| 54 | |||
| 55 | // cursor management | ||
| 56 | export function cursorUp(n: number): string { | ||
| 57 | return n ? `\x1b[${n}A` : ""; | ||
| 58 | } | ||
| 59 | export const startOfLine: string = "\r"; | ||
| 60 | export const startOfNextLine: string = "\n"; | ||
| 61 | |||
| 62 | // erasure | ||
| 63 | export const clearToEndOfLine: string = "\x1b[K"; | ||
| 64 | export const clearToStartOfLine: string = "\x1b[1K"; | ||
| 65 | export const clearFullLine: string = "\x1b[2K"; | ||
| 66 | |||
| 67 | // Synchronized Output | ||
| 68 | // https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 | ||
| 69 | export const syncStart: string = "\x1b[?2026h"; | ||
| 70 | export const syncEnd: string = "\x1b[?2026l"; | ||
| 71 | |||
| 72 | export 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 | */ | ||
| 91 | export 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 | |||
| 107 | export 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 | |||
| 131 | import * as self from "lib/log/ansi.ts"; | ||
lib/log/headless.ts+42-29| ... | @@ -1,37 +1,47 @@ | ... | @@ -1,37 +1,47 @@ |
| 1 | // This file is under heavy construction. | 1 | /** |
| 2 | // - I am happy with the overall API, likely no changes. | 2 | * terminal widget rendering and logging is done by specifying all system APIs up front in an |
| 3 | // - There are certainly a lot of bugs. | 3 | * interface, creating an instance of the widget host. `lib/log.ts` fufills |
| 4 | // | 4 | * this interface with `node:process` and `globalThis`. |
| 5 | // Rendering for 'lib/term' is done by specifying all system APIs up front in | 5 | * |
| 6 | // an interface and creating an instance of the widget host. The main file | 6 | * this file is under heavy construction. |
| 7 | // fufills this interface with `node:process` and `globalThis`. | 7 | * - I am happy with the overall API, likely no changes. |
| 8 | // | 8 | * - There are certainly a lot of bugs. |
| 9 | // Some notes on widget rendering: | 9 | * |
| 10 | // - Never flush immediately (exception for `getDrawLock`), always do it next tick. | 10 | * some notes on widget rendering: |
| 11 | // - Maximum of one `wait` call at once. When the expected time suddenly | 11 | * - Never flush immediately (exception for |
| 12 | // shrinks, the timer is rescheduled. | 12 | * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick. |
| 13 | // - When redrawing widget lines, three tricks are done to reduce flickering: | 13 | * - Maximum of one `wait` call at once. When the expected time suddenly |
| 14 | // 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) | 14 | * shrinks, the timer is rescheduled. |
| 15 | // 2. A simple prefix-based diffing algorithm for skipping unchanged text | 15 | * - When redrawing widget lines, three tricks are done to reduce flickering: |
| 16 | // 3. Avoid clearing a line before redrawing it. | 16 | * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) |
| 17 | // Points 2 and 3 are used for terminals that are slow or have | 17 | * 2. A simple prefix-based diffing algorithm for skipping unchanged text |
| 18 | // | 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 | */ | ||
| 19 | 23 | ||
| 24 | /** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */ | ||
| 20 | export interface WidgetHostEnv { | 25 | export interface WidgetHostEnv { |
| 26 | /** Recieves ANSI escape sequences for interactive data. */ | ||
| 21 | writeInteractive(text: string): void; | 27 | writeInteractive(text: string): void; |
| 28 | /** Recieves log content (from `writeLine`). */ | ||
| 22 | writeOutput(text: string): void; | 29 | writeOutput(text: string): void; |
| 30 | /** Monotonic. */ | ||
| 23 | now(): ReturnType<typeof performance.now>; | 31 | now(): ReturnType<typeof performance.now>; |
| 24 | /** 0ms indicates "one frame" */ | 32 | /** 0ms indicates "one frame". */ |
| 25 | wait(ms: number, cb: () => void): () => void; | 33 | wait(ms: number, cb: () => void): () => void; |
| 34 | /** Called often. */ | ||
| 26 | getSize(): { columns: number; rows: number }; | 35 | getSize(): { columns: number; rows: number }; |
| 27 | // onInput?(write: (bytes: Uint8Array | string) => void): () => void; | 36 | // onInput?(write: (bytes: Uint8Array | string) => void): () => void; |
| 28 | } | 37 | } |
| 29 | 38 | ||
| 30 | /** Not intended to be implemented manually. See `widgetHost` */ | 39 | /** Returned by {@linkcode widgetHost} */ |
| 31 | export interface WidgetHost { | 40 | export interface WidgetHost { |
| 32 | /** | 41 | /** |
| 33 | * no built-in prefix or formatting. ensures the text does not interweave. | 42 | * 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. | 43 | * with widgets. data will be flushed in the next frame or when drawing is |
| 44 | * unlocked. | ||
| 35 | */ | 45 | */ |
| 36 | writeLine(text: string): void; | 46 | writeLine(text: string): void; |
| 37 | /** | 47 | /** |
| ... | @@ -55,7 +65,7 @@ interface WidgetState { | ... | @@ -55,7 +65,7 @@ interface WidgetState { |
| 55 | unsub: (() => void) | null; | 65 | unsub: (() => void) | null; |
| 56 | } | 66 | } |
| 57 | 67 | ||
| 58 | /** Implement a widget host by providing an environment. */ | 68 | /** Implement a widget host by providing an environment implementation. */ |
| 59 | export function widgetHost(env: WidgetHostEnv): WidgetHost { | 69 | export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 60 | const { writeOutput, writeInteractive, now, wait, getSize } = env; | 70 | const { writeOutput, writeInteractive, now, wait, getSize } = env; |
| 61 | type Cancel = ReturnType<typeof wait>; | 71 | type Cancel = ReturnType<typeof wait>; |
| ... | @@ -97,7 +107,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { | ... | @@ -97,7 +107,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 97 | if (rowsLeft === 1) break; | 107 | if (rowsLeft === 1) break; |
| 98 | const lines = outText.split("\n").slice(0, rowsLeft); | 108 | const lines = outText.split("\n").slice(0, rowsLeft); |
| 99 | newWidgetLines.push( | 109 | newWidgetLines.push( |
| 100 | ...lines.map((line) => string.trimToWidth(line, columns - 1)), | 110 | ...lines.map((line) => ansi.trimToWidth(line, columns - 1)), |
| 101 | ); | 111 | ); |
| 102 | 112 | ||
| 103 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | 113 | next = Math.min(next, UNWRAP(internals[w]).frameTime); |
| ... | @@ -157,8 +167,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { | ... | @@ -157,8 +167,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 157 | : newLine) + | 167 | : newLine) + |
| 158 | // clear rest of line if needed | 168 | // clear rest of line if needed |
| 159 | (oldLines[i] && | 169 | (oldLines[i] && |
| 160 | string.widthInTerminal(oldLines[i]) > | 170 | ansi.widthInTerminal(oldLines[i]) > |
| 161 | string.widthInTerminal(newLine) | 171 | ansi.widthInTerminal(newLine) |
| 162 | ? ansi.clearToEndOfLine | 172 | ? ansi.clearToEndOfLine |
| 163 | : "") + | 173 | : "") + |
| 164 | "\n" | 174 | "\n" |
| ... | @@ -185,8 +195,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { | ... | @@ -185,8 +195,8 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 185 | : newLine) + | 195 | : newLine) + |
| 186 | // clear rest of line if needed | 196 | // clear rest of line if needed |
| 187 | (lines[i] && | 197 | (lines[i] && |
| 188 | string.widthInTerminal(lines[i]) > | 198 | ansi.widthInTerminal(lines[i]) > |
| 189 | string.widthInTerminal(newLine) | 199 | ansi.widthInTerminal(newLine) |
| 190 | ? ansi.clearToEndOfLine | 200 | ? ansi.clearToEndOfLine |
| 191 | : "") + | 201 | : "") + |
| 192 | "\n" | 202 | "\n" |
| ... | @@ -264,6 +274,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { | ... | @@ -264,6 +274,7 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { |
| 264 | }; | 274 | }; |
| 265 | } | 275 | } |
| 266 | 276 | ||
| 277 | /** Input interface for {@linkcode logger} */ | ||
| 267 | export interface LoggerOptions { | 278 | export interface LoggerOptions { |
| 268 | /** Provide either a line writer or a log writer. */ | 279 | /** Provide either a line writer or a log writer. */ |
| 269 | log: { | 280 | log: { |
| ... | @@ -274,6 +285,7 @@ export interface LoggerOptions { | ... | @@ -274,6 +285,7 @@ export interface LoggerOptions { |
| 274 | colors: boolean; | 285 | colors: boolean; |
| 275 | } | 286 | } |
| 276 | 287 | ||
| 288 | /** Implement a logging scope by providing an environment interface. */ | ||
| 277 | export function logger(env: LoggerOptions): log.Scope { | 289 | export function logger(env: LoggerOptions): log.Scope { |
| 278 | const { colors, log } = env; | 290 | const { colors, log } = env; |
| 279 | const levels = colors | 291 | const levels = colors |
| ... | @@ -314,6 +326,7 @@ export function logger(env: LoggerOptions): log.Scope { | ... | @@ -314,6 +326,7 @@ export function logger(env: LoggerOptions): log.Scope { |
| 314 | } | 326 | } |
| 315 | 327 | ||
| 316 | function createSubScope(this: log.Scope, name: string) { | 328 | function createSubScope(this: log.Scope, name: string) { |
| 329 | // @ts-ignore | ||
| 317 | const { name: parent } = this; | 330 | const { name: parent } = this; |
| 318 | return scoped(parent ? parent + "/" + name : name); | 331 | return scoped(parent ? parent + "/" + name : name); |
| 319 | } | 332 | } |
| ... | @@ -323,7 +336,7 @@ export function logger(env: LoggerOptions): log.Scope { | ... | @@ -323,7 +336,7 @@ export function logger(env: LoggerOptions): log.Scope { |
| 323 | 336 | ||
| 324 | type Level = 0 | 1 | 2; | 337 | type Level = 0 | 1 | 2; |
| 325 | 338 | ||
| 326 | import * as ansi from "lib/log/ansi.ts"; | 339 | import * as ansi from "lib/string/ansi.ts"; |
| 327 | import * as string from "lib/string.ts"; | 340 | import * as string from "lib/string.ts"; |
| 328 | import * as ts from "lib/ts.ts"; | 341 | import * as ts from "lib/ts.ts"; |
| 329 | import * as stack from "lib/log/stack.ts"; | 342 | import * as stack from "lib/log/stack.ts"; |
lib/log/progress.ts+7-11| ... | @@ -5,12 +5,10 @@ | ... | @@ -5,12 +5,10 @@ |
| 5 | // core, but still leaving the top level `start` function. | 5 | // core, but still leaving the top level `start` function. |
| 6 | // - There are an overwhelming amount of bugs | 6 | // - There are an overwhelming amount of bugs |
| 7 | // | 7 | // |
| 8 | // A progress tree based on my past uses with clover console v3 and the Zig | 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, | 9 | // `Progress` API. Pass `Ref` to functions which should track progress, calling |
| 10 | // calling `.start()` to create child items. Or, multiple top-level progress | 10 | // `.start()` to create child items. Or, multiple top-level progress bars can |
| 11 | // bars can be created with the singleton's `start()` function. | 11 | // be created with the singleton's `start()` function. |
| 12 | import * as self from "lib/log/progress.ts"; | ||
| 13 | self satisfies Ref; // prove you can use the namespace import as a `Progress` | ||
| 14 | 12 | ||
| 15 | /** | 13 | /** |
| 16 | * creates a new trackable unit of work as a child of this one. | 14 | * creates a new trackable unit of work as a child of this one. |
| ... | @@ -28,7 +26,7 @@ export interface Ref { | ... | @@ -28,7 +26,7 @@ export interface Ref { |
| 28 | start(text: string, opts?: StartOptions): Node; | 26 | start(text: string, opts?: StartOptions): Node; |
| 29 | } | 27 | } |
| 30 | 28 | ||
| 31 | export interface Node extends Ref { | 29 | export interface Node extends Ref, Disposable { |
| 32 | /** reactive */ | 30 | /** reactive */ |
| 33 | text: string; | 31 | text: string; |
| 34 | /** reactive */ | 32 | /** reactive */ |
| ... | @@ -62,8 +60,6 @@ export interface Node extends Ref { | ... | @@ -62,8 +60,6 @@ export interface Node extends Ref { |
| 62 | 60 | ||
| 63 | /** end this Node or RootNode. ending collapses children */ | 61 | /** end this Node or RootNode. ending collapses children */ |
| 64 | end(): void; | 62 | end(): void; |
| 65 | |||
| 66 | [Symbol.dispose](): void; | ||
| 67 | } | 63 | } |
| 68 | 64 | ||
| 69 | export interface StartOptions { | 65 | export interface StartOptions { |
| ... | @@ -415,6 +411,6 @@ const global = /** @__PURE__ */ new Headless(log); | ... | @@ -415,6 +411,6 @@ const global = /** @__PURE__ */ new Headless(log); |
| 415 | import * as process from "node:process"; | 411 | import * as process from "node:process"; |
| 416 | import * as log from "lib/log.ts"; | 412 | import * as log from "lib/log.ts"; |
| 417 | import * as headless from "lib/log/headless.ts"; | 413 | import * as headless from "lib/log/headless.ts"; |
| 418 | import * as ansi from "lib/log/ansi.ts"; | 414 | import * as ansi from "lib/string/ansi.ts"; |
| 419 | import * as string from "lib/string.ts"; | 415 | import * as string from "lib/string.ts"; |
| 420 | import { UNWRAP } from "lib/assert.ts"; | 416 | import { ASSERT, UNWRAP } from "lib/assert.ts"; |
lib/log/stack.ts+23-28| ... | @@ -2,7 +2,8 @@ | ... | @@ -2,7 +2,8 @@ |
| 2 | * the default stack trace formatters in node.js, deno, and bun are all | 2 | * the default stack trace formatters in node.js, deno, and bun are all |
| 3 | * extremely low quality. they don't highlight the most important information | 3 | * extremely low quality. they don't highlight the most important information |
| 4 | * and spend too much space on showing source code. this module provides tools | 4 | * 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. | ||
| 6 | * | 7 | * |
| 7 | * to get a taste of the formatter: | 8 | * to get a taste of the formatter: |
| 8 | * ```ts | 9 | * ```ts |
| ... | @@ -39,7 +40,7 @@ export interface Frame { | ... | @@ -39,7 +40,7 @@ export interface Frame { |
| 39 | /** | 40 | /** |
| 40 | * parse a error stack trace from either v8 (node, deno, chrome), | 41 | * parse a error stack trace from either v8 (node, deno, chrome), |
| 41 | * jsc (safari, bun), or spidermonkey (firefox). | 42 | * jsc (safari, bun), or spidermonkey (firefox). |
| 42 | * | 43 | * |
| 43 | * this function is derived from clover's work on bun's DevServer, which she | 44 | * this function is derived from clover's work on bun's DevServer, which she |
| 44 | * derived from stacktracejs. this file is more refined than those two: | 45 | * derived from stacktracejs. this file is more refined than those two: |
| 45 | * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts | 46 | * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts |
| ... | @@ -52,7 +53,9 @@ export interface Frame { | ... | @@ -52,7 +53,9 @@ export interface Frame { |
| 52 | export function parse(error: Error | string): Frame[] | null { | 53 | export function parse(error: Error | string): Frame[] | null { |
| 53 | const stack = (error as Error)?.stack ?? error; | 54 | const stack = (error as Error)?.stack ?? error; |
| 54 | if (typeof stack !== "string") return null; | 55 | 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); | ||
| 56 | } | 59 | } |
| 57 | 60 | ||
| 58 | /** | 61 | /** |
| ... | @@ -65,7 +68,7 @@ export function capture(trimStart = 2): Frame[] { | ... | @@ -65,7 +68,7 @@ export function capture(trimStart = 2): Frame[] { |
| 65 | 68 | ||
| 66 | /** | 69 | /** |
| 67 | * convert an error into a human readable and debuggable string. | 70 | * convert an error into a human readable and debuggable string. |
| 68 | * | 71 | * |
| 69 | * this printer puts the error metadata at the top. each stack frame gets two | 72 | * this printer puts the error metadata at the top. each stack frame gets two |
| 70 | * lines: the absolute path or url, and then one line of source code. having | 73 | * lines: the absolute path or url, and then one line of source code. having |
| 71 | * five lines of context on just the top frame is useless, since five lines is | 74 | * 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 { | ... | @@ -238,8 +241,8 @@ export function formatFrame(frame: Frame, colors: boolean): string { |
| 238 | 241 | ||
| 239 | const existCache = new Map<string, boolean>(); | 242 | const existCache = new Map<string, boolean>(); |
| 240 | function getPackageRoot(absPath: string) { | 243 | function getPackageRoot(absPath: string) { |
| 241 | const fs = nodeBuiltin("node:fs"); | 244 | const fs = node.builtin("fs"); |
| 242 | const path = nodeBuiltin("node:path"); | 245 | const path = node.builtin("path"); |
| 243 | const process = globalThis.process; | 246 | const process = globalThis.process; |
| 244 | if (!fs || !path || !process) return null; | 247 | if (!fs || !path || !process) return null; |
| 245 | 248 | ||
| ... | @@ -302,14 +305,12 @@ function getPackageRoot(absPath: string) { | ... | @@ -302,14 +305,12 @@ function getPackageRoot(absPath: string) { |
| 302 | 305 | ||
| 303 | const codeCache = new Map<string, string[]>(); | 306 | const codeCache = new Map<string, string[]>(); |
| 304 | function getSourceCode(file: string) { | 307 | function getSourceCode(file: string) { |
| 305 | const fs = nodeBuiltin("node:fs"); | 308 | const fs = node.builtin("fs"); |
| 306 | const path = nodeBuiltin("node:path"); | 309 | const path = node.builtin("path"); |
| 307 | if (!fs || !path) return null; | 310 | if (!fs || !path) return null; |
| 308 | if (file.startsWith("node:")) { | 311 | if (file.startsWith("node:")) { |
| 309 | // node.js has a secret binding for all the internal source codes | 312 | // node.js has a secret binding for all the internal source codes |
| 310 | type Process = { binding(name: "natives"): Record<string, string> }; | 313 | return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null; |
| 311 | return (globalThis.process as unknown as Process) | ||
| 312 | ?.binding?.("natives")[file.slice(5)]?.split("\n") ?? null; | ||
| 313 | } else { | 314 | } else { |
| 314 | // bun and deno do not offer a way to get source code of internal modules, | 315 | // bun and deno do not offer a way to get source code of internal modules, |
| 315 | // probably for good reason. | 316 | // probably for good reason. |
| ... | @@ -341,16 +342,16 @@ function parseV8OrIe(stack: string): Frame[] { | ... | @@ -341,16 +342,16 @@ function parseV8OrIe(stack: string): Frame[] { |
| 341 | 342 | ||
| 342 | // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in | 343 | // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in |
| 343 | // case it has spaces in it, as the string is split on \s+ later on | 344 | // 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(/ (\(.+\)$)/); |
| 345 | 346 | ||
| 346 | // remove the parenthesized location from the line, if it was matched | 347 | // remove the parenthesized location from the line, if it was matched |
| 347 | sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; | 348 | sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; |
| 348 | 349 | ||
| 349 | // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine | 350 | // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine |
| 350 | // because this line doesn't have function name | 351 | // because this line doesn't have function name |
| 351 | let locationParts = extractLocation(loc?.[1] ?? sanitizedLine); | 352 | const locationParts = extractLocation(loc?.[1] ?? sanitizedLine); |
| 352 | let functionName = (loc && sanitizedLine) || undefined; | 353 | const functionName = (loc && sanitizedLine) || undefined; |
| 353 | let fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 | 354 | const fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 |
| 354 | ? undefined | 355 | ? undefined |
| 355 | : locationParts[0]; | 356 | : locationParts[0]; |
| 356 | 357 | ||
| ... | @@ -377,11 +378,10 @@ function parseJscOrSpidermonkey(stack: string): Frame[] { | ... | @@ -377,11 +378,10 @@ function parseJscOrSpidermonkey(stack: string): Frame[] { |
| 377 | // Safari eval frames only have function names and nothing else | 378 | // Safari eval frames only have function names and nothing else |
| 378 | fn = source.endsWith("@") ? source.slice(0, -1) : source; | 379 | fn = source.endsWith("@") ? source.slice(0, -1) : source; |
| 379 | } else { | 380 | } else { |
| 380 | var functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/; | 381 | const matches = source.match(regexFunctionName); |
| 381 | var matches = source.match(functionNameRegex); | 382 | const functionName = matches && matches[1] ? matches[1] : undefined; |
| 382 | var functionName = matches && matches[1] ? matches[1] : undefined; | 383 | const locationParts = extractLocation( |
| 383 | var locationParts = extractLocation( | 384 | source.replace(regexFunctionName, ""), |
| 384 | source.replace(functionNameRegex, ""), | ||
| 385 | ); | 385 | ); |
| 386 | fn = functionName!; | 386 | fn = functionName!; |
| 387 | file = locationParts[0]; | 387 | file = locationParts[0]; |
| ... | @@ -408,14 +408,9 @@ function extractLocation(urlLike: string) { | ... | @@ -408,14 +408,9 @@ function extractLocation(urlLike: string) { |
| 408 | return [parts[1], parts[2] || undefined, parts[3] || undefined]; | 408 | return [parts[1], parts[2] || undefined, parts[3] || undefined]; |
| 409 | } | 409 | } |
| 410 | 410 | ||
| 411 | // TODO: extract | 411 | const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/; |
| 412 | function nodeBuiltin(name: "node:path"): typeof import("node:path") | null; | ||
| 413 | function nodeBuiltin(name: "node:fs"): typeof import("node:fs") | null; | ||
| 414 | function nodeBuiltin(name: string) { | ||
| 415 | return globalThis.process?.getBuiltinModule(name) ?? null; | ||
| 416 | } | ||
| 417 | |||
| 418 | const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m; | 412 | const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m; |
| 419 | const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/; | 413 | const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/; |
| 420 | 414 | ||
| 421 | import * as ansi from "lib/log/ansi.ts"; | 415 | import * as ansi from "lib/string/ansi.ts"; |
| 416 | import * 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. | ||
| 3 | test("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 | |||
| 26 | import { test } from "node:test"; | ||
| 27 | import assert from "node:assert"; | ||
| 28 | import * 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 | */ | ||
| 7 | function 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 | } | ||
| 19 | export { contentTypeFor as for }; | ||
| 20 | |||
| 21 | // special-cased file names | ||
| 22 | const fullNames = new Map([ | ||
| 23 | ["rss.xml", "application/rss+xml"], | ||
| 24 | ]); | ||
| 25 | |||
| 26 | // this string literal, when minified, is shorter than key-value pairs. | ||
| 27 | const 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 | |||
| 9 | export type ErrorCode = | ||
| 10 | | keyof typeof import("node:os").constants.errno | ||
| 11 | | (string & {}); | ||
| 12 | |||
| 13 | export const process: Process | undefined = | ||
| 14 | (globalThis as typeof globalThis & { process?: Process }).process; | ||
| 15 | |||
| 16 | interface 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 | |||
| 25 | interface 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 | */ | ||
| 37 | interface 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 | */ | ||
| 54 | interface 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 | |||
| 63 | export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | null { | ||
| 64 | return process?.getBuiltinModule(name) ?? null; | ||
| 65 | } | ||
| 66 | |||
| 67 | export 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 | |||
| 4 | export 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 | |||
| 26 | interface TsToJsdoc { | ||
| 27 | transpileFile: (opts: { code: string }) => string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export 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 | |||
| 116 | if (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 | |||
| 128 | import * as progress from "lib/log/progress.ts"; | ||
| 129 | import * as path from "node:path"; | ||
| 130 | import * as fs from "node:fs/promises"; | ||
lib/queue.ts+11-2| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | // Implements a priority queue. Jobs can be automatically cancelled via | 1 | /** |
| 2 | // AbortSignal rescheduled if higher priority tasks come in. | 2 | * implements a priority queue. jobs are be automatically cancelled via |
| 3 | * `AbortSignal` and rescheduled when higher priority tasks come in. | ||
| 4 | * @module | ||
| 5 | */ | ||
| 3 | 6 | ||
| 4 | /** schedule `item.run` to be called at some point on the global queue */ | 7 | /** schedule `item.run` to be called at some point on the global queue */ |
| 5 | export function run<T>(item: Job<T>): Promise<T> { | 8 | export function run<T>(item: Job<T>): Promise<T> { |
| ... | @@ -28,10 +31,15 @@ export interface Job<out T> { | ... | @@ -28,10 +31,15 @@ export interface Job<out T> { |
| 28 | } | 31 | } |
| 29 | 32 | ||
| 30 | export class PriorityQueue { | 33 | export class PriorityQueue { |
| 34 | /** Read only */ | ||
| 31 | concurrency: number; | 35 | concurrency: number; |
| 36 | /** Read only */ | ||
| 32 | coresRemain: number; | 37 | coresRemain: number; |
| 38 | /** Read only */ | ||
| 33 | jobs: Array<Internal> = []; | 39 | jobs: Array<Internal> = []; |
| 40 | /** Read only */ | ||
| 34 | active: Array<Internal> = []; | 41 | active: Array<Internal> = []; |
| 42 | /** Can be assigned to react to queue changes. */ | ||
| 35 | onChange: (() => void) | null = null; | 43 | onChange: (() => void) | null = null; |
| 36 | 44 | ||
| 37 | constructor(concurrency: number) { | 45 | constructor(concurrency: number) { |
| ... | @@ -165,6 +173,7 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) { | ... | @@ -165,6 +173,7 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) { |
| 165 | arr.push(item); | 173 | arr.push(item); |
| 166 | } | 174 | } |
| 167 | 175 | ||
| 176 | declare var navigator: { hardwareConcurrency: number }; | ||
| 168 | const global = new PriorityQueue(navigator.hardwareConcurrency); | 177 | const global = new PriorityQueue(navigator.hardwareConcurrency); |
| 169 | 178 | ||
| 170 | import { UNWRAP } from "lib/assert.ts"; | 179 | import { UNWRAP } from "lib/assert.ts"; |
lib/readme.changes.md+23-8| ... | @@ -2,18 +2,33 @@ | ... | @@ -2,18 +2,33 @@ |
| 2 | 2 | ||
| 3 | ## v2 | 3 | ## v2 |
| 4 | 4 | ||
| 5 | - top level readme now explains general usage patterns. | 5 | ### breaking |
| 6 | |||
| 6 | - rework `log.Scope`, remove self call signature so that the `log` module | 7 | - rework `log.Scope`, remove self call signature so that the `log` module |
| 7 | satisfies `log.Scope`, while adding `log` and `debug` for explicit debugging | 8 | satisfies `log.Scope`, while adding `log` and `debug` for explicit debugging |
| 8 | logs. documentation prescribes doing `const console = log.scope("module");`. | 9 | 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` | ||
| 10 | - add `string.escapeRegExp()` | 24 | - add `string.escapeRegExp()` |
| 25 | - add `stack.capture()` | ||
| 11 | 26 | ||
| 12 | ## v1 | 27 | ## v1 |
| 13 | 28 | ||
| 14 | - new library `assert` | 29 | - new library `assert.ts` |
| 15 | - new library `log` | 30 | - new library `log.ts` |
| 16 | - new library `packaging` | 31 | - new library `packaging.ts` |
| 17 | - new library `queue` | 32 | - new library `queue.ts` |
| 18 | - new library `string` | 33 | - new library `string.ts` |
| 19 | - new library `ts` | 34 | - new library `ts.ts` |
lib/readme.md+14-9| ... | @@ -12,23 +12,26 @@ projects and at work. | ... | @@ -12,23 +12,26 @@ projects and at work. |
| 12 | all top-level namespaces stand alone as their own projects, and generally do not | 12 | all top-level namespaces stand alone as their own projects, and generally do not |
| 13 | depend on each other. there are no external dependencies; code is easy to audit. | 13 | depend on each other. there are no external dependencies; code is easy to audit. |
| 14 | 14 | ||
| 15 | <!-- - `async` --> | ||
| 16 | <!-- - `error` - interop with the `unknown` type in error handlers --> | ||
| 17 | <!-- - `fs` - rebuilt api for `node:fs` --> | 15 | <!-- - `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` --> | ||
| 22 | 16 | ||
| 23 | - `assert.ts` | 17 | - `assert.ts` |
| 18 | - `async.ts` | ||
| 19 | - `error.ts` - interop with the `unknown` type in error handlers | ||
| 24 | - `log.ts` - logging, terminal i/o, interactive widgets | 20 | - `log.ts` - logging, terminal i/o, interactive widgets |
| 25 | - `log/ansi.ts` - cursor and color constants | ||
| 26 | - `log/headless.ts` - use `log` and `log/progress` without a tty | 21 | - `log/headless.ts` - use `log` and `log/progress` without a tty |
| 27 | - `log/progress.ts` - progress bar, spinner, and tree | 22 | - `log/progress.ts` - progress bar, spinner, and tree |
| 28 | - `log/stack.ts` - stack trace parse and formatter | 23 | - `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 | ||
| 30 | - `queue.ts` - priority queue with rescheduling | 27 | - `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 | ||
| 31 | - `string.ts` | 31 | - `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 | ||
| 32 | - `ts.ts` typescript types and general purpose code | 35 | - `ts.ts` typescript types and general purpose code |
| 33 | 36 | ||
| 34 | there is currently no hosted documentation site. please refer to the source | 37 | there is currently no hosted documentation site. please refer to the source |
| ... | @@ -52,7 +55,9 @@ library separately: | ... | @@ -52,7 +55,9 @@ library separately: |
| 52 | 55 | ||
| 53 | ```ts | 56 | ```ts |
| 54 | import * as log from "@clo/lib/log.ts"; | 57 | import * as log from "@clo/lib/log.ts"; |
| 55 | import * 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. | ||
| 60 | import { Lru } from "@clo/lib/Lru.ts"; | ||
| 56 | ``` | 61 | ``` |
| 57 | 62 | ||
| 58 | these patterns developed initially when using a text editor without a language | 63 | these patterns developed initially when using a text editor without a language |
lib/readme.npm.md+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | ```sh | 1 | ```sh |
| 2 | # alias install "@paperclover/lib" as "@clo/lib" | 2 | # alias install "@paperclover/lib" as "@clo/lib" |
| 3 | npm install lib@npm:@paperclover/lib | 3 | npm install @clo/lib@npm:@paperclover/lib |
| 4 | ``` | 4 | ``` |
| 5 | 5 | ||
| 6 | the distribution on npm is transpiled with JSDoc annotations instead of | 6 | the 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. */ | ||
| 27 | export 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. */ | ||
| 35 | export 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 | |||
| 52 | interface 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 | */ | ||
| 83 | export 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 | |||
| 127 | export type ContextValue<T extends object = object> = T & { | ||
| 128 | [kContext]: Context<T>; | ||
| 129 | }; | ||
| 130 | |||
| 131 | /** the global state */ | ||
| 132 | export let current: State | null = null; | ||
| 133 | |||
| 134 | /** set the global state */ | ||
| 135 | export function setCurrent(r: State | null) { | ||
| 136 | current = r ?? null; | ||
| 137 | } | ||
| 138 | |||
| 139 | /** inline HTML into a render without escaping it */ | ||
| 140 | export function raw(node: ResolvedNode): Raw { | ||
| 141 | return [kRaw, node]; | ||
| 142 | } | ||
| 143 | |||
| 144 | /** constructs {@linkcode Element} */ | ||
| 145 | export 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} */ | ||
| 154 | export 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 */ | ||
| 163 | export 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. */ | ||
| 176 | export 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. */ | ||
| 186 | export type Element = [ | ||
| 187 | tag: typeof kElement, | ||
| 188 | type: string | Component, | ||
| 189 | attrs: Record<string, unknown>, | ||
| 190 | _?: "", // contents | ||
| 191 | source?: SrcLoc, | ||
| 192 | ]; | ||
| 193 | export 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 | */ | ||
| 198 | export type Component = (attrs: Record<any, any>) => Exclude<Node, undefined>; | ||
| 199 | /** emitted by JSX runtime */ | ||
| 200 | export 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 | */ | ||
| 212 | export 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 | |||
| 270 | export type ResolvedNode = | ||
| 271 | | ResolvedNode[] // Concat | ||
| 272 | | ResolvedElement // Render | ||
| 273 | | string; // Raw HTML | ||
| 274 | export 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 | */ | ||
| 286 | export 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 | */ | ||
| 292 | export 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 | |||
| 309 | function 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 | |||
| 363 | function 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 | |||
| 376 | export function quoteIfNeeded(text: string): string { | ||
| 377 | if (text.match(/["/> ]/)) return '"' + text + '"'; | ||
| 378 | return text; | ||
| 379 | } | ||
| 380 | |||
| 381 | // -- utility functions -- | ||
| 382 | |||
| 383 | /** Construct {@link State} */ | ||
| 384 | export 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 | |||
| 398 | export type ClassName = string | Record<string, boolean | null> | ClassName[]; | ||
| 399 | export 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 */ | ||
| 427 | export 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 */ | ||
| 434 | const 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 */ | ||
| 441 | export 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 */ | ||
| 451 | export 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} */ | ||
| 460 | const kElement = Symbol("Element"); | ||
| 461 | /** descriminator for {@linkcode Raw} */ | ||
| 462 | const kRaw = Symbol("Raw"); | ||
| 463 | /** secret key to facilitate {@linkcode init} with a `ContextValue[]` */ | ||
| 464 | const kContext: unique symbol = Symbol(); | ||
| 465 | |||
| 466 | import { 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. */ | ||
| 20 | export function Fragment( | ||
| 21 | { children }: { children: render.Node[] }, | ||
| 22 | ): render.Node[] { | ||
| 23 | return children; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** construct a JSX element in production mode. */ | ||
| 27 | export 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. */ | ||
| 38 | export 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. | ||
| 66 | export { jsx as jsxs }; | ||
| 67 | |||
| 68 | export interface IntrinsicElements { | ||
| 69 | [name: string]: Record<string, unknown>; | ||
| 70 | } | ||
| 71 | export interface ElementChildrenAttribute { | ||
| 72 | children: render.Node; | ||
| 73 | } | ||
| 74 | export type Element = render.Element; | ||
| 75 | export type ElementType = keyof IntrinsicElements | render.Component; | ||
| 76 | export type ElementClass = ReturnType<render.Component>; | ||
| 77 | |||
| 78 | import * as render from "lib/render.ts"; | ||
| 79 | import { ASSERT } from "lib/assert.ts"; | ||
lib/render/meta.test.tsx created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | // every metadata render requires a base URL | ||
| 2 | const base: meta.Template = { | ||
| 3 | base: new URL("https://paperclover.net"), | ||
| 4 | }; | ||
| 5 | |||
| 6 | test("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 | )); | ||
| 12 | test("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 | )); | ||
| 27 | test("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 | |||
| 47 | import { test } from "node:test"; | ||
| 48 | import assert from "node:assert"; | ||
| 49 | import * as meta from "lib/render/meta.ts"; | ||
lib/render/meta.ts created+255| ... | @@ -0,0 +1,255 @@ | ||
| 1 | export { renderMetadata as render }; | ||
| 2 | export 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 | } | ||
| 50 | export type Alternate = { type: string; url: string } | { | ||
| 51 | lang: string; | ||
| 52 | url: string; | ||
| 53 | }; | ||
| 54 | export 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. */ | ||
| 67 | export 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 | */ | ||
| 96 | type OpenGraphField = | ||
| 97 | | string | ||
| 98 | | { [field: string]: OpenGraphField } | ||
| 99 | | Array<OpenGraphField> | ||
| 100 | | (null | undefined); | ||
| 101 | /** Twitter uses various OpenGraph fields if these are not specified. */ | ||
| 102 | export 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 | } | ||
| 112 | export interface Template | ||
| 113 | extends Omit<Meta, "title" | "description" | "canonical"> { | ||
| 114 | base: URL; | ||
| 115 | titleTemplate?: (title: string) => string; | ||
| 116 | } | ||
| 117 | export interface Alternates { | ||
| 118 | canonical: string; | ||
| 119 | types: { [mime: string]: AlternateType }; | ||
| 120 | } | ||
| 121 | export interface AlternateType { | ||
| 122 | url: string; | ||
| 123 | title: string; | ||
| 124 | } | ||
| 125 | export 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. */ | ||
| 135 | function 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 | |||
| 235 | function 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 | |||
| 250 | function attr(value: string) { | ||
| 251 | return render.quoteIfNeeded(esc(value)); | ||
| 252 | } | ||
| 253 | |||
| 254 | import { escapeHtmlContent as esc } from "lib/render.ts"; | ||
| 255 | import * as render from "lib/render.ts"; | ||
lib/string.test.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | test("countNewlines", () => { | ||
| 2 | assert.equal(string.countNewlines("line 1\nline 2\nline 3"), 2); | ||
| 3 | assert.equal(string.countNewlines(""), 0); | ||
| 4 | }); | ||
| 5 | test("escapeRegExp", () => { | ||
| 6 | assert.equal( | ||
| 7 | string.escapeRegExp("delete * from users where id = $id"), | ||
| 8 | "delete \\* from users where id = \\$id", | ||
| 9 | ); | ||
| 10 | }); | ||
| 11 | test("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 | |||
| 33 | import { test } from "node:test"; | ||
| 34 | import assert from "node:assert"; | ||
| 35 | import * as string from "lib/string.ts"; | ||
lib/string.ts+16-153| ... | @@ -1,8 +1,3 @@ | ... | @@ -1,8 +1,3 @@ |
| 1 | /** Escape a value for use in a regular expression. */ | ||
| 2 | export function escapeRegExp(unsafeText: string) { | ||
| 3 | return unsafeText.replace(/[\$\\]/g, "\\$&"); | ||
| 4 | } | ||
| 5 | |||
| 6 | // TODO: this function is not benchmarked | 1 | // TODO: this function is not benchmarked |
| 7 | export function countNewlines(str: string): number { | 2 | export function countNewlines(str: string): number { |
| 8 | let count = 0; | 3 | let count = 0; |
| ... | @@ -12,160 +7,28 @@ export function countNewlines(str: string): number { | ... | @@ -12,160 +7,28 @@ export function countNewlines(str: string): number { |
| 12 | return count; | 7 | return count; |
| 13 | } | 8 | } |
| 14 | 9 | ||
| 15 | export function widthInTerminal(str: string): number { | 10 | /** Escape a string for use in a regular expression. */ |
| 16 | let width = 0; | 11 | export function escapeRegExp(unsafeText: string): string { |
| 17 | if (str.includes("\x1b")) str = str.replace(ansiEscapes, ""); | 12 | // TODO: in May 2025, RegExp.escape exists |
| 18 | for (let i = 0, { length } = str; i < length; i += 1) { | 13 | return unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 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 | |||
| 40 | export 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; | ||
| 89 | } | 14 | } |
| 90 | 15 | ||
| 91 | // derived from sindresorhus's wonderful `string-width` | 16 | /** Escape a string for use in a regular expression. */ |
| 92 | // https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js | 17 | export function escapeRegExpReplacement(unsafeText: string): string { |
| 93 | // https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js | 18 | return unsafeText.replace(/\$/g, "$$$$"); |
| 94 | // MIT license | ||
| 95 | const segmenter = /* @__PURE__ */ new Intl.Segmenter(); | ||
| 96 | const 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; | ||
| 98 | const zeroWidthClusterRegex = | ||
| 99 | /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; | ||
| 100 | const leadingNonPrintingRegex = | ||
| 101 | /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; | ||
| 102 | const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; | ||
| 103 | function 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 | ); | ||
| 161 | } | 19 | } |
| 162 | 20 | ||
| 21 | /** | ||
| 22 | * Escape a string for use in a POSIX shell. Prefers easier to read output vs | ||
| 23 | * simpler implementation. | ||
| 24 | */ | ||
| 163 | export function escapeShellArgument(s: string): string { | 25 | export function escapeShellArgument(s: string): string { |
| 164 | if (s.length === 0) return "''"; | 26 | 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; |
| 166 | if (!s.includes("'")) return `'${s}'`; | 28 | if (!s.includes("'")) return `'${s}'`; |
| 167 | if (!/["$`\\]/.test(s)) return `"${s}"`; | 29 | 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; | ||
| 169 | } | 34 | } |
| 170 | |||
| 171 | import { UNWRAP } from "lib/assert.ts"; |
lib/string/ansi.test.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | test("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 | }); | ||
| 11 | test("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 | |||
| 22 | import { test } from "node:test"; | ||
| 23 | import assert from "node:assert"; | ||
| 24 | import * 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 */ | ||
| 13 | export 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 | */ | ||
| 19 | export 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 | */ | ||
| 25 | export const dim: string = `\x1b[2m`; | ||
| 26 | /** resets `bold` and `dim` */ | ||
| 27 | export const resetWeight: string = `\x1b[22m`; | ||
| 28 | |||
| 29 | /** resets foreground color */ | ||
| 30 | export const fgReset: string = `\x1b[39m`; | ||
| 31 | /** sets the foreground color to black. on light mode terminals, this color may | ||
| 32 | * be visible. */ | ||
| 33 | export const fgBlack: string = `\x1b[30m`; | ||
| 34 | /** sets the foreground color to red */ | ||
| 35 | export const fgRed: string = `\x1b[31m`; | ||
| 36 | /** sets the foreground color to green */ | ||
| 37 | export const fgGreen: string = `\x1b[32m`; | ||
| 38 | /** sets the foreground color to yellow */ | ||
| 39 | export const fgYellow: string = `\x1b[33m`; | ||
| 40 | /** sets the foreground color to blue */ | ||
| 41 | export const fgBlue: string = `\x1b[34m`; | ||
| 42 | /** sets the foreground color to magenta */ | ||
| 43 | export const fgMagenta: string = `\x1b[35m`; | ||
| 44 | /** sets the foreground color to cyan */ | ||
| 45 | export 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 | */ | ||
| 51 | export const fgWhite: string = `\x1b[37m`; | ||
| 52 | /** | ||
| 53 | * sets the foreground color to bright black / grey / gray. consider `dim` for | ||
| 54 | * greyed out text. | ||
| 55 | */ | ||
| 56 | export const fgBrightBlack: string = `\x1b[90m`; | ||
| 57 | /** sets the foreground color to bright red */ | ||
| 58 | export const fgBrightRed: string = `\x1b[91m`; | ||
| 59 | /** sets the foreground color to bright green */ | ||
| 60 | export const fgBrightGreen: string = `\x1b[92m`; | ||
| 61 | /** sets the foreground color to bright yellow */ | ||
| 62 | export const fgBrightYellow: string = `\x1b[93m`; | ||
| 63 | /** sets the foreground color to bright blue */ | ||
| 64 | export const fgBrightBlue: string = `\x1b[94m`; | ||
| 65 | /** sets the foreground color to bright magenta */ | ||
| 66 | export const fgBrightMagenta: string = `\x1b[95m`; | ||
| 67 | /** sets the foreground color to bright cyan */ | ||
| 68 | export 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 | */ | ||
| 73 | export const fgBrightWhite: string = `\x1b[97m`; | ||
| 74 | /** sets the foreground color to a color in the 256 color palette. */ | ||
| 75 | export function fg256(n: number): string { | ||
| 76 | return `\x1b[38;5;${n}m`; | ||
| 77 | } | ||
| 78 | /** sets the foreground color to an rgb color. */ | ||
| 79 | export 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. */ | ||
| 84 | export const bgReset: string = `\x1b[49m`; | ||
| 85 | /** sets the cell background to black. consider `bgReset` to clear the background. */ | ||
| 86 | export const bgBlack: string = `\x1b[40m`; | ||
| 87 | /** sets the cell background to red. */ | ||
| 88 | export const bgRed: string = `\x1b[41m`; | ||
| 89 | /** sets the cell background to green. */ | ||
| 90 | export const bgGreen: string = `\x1b[42m`; | ||
| 91 | /** sets the cell background to yellow. */ | ||
| 92 | export const bgYellow: string = `\x1b[43m`; | ||
| 93 | /** sets the cell background to blue. */ | ||
| 94 | export const bgBlue: string = `\x1b[44m`; | ||
| 95 | /** sets the cell background to magenta. */ | ||
| 96 | export const bgMagenta: string = `\x1b[45m`; | ||
| 97 | /** sets the cell background to cyan. */ | ||
| 98 | export const bgCyan: string = `\x1b[46m`; | ||
| 99 | /** | ||
| 100 | * sets the cell background to (dark) white / grey / gray. this color is | ||
| 101 | * brighter than `bgBrightBlack`. | ||
| 102 | */ | ||
| 103 | export const bgWhite: string = `\x1b[47m`; | ||
| 104 | /** | ||
| 105 | * sets the cell background to bright black. consider `bgReset` to clear the | ||
| 106 | * background. | ||
| 107 | */ | ||
| 108 | export const bgBrightBlack: string = `\x1b[100m`; | ||
| 109 | /** sets the cell background to bright red. */ | ||
| 110 | export const bgBrightRed: string = `\x1b[101m`; | ||
| 111 | /** sets the cell background to bright green. */ | ||
| 112 | export const bgBrightGreen: string = `\x1b[102m`; | ||
| 113 | /** sets the cell background to bright yellow. */ | ||
| 114 | export const bgBrightYellow: string = `\x1b[103m`; | ||
| 115 | /** sets the cell background to bright blue. */ | ||
| 116 | export const bgBrightBlue: string = `\x1b[104m`; | ||
| 117 | /** sets the cell background to bright magenta. */ | ||
| 118 | export const bgBrightMagenta: string = `\x1b[105m`; | ||
| 119 | /** sets the cell background to bright cyan. */ | ||
| 120 | export const bgBrightCyan: string = `\x1b[106m`; | ||
| 121 | /** sets the cell background to bright white. */ | ||
| 122 | export const bgBrightWhite: string = `\x1b[107m`; | ||
| 123 | /** sets the background color to a color in the 256 color palette. */ | ||
| 124 | export function bg256(n: number): string { | ||
| 125 | return `\x1b[48;5;${n}m`; | ||
| 126 | } | ||
| 127 | /** sets the background color to an rgb color. */ | ||
| 128 | export 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. */ | ||
| 135 | export function cursorUp(n: number): string { | ||
| 136 | return n ? `\x1b[${n}A` : ""; | ||
| 137 | } | ||
| 138 | /** move the cursor to the start of it's current line. */ | ||
| 139 | export const startOfLine: string = "\r"; | ||
| 140 | /** move the cursor to the start of the next line. */ | ||
| 141 | export const startOfNextLine: string = "\n"; | ||
| 142 | |||
| 143 | // erasure | ||
| 144 | /** clear from and including the current cell right to the end of the line. */ | ||
| 145 | export const clearToEndOfLine: string = "\x1b[K"; | ||
| 146 | /** clear from and including the current cell left to the start of the line. */ | ||
| 147 | export const clearToStartOfLine: string = "\x1b[1K"; | ||
| 148 | /** clear the entire line that the cursor is on without moving it. */ | ||
| 149 | export const clearFullLine: string = "\x1b[2K"; | ||
| 150 | |||
| 151 | /** | ||
| 152 | * Begin Synchronized Output | ||
| 153 | * https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 | ||
| 154 | */ | ||
| 155 | export const syncStart: string = "\x1b[?2026h"; | ||
| 156 | /** End Synchronized Output */ | ||
| 157 | export 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 | */ | ||
| 163 | export 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 | */ | ||
| 183 | export 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 | */ | ||
| 203 | export 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. */ | ||
| 228 | export 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 | ||
| 258 | export 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 | ||
| 313 | const segmenter = /* @__PURE__ */ new Intl.Segmenter(); | ||
| 314 | const 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; | ||
| 316 | const zeroWidthClusterRegex = | ||
| 317 | /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; | ||
| 318 | const leadingNonPrintingRegex = | ||
| 319 | /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; | ||
| 320 | const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; | ||
| 321 | function 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 | |||
| 381 | import * as self from "lib/string/ansi.ts"; | ||
| 382 | import { 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 | |||
| 4 | const execFileRaw: typeof child_process.execFile.__promisify__ = util.promisify( | ||
| 5 | child_process.execFile, | ||
| 6 | ); | ||
| 7 | /*** @deprecated */ | ||
| 8 | export 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 */ | ||
| 23 | export interface SpawnOptions extends child_process.SpawnOptions { | ||
| 24 | cmd: string[]; | ||
| 25 | } | ||
| 26 | /*** @deprecated */ | ||
| 27 | export 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 */ | ||
| 41 | export interface ForkOptions extends child_process.ForkOptions { | ||
| 42 | cmd: string[]; | ||
| 43 | } | ||
| 44 | |||
| 45 | /*** @deprecated */ | ||
| 46 | export 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 */ | ||
| 60 | export 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"; | ||
| 90 | import * as events from "node:events"; | ||
| 91 | import * as util from "node:util"; | ||
| 92 | import * as child_process from "node:child_process"; | ||
| 93 | import { Buffer } from "node:buffer"; | ||
lib/subprocess/ffmpeg.ts created+198| ... | @@ -0,0 +1,198 @@ | ||
| 1 | export 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 | */ | ||
| 19 | export 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 | |||
| 62 | export interface Streams { | ||
| 63 | audio: boolean; | ||
| 64 | video: boolean; | ||
| 65 | } | ||
| 66 | |||
| 67 | export 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 | |||
| 89 | export 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 | |||
| 101 | export const defaultExtraOptions = [ | ||
| 102 | "-hide_banner", | ||
| 103 | "-stats", | ||
| 104 | ]; | ||
| 105 | |||
| 106 | export 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 | |||
| 191 | import * as child_process from "node:child_process"; | ||
| 192 | import * as subprocess from "lib/subprocess.ts"; | ||
| 193 | import * as readline from "node:readline"; | ||
| 194 | import * as process from "node:process"; | ||
| 195 | import events from "node:events"; | ||
| 196 | import * as path from "node:path"; | ||
| 197 | import type * as progress from "lib/log/progress.ts"; | ||
| 198 | import * as string from "lib/string.ts"; | ||
lib/ts.ts+11-1| ... | @@ -1,9 +1,19 @@ | ... | @@ -1,9 +1,19 @@ |
| 1 | export type Timer = ReturnType<typeof setTimeout>; | 1 | export type Timer = ReturnType<typeof setTimeout>; |
| 2 | export type Interval = ReturnType<typeof setInterval>; | 2 | export type Interval = ReturnType<typeof setInterval>; |
| 3 | 3 | ||
| 4 | export interface Dispose extends Disposable { | 4 | export interface VoidFunction { |
| 5 | (): void; | 5 | (): void; |
| 6 | } | 6 | } |
| 7 | |||
| 8 | /** Convenience type. Construct with `defer` */ | ||
| 9 | export 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 | */ | ||
| 7 | export function defer(fn: () => void): Dispose { | 17 | export function defer(fn: () => void): Dispose { |
| 8 | const f = fn as Dispose; | 18 | const f = fn as Dispose; |
| 9 | f[Symbol.dispose] = f; | 19 | f[Symbol.dispose] = f; |
package.json+3-4| ... | @@ -25,12 +25,11 @@ | ... | @@ -25,12 +25,11 @@ |
| 25 | "typescript": "5.8.3" | 25 | "typescript": "5.8.3" |
| 26 | }, | 26 | }, |
| 27 | "imports": { | 27 | "imports": { |
| 28 | "#backend": "./src/backend.ts", | ||
| 29 | "#debug": "./framework/debug.safe.ts", | ||
| 30 | "#sitegen": "./framework/lib/sitegen.ts", | 28 | "#sitegen": "./framework/lib/sitegen.ts", |
| 31 | "#sitegen/*": "./framework/lib/*.ts", | 29 | "#sitegen/*": "./framework/lib/*.ts", |
| 32 | "#engine/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", | 30 | "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts", |
| 33 | "#engine/*": "./framework/engine/*.ts", | 31 | "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts", |
| 32 | "#marko/runtime": "./framework/engine/marko-runtime.ts", | ||
| 34 | "#marko/html": { | 33 | "#marko/html": { |
| 35 | "types": "marko/html", | 34 | "types": "marko/html", |
| 36 | "production": "marko/production", | 35 | "production": "marko/production", |
run.js+2-4| ... | @@ -4,6 +4,7 @@ import * as zlib from "node:zlib"; | ... | @@ -4,6 +4,7 @@ import * as zlib from "node:zlib"; |
| 4 | import * as url from "node:url"; | 4 | import * as url from "node:url"; |
| 5 | import * as fs from "node:fs"; | 5 | import * as fs from "node:fs"; |
| 6 | import * as os from "node:os"; | 6 | import * as os from "node:os"; |
| 7 | import assert from "node:assert"; | ||
| 7 | import process from "node:process"; | 8 | import process from "node:process"; |
| 8 | 9 | ||
| 9 | // Disable experimental warnings (Type Stripping, etc) | 10 | // Disable experimental warnings (Type Stripping, etc) |
| ... | @@ -16,11 +17,8 @@ process.emit = function (event, error) { | ... | @@ -16,11 +17,8 @@ process.emit = function (event, error) { |
| 16 | }; | 17 | }; |
| 17 | 18 | ||
| 18 | try { | 19 | try { |
| 19 | // fails in a runtime without ts support | ||
| 20 | await import("#debug"); | ||
| 21 | |||
| 22 | // required APIs | 20 | // required APIs |
| 23 | ASSERT(zlib.zstdCompress); | 21 | assert(zlib.zstdCompress); |
| 24 | 22 | ||
| 25 | // seems to fail in bun | 23 | // seems to fail in bun |
| 26 | (await import("source-map-support")).install({ hookRequire: true }); | 24 | (await import("source-map-support")).install({ hookRequire: true }); |
src/deploy.ts+1-1| ... | @@ -46,6 +46,6 @@ export async function main() { | ... | @@ -46,6 +46,6 @@ export async function main() { |
| 46 | import * as incr from "../framework/incremental.ts"; | 46 | import * as incr from "../framework/incremental.ts"; |
| 47 | import { generate } from "../framework/generate.ts"; | 47 | import { generate } from "../framework/generate.ts"; |
| 48 | import * as progress from "lib/log/progress.ts"; | 48 | import * as progress from "lib/log/progress.ts"; |
| 49 | import * as subprocess from "#sitegen/subprocess"; | 49 | import * as subprocess from "lib/subprocess.ts"; |
| 50 | import * as rsync from "./file-viewer/rsync.ts"; | 50 | import * as rsync from "./file-viewer/rsync.ts"; |
| 51 | import { Path } from "#sitegen/path"; | 51 | import { Path } from "#sitegen/path"; |
src/file-viewer/backend.tsx+5-4| ... | @@ -93,7 +93,7 @@ app.get("/file/*", async (c, next) => { | ... | @@ -93,7 +93,7 @@ app.get("/file/*", async (c, next) => { |
| 93 | 93 | ||
| 94 | const headers = new Headers({ | 94 | const headers = new Headers({ |
| 95 | Vary: "Accept-Encoding, Accept", | 95 | Vary: "Accept-Encoding, Accept", |
| 96 | "Content-Type": contentTypeFor(derivedKey || file.path), | 96 | "Content-Type": mime.for(derivedKey || file.path), |
| 97 | "Content-Length": size.toString(), | 97 | "Content-Length": size.toString(), |
| 98 | ETag: etag, | 98 | ETag: etag, |
| 99 | "Last-Modified": date.toUTCString(), | 99 | "Last-Modified": date.toUTCString(), |
| ... | @@ -269,7 +269,7 @@ function getPartialPage(c: Context, rawFilePath: string) { | ... | @@ -269,7 +269,7 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 269 | return c.json({ error: "File not found" }, 404); | 269 | return c.json({ error: "File not found" }, 404); |
| 270 | } | 270 | } |
| 271 | 271 | ||
| 272 | const r = render.init(false, {}); | 272 | const r = render.init(false, []); |
| 273 | let root = render.resolveNode( | 273 | let root = render.resolveNode( |
| 274 | r, | 274 | r, |
| 275 | <MediaPanel | 275 | <MediaPanel |
| ... | @@ -287,10 +287,10 @@ function getPartialPage(c: Context, rawFilePath: string) { | ... | @@ -287,10 +287,10 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 287 | 287 | ||
| 288 | import { type Context, Hono } from "hono"; | 288 | import { type Context, Hono } from "hono"; |
| 289 | 289 | ||
| 290 | import * as render from "#engine/render"; | 290 | import * as render from "lib/render.ts"; |
| 291 | import { etagMatches, serveAsset } from "#sitegen/assets"; | 291 | import { etagMatches, serveAsset } from "#sitegen/assets"; |
| 292 | import * as view from "#sitegen/view"; | 292 | import * as view from "#sitegen/view"; |
| 293 | import { contentTypeFor } from "#sitegen/mime"; | 293 | import * as mime from "lib/mime.ts"; |
| 294 | 294 | ||
| 295 | import { requireFriendAuth } from "@/friend-auth.ts"; | 295 | import { requireFriendAuth } from "@/friend-auth.ts"; |
| 296 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; | 296 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; |
| ... | @@ -299,3 +299,4 @@ import { MediaPanel } from "@/file-viewer/views/clofi.tsx"; | ... | @@ -299,3 +299,4 @@ import { MediaPanel } from "@/file-viewer/views/clofi.tsx"; |
| 299 | import { Speedbump } from "@/file-viewer/cotyledon.tsx"; | 299 | import { Speedbump } from "@/file-viewer/cotyledon.tsx"; |
| 300 | import * as cache from "@/file-viewer/cache.ts"; | 300 | import * as cache from "@/file-viewer/cache.ts"; |
| 301 | import * as derived from "@/file-viewer/models/derived.ts"; | 301 | import * as derived from "@/file-viewer/models/derived.ts"; |
| 302 | import { UNWRAP } from "lib/assert.ts"; |
src/file-viewer/bin/scan3.ts+4-3| ... | @@ -981,9 +981,9 @@ async function scrubLocationMetadata( | ... | @@ -981,9 +981,9 @@ async function scrubLocationMetadata( |
| 981 | 981 | ||
| 982 | const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; | 982 | const monthMilliseconds = 30 * 24 * 60 * 60 * 1000; |
| 983 | 983 | ||
| 984 | import * as async from "#sitegen/async"; | 984 | import * as async from "lib/async.ts"; |
| 985 | import * as fs from "#sitegen/fs"; | 985 | import * as fs from "#sitegen/fs"; |
| 986 | import * as subprocess from "#sitegen/subprocess"; | 986 | import * as subprocess from "lib/subprocess.ts"; |
| 987 | import { Path } from "#sitegen/path"; | 987 | import { Path } from "#sitegen/path"; |
| 988 | import * as queue from "../../../lib/queue.ts"; | 988 | import * as queue from "../../../lib/queue.ts"; |
| 989 | import * as log from "../../../lib/log.ts"; | 989 | import * as log from "../../../lib/log.ts"; |
| ... | @@ -1006,9 +1006,10 @@ import { | ... | @@ -1006,9 +1006,10 @@ import { |
| 1006 | } from "@/file-viewer/format.ts"; | 1006 | } from "@/file-viewer/format.ts"; |
| 1007 | import * as rules from "@/file-viewer/rules.ts"; | 1007 | import * as rules from "@/file-viewer/rules.ts"; |
| 1008 | import * as highlight from "@/file-viewer/highlight.ts"; | 1008 | import * as highlight from "@/file-viewer/highlight.ts"; |
| 1009 | import * as ffmpeg from "@/file-viewer/ffmpeg.ts"; | 1009 | import * as ffmpeg from "lib/subprocess/ffmpeg.ts"; |
| 1010 | import * as rsync from "@/file-viewer/rsync.ts"; | 1010 | import * as rsync from "@/file-viewer/rsync.ts"; |
| 1011 | import * as derived from "@/file-viewer/models/derived.ts"; | 1011 | import * as derived from "@/file-viewer/models/derived.ts"; |
| 1012 | import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; | 1012 | import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; |
| 1013 | 1013 | ||
| 1014 | import { rawFileRoot as root } from "../paths.ts"; | 1014 | import { rawFileRoot as root } from "../paths.ts"; |
| 1015 | import { UNWRAP } from "lib/assert.ts"; |
src/file-viewer/cache.ts+4-3| ... | @@ -573,8 +573,8 @@ async function writeStream( | ... | @@ -573,8 +573,8 @@ async function writeStream( |
| 573 | import * as util from "node:util"; | 573 | import * as util from "node:util"; |
| 574 | import * as fsCallbacks from "node:fs"; | 574 | import * as fsCallbacks from "node:fs"; |
| 575 | import * as fs from "#sitegen/fs"; | 575 | import * as fs from "#sitegen/fs"; |
| 576 | import * as async from "#sitegen/async"; | 576 | import * as async from "lib/async.ts"; |
| 577 | import * as error from "#sitegen/error"; | 577 | import * as error from "lib/error.ts"; |
| 578 | import * as stream from "node:stream"; | 578 | import * as stream from "node:stream"; |
| 579 | import * as crypto from "node:crypto"; | 579 | import * as crypto from "node:crypto"; |
| 580 | import * as http from "node:http"; | 580 | import * as http from "node:http"; |
| ... | @@ -583,5 +583,6 @@ import * as paths from "./paths.ts"; | ... | @@ -583,5 +583,6 @@ import * as paths from "./paths.ts"; |
| 583 | import * as log from "lib/log.ts"; | 583 | import * as log from "lib/log.ts"; |
| 584 | import { MediaFile } from "./models/MediaFile.ts"; | 584 | import { MediaFile } from "./models/MediaFile.ts"; |
| 585 | import type { ReadableStream } from "node:stream/web"; | 585 | import type { ReadableStream } from "node:stream/web"; |
| 586 | import { Lru } from "#sitegen/lru"; | 586 | import { Lru } from "lib/Lru.ts"; |
| 587 | import { Path } from "#sitegen/path"; | 587 | import { Path } from "#sitegen/path"; |
| 588 | import { 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 | |||
| 4 | export 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 | |||
| 16 | export const defaultExtraOptions = [ | ||
| 17 | "-hide_banner", | ||
| 18 | "-stats", | ||
| 19 | ]; | ||
| 20 | |||
| 21 | export interface SpawnOptions { | ||
| 22 | args: string[]; | ||
| 23 | ffmpeg?: string; | ||
| 24 | progress: progress.Node; | ||
| 25 | cwd: Path; | ||
| 26 | } | ||
| 27 | |||
| 28 | export 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 | |||
| 71 | export 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 | |||
| 93 | export 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 | |||
| 178 | import * as child_process from "node:child_process"; | ||
| 179 | import * as subprocess from "#sitegen/subprocess"; | ||
| 180 | import * as readline from "node:readline"; | ||
| 181 | import * as process from "node:process"; | ||
| 182 | import events from "node:events"; | ||
| 183 | import * as path from "node:path"; | ||
| 184 | import * as progress from "../../lib/log/progress.ts"; | ||
| 185 | import type { Path } from "#sitegen/path"; | ||
| 186 | import * as string from "lib/string.ts"; | ||
src/file-viewer/format.ts+1-1| ... | @@ -288,4 +288,4 @@ export function formatDate(dateTime: Date) { | ... | @@ -288,4 +288,4 @@ export function formatDate(dateTime: Date) { |
| 288 | } | 288 | } |
| 289 | 289 | ||
| 290 | import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | 290 | import type { MediaFile } from "@/file-viewer/models/MediaFile.ts"; |
| 291 | import { escapeHtml } from "#engine/render"; | 291 | import { escapeHtml } from "lib/render.ts"; |
src/file-viewer/highlight.ts+2-2| ... | @@ -192,9 +192,9 @@ export async function main() { | ... | @@ -192,9 +192,9 @@ export async function main() { |
| 192 | } | 192 | } |
| 193 | } | 193 | } |
| 194 | 194 | ||
| 195 | import * as async from "#sitegen/async"; | 195 | import * as async from "lib/async.ts"; |
| 196 | import * as fs from "#sitegen/fs"; | 196 | import * as fs from "#sitegen/fs"; |
| 197 | import * as path from "node:path"; | 197 | import * as path from "node:path"; |
| 198 | import * as oniguruma from "vscode-oniguruma"; | 198 | import * as oniguruma from "vscode-oniguruma"; |
| 199 | import * as textmate from "vscode-textmate"; | 199 | import * as textmate from "vscode-textmate"; |
| 200 | import * as render from "#engine/render"; | 200 | import * as render from "lib/render.ts"; |
src/file-viewer/models/MediaFile.ts+1| ... | @@ -461,3 +461,4 @@ const getDirectoriesToReindexQuery = db.prepare(` | ... | @@ -461,3 +461,4 @@ const getDirectoriesToReindexQuery = db.prepare(` |
| 461 | import { getDb } from "#sitegen/sqlite"; | 461 | import { getDb } from "#sitegen/sqlite"; |
| 462 | import * as path from "node:path/posix"; | 462 | import * as path from "node:path/posix"; |
| 463 | import { FilePermissions } from "./FilePermissions.ts"; | 463 | import { FilePermissions } from "./FilePermissions.ts"; |
| 464 | import { ASSERT } from "lib/assert.ts"; |
src/file-viewer/transcode-rules.ts+1-1| ... | @@ -313,4 +313,4 @@ export function getH264HlsArgs( | ... | @@ -313,4 +313,4 @@ export function getH264HlsArgs( |
| 313 | } | 313 | } |
| 314 | 314 | ||
| 315 | import type { Path } from "#sitegen/path"; | 315 | import type { Path } from "#sitegen/path"; |
| 316 | import * as ffmpeg from "./ffmpeg.ts"; | 316 | import * as ffmpeg from "lib/subprocess/ffmpeg.ts"; |
src/file-viewer/views/clofi.tsx+5-4| ... | @@ -89,7 +89,7 @@ export default function MediaList({ | ... | @@ -89,7 +89,7 @@ export default function MediaList({ |
| 89 | <MediaPanel | 89 | <MediaPanel |
| 90 | file={entry} | 90 | file={entry} |
| 91 | isLast={isLast} | 91 | isLast={isLast} |
| 92 | activeFilename={parts[i + 1]} | 92 | activeFilename={parts[i + 1] ?? null} |
| 93 | hasCotyledonCookie={hasCotyledonCookie} | 93 | hasCotyledonCookie={hasCotyledonCookie} |
| 94 | /> | 94 | /> |
| 95 | ); | 95 | ); |
| ... | @@ -163,7 +163,7 @@ export function MediaPanel({ | ... | @@ -163,7 +163,7 @@ export function MediaPanel({ |
| 163 | className="custom mobile-back" | 163 | className="custom mobile-back" |
| 164 | /> | 164 | /> |
| 165 | ); | 165 | ); |
| 166 | const canvases: Record<string, string> = { | 166 | const canvases: Record<string, () => void> = { |
| 167 | "/2017": () => addScript("../scripts/canvas_2017.client.ts"), | 167 | "/2017": () => addScript("../scripts/canvas_2017.client.ts"), |
| 168 | "/2018": () => addScript("../scripts/canvas_2018.client.ts"), | 168 | "/2018": () => addScript("../scripts/canvas_2018.client.ts"), |
| 169 | "/2019": () => addScript("../scripts/canvas_2019.client.ts"), | 169 | "/2019": () => addScript("../scripts/canvas_2019.client.ts"), |
| ... | @@ -923,8 +923,9 @@ import { | ... | @@ -923,8 +923,9 @@ import { |
| 923 | highlightLinksInTextView, | 923 | highlightLinksInTextView, |
| 924 | } from "@/file-viewer/format.ts"; | 924 | } from "@/file-viewer/format.ts"; |
| 925 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; | 925 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; |
| 926 | import * as mime from "#sitegen/mime"; | 926 | import * as mime from "lib/mime.ts"; |
| 927 | import * as sort from "../sort.ts"; | 927 | import * as sort from "../sort.ts"; |
| 928 | import CloverVideo from "@/tags/clover-video.marko"; | 928 | import CloverVideo from "@/tags/clover-video.marko"; |
| 929 | import Image from "@/tags/image.tsx"; | 929 | import Image from "@/tags/image.tsx"; |
| 930 | import * as render from "#engine/render"; | 930 | import * as render from "lib/render.ts"; |
| 931 | import { UNWRAP } from "lib/assert.ts"; |
src/package-lib.ts created+226| ... | @@ -0,0 +1,226 @@ | ||
| 1 | export 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 | |||
| 23 | export 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 | // | ||
| 209 | export 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 | |||
| 222 | import * as progress from "lib/log/progress.ts"; | ||
| 223 | import * as path from "node:path"; | ||
| 224 | import * as fs from "node:fs/promises"; | ||
| 225 | import * as subprocess from "lib/subprocess.ts"; | ||
| 226 | import { UNWRAP } from "lib/assert.ts"; | ||
src/pages/index.marko+1-1| ... | @@ -20,7 +20,7 @@ export const meta = { | ... | @@ -20,7 +20,7 @@ export const meta = { |
| 20 | <p><a href="/q+a">questions and answers</a></p> | 20 | <p><a href="/q+a">questions and answers</a></p> |
| 21 | <p><a href="/file">file browser</a></p> | 21 | <p><a href="/file">file browser</a></p> |
| 22 | <p><a href="/subscribe">mailing list</a></p> | 22 | <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> |
| 24 | <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p> | 24 | <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p> |
| 25 | <h1>paper clover</h1> | 25 | <h1>paper clover</h1> |
| 26 | </div> | 26 | </div> |
src/q+a/clover-markdown.tsx+1-1| ... | @@ -268,4 +268,4 @@ import { | ... | @@ -268,4 +268,4 @@ import { |
| 268 | parseCaptureInline, | 268 | parseCaptureInline, |
| 269 | parseInline, | 269 | parseInline, |
| 270 | } from "./simple-markdown.ts"; | 270 | } from "./simple-markdown.ts"; |
| 271 | import * as render from "#engine/render"; | 271 | import * as render from "lib/render.ts"; |
src/q+a/image.tsx+1-1| ... | @@ -78,5 +78,5 @@ import * as fs from "#sitegen/fs"; | ... | @@ -78,5 +78,5 @@ import * as fs from "#sitegen/fs"; |
| 78 | import * as path from "node:path"; | 78 | import * as path from "node:path"; |
| 79 | import * as puppeteer from "puppeteer"; | 79 | import * as puppeteer from "puppeteer"; |
| 80 | import { Question } from "@/q+a/models/Question.ts"; | 80 | import { Question } from "@/q+a/models/Question.ts"; |
| 81 | import { RefCountedExpirable } from "#sitegen/async"; | 81 | import { RefCountedExpirable } from "lib/async.ts"; |
| 82 | import * as view from "#sitegen/view"; | 82 | import * as view from "#sitegen/view"; |
src/q+a/scripts/editor.client.tsx+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { EditorState } from "@codemirror/state"; | 1 | import { EditorState } from "@codemirror/state"; |
| 2 | import { basicSetup, EditorView } from "codemirror"; | 2 | import { basicSetup, EditorView } from "codemirror"; |
| 3 | import * as render from "#engine/render"; | 3 | import * as render from "lib/render.ts"; |
| 4 | import type { ScriptPayload } from "@/q+a/views/editor.marko"; | 4 | import type { ScriptPayload } from "@/q+a/views/editor.marko"; |
| 5 | import QuestionRender from "@/q+a/tags/question.marko"; | 5 | import QuestionRender from "@/q+a/tags/question.marko"; |
| 6 | 6 |
src/site.ts+1-1| ... | @@ -113,6 +113,6 @@ export async function main() { | ... | @@ -113,6 +113,6 @@ export async function main() { |
| 113 | 113 | ||
| 114 | import * as path from "node:path"; | 114 | import * as path from "node:path"; |
| 115 | import * as font from "../framework/font.ts"; | 115 | import * as font from "../framework/font.ts"; |
| 116 | import type { Template as MetaTemplate } from "#sitegen/meta"; | 116 | import type { Template as MetaTemplate } from "lib/render/meta.ts"; |
| 117 | import type * as sg from "#sitegen"; | 117 | import type * as sg from "#sitegen"; |
| 118 | import { nasRoot } from "./file-viewer/paths.ts"; | 118 | import { nasRoot } from "./file-viewer/paths.ts"; |
src/source-of-truth.ts+3-2| ... | @@ -87,7 +87,7 @@ app.get("/file/*", async (c) => { | ... | @@ -87,7 +87,7 @@ app.get("/file/*", async (c) => { |
| 87 | read: fsCallbacks.read, | 87 | read: fsCallbacks.read, |
| 88 | }, | 88 | }, |
| 89 | }); | 89 | }); |
| 90 | c.header("Content-Type", mime.contentTypeFor(fullPath)); | 90 | c.header("Content-Type", mime.for(fullPath)); |
| 91 | return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream); | 91 | return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream); |
| 92 | }); | 92 | }); |
| 93 | 93 | ||
| ... | @@ -111,5 +111,6 @@ import * as fs from "#sitegen/fs"; | ... | @@ -111,5 +111,6 @@ import * as fs from "#sitegen/fs"; |
| 111 | import * as fsCallbacks from "node:fs"; | 111 | import * as fsCallbacks from "node:fs"; |
| 112 | import * as util from "node:util"; | 112 | import * as util from "node:util"; |
| 113 | import * as stream from "node:stream"; | 113 | import * as stream from "node:stream"; |
| 114 | import * as mime from "#sitegen/mime"; | 114 | import * as mime from "lib/mime.ts"; |
| 115 | import { derivedFileRoot, rawFileRoot } from "./file-viewer/paths.ts"; | 115 | import { derivedFileRoot, rawFileRoot } from "./file-viewer/paths.ts"; |
| 116 | import { ASSERT } from "lib/assert.ts"; |
src/tags/clover-video.marko+1-1| ... | @@ -57,4 +57,4 @@ export interface Input { | ... | @@ -57,4 +57,4 @@ export interface Input { |
| 57 | client import "./clover-video.client.ts"; | 57 | client import "./clover-video.client.ts"; |
| 58 | 58 | ||
| 59 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | 59 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; |
| 60 | 60 | import { UNWRAP, ASSERT } from "lib/assert.ts"; |
src/tags/image.tsx+2-1| ... | @@ -5,7 +5,7 @@ interface Props { | ... | @@ -5,7 +5,7 @@ interface Props { |
| 5 | objectFit?: string; | 5 | objectFit?: string; |
| 6 | 6 | ||
| 7 | style?: string; | 7 | style?: string; |
| 8 | className?: string; | 8 | class?: string; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export default function Image( | 11 | export default function Image( |
| ... | @@ -53,3 +53,4 @@ export default function Image( | ... | @@ -53,3 +53,4 @@ export default function Image( |
| 53 | import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; | 53 | import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; |
| 54 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | 54 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; |
| 55 | import { escapeUri } from "@/file-viewer/format.ts"; | 55 | import { escapeUri } from "@/file-viewer/format.ts"; |
| 56 | import { UNWRAP } from "lib/assert.ts"; |
suspense.test.tsx created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import { test } from "node:test"; | ||
| 2 | import { renderStreaming, Suspense } from "./suspense.ts"; | ||
| 3 | |||
| 4 | test("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 | */ | ||
| 14 | const userData = render.userData<null | State>(() => null); | ||
| 15 | |||
| 16 | export 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 | */ | ||
| 32 | export 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. | ||
| 62 | export 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 | |||
| 105 | interface State { | ||
| 106 | nested: boolean; | ||
| 107 | nextId: number; | ||
| 108 | completed: number; | ||
| 109 | pushChunk(name: string, node: render.ResolvedNode): void; | ||
| 110 | } | ||
| 111 | |||
| 112 | export declare namespace Suspense { | ||
| 113 | export type { Input }; | ||
| 114 | } | ||
| 115 | |||
| 116 | import * as render from "lib/render.ts"; | ||
tsconfig.json+1-1| ... | @@ -4,7 +4,7 @@ | ... | @@ -4,7 +4,7 @@ |
| 4 | "baseUrl": ".", | 4 | "baseUrl": ".", |
| 5 | "incremental": true, | 5 | "incremental": true, |
| 6 | "jsx": "react-jsxdev", | 6 | "jsx": "react-jsxdev", |
| 7 | "jsxImportSource": "#engine", | 7 | "jsxImportSource": "#jsx", |
| 8 | "lib": ["esnext", "dom", "dom.iterable"], | 8 | "lib": ["esnext", "dom", "dom.iterable"], |
| 9 | "module": "nodenext", | 9 | "module": "nodenext", |
| 10 | "noEmit": true, | 10 | "noEmit": true, |