This commit is contained in:
chloe caruso 2025-07-07 20:58:02 -07:00
parent f1b1c650ce
commit ea5f2bc325
48 changed files with 5217 additions and 5177 deletions

View file

@ -16,7 +16,10 @@
pkgs.nodejs_24 # runtime pkgs.nodejs_24 # runtime
pkgs.deno # formatter pkgs.deno # formatter
(pkgs.ffmpeg.override { (pkgs.ffmpeg.override {
withOpus = true;
withSvtav1 = true; withSvtav1 = true;
withJxl = true;
withWebp = true;
}) })
]; ];
}; };

View file

@ -1,35 +1,35 @@
import "@paperclover/console/inject"; import "@paperclover/console/inject";
import "#debug"; import "#debug";
const protocol = "http"; const protocol = "http";
const server = serve({ const server = serve({
fetch: app.fetch, fetch: app.fetch,
}, ({ address, port }) => { }, ({ address, port }) => {
if (address === "::") address = "::1"; if (address === "::") address = "::1";
console.info(url.format({ console.info(url.format({
protocol, protocol,
hostname: address, hostname: address,
port, port,
})); }));
}); });
process.on("SIGINT", () => { process.on("SIGINT", () => {
server.close(); server.close();
process.exit(0); process.exit(0);
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
server.close((err) => { server.close((err) => {
if (err) { if (err) {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
} }
process.exit(0); process.exit(0);
}); });
}); });
import app from "#backend"; import app from "#backend";
import url from "node:url"; import url from "node:url";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import process from "node:process"; import process from "node:process";

View file

@ -1,4 +1,4 @@
import "@paperclover/console/inject"; import "@paperclover/console/inject";
export default app; export default app;
import app from "#backend"; import app from "#backend";

View file

@ -1,17 +1,17 @@
globalThis.UNWRAP = (t, ...args) => { globalThis.UNWRAP = (t, ...args) => {
if (t == null) { if (t == null) {
throw new Error( throw new Error(
args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")", args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
); );
} }
return t; return t;
}; };
globalThis.ASSERT = (t, ...args) => { globalThis.ASSERT = (t, ...args) => {
if (!t) { if (!t) {
throw new Error( throw new Error(
args.length > 0 ? util.format(...args) : "Assertion Failed", args.length > 0 ? util.format(...args) : "Assertion Failed",
); );
} }
}; };
import * as util from "node:util"; import * as util from "node:util";

View file

@ -1,4 +1,4 @@
declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T; declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
declare function ASSERT(value: unknown, ...log: unknown[]): asserts value; declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
type Timer = ReturnType<typeof setTimeout>; type Timer = ReturnType<typeof setTimeout>;

View file

@ -1,54 +1,54 @@
export const Fragment = ({ children }: { children: engine.Node[] }) => children; export const Fragment = ({ children }: { children: engine.Node[] }) => children;
export function jsx( export function jsx(
type: string | engine.Component, type: string | engine.Component,
props: Record<string, unknown>, props: Record<string, unknown>,
): engine.Element { ): engine.Element {
if (typeof type !== "function" && typeof type !== "string") { if (typeof type !== "function" && typeof type !== "string") {
throw new Error("Invalid component type: " + engine.inspect(type)); throw new Error("Invalid component type: " + engine.inspect(type));
} }
return [engine.kElement, type, props]; return [engine.kElement, type, props];
} }
export function jsxDEV( export function jsxDEV(
type: string | engine.Component, type: string | engine.Component,
props: Record<string, unknown>, props: Record<string, unknown>,
// Unused with the clover engine // Unused with the clover engine
_key: string, _key: string,
// Unused with the clover engine // Unused with the clover engine
_isStaticChildren: boolean, _isStaticChildren: boolean,
source: engine.SrcLoc, source: engine.SrcLoc,
): engine.Element { ): engine.Element {
const { fileName, lineNumber, columnNumber } = source; const { fileName, lineNumber, columnNumber } = source;
// Assert the component type is valid to render. // Assert the component type is valid to render.
if (typeof type !== "function" && typeof type !== "string") { if (typeof type !== "function" && typeof type !== "string") {
throw new Error( throw new Error(
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` + `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
engine.inspect(type) + engine.inspect(type) +
". Clover SSR element must be a function or string", ". Clover SSR element must be a function or string",
); );
} }
// Construct an `ssr.Element` // Construct an `ssr.Element`
return [engine.kElement, type, props, "", source]; return [engine.kElement, type, props, "", source];
} }
// jsxs // jsxs
export { jsx as jsxs }; export { jsx as jsxs };
declare global { declare global {
namespace JSX { namespace JSX {
interface IntrinsicElements { interface IntrinsicElements {
[name: string]: Record<string, unknown>; [name: string]: Record<string, unknown>;
} }
interface ElementChildrenAttribute { interface ElementChildrenAttribute {
children: Node; children: Node;
} }
type Element = engine.Element; type Element = engine.Element;
type ElementType = keyof IntrinsicElements | engine.Component; type ElementType = keyof IntrinsicElements | engine.Component;
type ElementClass = ReturnType<engine.Component>; type ElementClass = ReturnType<engine.Component>;
} }
} }
import * as engine from "./ssr.ts"; import * as engine from "./ssr.ts";

View file

@ -1,147 +1,147 @@
// This file is used to integrate Marko into the Clover Engine and Sitegen // This file is used to integrate Marko into the Clover Engine and Sitegen
// To use, replace the "marko/html" import with this file. // To use, replace the "marko/html" import with this file.
export * from "#marko/html"; export * from "#marko/html";
interface BodyContentObject { interface BodyContentObject {
[x: PropertyKey]: unknown; [x: PropertyKey]: unknown;
content: ServerRenderer; content: ServerRenderer;
} }
export const createTemplate = ( export const createTemplate = (
templateId: string, templateId: string,
renderer: ServerRenderer, renderer: ServerRenderer,
) => { ) => {
const { render } = marko.createTemplate(templateId, renderer); const { render } = marko.createTemplate(templateId, renderer);
function wrap(props: Record<string, unknown>, n: number) { function wrap(props: Record<string, unknown>, n: number) {
// Marko Custom Tags // Marko Custom Tags
const cloverAsyncMarker = { isAsync: false }; const cloverAsyncMarker = { isAsync: false };
let r: engine.Render | undefined = undefined; let r: engine.Render | undefined = undefined;
try { try {
r = engine.getCurrentRender(); r = engine.getCurrentRender();
} catch {} } catch {}
// Support using Marko outside of Clover SSR // Support using Marko outside of Clover SSR
if (r) { if (r) {
engine.setCurrentRender(null); engine.setCurrentRender(null);
const markoResult = render.call(renderer, { const markoResult = render.call(renderer, {
...props, ...props,
$global: { clover: r, cloverAsyncMarker }, $global: { clover: r, cloverAsyncMarker },
}); });
if (cloverAsyncMarker.isAsync) { if (cloverAsyncMarker.isAsync) {
return markoResult.then(engine.html); return markoResult.then(engine.html);
} }
const rr = markoResult.toString(); const rr = markoResult.toString();
return engine.html(rr); return engine.html(rr);
} else { } else {
return renderer(props, n); return renderer(props, n);
} }
} }
wrap.render = render; wrap.render = render;
wrap.unwrapped = renderer; wrap.unwrapped = renderer;
return wrap; return wrap;
}; };
export const dynamicTag = ( export const dynamicTag = (
scopeId: number, scopeId: number,
accessor: Accessor, accessor: Accessor,
tag: unknown | string | ServerRenderer | BodyContentObject, tag: unknown | string | ServerRenderer | BodyContentObject,
inputOrArgs: unknown, inputOrArgs: unknown,
content?: (() => void) | 0, content?: (() => void) | 0,
inputIsArgs?: 1, inputIsArgs?: 1,
serializeReason?: 1 | 0, serializeReason?: 1 | 0,
) => { ) => {
if (typeof tag === "function") { if (typeof tag === "function") {
clover: { clover: {
const unwrapped = (tag as any).unwrapped; const unwrapped = (tag as any).unwrapped;
if (unwrapped) { if (unwrapped) {
tag = unwrapped; tag = unwrapped;
break clover; break clover;
} }
let r: engine.Render; let r: engine.Render;
try { try {
r = engine.getCurrentRender(); r = engine.getCurrentRender();
if (!r) throw 0; if (!r) throw 0;
} catch { } catch {
r = marko.$global().clover as engine.Render; r = marko.$global().clover as engine.Render;
} }
if (!r) throw new Error("No Clover Render Active"); if (!r) throw new Error("No Clover Render Active");
const subRender = engine.initRender(r.async !== -1, r.addon); const subRender = engine.initRender(r.async !== -1, r.addon);
const resolved = engine.resolveNode(subRender, [ const resolved = engine.resolveNode(subRender, [
engine.kElement, engine.kElement,
tag, tag,
inputOrArgs, inputOrArgs,
]); ]);
if (subRender.async > 0) { if (subRender.async > 0) {
const marker = marko.$global().cloverAsyncMarker as Async; const marker = marko.$global().cloverAsyncMarker as Async;
marker.isAsync = true; marker.isAsync = true;
// Wait for async work to finish // Wait for async work to finish
const { resolve, reject, promise } = Promise.withResolvers<string>(); const { resolve, reject, promise } = Promise.withResolvers<string>();
subRender.asyncDone = () => { subRender.asyncDone = () => {
const rejections = subRender.rejections; const rejections = subRender.rejections;
if (!rejections) return resolve(engine.renderNode(resolved)); if (!rejections) return resolve(engine.renderNode(resolved));
(r.rejections ??= []).push(...rejections); (r.rejections ??= []).push(...rejections);
return reject(new Error("Render had errors")); return reject(new Error("Render had errors"));
}; };
marko.fork( marko.fork(
scopeId, scopeId,
accessor, accessor,
promise, promise,
(string: string) => marko.write(string), (string: string) => marko.write(string),
0, 0,
); );
} else { } else {
marko.write(engine.renderNode(resolved)); marko.write(engine.renderNode(resolved));
} }
return; return;
} }
} }
return marko.dynamicTag( return marko.dynamicTag(
scopeId, scopeId,
accessor, accessor,
tag, tag,
inputOrArgs, inputOrArgs,
content, content,
inputIsArgs, inputIsArgs,
serializeReason, serializeReason,
); );
}; };
export function fork( export function fork(
scopeId: number, scopeId: number,
accessor: Accessor, accessor: Accessor,
promise: Promise<unknown>, promise: Promise<unknown>,
callback: (data: unknown) => void, callback: (data: unknown) => void,
serializeMarker?: 0 | 1, serializeMarker?: 0 | 1,
) { ) {
const marker = marko.$global().cloverAsyncMarker as Async; const marker = marko.$global().cloverAsyncMarker as Async;
marker.isAsync = true; marker.isAsync = true;
marko.fork(scopeId, accessor, promise, callback, serializeMarker); marko.fork(scopeId, accessor, promise, callback, serializeMarker);
} }
export function escapeXML(input: unknown) { export function escapeXML(input: unknown) {
// The rationale of this check is that the default toString method // The rationale of this check is that the default toString method
// creating `[object Object]` is universally useless to any end user. // creating `[object Object]` is universally useless to any end user.
if ( if (
input == null || input == null ||
(typeof input === "object" && input && (typeof input === "object" && input &&
// only block this if it's the default `toString` // only block this if it's the default `toString`
input.toString === Object.prototype.toString) input.toString === Object.prototype.toString)
) { ) {
throw new Error( throw new Error(
`Unexpected value in template placeholder: '` + `Unexpected value in template placeholder: '` +
engine.inspect(input) + "'. " + engine.inspect(input) + "'. " +
`To emit a literal '${input}', use \${String(value)}`, `To emit a literal '${input}', use \${String(value)}`,
); );
} }
return marko.escapeXML(input); return marko.escapeXML(input);
} }
interface Async { interface Async {
isAsync: boolean; isAsync: boolean;
} }
import * as engine from "./ssr.ts"; import * as engine from "./ssr.ts";
import type { ServerRenderer } from "marko/html/template"; import type { ServerRenderer } from "marko/html/template";
import { type Accessor } from "marko/common/types"; import { type Accessor } from "marko/common/types";
import * as marko from "#marko/html"; import * as marko from "#marko/html";

View file

@ -1,41 +1,41 @@
import { test } from "node:test"; import { test } from "node:test";
import * as engine from "./ssr.ts"; import * as engine from "./ssr.ts";
test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3")); test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3"));
test("simple tree", (t) => test("simple tree", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync( engine.ssrSync(
<main class={["a", "b"]}> <main class={["a", "b"]}>
<h1 style="background-color:red">hello world</h1> <h1 style="background-color:red">hello world</h1>
<p>haha</p> <p>haha</p>
{1}| {1}|
{0}| {0}|
{true}| {true}|
{false}| {false}|
{null}| {null}|
{undefined}| {undefined}|
</main>, </main>,
).text, ).text,
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>', '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
)); ));
test("unescaped/escaped html", (t) => test("unescaped/escaped html", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text, engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
"<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>", "<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
)); ));
test("clsx built-in", (t) => test("clsx built-in", (t) =>
t.assert.equal( t.assert.equal(
engine.ssrSync( engine.ssrSync(
<> <>
<a class="a" /> <a class="a" />
<b class={null} /> <b class={null} />
<c class={undefined} /> <c class={undefined} />
<d class={["a", "b", null]} /> <d class={["a", "b", null]} />
<e class={{ a: true, b: false }} /> <e class={{ a: true, b: false }} />
<e <e
class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]} class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
/> />
</>, </>,
).text, ).text,
'<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>', '<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
)); ));

View file

@ -1,311 +1,311 @@
// Clover's Rendering Engine is the backbone of her website generator. It // Clover's Rendering Engine is the backbone of her website generator. It
// converts objects and components (functions returning 'Node') into HTML. The // converts objects and components (functions returning 'Node') into HTML. The
// engine is simple and self-contained, with integrations for JSX and Marko // engine is simple and self-contained, with integrations for JSX and Marko
// (which can interop with each-other) are provided next to this file. // (which can interop with each-other) are provided next to this file.
// //
// Add-ons to the rendering engine can provide opaque data, And retrieve it // Add-ons to the rendering engine can provide opaque data, And retrieve it
// within component calls with 'getAddonData'. For example, 'sitegen' uses this // within component calls with 'getAddonData'. For example, 'sitegen' uses this
// to track needed client scripts without introducing patches to the engine. // to track needed client scripts without introducing patches to the engine.
export type Addons = Record<string | symbol, unknown>; export type Addons = Record<string | symbol, unknown>;
export function ssrSync(node: Node): Result; export function ssrSync(node: Node): Result;
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>; export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
export function ssrSync(node: Node, addon: Addons = {}) { export function ssrSync(node: Node, addon: Addons = {}) {
const r = initRender(false, addon); const r = initRender(false, addon);
const resolved = resolveNode(r, node); const resolved = resolveNode(r, node);
return { text: renderNode(resolved), addon }; return { text: renderNode(resolved), addon };
} }
export function ssrAsync(node: Node): Promise<Result>; export function ssrAsync(node: Node): Promise<Result>;
export function ssrAsync<A extends Addons>( export function ssrAsync<A extends Addons>(
node: Node, node: Node,
addon: A, addon: A,
): Promise<Result<A>>; ): Promise<Result<A>>;
export function ssrAsync(node: Node, addon: Addons = {}) { export function ssrAsync(node: Node, addon: Addons = {}) {
const r = initRender(true, addon); const r = initRender(true, addon);
const resolved = resolveNode(r, node); const resolved = resolveNode(r, node);
if (r.async === 0) { if (r.async === 0) {
return Promise.resolve({ text: renderNode(resolved), addon }); return Promise.resolve({ text: renderNode(resolved), addon });
} }
const { resolve, reject, promise } = Promise.withResolvers<Result>(); const { resolve, reject, promise } = Promise.withResolvers<Result>();
r.asyncDone = () => { r.asyncDone = () => {
const rejections = r.rejections; const rejections = r.rejections;
if (!rejections) return resolve({ text: renderNode(resolved), addon }); if (!rejections) return resolve({ text: renderNode(resolved), addon });
if (rejections.length === 1) return reject(rejections[0]); if (rejections.length === 1) return reject(rejections[0]);
return reject(new AggregateError(rejections)); return reject(new AggregateError(rejections));
}; };
return promise; return promise;
} }
/** Inline HTML into a render without escaping it */ /** Inline HTML into a render without escaping it */
export function html(rawText: ResolvedNode): DirectHtml { export function html(rawText: ResolvedNode): DirectHtml {
return [kDirectHtml, rawText]; return [kDirectHtml, rawText];
} }
interface Result<A extends Addons = Addons> { interface Result<A extends Addons = Addons> {
text: string; text: string;
addon: A; addon: A;
} }
export interface Render { export interface Render {
/** /**
* Set to '-1' if rendering synchronously * Set to '-1' if rendering synchronously
* Number of async promises the render is waiting on. * Number of async promises the render is waiting on.
*/ */
async: number | -1; async: number | -1;
asyncDone: null | (() => void); asyncDone: null | (() => void);
/** When components reject, those are logged here */ /** When components reject, those are logged here */
rejections: unknown[] | null; rejections: unknown[] | null;
/** Add-ons to the rendering engine store state here */ /** Add-ons to the rendering engine store state here */
addon: Addons; addon: Addons;
} }
export const kElement = Symbol("Element"); export const kElement = Symbol("Element");
export const kDirectHtml = Symbol("DirectHtml"); export const kDirectHtml = Symbol("DirectHtml");
/** Node represents a webpage that can be 'rendered' into HTML. */ /** Node represents a webpage that can be 'rendered' into HTML. */
export type Node = export type Node =
| number | number
| string // Escape HTML | string // Escape HTML
| Node[] // Concat | Node[] // Concat
| Element // Render | Element // Render
| DirectHtml // Insert | DirectHtml // Insert
| Promise<Node> // Await | Promise<Node> // Await
// Ignore // Ignore
| undefined | undefined
| null | null
| boolean; | boolean;
export type Element = [ export type Element = [
tag: typeof kElement, tag: typeof kElement,
type: string | Component, type: string | Component,
props: Record<string, unknown>, props: Record<string, unknown>,
_?: "", _?: "",
source?: SrcLoc, source?: SrcLoc,
]; ];
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode]; export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
/** /**
* Components must return a value; 'undefined' is prohibited here * Components must return a value; 'undefined' is prohibited here
* to avoid functions that are missing a return statement. * to avoid functions that are missing a return statement.
*/ */
export type Component = ( export type Component = (
props: Record<any, any>, props: Record<any, any>,
) => Exclude<Node, undefined>; ) => Exclude<Node, undefined>;
/** Emitted by JSX runtime */ /** Emitted by JSX runtime */
export interface SrcLoc { export interface SrcLoc {
fileName: string; fileName: string;
lineNumber: number; lineNumber: number;
columnNumber: number; columnNumber: number;
} }
/** /**
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
* marked in the 'Render'. This operation performs everything besides the final * marked in the 'Render'. This operation performs everything besides the final
* string concatenation. This function is agnostic across async/sync modes. * string concatenation. This function is agnostic across async/sync modes.
*/ */
export function resolveNode(r: Render, node: unknown): ResolvedNode { export function resolveNode(r: Render, node: unknown): ResolvedNode {
if (!node && node !== 0) return ""; // falsy, non numeric if (!node && node !== 0) return ""; // falsy, non numeric
if (typeof node !== "object") { if (typeof node !== "object") {
if (node === true) return ""; // booleans are ignored if (node === true) return ""; // booleans are ignored
if (typeof node === "string") return escapeHtml(node); if (typeof node === "string") return escapeHtml(node);
if (typeof node === "number") return String(node); // no escaping ever if (typeof node === "number") return String(node); // no escaping ever
throw new Error(`Cannot render ${inspect(node)} to HTML`); throw new Error(`Cannot render ${inspect(node)} to HTML`);
} }
if (node instanceof Promise) { if (node instanceof Promise) {
if (r.async === -1) { if (r.async === -1) {
throw new Error(`Asynchronous rendering is not supported here.`); throw new Error(`Asynchronous rendering is not supported here.`);
} }
const placeholder: InsertionPoint = [null]; const placeholder: InsertionPoint = [null];
r.async += 1; r.async += 1;
node node
.then((result) => void (placeholder[0] = resolveNode(r, result))) .then((result) => void (placeholder[0] = resolveNode(r, result)))
// Intentionally catching errors in `resolveNode` // Intentionally catching errors in `resolveNode`
.catch((e) => (r.rejections ??= []).push(e)) .catch((e) => (r.rejections ??= []).push(e))
.finally(() => { .finally(() => {
if (--r.async == 0) { if (--r.async == 0) {
if (r.asyncDone == null) throw new Error("r.asyncDone == null"); if (r.asyncDone == null) throw new Error("r.asyncDone == null");
r.asyncDone(); r.asyncDone();
r.asyncDone = null; r.asyncDone = null;
} }
}); });
// This lie is checked with an assertion in `renderNode` // This lie is checked with an assertion in `renderNode`
return placeholder as [ResolvedNode]; return placeholder as [ResolvedNode];
} }
if (!Array.isArray(node)) { if (!Array.isArray(node)) {
throw new Error(`Invalid node type: ${inspect(node)}`); throw new Error(`Invalid node type: ${inspect(node)}`);
} }
const type = node[0]; const type = node[0];
if (type === kElement) { if (type === kElement) {
const { 1: tag, 2: props } = node; const { 1: tag, 2: props } = node;
if (typeof tag === "function") { if (typeof tag === "function") {
currentRender = r; currentRender = r;
try { try {
return resolveNode(r, tag(props)); return resolveNode(r, tag(props));
} catch (e) { } catch (e) {
const { 4: src } = node; const { 4: src } = node;
if (e && typeof e === "object") { if (e && typeof e === "object") {
} }
} finally { } finally {
currentRender = null; currentRender = null;
} }
} }
if (typeof tag !== "string") throw new Error("Unexpected " + typeof type); if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
const children = props?.children; const children = props?.children;
if (children) return [kElement, tag, props, resolveNode(r, children)]; if (children) return [kElement, tag, props, resolveNode(r, children)];
return node; return node;
} }
if (type === kDirectHtml) return node[1]; if (type === kDirectHtml) return node[1];
return node.map((elem) => resolveNode(r, elem)); return node.map((elem) => resolveNode(r, elem));
} }
export type ResolvedNode = export type ResolvedNode =
| ResolvedNode[] // Concat | ResolvedNode[] // Concat
| ResolvedElement // Render | ResolvedElement // Render
| string; // Direct HTML | string; // Direct HTML
export type ResolvedElement = [ export type ResolvedElement = [
tag: typeof kElement, tag: typeof kElement,
type: string, type: string,
props: Record<string, unknown>, props: Record<string, unknown>,
children: ResolvedNode, children: ResolvedNode,
]; ];
/** /**
* Async rendering is done by creating an array of one item, * Async rendering is done by creating an array of one item,
* which is already a valid 'Node', but the element is written * which is already a valid 'Node', but the element is written
* once the data is available. The 'Render' contains a count * once the data is available. The 'Render' contains a count
* of how many async jobs are left. * of how many async jobs are left.
*/ */
export type InsertionPoint = [null | ResolvedNode]; export type InsertionPoint = [null | ResolvedNode];
/** /**
* Convert 'ResolvedNode' into HTML text. This operation happens after all * Convert 'ResolvedNode' into HTML text. This operation happens after all
* async work is settled. The HTML is emitted as concisely as possible. * async work is settled. The HTML is emitted as concisely as possible.
*/ */
export function renderNode(node: ResolvedNode): string { export function renderNode(node: ResolvedNode): string {
if (typeof node === "string") return node; if (typeof node === "string") return node;
ASSERT(node, "Unresolved Render Node"); ASSERT(node, "Unresolved Render Node");
const type = node[0]; const type = node[0];
if (type === kElement) { if (type === kElement) {
return renderElement(node as ResolvedElement); return renderElement(node as ResolvedElement);
} }
node = node as ResolvedNode[]; // TS cannot infer. node = node as ResolvedNode[]; // TS cannot infer.
let out = type ? renderNode(type) : ""; let out = type ? renderNode(type) : "";
let len = node.length; let len = node.length;
for (let i = 1; i < len; i++) { for (let i = 1; i < len; i++) {
const elem = node[i]; const elem = node[i];
if (elem) out += renderNode(elem); if (elem) out += renderNode(elem);
} }
return out; return out;
} }
function renderElement(element: ResolvedElement) { function renderElement(element: ResolvedElement) {
const { 1: tag, 2: props, 3: children } = element; const { 1: tag, 2: props, 3: children } = element;
let out = "<" + tag; let out = "<" + tag;
let needSpace = true; let needSpace = true;
for (const prop in props) { for (const prop in props) {
const value = props[prop]; const value = props[prop];
if (!value || typeof value === "function") continue; if (!value || typeof value === "function") continue;
let attr; let attr;
switch (prop) { switch (prop) {
default: default:
attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`; attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
break; break;
case "className": case "className":
// Legacy React Compat // Legacy React Compat
case "class": case "class":
attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`; attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
break; break;
case "htmlFor": case "htmlFor":
throw new Error("Do not use the `htmlFor` attribute. Use `for`"); throw new Error("Do not use the `htmlFor` attribute. Use `for`");
// Do not process these // Do not process these
case "children": case "children":
case "ref": case "ref":
case "dangerouslySetInnerHTML": case "dangerouslySetInnerHTML":
case "key": case "key":
continue; continue;
} }
if (needSpace) out += " ", needSpace = !attr.endsWith('"'); if (needSpace) out += " ", needSpace = !attr.endsWith('"');
out += attr; out += attr;
} }
out += ">"; out += ">";
if (children) out += renderNode(children); if (children) out += renderNode(children);
if ( if (
tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" && tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
tag !== "link" && tag !== "hr" tag !== "link" && tag !== "hr"
) { ) {
out += `</${tag}>`; out += `</${tag}>`;
} }
return out; return out;
} }
export function renderStyleAttribute(style: Record<string, string>) { export function renderStyleAttribute(style: Record<string, string>) {
let out = ``; let out = ``;
for (const styleName in style) { for (const styleName in style) {
if (out) out += ";"; if (out) out += ";";
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
escapeHtml(String(style[styleName])) escapeHtml(String(style[styleName]))
}`; }`;
} }
return "style=" + quoteIfNeeded(out); return "style=" + quoteIfNeeded(out);
} }
export function quoteIfNeeded(text: string) { export function quoteIfNeeded(text: string) {
if (text.includes(" ")) return '"' + text + '"'; if (text.includes(" ")) return '"' + text + '"';
return text; return text;
} }
// -- utility functions -- // -- utility functions --
export function initRender(allowAsync: boolean, addon: Addons): Render { export function initRender(allowAsync: boolean, addon: Addons): Render {
return { return {
async: allowAsync ? 0 : -1, async: allowAsync ? 0 : -1,
rejections: null, rejections: null,
asyncDone: null, asyncDone: null,
addon, addon,
}; };
} }
let currentRender: Render | null = null; let currentRender: Render | null = null;
export function getCurrentRender() { export function getCurrentRender() {
if (!currentRender) throw new Error("No Render Active"); if (!currentRender) throw new Error("No Render Active");
return currentRender; return currentRender;
} }
export function setCurrentRender(r?: Render | null) { export function setCurrentRender(r?: Render | null) {
currentRender = r ?? null; currentRender = r ?? null;
} }
export function getUserData<T>(namespace: PropertyKey, def: () => T): T { export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
return (getCurrentRender().addon[namespace] ??= def()) as T; return (getCurrentRender().addon[namespace] ??= def()) as T;
} }
export function inspect(object: unknown) { export function inspect(object: unknown) {
try { try {
return require("node:util").inspect(object); return require("node:util").inspect(object);
} catch { } catch {
return typeof object; return typeof object;
} }
} }
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[]; export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
export function clsx(mix: ClsxInput) { export function clsx(mix: ClsxInput) {
var k, y, str = ""; var k, y, str = "";
if (typeof mix === "string") { if (typeof mix === "string") {
return mix; return mix;
} else if (typeof mix === "object") { } else if (typeof mix === "object") {
if (Array.isArray(mix)) { if (Array.isArray(mix)) {
for (k = 0; k < mix.length; k++) { for (k = 0; k < mix.length; k++) {
if (mix[k] && (y = clsx(mix[k]))) { if (mix[k] && (y = clsx(mix[k]))) {
str && (str += " "); str && (str += " ");
str += y; str += y;
} }
} }
} else { } else {
for (k in mix) { for (k in mix) {
if (mix[k]) { if (mix[k]) {
str && (str += " "); str && (str += " ");
str += k; str += k;
} }
} }
} }
} }
return str; return str;
} }
export const escapeHtml = (unsafeText: string) => export const escapeHtml = (unsafeText: string) =>
String(unsafeText) String(unsafeText)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;"); .replace(/"/g, "&quot;").replace(/'/g, "&#x27;").replace(/`/g, "&#x60;");

View file

@ -1,40 +1,40 @@
import { test } from "node:test"; import { test } from "node:test";
import { renderStreaming, Suspense } from "./suspense.ts"; import { renderStreaming, Suspense } from "./suspense.ts";
test("sanity", async (t) => { test("sanity", async (t) => {
let resolve: () => void = null!; let resolve: () => void = null!;
// @ts-expect-error // @ts-expect-error
async function AsyncComponent() { async function AsyncComponent() {
await new Promise<void>((done) => resolve = done); await new Promise<void>((done) => resolve = done);
return <button>wow!</button>; return <button>wow!</button>;
} }
const example = ( const example = (
<main> <main>
<h1>app shell</h1> <h1>app shell</h1>
<Suspense fallback="loading..."> <Suspense fallback="loading...">
<AsyncComponent /> <AsyncComponent />
</Suspense> </Suspense>
<footer>(c) 2025</footer> <footer>(c) 2025</footer>
</main> </main>
); );
const iterator = renderStreaming(example); const iterator = renderStreaming(example);
const assertContinue = (actual: unknown, value: unknown) => const assertContinue = (actual: unknown, value: unknown) =>
t.assert.deepEqual(actual, { done: false, value }); t.assert.deepEqual(actual, { done: false, value });
assertContinue( assertContinue(
await iterator.next(), await iterator.next(),
"<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>", "<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>",
); );
t.assert.ok(resolve !== null), resolve(); t.assert.ok(resolve !== null), resolve();
assertContinue( assertContinue(
await iterator.next(), await iterator.next(),
"<button slot=suspended_1>wow!</button>", "<button slot=suspended_1>wow!</button>",
); );
t.assert.deepEqual( t.assert.deepEqual(
await iterator.next(), await iterator.next(),
{ done: true, value: {} }, { done: true, value: {} },
); );
}); });

View file

@ -1,102 +1,102 @@
// This file implements out-of-order HTML streaming, mimicking the React // This file implements out-of-order HTML streaming, mimicking the React
// Suspense API. To use, place Suspense around an expensive async component // Suspense API. To use, place Suspense around an expensive async component
// and render the page with 'renderStreaming'. // and render the page with 'renderStreaming'.
// //
// Implementation of this article: // Implementation of this article:
// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/ // https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
// //
// I would link to an article from Next.js or React, but their examples // I would link to an article from Next.js or React, but their examples
// are too verbose and not informative to what they actually do. // are too verbose and not informative to what they actually do.
const kState = Symbol("SuspenseState"); const kState = Symbol("SuspenseState");
interface SuspenseProps { interface SuspenseProps {
children: ssr.Node; children: ssr.Node;
fallback?: ssr.Node; fallback?: ssr.Node;
} }
interface State { interface State {
nested: boolean; nested: boolean;
nextId: number; nextId: number;
completed: number; completed: number;
pushChunk(name: string, node: ssr.ResolvedNode): void; pushChunk(name: string, node: ssr.ResolvedNode): void;
} }
export function Suspense({ children, fallback }: SuspenseProps): ssr.Node { export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
const state = ssr.getUserData<State>(kState, () => { const state = ssr.getUserData<State>(kState, () => {
throw new Error("Can only use <Suspense> with 'renderStreaming'"); throw new Error("Can only use <Suspense> with 'renderStreaming'");
}); });
if (state.nested) throw new Error("<Suspense> cannot be nested"); if (state.nested) throw new Error("<Suspense> cannot be nested");
const parent = ssr.getCurrentRender()!; const parent = ssr.getCurrentRender()!;
const r = ssr.initRender(true, { [kState]: { nested: true } }); const r = ssr.initRender(true, { [kState]: { nested: true } });
const resolved = ssr.resolveNode(r, children); const resolved = ssr.resolveNode(r, children);
if (r.async == 0) return ssr.html(resolved); if (r.async == 0) return ssr.html(resolved);
const name = "suspended_" + (++state.nextId); const name = "suspended_" + (++state.nextId);
state.nested = true; state.nested = true;
const ip: [ssr.ResolvedNode] = [ const ip: [ssr.ResolvedNode] = [
[ [
ssr.kElement, ssr.kElement,
"slot", "slot",
{ name }, { name },
fallback ? ssr.resolveNode(parent, fallback) : "", fallback ? ssr.resolveNode(parent, fallback) : "",
], ],
]; ];
state.nested = false; state.nested = false;
r.asyncDone = () => { r.asyncDone = () => {
const rejections = r.rejections; const rejections = r.rejections;
if (rejections && rejections.length > 0) throw new Error("TODO"); if (rejections && rejections.length > 0) throw new Error("TODO");
state.pushChunk?.(name, ip[0] = resolved); state.pushChunk?.(name, ip[0] = resolved);
}; };
return ssr.html(ip); return ssr.html(ip);
} }
// TODO: add a User-Agent parameter, which is used to determine if a // TODO: add a User-Agent parameter, which is used to determine if a
// fallback path must be used. // fallback path must be used.
// - Before ~2024 needs to use a JS implementation. // - Before ~2024 needs to use a JS implementation.
// - IE should probably bail out entirely. // - IE should probably bail out entirely.
export async function* renderStreaming< export async function* renderStreaming<
T extends ssr.Addons = Record<never, unknown>, T extends ssr.Addons = Record<never, unknown>,
>( >(
node: ssr.Node, node: ssr.Node,
addon: T = {} as T, addon: T = {} as T,
) { ) {
const { const {
text: begin, text: begin,
addon: { [kState]: state, ...addonOutput }, addon: { [kState]: state, ...addonOutput },
} = await ssr.ssrAsync(node, { } = await ssr.ssrAsync(node, {
...addon, ...addon,
[kState]: { [kState]: {
nested: false, nested: false,
nextId: 0, nextId: 0,
completed: 0, completed: 0,
pushChunk: () => {}, pushChunk: () => {},
} satisfies State as State, } satisfies State as State,
}); });
if (state.nextId === 0) { if (state.nextId === 0) {
yield begin; yield begin;
return addonOutput as unknown as T; return addonOutput as unknown as T;
} }
let resolve: (() => void) | null = null; let resolve: (() => void) | null = null;
let chunks: string[] = []; let chunks: string[] = [];
state.pushChunk = (slot, node) => { state.pushChunk = (slot, node) => {
while (node.length === 1 && Array.isArray(node)) node = node[0]; while (node.length === 1 && Array.isArray(node)) node = node[0];
if (node[0] === ssr.kElement) { if (node[0] === ssr.kElement) {
(node as ssr.ResolvedElement)[2].slot = slot; (node as ssr.ResolvedElement)[2].slot = slot;
} else { } else {
node = [ssr.kElement, "clover-suspense", { node = [ssr.kElement, "clover-suspense", {
style: "display:contents", style: "display:contents",
slot, slot,
}, node]; }, node];
} }
chunks.push(ssr.renderNode(node)); chunks.push(ssr.renderNode(node));
resolve?.(); resolve?.();
}; };
yield `<template shadowrootmode=open>${begin}</template>`; yield `<template shadowrootmode=open>${begin}</template>`;
do { do {
await new Promise<void>((done) => resolve = done); await new Promise<void>((done) => resolve = done);
yield* chunks; yield* chunks;
chunks = []; chunks = [];
} while (state.nextId < state.completed); } while (state.nextId < state.completed);
return addonOutput as unknown as T; return addonOutput as unknown as T;
} }
import * as ssr from "./ssr.ts"; import * as ssr from "./ssr.ts";

View file

@ -1,79 +1,79 @@
export function virtualFiles( export function virtualFiles(
map: Record<string, string | esbuild.OnLoadResult>, map: Record<string, string | esbuild.OnLoadResult>,
) { ) {
return { return {
name: "clover vfs", name: "clover vfs",
setup(b) { setup(b) {
b.onResolve( b.onResolve(
{ {
filter: new RegExp( filter: new RegExp(
`^(?:${ `^(?:${
Object.keys(map).map((file) => string.escapeRegExp(file)).join( Object.keys(map).map((file) => string.escapeRegExp(file)).join(
"|", "|",
) )
})\$`, })\$`,
), ),
}, },
({ path }) => ({ path, namespace: "vfs" }), ({ path }) => ({ path, namespace: "vfs" }),
); );
b.onLoad( b.onLoad(
{ filter: /./, namespace: "vfs" }, { filter: /./, namespace: "vfs" },
({ path }) => { ({ path }) => {
const entry = map[path]; const entry = map[path];
return ({ return ({
resolveDir: ".", resolveDir: ".",
loader: "ts", loader: "ts",
...typeof entry === "string" ? { contents: entry } : entry, ...typeof entry === "string" ? { contents: entry } : entry,
}); });
}, },
); );
}, },
} satisfies esbuild.Plugin; } satisfies esbuild.Plugin;
} }
export function banFiles( export function banFiles(
files: string[], files: string[],
) { ) {
return { return {
name: "clover vfs", name: "clover vfs",
setup(b) { setup(b) {
b.onResolve( b.onResolve(
{ {
filter: new RegExp( filter: new RegExp(
`^(?:${ `^(?:${
files.map((file) => string.escapeRegExp(file)).join("|") files.map((file) => string.escapeRegExp(file)).join("|")
})\$`, })\$`,
), ),
}, },
({ path, importer }) => { ({ path, importer }) => {
throw new Error( throw new Error(
`Loading ${path} (from ${importer}) is banned!`, `Loading ${path} (from ${importer}) is banned!`,
); );
}, },
); );
}, },
} satisfies esbuild.Plugin; } satisfies esbuild.Plugin;
} }
export function projectRelativeResolution(root = process.cwd() + "/src") { export function projectRelativeResolution(root = process.cwd() + "/src") {
return { return {
name: "project relative resolution ('@/' prefix)", name: "project relative resolution ('@/' prefix)",
setup(b) { setup(b) {
b.onResolve({ filter: /^@\// }, ({ path: id }) => { b.onResolve({ filter: /^@\// }, ({ path: id }) => {
return { return {
path: path.resolve(root, id.slice(2)), path: path.resolve(root, id.slice(2)),
}; };
}); });
b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => { b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
return { return {
path: hot.resolveFrom(importer, id), path: hot.resolveFrom(importer, id),
}; };
}); });
}, },
} satisfies esbuild.Plugin; } satisfies esbuild.Plugin;
} }
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import * as string from "#sitegen/string"; import * as string from "#sitegen/string";
import * as path from "node:path"; import * as path from "node:path";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";

View file

@ -96,7 +96,9 @@ Module._resolveFilename = (...args) => {
try { try {
return require.resolve(replacedPath, { paths: [projectSrc] }); return require.resolve(replacedPath, { paths: [projectSrc] });
} catch (err: any) { } catch (err: any) {
if (err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1) { if (
err.code === "MODULE_NOT_FOUND" && (err?.requireStack?.length ?? 0) <= 1
) {
err.message.replace(replacedPath, args[0]); err.message.replace(replacedPath, args[0]);
} }
} }

View file

@ -1,297 +1,299 @@
const five_minutes = 5 * 60 * 1000; const five_minutes = 5 * 60 * 1000;
interface QueueOptions<T, R> { interface QueueOptions<T, R> {
name: string; name: string;
fn: (item: T, spin: Spinner) => Promise<R>; fn: (item: T, spin: Spinner) => Promise<R>;
getItemText?: (item: T) => string; getItemText?: (item: T) => string;
maxJobs?: number; maxJobs?: number;
passive?: boolean; passive?: boolean;
} }
// Process multiple items in parallel, queue up as many. // Process multiple items in parallel, queue up as many.
export class Queue<T, R> { export class Queue<T, R> {
#name: string; #name: string;
#fn: (item: T, spin: Spinner) => Promise<R>; #fn: (item: T, spin: Spinner) => Promise<R>;
#maxJobs: number; #maxJobs: number;
#getItemText: (item: T) => string; #getItemText: (item: T) => string;
#passive: boolean; #passive: boolean;
#active: Spinner[] = []; #active: Spinner[] = [];
#queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = []; #queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
#cachedProgress: Progress<{ active: Spinner[] }> | null = null; #cachedProgress: Progress<{ active: Spinner[] }> | null = null;
#done: number = 0; #done: number = 0;
#total: number = 0; #total: number = 0;
#onComplete: (() => void) | null = null; #onComplete: (() => void) | null = null;
#estimate: number | null = null; #estimate: number | null = null;
#errors: unknown[] = []; #errors: unknown[] = [];
constructor(options: QueueOptions<T, R>) { constructor(options: QueueOptions<T, R>) {
this.#name = options.name; this.#name = options.name;
this.#fn = options.fn; this.#fn = options.fn;
this.#maxJobs = options.maxJobs ?? 5; this.#maxJobs = options.maxJobs ?? 5;
this.#getItemText = options.getItemText ?? defaultGetItemText; this.#getItemText = options.getItemText ?? defaultGetItemText;
this.#passive = options.passive ?? false; this.#passive = options.passive ?? false;
} }
cancel() { cancel() {
const bar = this.#cachedProgress; const bar = this.#cachedProgress;
bar?.stop(); bar?.stop();
this.#queue = []; this.#queue = [];
} }
get bar() { get bar() {
const cached = this.#cachedProgress; const cached = this.#cachedProgress;
if (!cached) { if (!cached) {
const bar = this.#cachedProgress = new Progress({ const bar = this.#cachedProgress = new Progress({
spinner: null, spinner: null,
text: ({ active }) => { text: ({ active }) => {
const now = performance.now(); const now = performance.now();
let text = `[${this.#done}/${this.#total}] ${this.#name}`; let text = `[${this.#done}/${this.#total}] ${this.#name}`;
let n = 0; let n = 0;
for (const item of active) { for (const item of active) {
let itemText = "- " + item.format(now); let itemText = "- " + item.format(now);
text += `\n` + text += `\n` +
itemText.slice(0, Math.max(0, process.stdout.columns - 1)); itemText.slice(0, Math.max(0, process.stdout.columns - 1));
if (n > 10) { if (n > 10) {
text += `\n ... + ${active.length - n} more`; text += `\n ... + ${active.length - n} more`;
break; break;
} }
n++; n++;
} }
return text; return text;
}, },
props: { props: {
active: [] as Spinner[], active: [] as Spinner[],
}, },
}); });
bar.value = 0; bar.value = 0;
return bar; return bar;
} }
return cached; return cached;
} }
addReturn(args: T) { addReturn(args: T) {
this.#total += 1; this.#total += 1;
this.updateTotal(); this.updateTotal();
if (this.#active.length >= this.#maxJobs) { if (this.#active.length >= this.#maxJobs) {
const { promise, resolve, reject } = Promise.withResolvers<R>(); const { promise, resolve, reject } = Promise.withResolvers<R>();
this.#queue.push([args, resolve, reject]); this.#queue.push([args, resolve, reject]);
return promise; return promise;
} }
return this.#run(args); return this.#run(args);
} }
add(args: T) { add(args: T) {
return this.addReturn(args).then(() => {}, () => {}); return this.addReturn(args).then(() => {}, () => {});
} }
addMany(items: T[]) { addMany(items: T[]) {
this.#total += items.length; this.#total += items.length;
this.updateTotal(); this.updateTotal();
const runNowCount = this.#maxJobs - this.#active.length; const runNowCount = this.#maxJobs - this.#active.length;
const runNow = items.slice(0, runNowCount); const runNow = items.slice(0, runNowCount);
const runLater = items.slice(runNowCount); const runLater = items.slice(runNowCount);
this.#queue.push(...runLater.reverse().map<[T]>((x) => [x])); this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
runNow.map((item) => this.#run(item).catch(() => {})); runNow.map((item) => this.#run(item).catch(() => {}));
} }
async #run(args: T): Promise<R> { async #run(args: T): Promise<R> {
const bar = this.bar; const bar = this.bar;
const itemText = this.#getItemText(args); const itemText = this.#getItemText(args);
const spinner = new Spinner(itemText); const spinner = new Spinner(itemText);
spinner.stop(); spinner.stop();
(spinner as any).redraw = () => (bar as any).redraw(); (spinner as any).redraw = () => (bar as any).redraw();
const active = this.#active; const active = this.#active;
try { try {
active.unshift(spinner); active.unshift(spinner);
bar.props = { active }; bar.props = { active };
console.log(this.#name + ": " + itemText); // console.log(this.#name + ": " + itemText);
const result = await this.#fn(args, spinner); const result = await this.#fn(args, spinner);
this.#done++; this.#done++;
return result; return result;
} catch (err) { } catch (err) {
if (err && typeof err === "object") { if (err && typeof err === "object") {
(err as any).job = itemText; (err as any).job = itemText;
} }
this.#errors.push(err); this.#errors.push(err);
throw err; console.error(util.inspect(err, false, Infinity, true));
} finally { throw err;
active.splice(active.indexOf(spinner), 1); } finally {
bar.props = { active }; active.splice(active.indexOf(spinner), 1);
bar.value = this.#done; bar.props = { active };
bar.value = this.#done;
// Process next item
const next = this.#queue.shift(); // Process next item
if (next) { const next = this.#queue.shift();
const args = next[0]; if (next) {
this.#run(args) const args = next[0];
.then((result) => next[1]?.(result)) this.#run(args)
.catch((err) => next[2]?.(err)); .then((result) => next[1]?.(result))
} else if (this.#active.length === 0) { .catch((err) => next[2]?.(err));
if (this.#passive) { } else if (this.#active.length === 0) {
this.bar.stop(); if (this.#passive) {
this.#cachedProgress = null; this.bar.stop();
} this.#cachedProgress = null;
this.#onComplete?.(); }
} this.#onComplete?.();
} }
} }
}
updateTotal() {
const bar = this.bar; updateTotal() {
bar.total = Math.max(this.#total, this.#estimate ?? 0); const bar = this.bar;
} bar.total = Math.max(this.#total, this.#estimate ?? 0);
}
set estimate(e: number) {
this.#estimate = e; set estimate(e: number) {
if (this.#cachedProgress) { this.#estimate = e;
this.updateTotal(); if (this.#cachedProgress) {
} this.updateTotal();
} }
}
async done(o?: { method: "success" | "stop" }) {
if (this.#active.length === 0) { async done(o?: { method: "success" | "stop" }) {
this.#end(o); if (this.#active.length === 0) {
return; this.#end(o);
} return;
}
const { promise, resolve } = Promise.withResolvers<void>();
this.#onComplete = resolve; const { promise, resolve } = Promise.withResolvers<void>();
await promise; this.#onComplete = resolve;
this.#end(o); await promise;
} this.#end(o);
}
#end(
{ method = this.#passive ? "stop" : "success" }: { #end(
method?: "success" | "stop"; { method = this.#passive ? "stop" : "success" }: {
} = {}, method?: "success" | "stop";
) { } = {},
const bar = this.#cachedProgress; ) {
if (this.#errors.length > 0) { const bar = this.#cachedProgress;
if (bar) bar.stop(); if (this.#errors.length > 0) {
throw new AggregateError( if (bar) bar.stop();
this.#errors, throw new AggregateError(
this.#errors.length + " jobs failed in '" + this.#name + "'", this.#errors,
); this.#errors.length + " jobs failed in '" + this.#name + "'",
} );
}
if (bar) bar[method]();
} if (bar) bar[method]();
}
get active(): boolean {
return this.#active.length !== 0; get active(): boolean {
} return this.#active.length !== 0;
}
[Symbol.dispose]() {
if (this.active) { [Symbol.dispose]() {
this.cancel(); if (this.active) {
} this.cancel();
} }
} }
}
const cwd = process.cwd();
function defaultGetItemText(item: unknown) { const cwd = process.cwd();
let itemText = ""; function defaultGetItemText(item: unknown) {
if (typeof item === "string") { let itemText = "";
itemText = item; if (typeof item === "string") {
} else if (typeof item === "object" && item !== null) { itemText = item;
const { path, label, id } = item as any; } else if (typeof item === "object" && item !== null) {
itemText = label ?? path ?? id ?? JSON.stringify(item); const { path, label, id } = item as any;
} else { itemText = label ?? path ?? id ?? JSON.stringify(item);
itemText = JSON.stringify(item); } else {
} itemText = JSON.stringify(item);
}
if (itemText.startsWith(cwd)) {
itemText = path.relative(cwd, itemText); if (itemText.startsWith(cwd)) {
} itemText = path.relative(cwd, itemText);
return itemText; }
} return itemText;
}
export class OnceMap<T> {
private ongoing = new Map<string, Promise<T>>(); export class OnceMap<T> {
private ongoing = new Map<string, Promise<T>>();
get(key: string, compute: () => Promise<T>) {
if (this.ongoing.has(key)) { get(key: string, compute: () => Promise<T>) {
return this.ongoing.get(key)!; if (this.ongoing.has(key)) {
} return this.ongoing.get(key)!;
}
const result = compute();
this.ongoing.set(key, result); const result = compute();
return result; this.ongoing.set(key, result);
} return result;
} }
}
interface ARCEValue<T> {
value: T; interface ARCEValue<T> {
[Symbol.dispose]: () => void; value: T;
} [Symbol.dispose]: () => void;
}
export function RefCountedExpirable<T>(
init: () => Promise<T>, export function RefCountedExpirable<T>(
deinit: (value: T) => void, init: () => Promise<T>,
expire: number = five_minutes, deinit: (value: T) => void,
): () => Promise<ARCEValue<T>> { expire: number = five_minutes,
let refs = 0; ): () => Promise<ARCEValue<T>> {
let item: ARCEValue<T> | null = null; let refs = 0;
let loading: Promise<ARCEValue<T>> | null = null; let item: ARCEValue<T> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null; let loading: Promise<ARCEValue<T>> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function deref() {
ASSERT(item !== null); function deref() {
if (--refs !== 0) return; ASSERT(item !== null);
ASSERT(timer === null); if (--refs !== 0) return;
timer = setTimeout(() => { ASSERT(timer === null);
ASSERT(refs === 0); timer = setTimeout(() => {
ASSERT(loading === null); ASSERT(refs === 0);
ASSERT(item !== null); ASSERT(loading === null);
deinit(item.value); ASSERT(item !== null);
item = null; deinit(item.value);
timer = null; item = null;
}, expire); timer = null;
} }, expire);
}
return async function () {
if (timer !== null) { return async function () {
clearTimeout(timer); if (timer !== null) {
timer = null; clearTimeout(timer);
} timer = null;
if (item !== null) { }
refs++; if (item !== null) {
return item; refs++;
} return item;
if (loading !== null) { }
refs++; if (loading !== null) {
return loading; refs++;
} return loading;
const p = Promise.withResolvers<ARCEValue<T>>(); }
loading = p.promise; const p = Promise.withResolvers<ARCEValue<T>>();
try { loading = p.promise;
const value = await init(); try {
item = { value, [Symbol.dispose]: deref }; const value = await init();
refs++; item = { value, [Symbol.dispose]: deref };
p.resolve(item); refs++;
return item; p.resolve(item);
} catch (e) { return item;
p.reject(e); } catch (e) {
throw e; p.reject(e);
} finally { throw e;
loading = null; } finally {
} loading = null;
}; }
} };
}
export function once<T>(fn: () => Promise<T>): () => Promise<T> {
let result: T | Promise<T> | null = null; export function once<T>(fn: () => Promise<T>): () => Promise<T> {
return async () => { let result: T | Promise<T> | null = null;
if (result) return result; return async () => {
result = await fn(); if (result) return result;
return result; result = await fn();
}; return result;
} };
}
import { Progress } from "@paperclover/console/Progress";
import { Spinner } from "@paperclover/console/Spinner"; import { Progress } from "@paperclover/console/Progress";
import * as path from "node:path"; import { Spinner } from "@paperclover/console/Spinner";
import process from "node:process"; import * as path from "node:path";
import process from "node:process";
import * as util from "node:util";

View file

@ -1,24 +1,24 @@
export interface Meta { export interface Meta {
title: string; title: string;
description?: string | undefined; description?: string | undefined;
openGraph?: OpenGraph; openGraph?: OpenGraph;
alternates?: Alternates; alternates?: Alternates;
} }
export interface OpenGraph { export interface OpenGraph {
title?: string; title?: string;
description?: string | undefined; description?: string | undefined;
type: string; type: string;
url: string; url: string;
} }
export interface Alternates { export interface Alternates {
canonical: string; canonical: string;
types: { [mime: string]: AlternateType }; types: { [mime: string]: AlternateType };
} }
export interface AlternateType { export interface AlternateType {
url: string; url: string;
title: string; title: string;
} }
export function renderMeta({ title }: Meta): string { export function renderMeta({ title }: Meta): string {
return `<title>${esc(title)}</title>`; return `<title>${esc(title)}</title>`;
} }
import { escapeHtml as esc } from "../engine/ssr.ts"; import { escapeHtml as esc } from "../engine/ssr.ts";

View file

@ -1,3 +1,3 @@
export function escapeRegExp(source: string) { export function escapeRegExp(source: string) {
return source.replace(/[\$\\]/g, "\\$&"); return source.replace(/[\$\\]/g, "\\$&");
} }

View file

@ -1,100 +1,100 @@
// This import is generated by code 'bundle.ts' // This import is generated by code 'bundle.ts'
export interface View { export interface View {
component: engine.Component; component: engine.Component;
meta: meta:
| meta.Meta | meta.Meta
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta); | ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
layout?: engine.Component; layout?: engine.Component;
inlineCss: string; inlineCss: string;
scripts: Record<string, string>; scripts: Record<string, string>;
} }
let views: Record<string, View> = null!; let views: Record<string, View> = null!;
let scripts: Record<string, string> = null!; let scripts: Record<string, string> = null!;
// An older version of the Clover Engine supported streaming suspense // An older version of the Clover Engine supported streaming suspense
// boundaries, but those were never used. Pages will wait until they // boundaries, but those were never used. Pages will wait until they
// are fully rendered before sending. // are fully rendered before sending.
export async function renderView( export async function renderView(
context: hono.Context, context: hono.Context,
id: string, id: string,
props: Record<string, unknown>, props: Record<string, unknown>,
) { ) {
return context.html(await renderViewToString(id, { context, ...props })); return context.html(await renderViewToString(id, { context, ...props }));
} }
export async function renderViewToString( export async function renderViewToString(
id: string, id: string,
props: Record<string, unknown>, props: Record<string, unknown>,
) { ) {
views ?? ({ views, scripts } = require("$views")); views ?? ({ views, scripts } = require("$views"));
// The view contains pre-bundled CSS and scripts, but keeps the scripts // The view contains pre-bundled CSS and scripts, but keeps the scripts
// separate for run-time dynamic scripts. For example, the file viewer // separate for run-time dynamic scripts. For example, the file viewer
// includes the canvas for the current page, but only the current page. // includes the canvas for the current page, but only the current page.
const { const {
component, component,
inlineCss, inlineCss,
layout, layout,
meta: metadata, meta: metadata,
}: View = UNWRAP(views[id], `Missing view ${id}`); }: View = UNWRAP(views[id], `Missing view ${id}`);
// -- metadata -- // -- metadata --
const renderedMetaPromise = Promise.resolve( const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata(props) : metadata, typeof metadata === "function" ? metadata(props) : metadata,
).then((m) => meta.renderMeta(m)); ).then((m) => meta.renderMeta(m));
// -- html -- // -- html --
let page: engine.Element = [engine.kElement, component, props]; let page: engine.Element = [engine.kElement, component, props];
if (layout) page = [engine.kElement, layout, { children: page }]; if (layout) page = [engine.kElement, layout, { children: page }];
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, { const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
sitegen: sg.initRender(), sitegen: sg.initRender(),
}); });
// -- join document and send -- // -- join document and send --
return wrapDocument({ return wrapDocument({
body, body,
head: await renderedMetaPromise, head: await renderedMetaPromise,
inlineCss, inlineCss,
scripts: joinScripts( scripts: joinScripts(
Array.from( Array.from(
sitegen.scripts, sitegen.scripts,
(id) => UNWRAP(scripts[id], `Missing script ${id}`), (id) => UNWRAP(scripts[id], `Missing script ${id}`),
), ),
), ),
}); });
} }
export function provideViewData(v: typeof views, s: typeof scripts) { export function provideViewData(v: typeof views, s: typeof scripts) {
views = v; views = v;
scripts = s; scripts = s;
} }
export function joinScripts(scriptSources: string[]) { export function joinScripts(scriptSources: string[]) {
const { length } = scriptSources; const { length } = scriptSources;
if (length === 0) return ""; if (length === 0) return "";
if (length === 1) return scriptSources[0]; if (length === 1) return scriptSources[0];
return scriptSources.map((source) => `{${source}}`).join(";"); return scriptSources.map((source) => `{${source}}`).join(";");
} }
export function wrapDocument({ export function wrapDocument({
body, body,
head, head,
inlineCss, inlineCss,
scripts, scripts,
}: { }: {
head: string; head: string;
body: string; body: string;
inlineCss: string; inlineCss: string;
scripts: string; scripts: string;
}) { }) {
return `<!doctype html><html lang=en><head>${head}${ return `<!doctype html><html lang=en><head>${head}${
inlineCss ? `<style>${inlineCss}</style>` : "" inlineCss ? `<style>${inlineCss}</style>` : ""
}</head><body>${body}${ }</head><body>${body}${
scripts ? `<script>${scripts}</script>` : "" scripts ? `<script>${scripts}</script>` : ""
}</body></html>`; }</body></html>`;
} }
import * as meta from "./meta.ts"; import * as meta from "./meta.ts";
import type * as hono from "#hono"; import type * as hono from "#hono";
import * as engine from "../engine/ssr.ts"; import * as engine from "../engine/ssr.ts";
import * as sg from "./sitegen.ts"; import * as sg from "./sitegen.ts";

View file

@ -1,198 +1,198 @@
// File watcher and live reloading site generator // File watcher and live reloading site generator
const debounceMilliseconds = 25; const debounceMilliseconds = 25;
export async function main() { export async function main() {
let subprocess: child_process.ChildProcess | null = null; let subprocess: child_process.ChildProcess | null = null;
// Catch up state by running a main build. // Catch up state by running a main build.
const { incr } = await generate.main(); const { incr } = await generate.main();
// ...and watch the files that cause invals. // ...and watch the files that cause invals.
const watch = new Watch(rebuild); const watch = new Watch(rebuild);
watch.add(...incr.invals.keys()); watch.add(...incr.invals.keys());
statusLine(); statusLine();
// ... and then serve it! // ... and then serve it!
serve(); serve();
function serve() { function serve() {
if (subprocess) { if (subprocess) {
subprocess.removeListener("close", onSubprocessClose); subprocess.removeListener("close", onSubprocessClose);
subprocess.kill(); subprocess.kill();
} }
subprocess = child_process.fork(".clover/out/server.js", [ subprocess = child_process.fork(".clover/out/server.js", [
"--development", "--development",
], { ], {
stdio: "inherit", stdio: "inherit",
}); });
subprocess.on("close", onSubprocessClose); subprocess.on("close", onSubprocessClose);
} }
function onSubprocessClose(code: number | null, signal: string | null) { function onSubprocessClose(code: number | null, signal: string | null) {
subprocess = null; subprocess = null;
const status = code != null ? `code ${code}` : `signal ${signal}`; const status = code != null ? `code ${code}` : `signal ${signal}`;
console.error(`Backend process exited with ${status}`); console.error(`Backend process exited with ${status}`);
} }
process.on("beforeExit", () => { process.on("beforeExit", () => {
subprocess?.removeListener("close", onSubprocessClose); subprocess?.removeListener("close", onSubprocessClose);
}); });
function rebuild(files: string[]) { function rebuild(files: string[]) {
files = files.map((file) => path.relative(hot.projectRoot, file)); files = files.map((file) => path.relative(hot.projectRoot, file));
const changed: string[] = []; const changed: string[] = [];
for (const file of files) { for (const file of files) {
let mtimeMs: number | null = null; let mtimeMs: number | null = null;
try { try {
mtimeMs = fs.statSync(file).mtimeMs; mtimeMs = fs.statSync(file).mtimeMs;
} catch (err: any) { } catch (err: any) {
if (err?.code !== "ENOENT") throw err; if (err?.code !== "ENOENT") throw err;
} }
if (incr.updateStat(file, mtimeMs)) changed.push(file); if (incr.updateStat(file, mtimeMs)) changed.push(file);
} }
if (changed.length === 0) { if (changed.length === 0) {
console.warn("Files were modified but the 'modify' time did not change."); console.warn("Files were modified but the 'modify' time did not change.");
return; return;
} }
withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({ withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
text: "Rebuilding", text: "Rebuilding",
successText: generate.successText, successText: generate.successText,
failureText: () => "sitegen FAIL", failureText: () => "sitegen FAIL",
}, async (spinner) => { }, async (spinner) => {
console.info("---"); console.info("---");
console.info( console.info(
"Updated" + "Updated" +
(changed.length === 1 (changed.length === 1
? " " + changed[0] ? " " + changed[0]
: changed.map((file) => "\n- " + file)), : changed.map((file) => "\n- " + file)),
); );
const result = await generate.sitegen(spinner, incr); const result = await generate.sitegen(spinner, incr);
incr.toDisk(); // Allows picking up this state again incr.toDisk(); // Allows picking up this state again
for (const file of watch.files) { for (const file of watch.files) {
const relative = path.relative(hot.projectRoot, file); const relative = path.relative(hot.projectRoot, file);
if (!incr.invals.has(relative)) watch.remove(file); if (!incr.invals.has(relative)) watch.remove(file);
} }
return result; return result;
}).then((result) => { }).then((result) => {
// Restart the server if it was changed or not running. // Restart the server if it was changed or not running.
if ( if (
!subprocess || !subprocess ||
result.inserted.some(({ kind }) => kind === "backendReplace") result.inserted.some(({ kind }) => kind === "backendReplace")
) { ) {
serve(); serve();
} else if ( } else if (
subprocess && subprocess &&
result.inserted.some(({ kind }) => kind === "asset") result.inserted.some(({ kind }) => kind === "asset")
) { ) {
subprocess.send({ type: "clover.assets.reload" }); subprocess.send({ type: "clover.assets.reload" });
} }
return result; return result;
}).catch((err) => { }).catch((err) => {
console.error(util.inspect(err)); console.error(util.inspect(err));
}).finally(statusLine); }).finally(statusLine);
} }
function statusLine() { function statusLine() {
console.info( console.info(
`Watching ${incr.invals.size} files \x1b[36m[last change: ${ `Watching ${incr.invals.size} files \x1b[36m[last change: ${
new Date().toLocaleTimeString() new Date().toLocaleTimeString()
}]\x1b[39m`, }]\x1b[39m`,
); );
} }
} }
class Watch { class Watch {
files = new Set<string>(); files = new Set<string>();
stale = new Set<string>(); stale = new Set<string>();
onChange: (files: string[]) => void; onChange: (files: string[]) => void;
watchers: fs.FSWatcher[] = []; watchers: fs.FSWatcher[] = [];
/** Has a trailing slash */ /** Has a trailing slash */
roots: string[] = []; roots: string[] = [];
debounce: ReturnType<typeof setTimeout> | null = null; debounce: ReturnType<typeof setTimeout> | null = null;
constructor(onChange: Watch["onChange"]) { constructor(onChange: Watch["onChange"]) {
this.onChange = onChange; this.onChange = onChange;
} }
add(...files: string[]) { add(...files: string[]) {
const { roots, watchers } = this; const { roots, watchers } = this;
let newRoots: string[] = []; let newRoots: string[] = [];
for (let file of files) { for (let file of files) {
file = path.resolve(file); file = path.resolve(file);
if (this.files.has(file)) continue; if (this.files.has(file)) continue;
this.files.add(file); this.files.add(file);
// Find an existing watcher // Find an existing watcher
if (roots.some((root) => file.startsWith(root))) continue; if (roots.some((root) => file.startsWith(root))) continue;
if (newRoots.some((root) => file.startsWith(root))) continue; if (newRoots.some((root) => file.startsWith(root))) continue;
newRoots.push(path.dirname(file) + path.sep); newRoots.push(path.dirname(file) + path.sep);
} }
if (newRoots.length === 0) return; if (newRoots.length === 0) return;
// Filter out directories that are already specified // Filter out directories that are already specified
newRoots = newRoots newRoots = newRoots
.sort((a, b) => a.length - b.length) .sort((a, b) => a.length - b.length)
.filter((dir, i, a) => { .filter((dir, i, a) => {
for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false; for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false;
return true; return true;
}); });
// Append Watches // Append Watches
let i = roots.length; let i = roots.length;
for (const root of newRoots) { for (const root of newRoots) {
this.watchers.push(fs.watch( this.watchers.push(fs.watch(
root, root,
{ recursive: true, encoding: "utf-8" }, { recursive: true, encoding: "utf-8" },
this.#handleEvent.bind(this, root), this.#handleEvent.bind(this, root),
)); ));
this.roots.push(root); this.roots.push(root);
} }
// If any new roots shadow over and old one, delete it! // If any new roots shadow over and old one, delete it!
while (i > 0) { while (i > 0) {
i -= 1; i -= 1;
const root = roots[i]; const root = roots[i];
if (newRoots.some((newRoot) => root.startsWith(newRoot))) { if (newRoots.some((newRoot) => root.startsWith(newRoot))) {
watchers.splice(i, 1)[0].close(); watchers.splice(i, 1)[0].close();
roots.splice(i, 1); roots.splice(i, 1);
} }
} }
} }
remove(...files: string[]) { remove(...files: string[]) {
for (const file of files) this.files.delete(path.resolve(file)); for (const file of files) this.files.delete(path.resolve(file));
// Find watches that are covering no files // Find watches that are covering no files
const { roots, watchers } = this; const { roots, watchers } = this;
const existingFiles = Array.from(this.files); const existingFiles = Array.from(this.files);
let i = roots.length; let i = roots.length;
while (i > 0) { while (i > 0) {
i -= 1; i -= 1;
const root = roots[i]; const root = roots[i];
if (!existingFiles.some((file) => file.startsWith(root))) { if (!existingFiles.some((file) => file.startsWith(root))) {
watchers.splice(i, 1)[0].close(); watchers.splice(i, 1)[0].close();
roots.splice(i, 1); roots.splice(i, 1);
} }
} }
} }
stop() { stop() {
for (const w of this.watchers) w.close(); for (const w of this.watchers) w.close();
} }
#handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) { #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
if (!subPath) return; if (!subPath) return;
const file = path.join(root, subPath); const file = path.join(root, subPath);
if (!this.files.has(file)) return; if (!this.files.has(file)) return;
this.stale.add(file); this.stale.add(file);
const { debounce } = this; const { debounce } = this;
if (debounce !== null) clearTimeout(debounce); if (debounce !== null) clearTimeout(debounce);
this.debounce = setTimeout(() => { this.debounce = setTimeout(() => {
this.debounce = null; this.debounce = null;
this.onChange(Array.from(this.stale)); this.onChange(Array.from(this.stale));
this.stale.clear(); this.stale.clear();
}, debounceMilliseconds); }, debounceMilliseconds);
} }
} }
import * as fs from "node:fs"; import * as fs from "node:fs";
import { withSpinner } from "@paperclover/console/Spinner"; import { withSpinner } from "@paperclover/console/Spinner";
import * as generate from "./generate.ts"; import * as generate from "./generate.ts";
import * as path from "node:path"; import * as path from "node:path";
import * as util from "node:util"; import * as util from "node:util";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as child_process from "node:child_process"; import * as child_process from "node:child_process";

View file

@ -4,27 +4,27 @@ this repository contains clover's "sitegen" framework, which is a set of tools
that assist building websites. these tools power https://paperclover.net. that assist building websites. these tools power https://paperclover.net.
- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines) - **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
- A more practical JSX runtime (`class` instead of `className`, built-in - A more practical JSX runtime (`class` instead of `className`, built-in
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc). `clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
- Integration with [Marko][1] for concisely written components. - Integration with [Marko][1] for concisely written components.
- TODO: MDX-like compiler for content-heavy pages like blogs. - TODO: MDX-like compiler for content-heavy pages like blogs.
- Different languages can be used at the same time. Supports - Different languages can be used at the same time. Supports `async function`
`async function` components, `<Suspense />`, and custom extensions. components, `<Suspense />`, and custom extensions.
- **Incremental static site generator and build system.** - **Incremental static site generator and build system.**
- Build entire production site at start, incremental updates when pages - Build entire production site at start, incremental updates when pages
change; Build system state survives coding sessions. change; Build system state survives coding sessions.
- The only difference in development and production mode is hidden - The only difference in development and production mode is hidden source-maps
source-maps and stripped `console.debug` calls. The site you and stripped `console.debug` calls. The site you see locally is the same
see locally is the same site you see deployed. site you see deployed.
- (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs - (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs
checks when the files change. For example, changing a component re-tests checks when the files change. For example, changing a component re-tests
only pages that use that component and re-lints only the changed file. only pages that use that component and re-lints only the changed file.
- **Integrated libraries for building complex, content heavy web sites.** - **Integrated libraries for building complex, content heavy web sites.**
- Static asset serving with ETag and build-time compression. - Static asset serving with ETag and build-time compression.
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`) - Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`) - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
- TODO: Meta and Open Graph generation. (`export const meta`) - TODO: Meta and Open Graph generation. (`export const meta`)
- TODO: Font subsetting tools to reduce bytes downloaded by fonts. - TODO: Font subsetting tools to reduce bytes downloaded by fonts.
- **Built on the battle-tested Node.js runtime.** - **Built on the battle-tested Node.js runtime.**
[1]: https://next.markojs.com [1]: https://next.markojs.com
@ -42,6 +42,7 @@ Included is `src`, which contains `paperclover.net`. Website highlights:
## Development ## Development
minimum system requirements: minimum system requirements:
- a cpu with at least 1 core. - a cpu with at least 1 core.
- random access memory. - random access memory.
- windows 7 or later, macos, or other operating system. - windows 7 or later, macos, or other operating system.
@ -73,4 +74,3 @@ open a shell with all needed system dependencies.
## Contributions ## Contributions
No contributions to `src` accepted, only `framework`. No contributions to `src` accepted, only `framework`.

2
run.js
View file

@ -12,7 +12,7 @@ if (!zlib.zstdCompress) {
: null; : null;
globalThis.console.error( globalThis.console.error(
`sitegen depends on a node.js-compatibile runtime that supports zstd compression\n` + `sitegen depends on a node.js-compatibile runtime\n` +
`this is node.js version ${process.version}${ `this is node.js version ${process.version}${
brand ? ` (${brand})` : "" brand ? ` (${brand})` : ""
}\n\n` + }\n\n` +

View file

@ -1,75 +1,75 @@
const cookieAge = 60 * 60 * 24 * 365; // 1 year const cookieAge = 60 * 60 * 24 * 365; // 1 year
let lastKnownToken: string | null = null; let lastKnownToken: string | null = null;
function compareToken(token: string) { function compareToken(token: string) {
if (token === lastKnownToken) return true; if (token === lastKnownToken) return true;
lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim(); lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
return token === lastKnownToken; return token === lastKnownToken;
} }
export async function middleware(c: Context, next: Next) { export async function middleware(c: Context, next: Next) {
if (c.req.path.startsWith("/admin")) { if (c.req.path.startsWith("/admin")) {
return adminInner(c, next); return adminInner(c, next);
} }
return next(); return next();
} }
export function adminInner(c: Context, next: Next) { export function adminInner(c: Context, next: Next) {
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1]; const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
if (c.req.path === "/admin/login") { if (c.req.path === "/admin/login") {
const key = c.req.query("key"); const key = c.req.query("key");
if (key) { if (key) {
if (compareToken(key)) { if (compareToken(key)) {
return c.body(null, 303, { return c.body(null, 303, {
"Location": "/admin", "Location": "/admin",
"Set-Cookie": "Set-Cookie":
`admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, `admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
}); });
} }
return serveAsset(c, "/admin/login/fail", 403); return serveAsset(c, "/admin/login/fail", 403);
} }
if (token && compareToken(token)) { if (token && compareToken(token)) {
return c.redirect("/admin", 303); return c.redirect("/admin", 303);
} }
if (c.req.method === "POST") { if (c.req.method === "POST") {
return serveAsset(c, "/admin/login/fail", 403); return serveAsset(c, "/admin/login/fail", 403);
} else { } else {
return serveAsset(c, "/admin/login", 200); return serveAsset(c, "/admin/login", 200);
} }
} }
if (c.req.path === "/admin/logout") { if (c.req.path === "/admin/logout") {
return c.body(null, 303, { return c.body(null, 303, {
"Location": "/admin/login", "Location": "/admin/login",
"Set-Cookie": "Set-Cookie":
`admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`, `admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
}); });
} }
if (token && compareToken(token)) { if (token && compareToken(token)) {
return next(); return next();
} }
return c.redirect("/admin/login", 303); return c.redirect("/admin/login", 303);
} }
export function hasAdminToken(c: Context) { export function hasAdminToken(c: Context) {
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1]; const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
return token && compareToken(token); return token && compareToken(token);
} }
export async function main() { export async function main() {
const key = crypto.randomUUID(); const key = crypto.randomUUID();
await fs.writeMkdir(".clover/admin-token.txt", key); await fs.writeMkdir(".clover/admin-token.txt", key);
const start = ({ const start = ({
win32: "start", win32: "start",
darwin: "open", darwin: "open",
} as Record<string, string>)[process.platform] ?? "xdg-open"; } as Record<string, string>)[process.platform] ?? "xdg-open";
child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`); child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`);
} }
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { serveAsset } from "#sitegen/assets"; import { serveAsset } from "#sitegen/assets";
import * as child_process from "node:child_process"; import * as child_process from "node:child_process";

View file

@ -1,53 +1,53 @@
// This is the main file for the backend // This is the main file for the backend
const app = new Hono(); const app = new Hono();
const logHttp = scoped("http", { color: "magenta" }); const logHttp = scoped("http", { color: "magenta" });
// Middleware // Middleware
app.use(trimTrailingSlash()); app.use(trimTrailingSlash());
app.use(removeDuplicateSlashes); app.use(removeDuplicateSlashes);
app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4)))); app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
app.use(admin.middleware); app.use(admin.middleware);
// Backends // Backends
app.route("", require("./q+a/backend.ts").app); app.route("", require("./q+a/backend.ts").app);
app.route("", require("./file-viewer/backend.tsx").app); app.route("", require("./file-viewer/backend.tsx").app);
// Asset middleware has least precedence // Asset middleware has least precedence
app.use(assets.middleware); app.use(assets.middleware);
// Handlers // Handlers
app.notFound(assets.notFound); app.notFound(assets.notFound);
if (process.argv.includes("--development")) { if (process.argv.includes("--development")) {
app.onError((err, c) => { app.onError((err, c) => {
if (err instanceof HTTPException) { if (err instanceof HTTPException) {
// Get the custom response // Get the custom response
return err.getResponse(); return err.getResponse();
} }
return c.text(util.inspect(err), 500); return c.text(util.inspect(err), 500);
}); });
} }
export default app; export default app;
async function removeDuplicateSlashes(c: Context, next: Next) { async function removeDuplicateSlashes(c: Context, next: Next) {
const path = c.req.path; const path = c.req.path;
if (/\/\/+/.test(path)) { if (/\/\/+/.test(path)) {
const normalizedPath = path.replace(/\/\/+/g, "/"); const normalizedPath = path.replace(/\/\/+/g, "/");
const query = c.req.query(); const query = c.req.query();
const queryString = Object.keys(query).length > 0 const queryString = Object.keys(query).length > 0
? "?" + new URLSearchParams(query).toString() ? "?" + new URLSearchParams(query).toString()
: ""; : "";
return c.redirect(normalizedPath + queryString, 301); return c.redirect(normalizedPath + queryString, 301);
} }
await next(); await next();
} }
import { type Context, Hono, type Next } from "#hono"; import { type Context, Hono, type Next } from "#hono";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { trimTrailingSlash } from "hono/trailing-slash"; import { trimTrailingSlash } from "hono/trailing-slash";
import * as assets from "#sitegen/assets"; import * as assets from "#sitegen/assets";
import * as admin from "./admin.ts"; import * as admin from "./admin.ts";
import { scoped } from "@paperclover/console"; import { scoped } from "@paperclover/console";
import * as util from "node:util"; import * as util from "node:util";

View file

@ -1,8 +1,8 @@
export function main() { export function main() {
const meows = MediaFile.db.prepare(` const meows = MediaFile.db.prepare(`
select * from media_files; select * from media_files;
`).as(MediaFile).array(); `).as(MediaFile).array();
console.log(meows); console.log(meows);
} }
import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; import { MediaFile } from "@/file-viewer/models/MediaFile.ts";

File diff suppressed because it is too large Load diff

View file

@ -135,10 +135,7 @@ function highlightLines({
export const getRegistry = async.once(async () => { export const getRegistry = async.once(async () => {
const wasmBin = await fs.readFile( const wasmBin = await fs.readFile(
path.join( require.resolve("vscode-oniguruma/release/onig.wasm"),
import.meta.dirname,
"../node_modules/vscode-oniguruma/release/onig.wasm",
),
); );
await oniguruma.loadWASM(wasmBin); await oniguruma.loadWASM(wasmBin);

View file

@ -1,73 +1,73 @@
const db = getDb("cache.sqlite"); const db = getDb("cache.sqlite");
db.table( db.table(
"asset_refs", "asset_refs",
/* SQL */ ` /* SQL */ `
create table if not exists asset_refs ( create table if not exists asset_refs (
id integer primary key autoincrement, id integer primary key autoincrement,
key text not null UNIQUE, key text not null UNIQUE,
refs integer not null refs integer not null
); );
create table if not exists asset_ref_files ( create table if not exists asset_ref_files (
file text not null, file text not null,
id integer not null, id integer not null,
foreign key (id) references asset_refs(id) foreign key (id) references asset_refs(id) ON DELETE CASCADE
); );
create index asset_ref_files_id on asset_ref_files(id); create index asset_ref_files_id on asset_ref_files(id);
`, `,
); );
/** /**
* Uncompressed files are read directly from the media store root. Derivied * Uncompressed files are read directly from the media store root. Derivied
* assets like compressed files, optimized images, and streamable video are * assets like compressed files, optimized images, and streamable video are
* stored in the `derived` folder. After scanning, the derived assets are * stored in the `derived` folder. After scanning, the derived assets are
* uploaded into the store (storage1/clofi-derived dataset on NAS). Since * uploaded into the store (storage1/clofi-derived dataset on NAS). Since
* multiple files can share the same hash, the number of references is * multiple files can share the same hash, the number of references is
* tracked, and the derived content is only produced once. This means if a * tracked, and the derived content is only produced once. This means if a
* file is deleted, it should only decrement a reference count; deleting it * file is deleted, it should only decrement a reference count; deleting it
* once all references are removed. * once all references are removed.
*/ */
export class AssetRef { export class AssetRef {
/** Key which aws referenced */ /** Key which aws referenced */
id!: number; id!: number;
key!: string; key!: string;
refs!: number; refs!: number;
unref() { unref() {
decrementQuery.run(this.key); decrementQuery.run(this.key);
deleteUnreferencedQuery.run().changes > 0; deleteUnreferencedQuery.run().changes > 0;
} }
addFiles(files: string[]) { addFiles(files: string[]) {
for (const file of files) { for (const file of files) {
addFileQuery.run({ id: this.id, file }); addFileQuery.run({ id: this.id, file });
} }
} }
static get(key: string) { static get(key: string) {
return getQuery.get(key); return getQuery.get(key);
} }
static putOrIncrement(key: string) { static putOrIncrement(key: string) {
putOrIncrementQuery.get(key); putOrIncrementQuery.get(key);
return UNWRAP(AssetRef.get(key)); return UNWRAP(AssetRef.get(key));
} }
} }
const getQuery = db.prepare<[key: string]>(/* SQL */ ` const getQuery = db.prepare<[key: string]>(/* SQL */ `
select * from asset_refs where key = ?; select * from asset_refs where key = ?;
`).as(AssetRef); `).as(AssetRef);
const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ ` const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
insert into asset_refs (key, refs) values (?, 1) insert into asset_refs (key, refs) values (?, 1)
on conflict(key) do update set refs = refs + 1; on conflict(key) do update set refs = refs + 1;
`); `);
const decrementQuery = db.prepare<[key: string]>(/* SQL */ ` const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
update asset_refs set refs = refs - 1 where key = ? and refs > 0; update asset_refs set refs = refs - 1 where key = ? and refs > 0;
`); `);
const deleteUnreferencedQuery = db.prepare(/* SQL */ ` const deleteUnreferencedQuery = db.prepare(/* SQL */ `
delete from asset_refs where refs <= 0; delete from asset_refs where refs <= 0;
`); `);
const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ ` const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
insert into asset_ref_files (id, file) values ($id, $file); insert into asset_ref_files (id, file) values ($id, $file);
`); `);
import { getDb } from "#sitegen/sqlite"; import { getDb } from "#sitegen/sqlite";

View file

@ -1,59 +1,59 @@
const db = getDb("cache.sqlite"); const db = getDb("cache.sqlite");
db.table( db.table(
"permissions", "permissions",
/* SQL */ ` /* SQL */ `
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE IF NOT EXISTS permissions (
prefix TEXT PRIMARY KEY, prefix TEXT PRIMARY KEY,
allow INTEGER NOT NULL allow INTEGER NOT NULL
); );
`, `,
); );
export class FilePermissions { export class FilePermissions {
prefix!: string; prefix!: string;
/** Currently set to 1 always */ /** Currently set to 1 always */
allow!: number; allow!: number;
// -- static ops -- // -- static ops --
static getByPrefix(filePath: string): number { static getByPrefix(filePath: string): number {
return getByPrefixQuery.get(filePath)?.allow ?? 0; return getByPrefixQuery.get(filePath)?.allow ?? 0;
} }
static getExact(filePath: string): number { static getExact(filePath: string): number {
return getExactQuery.get(filePath)?.allow ?? 0; return getExactQuery.get(filePath)?.allow ?? 0;
} }
static setPermissions(prefix: string, allow: number) { static setPermissions(prefix: string, allow: number) {
if (allow) { if (allow) {
insertQuery.run({ prefix, allow }); insertQuery.run({ prefix, allow });
} else { } else {
deleteQuery.run(prefix); deleteQuery.run(prefix);
} }
} }
} }
const getByPrefixQuery = db.prepare< const getByPrefixQuery = db.prepare<
[prefix: string], [prefix: string],
Pick<FilePermissions, "allow"> Pick<FilePermissions, "allow">
>(/* SQL */ ` >(/* SQL */ `
SELECT allow SELECT allow
FROM permissions FROM permissions
WHERE ? GLOB prefix || '*' WHERE ? GLOB prefix || '*'
ORDER BY LENGTH(prefix) DESC ORDER BY LENGTH(prefix) DESC
LIMIT 1; LIMIT 1;
`); `);
const getExactQuery = db.prepare< const getExactQuery = db.prepare<
[file: string], [file: string],
Pick<FilePermissions, "allow"> Pick<FilePermissions, "allow">
>(/* SQL */ ` >(/* SQL */ `
SELECT allow FROM permissions WHERE ? == prefix SELECT allow FROM permissions WHERE ? == prefix
`); `);
const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ ` const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow); REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
`); `);
const deleteQuery = db.prepare<[file: string]>(/* SQL */ ` const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
DELETE FROM permissions WHERE prefix = ?; DELETE FROM permissions WHERE prefix = ?;
`); `);
import { getDb } from "#sitegen/sqlite"; import { getDb } from "#sitegen/sqlite";

View file

@ -1,436 +1,436 @@
const db = getDb("cache.sqlite"); const db = getDb("cache.sqlite");
db.table( db.table(
"media_files", "media_files",
/* SQL */ ` /* SQL */ `
create table media_files ( create table media_files (
id integer primary key autoincrement, id integer primary key autoincrement,
parent_id integer, parent_id integer,
path text unique, path text unique,
kind integer not null, kind integer not null,
timestamp integer not null, timestamp integer not null,
timestamp_updated integer not null default current_timestamp, timestamp_updated integer not null default current_timestamp,
hash text not null, hash text not null,
size integer not null, size integer not null,
duration integer not null default 0, duration integer not null default 0,
dimensions text not null default "", dimensions text not null default "",
contents text not null, contents text not null,
dirsort text, dirsort text,
processed integer not null, processed integer not null,
processors text not null default "", processors text not null default "",
foreign key (parent_id) references media_files(id) foreign key (parent_id) references media_files(id)
); );
-- index for quickly looking up files by path -- index for quickly looking up files by path
create index media_files_path on media_files (path); create index media_files_path on media_files (path);
-- index for quickly looking up children -- index for quickly looking up children
create index media_files_parent_id on media_files (parent_id); create index media_files_parent_id on media_files (parent_id);
-- index for quickly looking up recursive file children -- index for quickly looking up recursive file children
create index media_files_file_children on media_files (kind, path); create index media_files_file_children on media_files (kind, path);
-- index for finding directories that need to be processed -- index for finding directories that need to be processed
create index media_files_directory_processed on media_files (kind, processed); create index media_files_directory_processed on media_files (kind, processed);
`, `,
); );
export enum MediaFileKind { export enum MediaFileKind {
directory = 0, directory = 0,
file = 1, file = 1,
} }
export class MediaFile { export class MediaFile {
id!: number; id!: number;
parent_id!: number | null; parent_id!: number | null;
/** /**
* Has leading slash, does not have `/file` prefix. * Has leading slash, does not have `/file` prefix.
* @example "/2025/waterfalls/waterfalls.mp3" * @example "/2025/waterfalls/waterfalls.mp3"
*/ */
path!: string; path!: string;
kind!: MediaFileKind; kind!: MediaFileKind;
private timestamp!: number; private timestamp!: number;
private timestamp_updated!: number; private timestamp_updated!: number;
/** for mp3/mp4 files, measured in seconds */ /** for mp3/mp4 files, measured in seconds */
duration?: number; duration?: number;
/** for images and videos, the dimensions. Two numbers split by `x` */ /** for images and videos, the dimensions. Two numbers split by `x` */
dimensions?: string; dimensions?: string;
/** /**
* sha1 of * sha1 of
* - files: the contents * - files: the contents
* - directories: the JSON array of strings + the content of `readme.txt` * - directories: the JSON array of strings + the content of `readme.txt`
* this is used * this is used
* - to inform changes in caching mechanisms (etag, page render cache) * - to inform changes in caching mechanisms (etag, page render cache)
* - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd}) * - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
*/ */
hash!: string; hash!: string;
/** /**
* Depends on the file kind. * Depends on the file kind.
* *
* - For directories, this is the contents of `readme.txt`, if it exists. * - For directories, this is the contents of `readme.txt`, if it exists.
* - Otherwise, it is an empty string. * - Otherwise, it is an empty string.
*/ */
contents!: string; contents!: string;
/** /**
* For directories, if this is set, it is a JSON-encoded array of the explicit * For directories, if this is set, it is a JSON-encoded array of the explicit
* sorting order. Derived off of `.dirsort` files. * sorting order. Derived off of `.dirsort` files.
*/ */
dirsort!: string | null; dirsort!: string | null;
/** in bytes */ /** in bytes */
size!: number; size!: number;
/** /**
* 0 - not processed * 0 - not processed
* non-zero - processed * non-zero - processed
* *
* file: a bit-field of the processors. * file: a bit-field of the processors.
* directory: this is for re-indexing contents * directory: this is for re-indexing contents
*/ */
processed!: number; processed!: number;
processors!: string; processors!: string;
// -- instance ops -- // -- instance ops --
get date() { get date() {
return new Date(this.timestamp); return new Date(this.timestamp);
} }
get lastUpdateDate() { get lastUpdateDate() {
return new Date(this.timestamp_updated); return new Date(this.timestamp_updated);
} }
parseDimensions() { parseDimensions() {
const dimensions = this.dimensions; const dimensions = this.dimensions;
if (!dimensions) return null; if (!dimensions) return null;
const [width, height] = dimensions.split("x").map(Number); const [width, height] = dimensions.split("x").map(Number);
return { width, height }; return { width, height };
} }
get basename() { get basename() {
return path.basename(this.path); return path.basename(this.path);
} }
get basenameWithoutExt() { get basenameWithoutExt() {
return path.basename(this.path, path.extname(this.path)); return path.basename(this.path, path.extname(this.path));
} }
get extension() { get extension() {
return path.extname(this.path); return path.extname(this.path);
} }
getChildren() { getChildren() {
return MediaFile.getChildren(this.id) return MediaFile.getChildren(this.id)
.filter((file) => !file.basename.startsWith(".")); .filter((file) => !file.basename.startsWith("."));
} }
getPublicChildren() { getPublicChildren() {
const children = MediaFile.getChildren(this.id); const children = MediaFile.getChildren(this.id);
if (FilePermissions.getByPrefix(this.path) == 0) { if (FilePermissions.getByPrefix(this.path) == 0) {
return children.filter(({ path }) => FilePermissions.getExact(path) == 0); return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
} }
return children; return children;
} }
getParent() { getParent() {
const dirPath = this.path; const dirPath = this.path;
if (dirPath === "/") return null; if (dirPath === "/") return null;
const parentPath = path.dirname(dirPath); const parentPath = path.dirname(dirPath);
if (parentPath === dirPath) return null; if (parentPath === dirPath) return null;
const result = MediaFile.getByPath(parentPath); const result = MediaFile.getByPath(parentPath);
if (!result) return null; if (!result) return null;
ASSERT(result.kind === MediaFileKind.directory); ASSERT(result.kind === MediaFileKind.directory);
return result; return result;
} }
setProcessed(processed: number) { setProcessed(processed: number) {
setProcessedQuery.run({ id: this.id, processed }); setProcessedQuery.run({ id: this.id, processed });
this.processed = processed; this.processed = processed;
} }
setProcessors(processed: number, processors: string) { setProcessors(processed: number, processors: string) {
setProcessorsQuery.run({ id: this.id, processed, processors }); setProcessorsQuery.run({ id: this.id, processed, processors });
this.processed = processed; this.processed = processed;
this.processors = processors; this.processors = processors;
} }
setDuration(duration: number) { setDuration(duration: number) {
setDurationQuery.run({ id: this.id, duration }); setDurationQuery.run({ id: this.id, duration });
this.duration = duration; this.duration = duration;
} }
setDimensions(dimensions: string) { setDimensions(dimensions: string) {
setDimensionsQuery.run({ id: this.id, dimensions }); setDimensionsQuery.run({ id: this.id, dimensions });
this.dimensions = dimensions; this.dimensions = dimensions;
} }
setContents(contents: string) { setContents(contents: string) {
setContentsQuery.run({ id: this.id, contents }); setContentsQuery.run({ id: this.id, contents });
this.contents = contents; this.contents = contents;
} }
getRecursiveFileChildren() { getRecursiveFileChildren() {
if (this.kind !== MediaFileKind.directory) return []; if (this.kind !== MediaFileKind.directory) return [];
return getChildrenFilesRecursiveQuery.array(this.path + "/"); return getChildrenFilesRecursiveQuery.array(this.path + "/");
} }
delete() { delete() {
deleteCascadeQuery.run({ id: this.id }); deleteCascadeQuery.run({ id: this.id });
} }
// -- static ops -- // -- static ops --
static getByPath(filePath: string): MediaFile | null { static getByPath(filePath: string): MediaFile | null {
const result = getByPathQuery.get(filePath); const result = getByPathQuery.get(filePath);
if (result) return result; if (result) return result;
if (filePath === "/") { if (filePath === "/") {
return Object.assign(new MediaFile(), { return Object.assign(new MediaFile(), {
id: 0, id: 0,
parent_id: null, parent_id: null,
path: "/", path: "/",
kind: MediaFileKind.directory, kind: MediaFileKind.directory,
timestamp: 0, timestamp: 0,
timestamp_updated: Date.now(), timestamp_updated: Date.now(),
hash: "0".repeat(40), hash: "0".repeat(40),
contents: "the file scanner has not been run yet", contents: "the file scanner has not been run yet",
dirsort: null, dirsort: null,
size: 0, size: 0,
processed: 1, processed: 1,
}); });
} }
return null; return null;
} }
static createFile({ static createFile({
path: filePath, path: filePath,
date, date,
hash, hash,
size, size,
duration, duration,
dimensions, dimensions,
contents, contents,
}: CreateFile) { }: CreateFile) {
ASSERT( ASSERT(
!filePath.includes("\\") && filePath.startsWith("/"), !filePath.includes("\\") && filePath.startsWith("/"),
`Invalid path: ${filePath}`, `Invalid path: ${filePath}`,
); );
return createFileQuery.getNonNull({ return createFileQuery.getNonNull({
path: filePath, path: filePath,
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)), parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
timestamp: date.getTime(), timestamp: date.getTime(),
timestampUpdated: Date.now(), timestampUpdated: Date.now(),
hash, hash,
size, size,
duration, duration,
dimensions, dimensions,
contents, contents,
}); });
} }
static getOrPutDirectoryId(filePath: string) { static getOrPutDirectoryId(filePath: string) {
ASSERT( ASSERT(
!filePath.includes("\\") && filePath.startsWith("/"), !filePath.includes("\\") && filePath.startsWith("/"),
`Invalid path: ${filePath}`, `Invalid path: ${filePath}`,
); );
filePath = path.normalize(filePath); filePath = path.normalize(filePath);
const row = getDirectoryIdQuery.get(filePath); const row = getDirectoryIdQuery.get(filePath);
if (row) return row.id; if (row) return row.id;
let current = filePath; let current = filePath;
let parts = []; let parts = [];
let parentId: null | number = null; let parentId: null | number = null;
if (filePath === "/") { if (filePath === "/") {
return createDirectoryQuery.getNonNull({ return createDirectoryQuery.getNonNull({
path: filePath, path: filePath,
parentId, parentId,
}).id; }).id;
} }
// walk up the path until we find a directory that exists // walk up the path until we find a directory that exists
do { do {
parts.unshift(path.basename(current)); parts.unshift(path.basename(current));
current = path.dirname(current); current = path.dirname(current);
parentId = getDirectoryIdQuery.get(current)?.id ?? null; parentId = getDirectoryIdQuery.get(current)?.id ?? null;
} while (parentId == undefined && current !== "/"); } while (parentId == undefined && current !== "/");
if (parentId == undefined) { if (parentId == undefined) {
parentId = createDirectoryQuery.getNonNull({ parentId = createDirectoryQuery.getNonNull({
path: current, path: current,
parentId, parentId,
}).id; }).id;
} }
// walk back down the path, creating directories as needed // walk back down the path, creating directories as needed
for (const part of parts) { for (const part of parts) {
current = path.join(current, part); current = path.join(current, part);
ASSERT(parentId != undefined); ASSERT(parentId != undefined);
parentId = createDirectoryQuery.getNonNull({ parentId = createDirectoryQuery.getNonNull({
path: current, path: current,
parentId, parentId,
}).id; }).id;
} }
return parentId; return parentId;
} }
static markDirectoryProcessed({ static markDirectoryProcessed({
id, id,
timestamp, timestamp,
contents, contents,
size, size,
hash, hash,
dirsort, dirsort,
}: MarkDirectoryProcessed) { }: MarkDirectoryProcessed) {
markDirectoryProcessedQuery.get({ markDirectoryProcessedQuery.get({
id, id,
timestamp: timestamp.getTime(), timestamp: timestamp.getTime(),
contents, contents,
dirsort: dirsort ? JSON.stringify(dirsort) : "", dirsort: dirsort ? JSON.stringify(dirsort) : "",
hash, hash,
size, size,
}); });
} }
static setProcessed(id: number, processed: number) { static setProcessed(id: number, processed: number) {
setProcessedQuery.run({ id, processed }); setProcessedQuery.run({ id, processed });
} }
static createOrUpdateDirectory(dirPath: string) { static createOrUpdateDirectory(dirPath: string) {
const id = MediaFile.getOrPutDirectoryId(dirPath); const id = MediaFile.getOrPutDirectoryId(dirPath);
return updateDirectoryQuery.get(id); return updateDirectoryQuery.get(id);
} }
static getChildren(id: number) { static getChildren(id: number) {
return getChildrenQuery.array(id); return getChildrenQuery.array(id);
} }
static db = db; static db = db;
} }
// Create a `file` entry with a given path, date, file hash, size, and duration // Create a `file` entry with a given path, date, file hash, size, and duration
// If the file already exists, update the date and duration. // If the file already exists, update the date and duration.
// If the file exists and the hash is different, sets `compress` to 0. // If the file exists and the hash is different, sets `compress` to 0.
interface CreateFile { interface CreateFile {
path: string; path: string;
date: Date; date: Date;
hash: string; hash: string;
size: number; size: number;
duration: number; duration: number;
dimensions: string; dimensions: string;
contents: string; contents: string;
} }
// Set the `processed` flag true and update the metadata for a directory // Set the `processed` flag true and update the metadata for a directory
export interface MarkDirectoryProcessed { export interface MarkDirectoryProcessed {
id: number; id: number;
timestamp: Date; timestamp: Date;
contents: string; contents: string;
size: number; size: number;
hash: string; hash: string;
dirsort: null | string[]; dirsort: null | string[];
} }
export interface DirConfig { export interface DirConfig {
/** Overridden sorting */ /** Overridden sorting */
sort: string[]; sort: string[];
} }
// -- queries -- // -- queries --
// Get a directory ID by path, creating it if it doesn't exist // Get a directory ID by path, creating it if it doesn't exist
const createDirectoryQuery = db.prepare< const createDirectoryQuery = db.prepare<
[{ path: string; parentId: number | null }], [{ path: string; parentId: number | null }],
{ id: number } { id: number }
>( >(
/* SQL */ ` /* SQL */ `
insert into media_files ( insert into media_files (
path, parent_id, kind, timestamp, hash, size, path, parent_id, kind, timestamp, hash, size,
duration, dimensions, contents, dirsort, processed) duration, dimensions, contents, dirsort, processed)
values ( values (
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0, $path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
0, '', '', '', 0) 0, '', '', '', 0)
returning id; returning id;
`, `,
); );
const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ ` const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory}; SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
`); `);
const createFileQuery = db.prepare<[{ const createFileQuery = db.prepare<[{
path: string; path: string;
parentId: number; parentId: number;
timestamp: number; timestamp: number;
timestampUpdated: number; timestampUpdated: number;
hash: string; hash: string;
size: number; size: number;
duration: number; duration: number;
dimensions: string; dimensions: string;
contents: string; contents: string;
}], void>(/* SQL */ ` }], void>(/* SQL */ `
insert into media_files ( insert into media_files (
path, parent_id, kind, timestamp, timestamp_updated, hash, path, parent_id, kind, timestamp, timestamp_updated, hash,
size, duration, dimensions, contents, processed) size, duration, dimensions, contents, processed)
values ( values (
$path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated, $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
$hash, $size, $duration, $dimensions, $contents, 0) $hash, $size, $duration, $dimensions, $contents, 0)
on conflict(path) do update set on conflict(path) do update set
timestamp = excluded.timestamp, timestamp = excluded.timestamp,
timestamp_updated = excluded.timestamp_updated, timestamp_updated = excluded.timestamp_updated,
duration = excluded.duration, duration = excluded.duration,
size = excluded.size, size = excluded.size,
contents = excluded.contents, contents = excluded.contents,
processed = case processed = case
when media_files.hash != excluded.hash then 0 when media_files.hash != excluded.hash then 0
else media_files.processed else media_files.processed
end end
returning *; returning *;
`).as(MediaFile); `).as(MediaFile);
const setProcessedQuery = db.prepare<[{ const setProcessedQuery = db.prepare<[{
id: number; id: number;
processed: number; processed: number;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set processed = $processed where id = $id; update media_files set processed = $processed where id = $id;
`); `);
const setProcessorsQuery = db.prepare<[{ const setProcessorsQuery = db.prepare<[{
id: number; id: number;
processed: number; processed: number;
processors: string; processors: string;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set update media_files set
processed = $processed, processed = $processed,
processors = $processors processors = $processors
where id = $id; where id = $id;
`); `);
const setDurationQuery = db.prepare<[{ const setDurationQuery = db.prepare<[{
id: number; id: number;
duration: number; duration: number;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set duration = $duration where id = $id; update media_files set duration = $duration where id = $id;
`); `);
const setDimensionsQuery = db.prepare<[{ const setDimensionsQuery = db.prepare<[{
id: number; id: number;
dimensions: string; dimensions: string;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set dimensions = $dimensions where id = $id; update media_files set dimensions = $dimensions where id = $id;
`); `);
const setContentsQuery = db.prepare<[{ const setContentsQuery = db.prepare<[{
id: number; id: number;
contents: string; contents: string;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set contents = $contents where id = $id; update media_files set contents = $contents where id = $id;
`); `);
const getByPathQuery = db.prepare<[string]>(/* SQL */ ` const getByPathQuery = db.prepare<[string]>(/* SQL */ `
select * from media_files where path = ?; select * from media_files where path = ?;
`).as(MediaFile); `).as(MediaFile);
const markDirectoryProcessedQuery = db.prepare<[{ const markDirectoryProcessedQuery = db.prepare<[{
timestamp: number; timestamp: number;
contents: string; contents: string;
dirsort: string; dirsort: string;
hash: string; hash: string;
size: number; size: number;
id: number; id: number;
}]>(/* SQL */ ` }]>(/* SQL */ `
update media_files set update media_files set
processed = 1, processed = 1,
timestamp = $timestamp, timestamp = $timestamp,
contents = $contents, contents = $contents,
dirsort = $dirsort, dirsort = $dirsort,
hash = $hash, hash = $hash,
size = $size size = $size
where id = $id; where id = $id;
`); `);
const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ ` const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
update media_files set processed = 0 where id = ?; update media_files set processed = 0 where id = ?;
`); `);
const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ ` const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
select * from media_files where parent_id = ?; select * from media_files where parent_id = ?;
`).as(MediaFile); `).as(MediaFile);
const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ ` const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
select * from media_files select * from media_files
where path like ? || '%' where path like ? || '%'
and kind = ${MediaFileKind.file} and kind = ${MediaFileKind.file}
`).as(MediaFile); `).as(MediaFile);
const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ ` const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
with recursive items as ( with recursive items as (
select id, parent_id from media_files where id = $id select id, parent_id from media_files where id = $id
union all union all
select p.id, p.parent_id select p.id, p.parent_id
from media_files p from media_files p
join items c on p.id = c.parent_id join items c on p.id = c.parent_id
where p.parent_id is not null where p.parent_id is not null
and not exists ( and not exists (
select 1 from media_files child select 1 from media_files child
where child.parent_id = p.id where child.parent_id = p.id
and child.id <> c.id and child.id <> c.id
) )
) )
delete from media_files delete from media_files
where id in (select id from items) where id in (select id from items)
`); `);
import { getDb } from "#sitegen/sqlite"; import { getDb } from "#sitegen/sqlite";
import * as path from "node:path/posix"; import * as path from "node:path/posix";
import { FilePermissions } from "./FilePermissions.ts"; import { FilePermissions } from "./FilePermissions.ts";

View file

@ -1,34 +1,34 @@
import { MediaFile } from "../models/MediaFile.ts"; import { MediaFile } from "../models/MediaFile.ts";
import { MediaPanel } from "../views/clofi.tsx"; import { MediaPanel } from "../views/clofi.tsx";
import { addScript } from "#sitegen"; import { addScript } from "#sitegen";
export const theme = { export const theme = {
bg: "#312652", bg: "#312652",
fg: "#f0f0ff", fg: "#f0f0ff",
primary: "#fabe32", primary: "#fabe32",
}; };
export const meta = { title: "file not found" }; export const meta = { title: "file not found" };
export default function CotyledonPage() { export default function CotyledonPage() {
addScript("../scripts/canvas_cotyledon.client.ts"); addScript("../scripts/canvas_cotyledon.client.ts");
return ( return (
<div class="files ctld ctld-sb"> <div class="files ctld ctld-sb">
<MediaPanel <MediaPanel
file={MediaFile.getByPath("/")!} file={MediaFile.getByPath("/")!}
isLast={false} isLast={false}
activeFilename={null} activeFilename={null}
hasCotyledonCookie={false} hasCotyledonCookie={false}
/> />
<div class="panel last"> <div class="panel last">
<div className="header"></div> <div className="header"></div>
<div className="content file-view notfound"> <div className="content file-view notfound">
<p>this file does not exist ...</p> <p>this file does not exist ...</p>
<p> <p>
<a href="/file">return</a> <a href="/file">return</a>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View file

@ -1,143 +1,147 @@
// -- file extension rules -- // -- file extension rules --
/** Extensions that must have EXIF/etc data stripped */ /** Extensions that must have EXIF/etc data stripped */
export const extScrubExif = new Set([ export const extScrubExif = new Set([
".jpg", ".jpg",
".jpeg", ".jpeg",
".png", ".png",
".mov", ".mov",
".mp4", ".mp4",
".m4a", ".m4a",
]); ]);
/** Extensions that rendered syntax-highlighted code */ /** Extensions that rendered syntax-highlighted code */
export const extsCode = new Map<string, highlight.Language>(Object.entries({ export const extsCode = new Map<string, highlight.Language>(Object.entries({
".json": "json", ".json": "json",
".toml": "toml", ".toml": "toml",
".ts": "ts", ".ts": "ts",
".js": "ts", ".js": "ts",
".tsx": "tsx", ".tsx": "tsx",
".jsx": "tsx", ".jsx": "tsx",
".css": "css", ".css": "css",
".py": "python", ".py": "python",
".lua": "lua", ".lua": "lua",
".sh": "shell", ".sh": "shell",
".bat": "dosbatch", ".bat": "dosbatch",
".ps1": "powershell", ".ps1": "powershell",
".cmd": "dosbatch", ".cmd": "dosbatch",
".yaml": "yaml", ".yaml": "yaml",
".yml": "yaml", ".yml": "yaml",
".zig": "zig", ".zig": "zig",
".astro": "astro", ".astro": "astro",
".mdx": "mdx", ".mdx": "mdx",
".xml": "xml", ".xml": "xml",
".jsonc": "json", ".jsonc": "json",
".php": "php", ".php": "php",
".patch": "diff", ".patch": "diff",
".diff": "diff", ".diff": "diff",
})); }));
/** These files show an audio embed. */ /** These files show an audio embed. */
export const extsAudio = new Set([ export const extsAudio = new Set([
".mp3", ".mp3",
".flac", ".flac",
".wav", ".wav",
".ogg", ".ogg",
".m4a", ".m4a",
]); ]);
/** These files show a video embed. */ /** These files show a video embed. */
export const extsVideo = new Set([ export const extsVideo = new Set([
".mp4", ".mp4",
".mkv", ".mkv",
".webm", ".webm",
".avi", ".avi",
".mov", ".mov",
]); ]);
/** These files show an image embed */ /** These files show an image embed */
export const extsImage = new Set([ export const extsImage = new Set([
".jpg", ".jpg",
".jpeg", ".jpeg",
".png", ".png",
".gif", ".webp",
".webp", ".avif",
".avif", ".heic",
".heic", ]);
".svg", /** These files show an image embed, but aren't optimized */
]); export const extsImageLike = new Set([
...extsImage,
/** These files populate `duration` using `ffprobe` */ ".svg",
export const extsDuration = new Set([...extsAudio, ...extsVideo]); ".gif",
/** These files populate `dimensions` using `ffprobe` */ ]);
export const extsDimensions = new Set([...extsImage, ...extsVideo]);
/** These files populate `duration` using `ffprobe` */
/** These files read file contents into `contents`, as-is */ export const extsDuration = new Set([...extsAudio, ...extsVideo]);
export const extsReadContents = new Set([".txt", ".chat"]); /** These files populate `dimensions` using `ffprobe` */
export const extsDimensions = new Set([...extsImage, ...extsVideo]);
export const extsArchive = new Set([
".zip", /** These files read file contents into `contents`, as-is */
".rar", export const extsReadContents = new Set([".txt", ".chat"]);
".7z",
".tar", export const extsArchive = new Set([
".gz", ".zip",
".bz2", ".rar",
".xz", ".7z",
]); ".tar",
".gz",
/** ".bz2",
* Formats which are already compression formats, meaning a pass ".xz",
* through zstd would offer little to negative benefits ]);
*/
export const extsPreCompressed = new Set([ /**
...extsAudio, * Formats which are already compression formats, meaning a pass
...extsVideo, * through zstd would offer little to negative benefits
...extsImage, */
...extsArchive, export const extsPreCompressed = new Set([
// TODO: are any of these NOT good for compression ...extsAudio,
]); ...extsVideo,
...extsImage,
export function fileIcon( ...extsArchive,
file: Pick<MediaFile, "kind" | "basename" | "path">, // TODO: are any of these NOT good for compression
dirOpen?: boolean, ]);
) {
const { kind, basename } = file; export function fileIcon(
if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir"; file: Pick<MediaFile, "kind" | "basename" | "path">,
dirOpen?: boolean,
// -- special cases -- ) {
if (file.path === "/2024/for everyone") return "snow"; const { kind, basename } = file;
if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
// -- basename cases --
if (basename === "readme.txt") return "readme"; // -- special cases --
if (file.path === "/2024/for everyone") return "snow";
// -- extension cases --
const ext = path.extname(file.basename).toLowerCase(); // -- basename cases --
if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion"; if (basename === "readme.txt") return "readme";
if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
if (ext === ".blend") return "blend"; // -- extension cases --
if (ext === ".chat") return "chat"; const ext = path.extname(file.basename).toLowerCase();
if (ext === ".html") return "webpage"; if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
if (ext === ".lnk") return "link"; if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
if (ext === ".txt" || ext === ".md") return "text"; if (ext === ".blend") return "blend";
if (ext === ".chat") return "chat";
// -- extension categories -- if (ext === ".html") return "webpage";
if (extsVideo.has(ext)) return "video"; if (ext === ".lnk") return "link";
if (extsAudio.has(ext)) return "audio"; if (ext === ".txt" || ext === ".md") return "text";
if (extsImage.has(ext)) return "image";
if (extsArchive.has(ext)) return "archive"; // -- extension categories --
if (extsCode.has(ext)) return "code"; if (extsVideo.has(ext)) return "video";
if (extsAudio.has(ext)) return "audio";
return "file"; if (extsImage.has(ext)) return "image";
} if (extsArchive.has(ext)) return "archive";
if (extsCode.has(ext)) return "code";
// -- viewer rules --
const pathToCanvas = new Map<string, string>(Object.entries({ return "file";
"/2017": "2017", }
"/2018": "2018",
"/2019": "2019", // -- viewer rules --
"/2020": "2020", const pathToCanvas = new Map<string, string>(Object.entries({
"/2021": "2021", "/2017": "2017",
"/2022": "2022", "/2018": "2018",
"/2023": "2023", "/2019": "2019",
"/2024": "2024", "/2020": "2020",
})); "/2021": "2021",
"/2022": "2022",
import type * as highlight from "./highlight.ts"; "/2023": "2023",
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; "/2024": "2024",
import * as path from "node:path"; }));
import type * as highlight from "./highlight.ts";
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
import * as path from "node:path";

View file

@ -1,58 +1,58 @@
export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) { export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
const children = dir.getPublicChildren(); const children = dir.getPublicChildren();
let readme: MediaFile | null = null; let readme: MediaFile | null = null;
const groups = { const groups = {
// years 2025 and onwards // years 2025 and onwards
years: [] as MediaFile[], years: [] as MediaFile[],
// named categories // named categories
categories: [] as MediaFile[], categories: [] as MediaFile[],
// years 2017 to 2024 // years 2017 to 2024
cotyledon: [] as MediaFile[], cotyledon: [] as MediaFile[],
}; };
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
years: "#a2ff91", years: "#a2ff91",
categories: "#9c91ff", categories: "#9c91ff",
cotyledon: "#ff91ca", cotyledon: "#ff91ca",
}; };
for (const child of children) { for (const child of children) {
const basename = child.basename; const basename = child.basename;
if (basename === "readme.txt") { if (basename === "readme.txt") {
readme = child; readme = child;
continue; continue;
} }
const year = basename.match(/^(\d{4})/); const year = basename.match(/^(\d{4})/);
if (year) { if (year) {
const n = parseInt(year[1]); const n = parseInt(year[1]);
if (n >= 2025) { if (n >= 2025) {
groups.years.push(child); groups.years.push(child);
} else { } else {
groups.cotyledon.push(child); groups.cotyledon.push(child);
} }
} else { } else {
groups.categories.push(child); groups.categories.push(child);
} }
} }
let sections = []; let sections = [];
for (const [key, files] of Object.entries(groups)) { for (const [key, files] of Object.entries(groups)) {
if (key === "cotyledon" && !hasCotyledonCookie) { if (key === "cotyledon" && !hasCotyledonCookie) {
continue; continue;
} }
if (key === "years" || key === "cotyledon") { if (key === "years" || key === "cotyledon") {
files.sort((a, b) => { files.sort((a, b) => {
return b.basename.localeCompare(a.basename); return b.basename.localeCompare(a.basename);
}); });
} else { } else {
files.sort((a, b) => { files.sort((a, b) => {
return a.basename.localeCompare(b.basename); return a.basename.localeCompare(b.basename);
}); });
} }
sections.push({ key, titleColor: colorMap[key], files }); sections.push({ key, titleColor: colorMap[key], files });
} }
return { readme, sections }; return { readme, sections };
} }
import { MediaFile } from "./models/MediaFile.ts"; import { MediaFile } from "./models/MediaFile.ts";

View file

@ -99,14 +99,38 @@ export const imagePresets = [
"6", "6",
], ],
}, },
// TODO: avif {
ext: ".avif",
args: [
"-c:v",
"libaom-av1",
"-crf",
"30",
"-pix_fmt",
"yuv420p10le",
],
},
{ {
ext: ".jxl", ext: ".jxl",
args: ["-c:v", "libjxl", "-distance", "0.8", "-effort", "9"], args: [
"-c:v",
"libjxl",
"-distance",
"0.8",
"-effort",
"9",
"-update",
"-frames:v",
"1",
],
}, },
]; ];
export function getVideoArgs(preset: VideoEncodePreset, outbase: string, input: string[]) { export function getVideoArgs(
preset: VideoEncodePreset,
outbase: string,
input: string[],
) {
const cmd = [...input]; const cmd = [...input];
if (preset.codec === "av1") { if (preset.codec === "av1") {

View file

@ -1,43 +1,43 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
#lofi { #lofi {
padding: 32px; padding: 32px;
} }
h1 { h1 {
margin-top: 0; margin-top: 0;
font-size: 3em; font-size: 3em;
color: var(--primary); color: var(--primary);
font-family: monospace; font-family: monospace;
} }
ul, li { ul, li {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style-type: none; list-style-type: none;
} }
ul { ul {
padding-right: 4em; padding-right: 4em;
} }
li a { li a {
display: block; display: block;
color: white; color: white;
line-height: 2em; line-height: 2em;
padding: 0 1em; padding: 0 1em;
border-radius: 4px; border-radius: 4px;
} }
li a:hover { li a:hover {
background-color: rgba(255,255,255,0.2); background-color: rgba(255, 255, 255, 0.2);
font-weight: bold; font-weight: bold;
text-decoration: none!important; text-decoration: none !important;
} }
.dir a { .dir a {
color: #99eeFF color: #99eeff;
} }
.ext { .ext {
opacity: 0.5; opacity: 0.5;
} }
.meta { .meta {
margin-left: 1em; margin-left: 1em;
opacity: 0.75; opacity: 0.75;
} }

View file

@ -1,75 +1,75 @@
let friendPassword = ""; let friendPassword = "";
try { try {
friendPassword = require("./friends/hardcoded-password.ts").friendPassword; friendPassword = require("./friends/hardcoded-password.ts").friendPassword;
} catch {} } catch {}
export const app = new Hono(); export const app = new Hono();
const cookieAge = 60 * 60 * 24 * 30; // 1 month const cookieAge = 60 * 60 * 24 * 30; // 1 month
function checkFriendsCookie(c: Context) { function checkFriendsCookie(c: Context) {
const cookie = c.req.header("Cookie"); const cookie = c.req.header("Cookie");
if (!cookie) return false; if (!cookie) return false;
const cookies = cookie.split("; ").map((x) => x.split("=")); const cookies = cookie.split("; ").map((x) => x.split("="));
return cookies.some( return cookies.some(
(kv) => (kv) =>
kv[0].trim() === "friends_password" && kv[0].trim() === "friends_password" &&
kv[1].trim() && kv[1].trim() &&
kv[1].trim() === friendPassword, kv[1].trim() === friendPassword,
); );
} }
export function requireFriendAuth(c: Context) { export function requireFriendAuth(c: Context) {
const k = c.req.query("password") || c.req.query("k"); const k = c.req.query("password") || c.req.query("k");
if (k) { if (k) {
if (k === friendPassword) { if (k === friendPassword) {
return c.body(null, 303, { return c.body(null, 303, {
Location: "/friends", Location: "/friends",
"Set-Cookie": "Set-Cookie":
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
}); });
} else { } else {
return c.body(null, 303, { return c.body(null, 303, {
Location: "/friends", Location: "/friends",
}); });
} }
} }
if (checkFriendsCookie(c)) { if (checkFriendsCookie(c)) {
return undefined; return undefined;
} else { } else {
return serveAsset(c, "/friends/auth", 403); return serveAsset(c, "/friends/auth", 403);
} }
} }
app.get("/friends", (c) => { app.get("/friends", (c) => {
const friendAuthChallenge = requireFriendAuth(c); const friendAuthChallenge = requireFriendAuth(c);
if (friendAuthChallenge) return friendAuthChallenge; if (friendAuthChallenge) return friendAuthChallenge;
return serveAsset(c, "/friends", 200); return serveAsset(c, "/friends", 200);
}); });
let incorrectMap: Record<string, boolean> = {}; let incorrectMap: Record<string, boolean> = {};
app.post("/friends", async (c) => { app.post("/friends", async (c) => {
const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ?? const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ??
"unknown"; "unknown";
if (incorrectMap[ip]) { if (incorrectMap[ip]) {
return serveAsset(c, "/friends/auth/fail", 403); return serveAsset(c, "/friends/auth/fail", 403);
} }
const data = await c.req.formData(); const data = await c.req.formData();
const k = data.get("password"); const k = data.get("password");
if (k === friendPassword) { if (k === friendPassword) {
return c.body(null, 303, { return c.body(null, 303, {
Location: "/friends", Location: "/friends",
"Set-Cookie": "Set-Cookie":
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
}); });
} }
incorrectMap[ip] = true; incorrectMap[ip] = true;
await setTimeout(2500); await setTimeout(2500);
incorrectMap[ip] = false; incorrectMap[ip] = false;
return serveAsset(c, "/friends/auth/fail", 403); return serveAsset(c, "/friends/auth/fail", 403);
}); });
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { serveAsset } from "#sitegen/assets"; import { serveAsset } from "#sitegen/assets";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
import { getConnInfo } from "#hono/conninfo"; import { getConnInfo } from "#hono/conninfo";

View file

@ -118,4 +118,3 @@ code {
font-family: "rmo", monospace; font-family: "rmo", monospace;
font-size: inherit; font-size: inherit;
} }

View file

@ -1,47 +1,47 @@
body,html { body, html {
overflow: hidden; overflow: hidden;
} }
h1 { h1 {
color: #f09; color: #f09;
margin-bottom: 0; margin-bottom: 0;
} }
.job { .job {
padding: 18px; padding: 18px;
margin: 1em -18px; margin: 1em -18px;
border: 1px solid black; border: 1px solid black;
} }
.job *, footer * { .job *, footer * {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.job ul { .job ul {
margin-left: 1em; margin-left: 1em;
} }
.job li { .job li {
line-height: 1.5em; line-height: 1.5em;
} }
.job header, footer { .job header, footer {
display: grid; display: grid;
grid-template-columns: auto max-content; grid-template-columns: auto max-content;
grid-template-rows: 1fr 1fr; grid-template-rows: 1fr 1fr;
} }
footer { footer {
margin-top: 1.5em; margin-top: 1.5em;
} }
footer h2 { footer h2 {
font-size: 1em; font-size: 1em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
.job header > em, footer > em { .job header > em, footer > em {
margin-top: 2px; margin-top: 2px;
font-size: 1.25em; font-size: 1.25em;
} }
header h2, header em, footer h2, footer em { header h2, header em, footer h2, footer em {
display: inline-block; display: inline-block;
} }
header em, footer em { header em, footer em {
margin-left: 16px!important; margin-left: 16px !important;
text-align: right; text-align: right;
} }

View file

@ -1,97 +1,97 @@
// @ts-nocheck // @ts-nocheck
// manually obfuscated to make it very difficult to reverse engineer // manually obfuscated to make it very difficult to reverse engineer
// if you want to decode what the email is, visit the page! // if you want to decode what the email is, visit the page!
// stops people from automatically scraping the email address // stops people from automatically scraping the email address
// //
// Unfortunately this needs a rewrite to support Chrome without // Unfortunately this needs a rewrite to support Chrome without
// hardware acceleration and some Linux stuff. I will probably // hardware acceleration and some Linux stuff. I will probably
// go with a proof of work alternative. // go with a proof of work alternative.
requestAnimationFrame(() => { requestAnimationFrame(() => {
const hash = "SHA"; const hash = "SHA";
const a = [ const a = [
{ parentElement: document.getElementById("subscribe") }, { parentElement: document.getElementById("subscribe") },
function (b) { function (b) {
let c = 0, d = 0; let c = 0, d = 0;
for (let i = 0; i < b.length; i++) { for (let i = 0; i < b.length; i++) {
c = (c + b[i] ^ 0xF8) % 8; c = (c + b[i] ^ 0xF8) % 8;
d = (c * b[i] ^ 0x82) % 193; d = (c * b[i] ^ 0x82) % 193;
} }
a[c + 1]()[c](d, b.buffer); a[c + 1]()[c](d, b.buffer);
}, },
function () { function () {
const i = a[4](a[3]()); const i = a[4](a[3]());
const b = i.innerText = a.pop(); const b = i.innerText = a.pop();
if (a[b.indexOf("@") / 3]) { if (a[b.indexOf("@") / 3]) {
i.href = "mailto:" + b; i.href = "mailto:" + b;
} }
}, },
function () { function () {
return a[a.length % 10]; return a[a.length % 10];
}, },
function (x) { function (x) {
return x.parentElement; return x.parentElement;
}, },
function (b, c) { function (b, c) {
throw new Uint8Array( throw new Uint8Array(
c, c,
0, 0,
64, 64,
c.parentElement = this[8].call(b.call(this)).location, c.parentElement = this[8].call(b.call(this)).location,
); );
}, },
function (b, c) { function (b, c) {
this.width = 8; this.width = 8;
this.height = 16; this.height = 16;
b.clearColor(0.5, 0.7, 0.9, 1.0); b.clearColor(0.5, 0.7, 0.9, 1.0);
b.clear(16408 ^ this.width ^ this.height); b.clear(16408 ^ this.width ^ this.height);
const e = new Uint8Array(4 * this.width * this.height); const e = new Uint8Array(4 * this.width * this.height);
b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e); b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e);
let parent = a[this.width / 2](this); let parent = a[this.width / 2](this);
while (parent.tagName !== "BODY") { while (parent.tagName !== "BODY") {
parent = a[2 * this.height / this.width](parent); parent = a[2 * this.height / this.width](parent);
} }
try { try {
let d = [hash, e.length].join("-"); let d = [hash, e.length].join("-");
const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e); const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
[, d] = a; [, d] = a;
b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d); b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
} catch (e) { } catch (e) {
fetch(e).then(a[5]).catch(a[2]); fetch(e).then(a[5]).catch(a[2]);
} }
}, },
function (b, c) { function (b, c) {
const d = a.splice( const d = a.splice(
9, 9,
1, 1,
[ [
a[3]().parentElement.id, a[3]().parentElement.id,
c.parentElement.hostname, c.parentElement.hostname,
].join(String.fromCharCode(b)), ].join(String.fromCharCode(b)),
); );
var e = new Error(); var e = new Error();
Object.defineProperty(e, "stack", { Object.defineProperty(e, "stack", {
get() { get() {
a[9] = d; a[9] = d;
}, },
}); });
a[2].call(console.log(e)); a[2].call(console.log(e));
}, },
function () { function () {
return this; return this;
}, },
"[failed to verify your browser]", "[failed to verify your browser]",
function (a) { function (a) {
a = a.parentElement.ownerDocument.defaultView; a = a.parentElement.ownerDocument.defaultView;
return { parentElement: a.navigator.webdriver || a.crypto }; return { parentElement: a.navigator.webdriver || a.crypto };
}, },
]; ];
try { try {
const c = document.querySelector("canvas"); const c = document.querySelector("canvas");
const g = c.getContext("webgl2") || c.getContext("webgl"); const g = c.getContext("webgl2") || c.getContext("webgl");
a[0].parentElement.innerText = "[...loading...]"; a[0].parentElement.innerText = "[...loading...]";
g.field || requestAnimationFrame(a[6].bind(c, g, a[5])); g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
} catch { } catch {
a.pop(); a.pop();
fetch(":").then(a[5]).catch(a[2]); fetch(":").then(a[5]).catch(a[2]);
} }
}); });

View file

@ -1,89 +1,89 @@
// Artifacts used to be a central system in the old data-driven website. // Artifacts used to be a central system in the old data-driven website.
// Now, it simply refers to one of these link presets. Every project has // Now, it simply refers to one of these link presets. Every project has
// one canonical URL, which the questions page can refer to with `@id`. // one canonical URL, which the questions page can refer to with `@id`.
type Artifact = [title: string, url: string, type: ArtifactType]; type Artifact = [title: string, url: string, type: ArtifactType];
type ArtifactType = "music" | "game" | "project" | "video"; type ArtifactType = "music" | "game" | "project" | "video";
export const artifactMap: Record<string, Artifact> = { export const artifactMap: Record<string, Artifact> = {
// 2025 // 2025
"in-the-summer": ["in the summer", "", "music"], "in-the-summer": ["in the summer", "", "music"],
waterfalls: ["waterfalls", "/waterfalls", "music"], waterfalls: ["waterfalls", "/waterfalls", "music"],
lolzip: ["lol.zip", "", "project"], lolzip: ["lol.zip", "", "project"],
"g-is-missing": ["g is missing", "", "music"], "g-is-missing": ["g is missing", "", "music"],
"im-18-now": ["i'm 18 now", "", "music"], "im-18-now": ["i'm 18 now", "", "music"],
"programming-comparison": [ "programming-comparison": [
"thursday programming language comparison", "thursday programming language comparison",
"", "",
"video", "video",
], ],
aaaaaaaaa: ["aaaaaaaaa", "", "music"], aaaaaaaaa: ["aaaaaaaaa", "", "music"],
"its-snowing": ["it's snowing", "", "video"], "its-snowing": ["it's snowing", "", "video"],
// 2023 // 2023
"iphone-15-review": [ "iphone-15-review": [
"iphone 15 review", "iphone 15 review",
"/file/2023/iphone%2015%20review/iphone-15-review.mp4", "/file/2023/iphone%2015%20review/iphone-15-review.mp4",
"video", "video",
], ],
// 2022 // 2022
mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"], mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
"mystery-of-life": [ "mystery-of-life": [
"mystery of life", "mystery of life",
"/file/2022/mystery-of-life/mystery-of-life.mp4", "/file/2022/mystery-of-life/mystery-of-life.mp4",
"music", "music",
], ],
// 2021 // 2021
"top-10000-bread": [ "top-10000-bread": [
"top 10000 bread", "top 10000 bread",
"https://paperclover.net/file/2021/top-10000-bread/output.mp4", "https://paperclover.net/file/2021/top-10000-bread/output.mp4",
"video", "video",
], ],
"phoenix-write-soundtrack": [ "phoenix-write-soundtrack": [
"Phoenix, WRITE! soundtrack", "Phoenix, WRITE! soundtrack",
"/file/2021/phoenix-write/OST", "/file/2021/phoenix-write/OST",
"music", "music",
], ],
"phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"], "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
"money-visual-cover": [ "money-visual-cover": [
"money visual cover", "money visual cover",
"/file/2021/money-visual-cover/money-visual-cover.mp4", "/file/2021/money-visual-cover/money-visual-cover.mp4",
"video", "video",
], ],
"i-got-this-thing": [ "i-got-this-thing": [
"i got this thing", "i got this thing",
"/file/2021/i-got-this-thing/i-got-this-thing.mp4", "/file/2021/i-got-this-thing/i-got-this-thing.mp4",
"video", "video",
], ],
// 2020 // 2020
elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"], elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
"elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"], "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
// 2019 // 2019
"throw-soundtrack": [ "throw-soundtrack": [
"throw soundtrack", "throw soundtrack",
"/file/2019/throw/soundtrack", "/file/2019/throw/soundtrack",
"music", "music",
], ],
"elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"], "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
volar: [ volar: [
"volar visual cover", "volar visual cover",
"/file/2019/volar-visual-cover/volar.mp4", "/file/2019/volar-visual-cover/volar.mp4",
"video", "video",
], ],
wpm: [ wpm: [
"how to read 500 words per minute", "how to read 500 words per minute",
"/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4", "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
"video", "video",
], ],
"dice-roll": [ "dice-roll": [
"thursday dice roll", "thursday dice roll",
"/file/2019/thursday-dice-roll/thursday-dice-roll.mp4", "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
"video", "video",
], ],
"math-problem": [ "math-problem": [
"thursday math problem", "thursday math problem",
"/file/2019/thursday-math-problem/thursday-math-problem.mp4", "/file/2019/thursday-math-problem/thursday-math-problem.mp4",
"video", "video",
], ],
// 2018 // 2018
// 2017 // 2017
"hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"], "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
"test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"], "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
}; };

View file

@ -1,228 +1,228 @@
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY; const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
export const app = new Hono(); export const app = new Hono();
// Main page // Main page
app.get("/q+a", async (c) => { app.get("/q+a", async (c) => {
if (hasAdminToken(c)) { if (hasAdminToken(c)) {
return serveAsset(c, "/admin/q+a", 200); return serveAsset(c, "/admin/q+a", 200);
} }
return serveAsset(c, "/q+a", 200); return serveAsset(c, "/q+a", 200);
}); });
// Submit form // Submit form
app.post("/q+a", async (c) => { app.post("/q+a", async (c) => {
const form = await c.req.formData(); const form = await c.req.formData();
let text = form.get("text"); let text = form.get("text");
if (typeof text !== "string") { if (typeof text !== "string") {
return questionFailure(c, 400, "Bad Request"); return questionFailure(c, 400, "Bad Request");
} }
text = text.trim(); text = text.trim();
const input = { const input = {
date: new Date(), date: new Date(),
prompt: text, prompt: text,
sourceName: "unknown", sourceName: "unknown",
sourceLocation: "unknown", sourceLocation: "unknown",
sourceVPN: null, sourceVPN: null,
}; };
input.date.setMilliseconds(0); input.date.setMilliseconds(0);
if (text.length <= 0) { if (text.length <= 0) {
return questionFailure(c, 400, "Content is too short", text); return questionFailure(c, 400, "Content is too short", text);
} }
if (text.length > 16000) { if (text.length > 16000) {
return questionFailure(c, 400, "Content is too long", text); return questionFailure(c, 400, "Content is too long", text);
} }
// Ban patterns // Ban patterns
if ( if (
text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
) { ) {
// To prevent known automatic spam-bots from noticing something automatic is // To prevent known automatic spam-bots from noticing something automatic is
// happening, pretend that the question was successfully submitted. // happening, pretend that the question was successfully submitted.
return sendSuccess(c, new Date()); return sendSuccess(c, new Date());
} }
const ipAddr = c.req.header("cf-connecting-ip"); const ipAddr = c.req.header("cf-connecting-ip");
if (ipAddr) { if (ipAddr) {
input.sourceName = uniqueNamesGenerator({ input.sourceName = uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals], dictionaries: [adjectives, colors, animals],
separator: "-", separator: "-",
seed: ipAddr + PROXYCHECK_API_KEY, seed: ipAddr + PROXYCHECK_API_KEY,
}); });
} }
const cfIPCountry = c.req.header("cf-ipcountry"); const cfIPCountry = c.req.header("cf-ipcountry");
if (cfIPCountry) { if (cfIPCountry) {
input.sourceLocation = cfIPCountry; input.sourceLocation = cfIPCountry;
} }
if (ipAddr && PROXYCHECK_API_KEY) { if (ipAddr && PROXYCHECK_API_KEY) {
const proxyCheck = await fetch( const proxyCheck = await fetch(
`https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`, `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
{ {
method: "POST", method: "POST",
body: "ips=" + ipAddr, body: "ips=" + ipAddr,
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}, },
).then((res) => res.json()); ).then((res) => res.json());
if (ipAddr && proxyCheck[ipAddr]) { if (ipAddr && proxyCheck[ipAddr]) {
if (proxyCheck[ipAddr].proxy === "yes") { if (proxyCheck[ipAddr].proxy === "yes") {
input.sourceVPN = proxyCheck[ipAddr].operator?.name ?? input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
proxyCheck[ipAddr].organisation ?? proxyCheck[ipAddr].organisation ??
proxyCheck[ipAddr].provider ?? "unknown"; proxyCheck[ipAddr].provider ?? "unknown";
} }
if (Number(proxyCheck[ipAddr].risk) > 72) { if (Number(proxyCheck[ipAddr].risk) > 72) {
return questionFailure( return questionFailure(
c, c,
403, 403,
"This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.", "This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.",
text, text,
); );
} }
} }
} }
const date = Question.create( const date = Question.create(
QuestionType.pending, QuestionType.pending,
JSON.stringify(input), JSON.stringify(input),
input.date, input.date,
); );
await sendSuccess(c, date); await sendSuccess(c, date);
}); });
async function sendSuccess(c: Context, date: Date) { async function sendSuccess(c: Context, date: Date) {
if (c.req.header("Accept")?.includes("application/json")) { if (c.req.header("Accept")?.includes("application/json")) {
return c.json({ return c.json({
success: true, success: true,
message: "ok", message: "ok",
date: date.getTime(), date: date.getTime(),
id: formatQuestionId(date), id: formatQuestionId(date),
}, { status: 200 }); }, { status: 200 });
} }
c.res = await renderView(c, "q+a/success", { c.res = await renderView(c, "q+a/success", {
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`, permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
}); });
} }
// Question Permalink // Question Permalink
app.get("/q+a/:id", async (c, next) => { app.get("/q+a/:id", async (c, next) => {
// from deadname era, the seconds used to be in the url. // from deadname era, the seconds used to be in the url.
// this was removed so that the url can be crafted by hand. // this was removed so that the url can be crafted by hand.
let id = c.req.param("id"); let id = c.req.param("id");
if (id.length === 12 && /^\d+$/.test(id)) { if (id.length === 12 && /^\d+$/.test(id)) {
return c.redirect(`/q+a/${id.slice(0, 10)}`); return c.redirect(`/q+a/${id.slice(0, 10)}`);
} }
let image = false; let image = false;
if (id.endsWith(".png")) { if (id.endsWith(".png")) {
image = true; image = true;
id = id.slice(0, -4); id = id.slice(0, -4);
} }
const timestamp = questionIdToTimestamp(id); const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next(); if (!timestamp) return next();
const question = Question.getByDate(timestamp); const question = Question.getByDate(timestamp);
if (!question) return next(); if (!question) return next();
if (image) { if (image) {
return getQuestionImage(question, c.req.method === "HEAD"); return getQuestionImage(question, c.req.method === "HEAD");
} }
return renderView(c, "q+a/permalink", { question }); return renderView(c, "q+a/permalink", { question });
}); });
// Admin // Admin
app.get("/admin/q+a", async (c) => { app.get("/admin/q+a", async (c) => {
return serveAsset(c, "/admin/q+a", 200); return serveAsset(c, "/admin/q+a", 200);
}); });
app.get("/admin/q+a/inbox", async (c) => { app.get("/admin/q+a/inbox", async (c) => {
return renderView(c, "q+a/backend-inbox", {}); return renderView(c, "q+a/backend-inbox", {});
}); });
app.delete("/admin/q+a/:id", async (c, next) => { app.delete("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id"); const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id); const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next(); if (!timestamp) return next();
const question = Question.getByDate(timestamp); const question = Question.getByDate(timestamp);
if (!question) return next(); if (!question) return next();
const deleteFull = c.req.header("X-Delete-Full") === "true"; const deleteFull = c.req.header("X-Delete-Full") === "true";
if (deleteFull) { if (deleteFull) {
Question.deleteByQmid(question.qmid); Question.deleteByQmid(question.qmid);
} else { } else {
Question.rejectByQmid(question.qmid); Question.rejectByQmid(question.qmid);
} }
return c.json({ success: true, message: "ok" }); return c.json({ success: true, message: "ok" });
}); });
app.patch("/admin/q+a/:id", async (c, next) => { app.patch("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id"); const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id); const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next(); if (!timestamp) return next();
const question = Question.getByDate(timestamp); const question = Question.getByDate(timestamp);
if (!question) return next(); if (!question) return next();
const form = await c.req.raw.json(); const form = await c.req.raw.json();
if (typeof form.text !== "string" || typeof form.type !== "number") { if (typeof form.text !== "string" || typeof form.type !== "number") {
return questionFailure(c, 400, "Bad Request"); return questionFailure(c, 400, "Bad Request");
} }
Question.updateByQmid(question.qmid, form.text, form.type); Question.updateByQmid(question.qmid, form.text, form.type);
return c.json({ success: true, message: "ok" }); return c.json({ success: true, message: "ok" });
}); });
app.get("/admin/q+a/:id", async (c, next) => { app.get("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id"); const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id); const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next(); if (!timestamp) return next();
const question = Question.getByDate(timestamp); const question = Question.getByDate(timestamp);
if (!question) return next(); if (!question) return next();
let pendingInfo: null | PendingQuestionData = null; let pendingInfo: null | PendingQuestionData = null;
if (question.type === QuestionType.pending) { if (question.type === QuestionType.pending) {
pendingInfo = JSON.parse(question.text) as PendingQuestionData; pendingInfo = JSON.parse(question.text) as PendingQuestionData;
question.text = pendingInfo.prompt.trim().split("\n").map((line) => question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
line.trim().length === 0 ? "" : `q: ${line.trim()}` line.trim().length === 0 ? "" : `q: ${line.trim()}`
).join("\n") + "\n\n"; ).join("\n") + "\n\n";
question.type = QuestionType.normal; question.type = QuestionType.normal;
} }
return renderView(c, "q+a/editor", { return renderView(c, "q+a/editor", {
pendingInfo, pendingInfo,
question, question,
}); });
}); });
app.get("/q+a/things/random", async (c) => { app.get("/q+a/things/random", async (c) => {
c.res = await renderView(c, "q+a/things-random", {}); c.res = await renderView(c, "q+a/things-random", {});
}); });
async function questionFailure( async function questionFailure(
c: Context, c: Context,
status: ContentfulStatusCode, status: ContentfulStatusCode,
message: string, message: string,
content?: string, content?: string,
) { ) {
if (c.req.header("Accept")?.includes("application/json")) { if (c.req.header("Accept")?.includes("application/json")) {
return c.json({ success: false, message, id: null }, { status }); return c.json({ success: false, message, id: null }, { status });
} }
return await renderView(c, "q+a/fail", { return await renderView(c, "q+a/fail", {
error: message, error: message,
content, content,
}); });
} }
import { type Context, Hono } from "#hono"; import { type Context, Hono } from "#hono";
import type { ContentfulStatusCode } from "hono/utils/http-status"; import type { ContentfulStatusCode } from "hono/utils/http-status";
import { import {
adjectives, adjectives,
animals, animals,
colors, colors,
uniqueNamesGenerator, uniqueNamesGenerator,
} from "unique-names-generator"; } from "unique-names-generator";
import { hasAdminToken } from "../admin.ts"; import { hasAdminToken } from "../admin.ts";
import { serveAsset } from "#sitegen/assets"; import { serveAsset } from "#sitegen/assets";
import { import {
PendingQuestion, PendingQuestion,
PendingQuestionData, PendingQuestionData,
} from "./models/PendingQuestion.ts"; } from "./models/PendingQuestion.ts";
import { Question, QuestionType } from "./models/Question.ts"; import { Question, QuestionType } from "./models/Question.ts";
import { renderView } from "#sitegen/view"; import { renderView } from "#sitegen/view";
import { getQuestionImage } from "./image.tsx"; import { getQuestionImage } from "./image.tsx";
import { formatQuestionId, questionIdToTimestamp } from "./format.ts"; import { formatQuestionId, questionIdToTimestamp } from "./format.ts";

View file

@ -1,39 +1,39 @@
const dateFormat = new Intl.DateTimeFormat("sv", { const dateFormat = new Intl.DateTimeFormat("sv", {
timeZone: "EST", timeZone: "EST",
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
hour: "2-digit", hour: "2-digit",
day: "2-digit", day: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
// YYYY-MM-DD HH:MM // YYYY-MM-DD HH:MM
export function formatQuestionTimestamp(date: Date) { export function formatQuestionTimestamp(date: Date) {
return dateFormat.format(date); return dateFormat.format(date);
} }
// YYYY-MM-DDTHH:MM:00Z // YYYY-MM-DDTHH:MM:00Z
export function formatQuestionISOTimestamp(date: Date) { export function formatQuestionISOTimestamp(date: Date) {
const str = dateFormat.format(date); const str = dateFormat.format(date);
return `${str.slice(0, 10)}T${str.slice(11)}-05:00`; return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
} }
// YYMMDDHHMM // YYMMDDHHMM
export function formatQuestionId(date: Date) { export function formatQuestionId(date: Date) {
return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12); return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
} }
export function questionIdToTimestamp(id: string) { export function questionIdToTimestamp(id: string) {
if (id.length !== 10 || !/^\d+$/.test(id)) { if (id.length !== 10 || !/^\d+$/.test(id)) {
return null; return null;
} }
const date = new Date( const date = new Date(
`20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${ `20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
id.slice(6, 8) id.slice(6, 8)
}:${id.slice(8, 10)}:00 EST`, }:${id.slice(8, 10)}:00 EST`,
); );
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return null; return null;
} }
return date; return date;
} }

View file

@ -1,81 +1,81 @@
const width = 768; const width = 768;
const cacheImageDir = path.resolve(".clover/question_images"); const cacheImageDir = path.resolve(".clover/question_images");
// Cached browser session // Cached browser session
const getBrowser = RefCountedExpirable( const getBrowser = RefCountedExpirable(
() => () =>
puppeteer.launch({ puppeteer.launch({
args: ["--no-sandbox", "--disable-setuid-sandbox"], args: ["--no-sandbox", "--disable-setuid-sandbox"],
}), }),
(b) => b.close(), (b) => b.close(),
); );
export async function renderQuestionImage(question: Question) { export async function renderQuestionImage(question: Question) {
const html = await renderViewToString("q+a/image-embed", { question }); const html = await renderViewToString("q+a/image-embed", { question });
// this browser session will be reused if multiple images are generated // this browser session will be reused if multiple images are generated
// either at the same time or within a 5-minute time span. the dispose // either at the same time or within a 5-minute time span. the dispose
// symbol // symbol
using sharedBrowser = await getBrowser(); using sharedBrowser = await getBrowser();
const b = sharedBrowser.value; const b = sharedBrowser.value;
const p = await b.newPage(); const p = await b.newPage();
await p.setViewport({ width, height: 400 }); await p.setViewport({ width, height: 400 });
await p.setContent(html); await p.setContent(html);
try { try {
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 }); await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
} catch (e) {} } catch (e) {}
const height = await p.evaluate(() => { const height = await p.evaluate(() => {
const e = document.querySelector("main")!; const e = document.querySelector("main")!;
return e.getBoundingClientRect().height; return e.getBoundingClientRect().height;
}); });
const buf = await p.screenshot({ const buf = await p.screenshot({
path: "screenshot.png", path: "screenshot.png",
type: "png", type: "png",
captureBeyondViewport: true, captureBeyondViewport: true,
clip: { x: 0, width, y: 0, height: height, scale: 1.5 }, clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
}); });
await p.close(); await p.close();
return Buffer.from(buf); return Buffer.from(buf);
} }
export async function getQuestionImage( export async function getQuestionImage(
question: Question, question: Question,
headOnly: boolean, headOnly: boolean,
): Promise<Response> { ): Promise<Response> {
const hash = crypto.createHash("sha1") const hash = crypto.createHash("sha1")
.update(question.qmid + question.type + question.text) .update(question.qmid + question.type + question.text)
.digest("hex"); .digest("hex");
const headers = { const headers = {
"Content-Type": "image/png", "Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000", "Cache-Control": "public, max-age=31536000",
"ETag": `"${hash}"`, "ETag": `"${hash}"`,
"Last-Modified": question.date.toUTCString(), "Last-Modified": question.date.toUTCString(),
}; };
if (headOnly) { if (headOnly) {
return new Response(null, { headers }); return new Response(null, { headers });
} }
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`); const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
let buf: Buffer; let buf: Buffer;
try { try {
buf = await fs.readFile(cachedFilePath); buf = await fs.readFile(cachedFilePath);
} catch (e: any) { } catch (e: any) {
if (e.code !== "ENOENT") throw e; if (e.code !== "ENOENT") throw e;
buf = await renderQuestionImage(question); buf = await renderQuestionImage(question);
fs.writeMkdir(cachedFilePath, buf).catch(() => {}); fs.writeMkdir(cachedFilePath, buf).catch(() => {});
} }
return new Response(buf, { headers }); return new Response(buf, { headers });
} }
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import * as path from "node:path"; import * as path from "node:path";
import * as puppeteer from "puppeteer"; import * as puppeteer from "puppeteer";
import { Question } from "@/q+a/models/Question.ts"; import { Question } from "@/q+a/models/Question.ts";
import { RefCountedExpirable } from "#sitegen/async"; import { RefCountedExpirable } from "#sitegen/async";
import { renderViewToString } from "#sitegen/view"; import { renderViewToString } from "#sitegen/view";

View file

@ -1,116 +1,116 @@
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { basicSetup, EditorView } from "codemirror"; import { basicSetup, EditorView } from "codemirror";
import { ssrSync } from "#ssr"; import { ssrSync } from "#ssr";
import type { ScriptPayload } from "@/q+a/views/editor.marko"; import type { ScriptPayload } from "@/q+a/views/editor.marko";
import QuestionRender from "@/q+a/tags/question.marko"; import QuestionRender from "@/q+a/tags/question.marko";
declare const payload: ScriptPayload; declare const payload: ScriptPayload;
const date = new Date(payload.date); const date = new Date(payload.date);
const main = document.getElementById("edit-grid")! as HTMLDivElement; const main = document.getElementById("edit-grid")! as HTMLDivElement;
const preview = document.getElementById("preview")! as HTMLDivElement; const preview = document.getElementById("preview")! as HTMLDivElement;
function updatePreview(text: string) { function updatePreview(text: string) {
preview.innerHTML = ssrSync( preview.innerHTML = ssrSync(
<QuestionRender <QuestionRender
question={{ question={{
id: payload.id, id: payload.id,
qmid: payload.qmid, qmid: payload.qmid,
text: text, text: text,
type: payload.type, type: payload.type,
date, date,
}} }}
editor editor
/>, />,
).text; ).text;
} }
updatePreview(payload.text); updatePreview(payload.text);
const startState = EditorState.create({ const startState = EditorState.create({
doc: payload.text, doc: payload.text,
extensions: [ extensions: [
basicSetup, basicSetup,
EditorView.darkTheme.of(true), EditorView.darkTheme.of(true),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
updatePreview(update.state.doc.toString()); updatePreview(update.state.doc.toString());
} }
}), }),
EditorView.lineWrapping, EditorView.lineWrapping,
], ],
// selection: EditorSelection.create([ // selection: EditorSelection.create([
// EditorSelection.cursor(0), // EditorSelection.cursor(0),
// ], 0), // ], 0),
}); });
const view = new EditorView({ const view = new EditorView({
state: startState, state: startState,
parent: document.getElementById("editor")!, parent: document.getElementById("editor")!,
}); });
view.focus(); view.focus();
(globalThis as any).onCommitQuestion = wrapAction(async () => { (globalThis as any).onCommitQuestion = wrapAction(async () => {
const text = view.state.doc.toString(); const text = view.state.doc.toString();
const res = await fetch(`/admin/q+a/${payload.id}`, { const res = await fetch(`/admin/q+a/${payload.id}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
text, text,
type: payload.type, type: payload.type,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Failed to update question, status: " + res.status); throw new Error("Failed to update question, status: " + res.status);
} }
if (location.search.includes("return=inbox")) { if (location.search.includes("return=inbox")) {
location.href = "/admin/q+a/inbox"; location.href = "/admin/q+a/inbox";
} else { } else {
location.href = "/q+a#q" + payload.id; location.href = "/q+a#q" + payload.id;
} }
}); });
(globalThis as any).onDelete = wrapAction(async () => { (globalThis as any).onDelete = wrapAction(async () => {
if (confirm("Are you sure you want to delete this question?")) { if (confirm("Are you sure you want to delete this question?")) {
const res = await fetch(`/admin/q+a/${payload.id}`, { const res = await fetch(`/admin/q+a/${payload.id}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Failed to delete question, status: " + res.status); throw new Error("Failed to delete question, status: " + res.status);
} }
location.href = document.referrer || "/admin/q+a"; location.href = document.referrer || "/admin/q+a";
} }
}); });
(globalThis as any).onTypeChange = () => { (globalThis as any).onTypeChange = () => {
payload.type = parseInt( payload.type = parseInt(
(document.getElementById("type") as HTMLSelectElement).value, (document.getElementById("type") as HTMLSelectElement).value,
); );
updatePreview(view.state.doc.toString()); updatePreview(view.state.doc.toString());
}; };
function wrapAction(cb: () => Promise<void>) { function wrapAction(cb: () => Promise<void>) {
return async () => { return async () => {
main.style.opacity = "0.5"; main.style.opacity = "0.5";
main.style.pointerEvents = "none"; main.style.pointerEvents = "none";
const inputs = main.querySelectorAll("button,select,input") as NodeListOf< const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
HTMLButtonElement HTMLButtonElement
>; >;
inputs.forEach((b) => { inputs.forEach((b) => {
b.disabled = true; b.disabled = true;
}); });
try { try {
await cb(); await cb();
} catch (e: any) { } catch (e: any) {
main.style.opacity = "1"; main.style.opacity = "1";
main.style.pointerEvents = "auto"; main.style.pointerEvents = "auto";
inputs.forEach((b) => { inputs.forEach((b) => {
b.disabled = false; b.disabled = false;
}); });
alert(e.message); alert(e.message);
} }
}; };
} }

File diff suppressed because it is too large Load diff

View file

@ -1,74 +1,74 @@
// @ts-ignore // @ts-ignore
globalThis.onReply = (id: string) => { globalThis.onReply = (id: string) => {
location.href = `/admin/q+a/${id}?return=inbox`; location.href = `/admin/q+a/${id}?return=inbox`;
}; };
// @ts-ignore // @ts-ignore
globalThis.onDelete = async (id: string) => { globalThis.onDelete = async (id: string) => {
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement; const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
if (!div) return alert("Question not found"); if (!div) return alert("Question not found");
// Pending State // Pending State
div.style.opacity = "0.5"; div.style.opacity = "0.5";
div.style.pointerEvents = "none"; div.style.pointerEvents = "none";
div?.querySelectorAll("button").forEach((b) => { div?.querySelectorAll("button").forEach((b) => {
b.disabled = true; b.disabled = true;
}); });
try { try {
const resp = await fetch(`/admin/q+a/${id}`, { const resp = await fetch(`/admin/q+a/${id}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
}); });
if (resp.status !== 200) { if (resp.status !== 200) {
throw new Error("Failed to delete question, status: " + resp.status); throw new Error("Failed to delete question, status: " + resp.status);
} }
} catch (e: any) { } catch (e: any) {
div.style.opacity = "1"; div.style.opacity = "1";
div.style.pointerEvents = "auto"; div.style.pointerEvents = "auto";
div?.querySelectorAll("button").forEach((b) => { div?.querySelectorAll("button").forEach((b) => {
b.disabled = false; b.disabled = false;
}); });
return alert(e.message); return alert(e.message);
} }
div.remove(); div.remove();
}; };
// @ts-ignore // @ts-ignore
globalThis.onDeleteFull = async (id: string) => { globalThis.onDeleteFull = async (id: string) => {
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement; const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
if (!div) return alert("Question not found"); if (!div) return alert("Question not found");
// Confirmation // Confirmation
if (!confirm("Are you sure you want to delete this question?")) return; if (!confirm("Are you sure you want to delete this question?")) return;
// Pending State // Pending State
div.style.opacity = "0.5"; div.style.opacity = "0.5";
div.style.pointerEvents = "none"; div.style.pointerEvents = "none";
div?.querySelectorAll("button").forEach((b) => { div?.querySelectorAll("button").forEach((b) => {
b.disabled = true; b.disabled = true;
}); });
try { try {
const resp = await fetch(`/admin/q+a/${id}`, { const resp = await fetch(`/admin/q+a/${id}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"X-Delete-Full": "true", "X-Delete-Full": "true",
}, },
}); });
if (resp.status !== 200) { if (resp.status !== 200) {
throw new Error("Failed to delete question, status: " + resp.status); throw new Error("Failed to delete question, status: " + resp.status);
} }
} catch (e: any) { } catch (e: any) {
div.style.opacity = "1"; div.style.opacity = "1";
div.style.pointerEvents = "auto"; div.style.pointerEvents = "auto";
div?.querySelectorAll("button").forEach((b) => { div?.querySelectorAll("button").forEach((b) => {
b.disabled = false; b.disabled = false;
}); });
return alert(e.message); return alert(e.message);
} }
div.remove(); div.remove();
}; };

View file

@ -1,39 +1,39 @@
#edit-grid { #edit-grid {
display: grid; display: grid;
grid-template-columns: 1fr 80ch; grid-template-columns: 1fr 80ch;
grid-template-rows: 3rem 1fr; grid-template-rows: 3rem 1fr;
grid-gap: 1em; grid-gap: 1em;
height: 100vh; height: 100vh;
button { button {
margin-right: 1rem; margin-right: 1rem;
} }
main { main {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
} }
#topleft, #topright { #topleft, #topright {
padding: 1rem; padding: 1rem;
} }
#topleft { #topleft {
padding-right: 0rem; padding-right: 0rem;
} }
#topright { #topright {
padding-left: 0rem; padding-left: 0rem;
} }
#preview { #preview {
overflow-y: auto; overflow-y: auto;
e- { e- {
margin-top: 0; margin-top: 0;
} }
} }
#editor { #editor {
background-color: #303030; background-color: #303030;
overflow-y: scroll; overflow-y: scroll;
height: 100%; height: 100%;
} }
.cm-scroller { .cm-scroller {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
} }

View file

@ -3,7 +3,9 @@
<title>paper clover</title> <title>paper clover</title>
</head> </head>
<body bgcolor="black" style="word-wrap: initial"> <body bgcolor="black" style="word-wrap: initial">
<main style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh"> <main
style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh"
>
<div> <div>
<p style="margin: 0.5rem 0"> <p style="margin: 0.5rem 0">
<a <a
@ -56,7 +58,9 @@
<font color="#FF8147">feed</font> <font color="#FF8147">feed</font>
</a> </a>
</p> </p>
<h1 style="margin: -1.5rem 0 3rem 0; font-size: 7rem; font-weight: 400; font-family: times"> <h1
style="margin: -1.5rem 0 3rem 0; font-size: 7rem; font-weight: 400; font-family: times"
>
<font color="#B8E1FF">paper</font> <font color="#B8E1FF">paper</font>
<font color="#E8F4FF">clover</font> <font color="#E8F4FF">clover</font>
</h1> </h1>

View file

@ -1,58 +1,58 @@
import "./video.css"; import "./video.css";
import * as path from "node:path"; import * as path from "node:path";
import { addScript } from "#sitegen"; import { addScript } from "#sitegen";
import { PrecomputedBlurhash } from "./blurhash.tsx"; import { PrecomputedBlurhash } from "./blurhash.tsx";
export namespace Video { export namespace Video {
export interface Props { export interface Props {
title: string; title: string;
width: number; width: number;
height: number; height: number;
sources: string[]; sources: string[];
downloads: string[]; downloads: string[];
poster?: string; poster?: string;
posterHash?: string; posterHash?: string;
borderless?: boolean; borderless?: boolean;
} }
} }
export function Video( export function Video(
{ title, sources, height, poster, posterHash, width, borderless }: { title, sources, height, poster, posterHash, width, borderless }:
Video.Props, Video.Props,
) { ) {
addScript("./hls-polyfill.client.ts"); addScript("./hls-polyfill.client.ts");
return ( return (
<figure class={`video ${borderless ? "borderless" : ""}`}> <figure class={`video ${borderless ? "borderless" : ""}`}>
<figcaption>{title}</figcaption> <figcaption>{title}</figcaption>
{posterHash && <PrecomputedBlurhash hash={posterHash} />} {posterHash && <PrecomputedBlurhash hash={posterHash} />}
{poster && <img src={poster} alt="waterfalls" />} {poster && <img src={poster} alt="waterfalls" />}
<video <video
controls controls
preload="none" preload="none"
style={`width:100%;background:transparent;aspect-ratio:${ style={`width:100%;background:transparent;aspect-ratio:${
simplifyFraction(width, height) simplifyFraction(width, height)
}`} }`}
poster="data:null" poster="data:null"
> >
{sources.map((src) => ( {sources.map((src) => (
<source <source
src={src} src={src}
type={contentTypeFromExt(src)} type={contentTypeFromExt(src)}
/> />
))} ))}
</video> </video>
</figure> </figure>
); );
} }
export function contentTypeFromExt(src: string) { export function contentTypeFromExt(src: string) {
if (src.endsWith(".m3u8")) return "application/x-mpegURL"; if (src.endsWith(".m3u8")) return "application/x-mpegURL";
if (src.endsWith(".webm")) return "video/webm"; if (src.endsWith(".webm")) return "video/webm";
if (src.endsWith(".mp4")) return "video/mp4"; if (src.endsWith(".mp4")) return "video/mp4";
if (src.endsWith(".ogg")) return "video/ogg"; if (src.endsWith(".ogg")) return "video/ogg";
throw new Error("Unknown video extension: " + path.extname(src)); throw new Error("Unknown video extension: " + path.extname(src));
} }
const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a; const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a;
function simplifyFraction(n: number, d: number) { function simplifyFraction(n: number, d: number) {
const divisor = gcd(n, d); const divisor = gcd(n, d);
return `${n / divisor}/${d / divisor}`; return `${n / divisor}/${d / divisor}`;
} }

View file

@ -1,25 +1,24 @@
.video { .video {
border: 4px solid var(--fg); border: 4px solid var(--fg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
} }
.video > img, .video > img,
.video > span { .video > span {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: -1; z-index: -1;
} }
.video figcaption { .video figcaption {
background-color: var(--fg); background-color: var(--fg);
color: var(--bg); color: var(--bg);
width: 100%; width: 100%;
margin-top: -1px; margin-top: -1px;
padding-bottom: 2px; padding-bottom: 2px;
} }