format
This commit is contained in:
parent
f1b1c650ce
commit
ea5f2bc325
48 changed files with 5217 additions and 5177 deletions
|
@ -16,7 +16,10 @@
|
|||
pkgs.nodejs_24 # runtime
|
||||
pkgs.deno # formatter
|
||||
(pkgs.ffmpeg.override {
|
||||
withOpus = true;
|
||||
withSvtav1 = true;
|
||||
withJxl = true;
|
||||
withWebp = true;
|
||||
})
|
||||
];
|
||||
};
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
import "@paperclover/console/inject";
|
||||
import "#debug";
|
||||
|
||||
const protocol = "http";
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
}, ({ address, port }) => {
|
||||
if (address === "::") address = "::1";
|
||||
console.info(url.format({
|
||||
protocol,
|
||||
hostname: address,
|
||||
port,
|
||||
}));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
import app from "#backend";
|
||||
import url from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import process from "node:process";
|
||||
import "@paperclover/console/inject";
|
||||
import "#debug";
|
||||
|
||||
const protocol = "http";
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
}, ({ address, port }) => {
|
||||
if (address === "::") address = "::1";
|
||||
console.info(url.format({
|
||||
protocol,
|
||||
hostname: address,
|
||||
port,
|
||||
}));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
import app from "#backend";
|
||||
import url from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import process from "node:process";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import "@paperclover/console/inject";
|
||||
export default app;
|
||||
|
||||
import app from "#backend";
|
||||
import "@paperclover/console/inject";
|
||||
export default app;
|
||||
|
||||
import app from "#backend";
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
globalThis.UNWRAP = (t, ...args) => {
|
||||
if (t == null) {
|
||||
throw new Error(
|
||||
args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
|
||||
);
|
||||
}
|
||||
return t;
|
||||
};
|
||||
globalThis.ASSERT = (t, ...args) => {
|
||||
if (!t) {
|
||||
throw new Error(
|
||||
args.length > 0 ? util.format(...args) : "Assertion Failed",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
import * as util from "node:util";
|
||||
globalThis.UNWRAP = (t, ...args) => {
|
||||
if (t == null) {
|
||||
throw new Error(
|
||||
args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
|
||||
);
|
||||
}
|
||||
return t;
|
||||
};
|
||||
globalThis.ASSERT = (t, ...args) => {
|
||||
if (!t) {
|
||||
throw new Error(
|
||||
args.length > 0 ? util.format(...args) : "Assertion Failed",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
import * as util from "node:util";
|
||||
|
|
8
framework/definitions.d.ts
vendored
8
framework/definitions.d.ts
vendored
|
@ -1,4 +1,4 @@
|
|||
declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
|
||||
declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
|
||||
|
||||
type Timer = ReturnType<typeof setTimeout>;
|
||||
declare function UNWRAP<T>(value: T | null | undefined, ...log: unknown[]): T;
|
||||
declare function ASSERT(value: unknown, ...log: unknown[]): asserts value;
|
||||
|
||||
type Timer = ReturnType<typeof setTimeout>;
|
||||
|
|
|
@ -1,54 +1,54 @@
|
|||
export const Fragment = ({ children }: { children: engine.Node[] }) => children;
|
||||
|
||||
export function jsx(
|
||||
type: string | engine.Component,
|
||||
props: Record<string, unknown>,
|
||||
): engine.Element {
|
||||
if (typeof type !== "function" && typeof type !== "string") {
|
||||
throw new Error("Invalid component type: " + engine.inspect(type));
|
||||
}
|
||||
return [engine.kElement, type, props];
|
||||
}
|
||||
|
||||
export function jsxDEV(
|
||||
type: string | engine.Component,
|
||||
props: Record<string, unknown>,
|
||||
// Unused with the clover engine
|
||||
_key: string,
|
||||
// Unused with the clover engine
|
||||
_isStaticChildren: boolean,
|
||||
source: engine.SrcLoc,
|
||||
): engine.Element {
|
||||
const { fileName, lineNumber, columnNumber } = source;
|
||||
|
||||
// Assert the component type is valid to render.
|
||||
if (typeof type !== "function" && typeof type !== "string") {
|
||||
throw new Error(
|
||||
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
|
||||
engine.inspect(type) +
|
||||
". Clover SSR element must be a function or string",
|
||||
);
|
||||
}
|
||||
|
||||
// Construct an `ssr.Element`
|
||||
return [engine.kElement, type, props, "", source];
|
||||
}
|
||||
|
||||
// jsxs
|
||||
export { jsx as jsxs };
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
[name: string]: Record<string, unknown>;
|
||||
}
|
||||
interface ElementChildrenAttribute {
|
||||
children: Node;
|
||||
}
|
||||
type Element = engine.Element;
|
||||
type ElementType = keyof IntrinsicElements | engine.Component;
|
||||
type ElementClass = ReturnType<engine.Component>;
|
||||
}
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
export const Fragment = ({ children }: { children: engine.Node[] }) => children;
|
||||
|
||||
export function jsx(
|
||||
type: string | engine.Component,
|
||||
props: Record<string, unknown>,
|
||||
): engine.Element {
|
||||
if (typeof type !== "function" && typeof type !== "string") {
|
||||
throw new Error("Invalid component type: " + engine.inspect(type));
|
||||
}
|
||||
return [engine.kElement, type, props];
|
||||
}
|
||||
|
||||
export function jsxDEV(
|
||||
type: string | engine.Component,
|
||||
props: Record<string, unknown>,
|
||||
// Unused with the clover engine
|
||||
_key: string,
|
||||
// Unused with the clover engine
|
||||
_isStaticChildren: boolean,
|
||||
source: engine.SrcLoc,
|
||||
): engine.Element {
|
||||
const { fileName, lineNumber, columnNumber } = source;
|
||||
|
||||
// Assert the component type is valid to render.
|
||||
if (typeof type !== "function" && typeof type !== "string") {
|
||||
throw new Error(
|
||||
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
|
||||
engine.inspect(type) +
|
||||
". Clover SSR element must be a function or string",
|
||||
);
|
||||
}
|
||||
|
||||
// Construct an `ssr.Element`
|
||||
return [engine.kElement, type, props, "", source];
|
||||
}
|
||||
|
||||
// jsxs
|
||||
export { jsx as jsxs };
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
[name: string]: Record<string, unknown>;
|
||||
}
|
||||
interface ElementChildrenAttribute {
|
||||
children: Node;
|
||||
}
|
||||
type Element = engine.Element;
|
||||
type ElementType = keyof IntrinsicElements | engine.Component;
|
||||
type ElementClass = ReturnType<engine.Component>;
|
||||
}
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
|
|
|
@ -1,147 +1,147 @@
|
|||
// This file is used to integrate Marko into the Clover Engine and Sitegen
|
||||
// To use, replace the "marko/html" import with this file.
|
||||
export * from "#marko/html";
|
||||
|
||||
interface BodyContentObject {
|
||||
[x: PropertyKey]: unknown;
|
||||
content: ServerRenderer;
|
||||
}
|
||||
|
||||
export const createTemplate = (
|
||||
templateId: string,
|
||||
renderer: ServerRenderer,
|
||||
) => {
|
||||
const { render } = marko.createTemplate(templateId, renderer);
|
||||
function wrap(props: Record<string, unknown>, n: number) {
|
||||
// Marko Custom Tags
|
||||
const cloverAsyncMarker = { isAsync: false };
|
||||
let r: engine.Render | undefined = undefined;
|
||||
try {
|
||||
r = engine.getCurrentRender();
|
||||
} catch {}
|
||||
// Support using Marko outside of Clover SSR
|
||||
if (r) {
|
||||
engine.setCurrentRender(null);
|
||||
const markoResult = render.call(renderer, {
|
||||
...props,
|
||||
$global: { clover: r, cloverAsyncMarker },
|
||||
});
|
||||
if (cloverAsyncMarker.isAsync) {
|
||||
return markoResult.then(engine.html);
|
||||
}
|
||||
const rr = markoResult.toString();
|
||||
return engine.html(rr);
|
||||
} else {
|
||||
return renderer(props, n);
|
||||
}
|
||||
}
|
||||
wrap.render = render;
|
||||
wrap.unwrapped = renderer;
|
||||
return wrap;
|
||||
};
|
||||
|
||||
export const dynamicTag = (
|
||||
scopeId: number,
|
||||
accessor: Accessor,
|
||||
tag: unknown | string | ServerRenderer | BodyContentObject,
|
||||
inputOrArgs: unknown,
|
||||
content?: (() => void) | 0,
|
||||
inputIsArgs?: 1,
|
||||
serializeReason?: 1 | 0,
|
||||
) => {
|
||||
if (typeof tag === "function") {
|
||||
clover: {
|
||||
const unwrapped = (tag as any).unwrapped;
|
||||
if (unwrapped) {
|
||||
tag = unwrapped;
|
||||
break clover;
|
||||
}
|
||||
let r: engine.Render;
|
||||
try {
|
||||
r = engine.getCurrentRender();
|
||||
if (!r) throw 0;
|
||||
} catch {
|
||||
r = marko.$global().clover as engine.Render;
|
||||
}
|
||||
if (!r) throw new Error("No Clover Render Active");
|
||||
const subRender = engine.initRender(r.async !== -1, r.addon);
|
||||
const resolved = engine.resolveNode(subRender, [
|
||||
engine.kElement,
|
||||
tag,
|
||||
inputOrArgs,
|
||||
]);
|
||||
|
||||
if (subRender.async > 0) {
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
|
||||
// Wait for async work to finish
|
||||
const { resolve, reject, promise } = Promise.withResolvers<string>();
|
||||
subRender.asyncDone = () => {
|
||||
const rejections = subRender.rejections;
|
||||
if (!rejections) return resolve(engine.renderNode(resolved));
|
||||
(r.rejections ??= []).push(...rejections);
|
||||
return reject(new Error("Render had errors"));
|
||||
};
|
||||
marko.fork(
|
||||
scopeId,
|
||||
accessor,
|
||||
promise,
|
||||
(string: string) => marko.write(string),
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
marko.write(engine.renderNode(resolved));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return marko.dynamicTag(
|
||||
scopeId,
|
||||
accessor,
|
||||
tag,
|
||||
inputOrArgs,
|
||||
content,
|
||||
inputIsArgs,
|
||||
serializeReason,
|
||||
);
|
||||
};
|
||||
|
||||
export function fork(
|
||||
scopeId: number,
|
||||
accessor: Accessor,
|
||||
promise: Promise<unknown>,
|
||||
callback: (data: unknown) => void,
|
||||
serializeMarker?: 0 | 1,
|
||||
) {
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
marko.fork(scopeId, accessor, promise, callback, serializeMarker);
|
||||
}
|
||||
|
||||
export function escapeXML(input: unknown) {
|
||||
// The rationale of this check is that the default toString method
|
||||
// creating `[object Object]` is universally useless to any end user.
|
||||
if (
|
||||
input == null ||
|
||||
(typeof input === "object" && input &&
|
||||
// only block this if it's the default `toString`
|
||||
input.toString === Object.prototype.toString)
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected value in template placeholder: '` +
|
||||
engine.inspect(input) + "'. " +
|
||||
`To emit a literal '${input}', use \${String(value)}`,
|
||||
);
|
||||
}
|
||||
return marko.escapeXML(input);
|
||||
}
|
||||
|
||||
interface Async {
|
||||
isAsync: boolean;
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
import type { ServerRenderer } from "marko/html/template";
|
||||
import { type Accessor } from "marko/common/types";
|
||||
import * as marko from "#marko/html";
|
||||
// This file is used to integrate Marko into the Clover Engine and Sitegen
|
||||
// To use, replace the "marko/html" import with this file.
|
||||
export * from "#marko/html";
|
||||
|
||||
interface BodyContentObject {
|
||||
[x: PropertyKey]: unknown;
|
||||
content: ServerRenderer;
|
||||
}
|
||||
|
||||
export const createTemplate = (
|
||||
templateId: string,
|
||||
renderer: ServerRenderer,
|
||||
) => {
|
||||
const { render } = marko.createTemplate(templateId, renderer);
|
||||
function wrap(props: Record<string, unknown>, n: number) {
|
||||
// Marko Custom Tags
|
||||
const cloverAsyncMarker = { isAsync: false };
|
||||
let r: engine.Render | undefined = undefined;
|
||||
try {
|
||||
r = engine.getCurrentRender();
|
||||
} catch {}
|
||||
// Support using Marko outside of Clover SSR
|
||||
if (r) {
|
||||
engine.setCurrentRender(null);
|
||||
const markoResult = render.call(renderer, {
|
||||
...props,
|
||||
$global: { clover: r, cloverAsyncMarker },
|
||||
});
|
||||
if (cloverAsyncMarker.isAsync) {
|
||||
return markoResult.then(engine.html);
|
||||
}
|
||||
const rr = markoResult.toString();
|
||||
return engine.html(rr);
|
||||
} else {
|
||||
return renderer(props, n);
|
||||
}
|
||||
}
|
||||
wrap.render = render;
|
||||
wrap.unwrapped = renderer;
|
||||
return wrap;
|
||||
};
|
||||
|
||||
export const dynamicTag = (
|
||||
scopeId: number,
|
||||
accessor: Accessor,
|
||||
tag: unknown | string | ServerRenderer | BodyContentObject,
|
||||
inputOrArgs: unknown,
|
||||
content?: (() => void) | 0,
|
||||
inputIsArgs?: 1,
|
||||
serializeReason?: 1 | 0,
|
||||
) => {
|
||||
if (typeof tag === "function") {
|
||||
clover: {
|
||||
const unwrapped = (tag as any).unwrapped;
|
||||
if (unwrapped) {
|
||||
tag = unwrapped;
|
||||
break clover;
|
||||
}
|
||||
let r: engine.Render;
|
||||
try {
|
||||
r = engine.getCurrentRender();
|
||||
if (!r) throw 0;
|
||||
} catch {
|
||||
r = marko.$global().clover as engine.Render;
|
||||
}
|
||||
if (!r) throw new Error("No Clover Render Active");
|
||||
const subRender = engine.initRender(r.async !== -1, r.addon);
|
||||
const resolved = engine.resolveNode(subRender, [
|
||||
engine.kElement,
|
||||
tag,
|
||||
inputOrArgs,
|
||||
]);
|
||||
|
||||
if (subRender.async > 0) {
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
|
||||
// Wait for async work to finish
|
||||
const { resolve, reject, promise } = Promise.withResolvers<string>();
|
||||
subRender.asyncDone = () => {
|
||||
const rejections = subRender.rejections;
|
||||
if (!rejections) return resolve(engine.renderNode(resolved));
|
||||
(r.rejections ??= []).push(...rejections);
|
||||
return reject(new Error("Render had errors"));
|
||||
};
|
||||
marko.fork(
|
||||
scopeId,
|
||||
accessor,
|
||||
promise,
|
||||
(string: string) => marko.write(string),
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
marko.write(engine.renderNode(resolved));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return marko.dynamicTag(
|
||||
scopeId,
|
||||
accessor,
|
||||
tag,
|
||||
inputOrArgs,
|
||||
content,
|
||||
inputIsArgs,
|
||||
serializeReason,
|
||||
);
|
||||
};
|
||||
|
||||
export function fork(
|
||||
scopeId: number,
|
||||
accessor: Accessor,
|
||||
promise: Promise<unknown>,
|
||||
callback: (data: unknown) => void,
|
||||
serializeMarker?: 0 | 1,
|
||||
) {
|
||||
const marker = marko.$global().cloverAsyncMarker as Async;
|
||||
marker.isAsync = true;
|
||||
marko.fork(scopeId, accessor, promise, callback, serializeMarker);
|
||||
}
|
||||
|
||||
export function escapeXML(input: unknown) {
|
||||
// The rationale of this check is that the default toString method
|
||||
// creating `[object Object]` is universally useless to any end user.
|
||||
if (
|
||||
input == null ||
|
||||
(typeof input === "object" && input &&
|
||||
// only block this if it's the default `toString`
|
||||
input.toString === Object.prototype.toString)
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected value in template placeholder: '` +
|
||||
engine.inspect(input) + "'. " +
|
||||
`To emit a literal '${input}', use \${String(value)}`,
|
||||
);
|
||||
}
|
||||
return marko.escapeXML(input);
|
||||
}
|
||||
|
||||
interface Async {
|
||||
isAsync: boolean;
|
||||
}
|
||||
|
||||
import * as engine from "./ssr.ts";
|
||||
import type { ServerRenderer } from "marko/html/template";
|
||||
import { type Accessor } from "marko/common/types";
|
||||
import * as marko from "#marko/html";
|
||||
|
|
|
@ -1,41 +1,41 @@
|
|||
import { test } from "node:test";
|
||||
import * as engine from "./ssr.ts";
|
||||
|
||||
test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm <3"));
|
||||
test("simple tree", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(
|
||||
<main class={["a", "b"]}>
|
||||
<h1 style="background-color:red">hello world</h1>
|
||||
<p>haha</p>
|
||||
{1}|
|
||||
{0}|
|
||||
{true}|
|
||||
{false}|
|
||||
{null}|
|
||||
{undefined}|
|
||||
</main>,
|
||||
).text,
|
||||
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
|
||||
));
|
||||
test("unescaped/escaped html", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
|
||||
"<div><fuck>"&'`<></div>",
|
||||
));
|
||||
test("clsx built-in", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(
|
||||
<>
|
||||
<a class="a" />
|
||||
<b class={null} />
|
||||
<c class={undefined} />
|
||||
<d class={["a", "b", null]} />
|
||||
<e class={{ a: true, b: false }} />
|
||||
<e
|
||||
class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
|
||||
/>
|
||||
</>,
|
||||
).text,
|
||||
'<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
|
||||
));
|
||||
import { test } from "node:test";
|
||||
import * as engine from "./ssr.ts";
|
||||
|
||||
test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm <3"));
|
||||
test("simple tree", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(
|
||||
<main class={["a", "b"]}>
|
||||
<h1 style="background-color:red">hello world</h1>
|
||||
<p>haha</p>
|
||||
{1}|
|
||||
{0}|
|
||||
{true}|
|
||||
{false}|
|
||||
{null}|
|
||||
{undefined}|
|
||||
</main>,
|
||||
).text,
|
||||
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
|
||||
));
|
||||
test("unescaped/escaped html", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
|
||||
"<div><fuck>"&'`<></div>",
|
||||
));
|
||||
test("clsx built-in", (t) =>
|
||||
t.assert.equal(
|
||||
engine.ssrSync(
|
||||
<>
|
||||
<a class="a" />
|
||||
<b class={null} />
|
||||
<c class={undefined} />
|
||||
<d class={["a", "b", null]} />
|
||||
<e class={{ a: true, b: false }} />
|
||||
<e
|
||||
class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
|
||||
/>
|
||||
</>,
|
||||
).text,
|
||||
'<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
|
||||
));
|
||||
|
|
|
@ -1,311 +1,311 @@
|
|||
// Clover's Rendering Engine is the backbone of her website generator. It
|
||||
// converts objects and components (functions returning 'Node') into HTML. The
|
||||
// engine is simple and self-contained, with integrations for JSX and Marko
|
||||
// (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
|
||||
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
|
||||
// to track needed client scripts without introducing patches to the engine.
|
||||
export type Addons = Record<string | symbol, unknown>;
|
||||
|
||||
export function ssrSync(node: Node): Result;
|
||||
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
|
||||
export function ssrSync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(false, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
return { text: renderNode(resolved), addon };
|
||||
}
|
||||
|
||||
export function ssrAsync(node: Node): Promise<Result>;
|
||||
export function ssrAsync<A extends Addons>(
|
||||
node: Node,
|
||||
addon: A,
|
||||
): Promise<Result<A>>;
|
||||
export function ssrAsync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(true, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
if (r.async === 0) {
|
||||
return Promise.resolve({ text: renderNode(resolved), addon });
|
||||
}
|
||||
const { resolve, reject, promise } = Promise.withResolvers<Result>();
|
||||
r.asyncDone = () => {
|
||||
const rejections = r.rejections;
|
||||
if (!rejections) return resolve({ text: renderNode(resolved), addon });
|
||||
if (rejections.length === 1) return reject(rejections[0]);
|
||||
return reject(new AggregateError(rejections));
|
||||
};
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Inline HTML into a render without escaping it */
|
||||
export function html(rawText: ResolvedNode): DirectHtml {
|
||||
return [kDirectHtml, rawText];
|
||||
}
|
||||
|
||||
interface Result<A extends Addons = Addons> {
|
||||
text: string;
|
||||
addon: A;
|
||||
}
|
||||
|
||||
export interface Render {
|
||||
/**
|
||||
* Set to '-1' if rendering synchronously
|
||||
* Number of async promises the render is waiting on.
|
||||
*/
|
||||
async: number | -1;
|
||||
asyncDone: null | (() => void);
|
||||
/** When components reject, those are logged here */
|
||||
rejections: unknown[] | null;
|
||||
/** Add-ons to the rendering engine store state here */
|
||||
addon: Addons;
|
||||
}
|
||||
|
||||
export const kElement = Symbol("Element");
|
||||
export const kDirectHtml = Symbol("DirectHtml");
|
||||
|
||||
/** Node represents a webpage that can be 'rendered' into HTML. */
|
||||
export type Node =
|
||||
| number
|
||||
| string // Escape HTML
|
||||
| Node[] // Concat
|
||||
| Element // Render
|
||||
| DirectHtml // Insert
|
||||
| Promise<Node> // Await
|
||||
// Ignore
|
||||
| undefined
|
||||
| null
|
||||
| boolean;
|
||||
export type Element = [
|
||||
tag: typeof kElement,
|
||||
type: string | Component,
|
||||
props: Record<string, unknown>,
|
||||
_?: "",
|
||||
source?: SrcLoc,
|
||||
];
|
||||
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
|
||||
/**
|
||||
* Components must return a value; 'undefined' is prohibited here
|
||||
* to avoid functions that are missing a return statement.
|
||||
*/
|
||||
export type Component = (
|
||||
props: Record<any, any>,
|
||||
) => Exclude<Node, undefined>;
|
||||
/** Emitted by JSX runtime */
|
||||
export interface SrcLoc {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
|
||||
* marked in the 'Render'. This operation performs everything besides the final
|
||||
* string concatenation. This function is agnostic across async/sync modes.
|
||||
*/
|
||||
export function resolveNode(r: Render, node: unknown): ResolvedNode {
|
||||
if (!node && node !== 0) return ""; // falsy, non numeric
|
||||
if (typeof node !== "object") {
|
||||
if (node === true) return ""; // booleans are ignored
|
||||
if (typeof node === "string") return escapeHtml(node);
|
||||
if (typeof node === "number") return String(node); // no escaping ever
|
||||
throw new Error(`Cannot render ${inspect(node)} to HTML`);
|
||||
}
|
||||
if (node instanceof Promise) {
|
||||
if (r.async === -1) {
|
||||
throw new Error(`Asynchronous rendering is not supported here.`);
|
||||
}
|
||||
const placeholder: InsertionPoint = [null];
|
||||
r.async += 1;
|
||||
node
|
||||
.then((result) => void (placeholder[0] = resolveNode(r, result)))
|
||||
// Intentionally catching errors in `resolveNode`
|
||||
.catch((e) => (r.rejections ??= []).push(e))
|
||||
.finally(() => {
|
||||
if (--r.async == 0) {
|
||||
if (r.asyncDone == null) throw new Error("r.asyncDone == null");
|
||||
r.asyncDone();
|
||||
r.asyncDone = null;
|
||||
}
|
||||
});
|
||||
// This lie is checked with an assertion in `renderNode`
|
||||
return placeholder as [ResolvedNode];
|
||||
}
|
||||
if (!Array.isArray(node)) {
|
||||
throw new Error(`Invalid node type: ${inspect(node)}`);
|
||||
}
|
||||
const type = node[0];
|
||||
if (type === kElement) {
|
||||
const { 1: tag, 2: props } = node;
|
||||
if (typeof tag === "function") {
|
||||
currentRender = r;
|
||||
try {
|
||||
return resolveNode(r, tag(props));
|
||||
} catch (e) {
|
||||
const { 4: src } = node;
|
||||
if (e && typeof e === "object") {
|
||||
}
|
||||
} finally {
|
||||
currentRender = null;
|
||||
}
|
||||
}
|
||||
if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
|
||||
const children = props?.children;
|
||||
if (children) return [kElement, tag, props, resolveNode(r, children)];
|
||||
return node;
|
||||
}
|
||||
if (type === kDirectHtml) return node[1];
|
||||
return node.map((elem) => resolveNode(r, elem));
|
||||
}
|
||||
|
||||
export type ResolvedNode =
|
||||
| ResolvedNode[] // Concat
|
||||
| ResolvedElement // Render
|
||||
| string; // Direct HTML
|
||||
export type ResolvedElement = [
|
||||
tag: typeof kElement,
|
||||
type: string,
|
||||
props: Record<string, unknown>,
|
||||
children: ResolvedNode,
|
||||
];
|
||||
/**
|
||||
* Async rendering is done by creating an array of one item,
|
||||
* which is already a valid 'Node', but the element is written
|
||||
* once the data is available. The 'Render' contains a count
|
||||
* of how many async jobs are left.
|
||||
*/
|
||||
export type InsertionPoint = [null | ResolvedNode];
|
||||
|
||||
/**
|
||||
* Convert 'ResolvedNode' into HTML text. This operation happens after all
|
||||
* async work is settled. The HTML is emitted as concisely as possible.
|
||||
*/
|
||||
export function renderNode(node: ResolvedNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
ASSERT(node, "Unresolved Render Node");
|
||||
const type = node[0];
|
||||
if (type === kElement) {
|
||||
return renderElement(node as ResolvedElement);
|
||||
}
|
||||
node = node as ResolvedNode[]; // TS cannot infer.
|
||||
let out = type ? renderNode(type) : "";
|
||||
let len = node.length;
|
||||
for (let i = 1; i < len; i++) {
|
||||
const elem = node[i];
|
||||
if (elem) out += renderNode(elem);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function renderElement(element: ResolvedElement) {
|
||||
const { 1: tag, 2: props, 3: children } = element;
|
||||
let out = "<" + tag;
|
||||
let needSpace = true;
|
||||
for (const prop in props) {
|
||||
const value = props[prop];
|
||||
if (!value || typeof value === "function") continue;
|
||||
let attr;
|
||||
switch (prop) {
|
||||
default:
|
||||
attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
|
||||
break;
|
||||
case "className":
|
||||
// Legacy React Compat
|
||||
case "class":
|
||||
attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
|
||||
break;
|
||||
case "htmlFor":
|
||||
throw new Error("Do not use the `htmlFor` attribute. Use `for`");
|
||||
// Do not process these
|
||||
case "children":
|
||||
case "ref":
|
||||
case "dangerouslySetInnerHTML":
|
||||
case "key":
|
||||
continue;
|
||||
}
|
||||
if (needSpace) out += " ", needSpace = !attr.endsWith('"');
|
||||
out += attr;
|
||||
}
|
||||
out += ">";
|
||||
if (children) out += renderNode(children);
|
||||
if (
|
||||
tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
|
||||
tag !== "link" && tag !== "hr"
|
||||
) {
|
||||
out += `</${tag}>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function renderStyleAttribute(style: Record<string, string>) {
|
||||
let out = ``;
|
||||
for (const styleName in style) {
|
||||
if (out) out += ";";
|
||||
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
|
||||
escapeHtml(String(style[styleName]))
|
||||
}`;
|
||||
}
|
||||
return "style=" + quoteIfNeeded(out);
|
||||
}
|
||||
export function quoteIfNeeded(text: string) {
|
||||
if (text.includes(" ")) return '"' + text + '"';
|
||||
return text;
|
||||
}
|
||||
|
||||
// -- utility functions --
|
||||
|
||||
export function initRender(allowAsync: boolean, addon: Addons): Render {
|
||||
return {
|
||||
async: allowAsync ? 0 : -1,
|
||||
rejections: null,
|
||||
asyncDone: null,
|
||||
addon,
|
||||
};
|
||||
}
|
||||
|
||||
let currentRender: Render | null = null;
|
||||
export function getCurrentRender() {
|
||||
if (!currentRender) throw new Error("No Render Active");
|
||||
return currentRender;
|
||||
}
|
||||
export function setCurrentRender(r?: Render | null) {
|
||||
currentRender = r ?? null;
|
||||
}
|
||||
export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
|
||||
return (getCurrentRender().addon[namespace] ??= def()) as T;
|
||||
}
|
||||
|
||||
export function inspect(object: unknown) {
|
||||
try {
|
||||
return require("node:util").inspect(object);
|
||||
} catch {
|
||||
return typeof object;
|
||||
}
|
||||
}
|
||||
|
||||
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
|
||||
export function clsx(mix: ClsxInput) {
|
||||
var k, y, str = "";
|
||||
if (typeof mix === "string") {
|
||||
return mix;
|
||||
} else if (typeof mix === "object") {
|
||||
if (Array.isArray(mix)) {
|
||||
for (k = 0; k < mix.length; k++) {
|
||||
if (mix[k] && (y = clsx(mix[k]))) {
|
||||
str && (str += " ");
|
||||
str += y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (k in mix) {
|
||||
if (mix[k]) {
|
||||
str && (str += " ");
|
||||
str += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export const escapeHtml = (unsafeText: string) =>
|
||||
String(unsafeText)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
|
||||
// Clover's Rendering Engine is the backbone of her website generator. It
|
||||
// converts objects and components (functions returning 'Node') into HTML. The
|
||||
// engine is simple and self-contained, with integrations for JSX and Marko
|
||||
// (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
|
||||
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
|
||||
// to track needed client scripts without introducing patches to the engine.
|
||||
export type Addons = Record<string | symbol, unknown>;
|
||||
|
||||
export function ssrSync(node: Node): Result;
|
||||
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
|
||||
export function ssrSync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(false, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
return { text: renderNode(resolved), addon };
|
||||
}
|
||||
|
||||
export function ssrAsync(node: Node): Promise<Result>;
|
||||
export function ssrAsync<A extends Addons>(
|
||||
node: Node,
|
||||
addon: A,
|
||||
): Promise<Result<A>>;
|
||||
export function ssrAsync(node: Node, addon: Addons = {}) {
|
||||
const r = initRender(true, addon);
|
||||
const resolved = resolveNode(r, node);
|
||||
if (r.async === 0) {
|
||||
return Promise.resolve({ text: renderNode(resolved), addon });
|
||||
}
|
||||
const { resolve, reject, promise } = Promise.withResolvers<Result>();
|
||||
r.asyncDone = () => {
|
||||
const rejections = r.rejections;
|
||||
if (!rejections) return resolve({ text: renderNode(resolved), addon });
|
||||
if (rejections.length === 1) return reject(rejections[0]);
|
||||
return reject(new AggregateError(rejections));
|
||||
};
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Inline HTML into a render without escaping it */
|
||||
export function html(rawText: ResolvedNode): DirectHtml {
|
||||
return [kDirectHtml, rawText];
|
||||
}
|
||||
|
||||
interface Result<A extends Addons = Addons> {
|
||||
text: string;
|
||||
addon: A;
|
||||
}
|
||||
|
||||
export interface Render {
|
||||
/**
|
||||
* Set to '-1' if rendering synchronously
|
||||
* Number of async promises the render is waiting on.
|
||||
*/
|
||||
async: number | -1;
|
||||
asyncDone: null | (() => void);
|
||||
/** When components reject, those are logged here */
|
||||
rejections: unknown[] | null;
|
||||
/** Add-ons to the rendering engine store state here */
|
||||
addon: Addons;
|
||||
}
|
||||
|
||||
export const kElement = Symbol("Element");
|
||||
export const kDirectHtml = Symbol("DirectHtml");
|
||||
|
||||
/** Node represents a webpage that can be 'rendered' into HTML. */
|
||||
export type Node =
|
||||
| number
|
||||
| string // Escape HTML
|
||||
| Node[] // Concat
|
||||
| Element // Render
|
||||
| DirectHtml // Insert
|
||||
| Promise<Node> // Await
|
||||
// Ignore
|
||||
| undefined
|
||||
| null
|
||||
| boolean;
|
||||
export type Element = [
|
||||
tag: typeof kElement,
|
||||
type: string | Component,
|
||||
props: Record<string, unknown>,
|
||||
_?: "",
|
||||
source?: SrcLoc,
|
||||
];
|
||||
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
|
||||
/**
|
||||
* Components must return a value; 'undefined' is prohibited here
|
||||
* to avoid functions that are missing a return statement.
|
||||
*/
|
||||
export type Component = (
|
||||
props: Record<any, any>,
|
||||
) => Exclude<Node, undefined>;
|
||||
/** Emitted by JSX runtime */
|
||||
export interface SrcLoc {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
|
||||
* marked in the 'Render'. This operation performs everything besides the final
|
||||
* string concatenation. This function is agnostic across async/sync modes.
|
||||
*/
|
||||
export function resolveNode(r: Render, node: unknown): ResolvedNode {
|
||||
if (!node && node !== 0) return ""; // falsy, non numeric
|
||||
if (typeof node !== "object") {
|
||||
if (node === true) return ""; // booleans are ignored
|
||||
if (typeof node === "string") return escapeHtml(node);
|
||||
if (typeof node === "number") return String(node); // no escaping ever
|
||||
throw new Error(`Cannot render ${inspect(node)} to HTML`);
|
||||
}
|
||||
if (node instanceof Promise) {
|
||||
if (r.async === -1) {
|
||||
throw new Error(`Asynchronous rendering is not supported here.`);
|
||||
}
|
||||
const placeholder: InsertionPoint = [null];
|
||||
r.async += 1;
|
||||
node
|
||||
.then((result) => void (placeholder[0] = resolveNode(r, result)))
|
||||
// Intentionally catching errors in `resolveNode`
|
||||
.catch((e) => (r.rejections ??= []).push(e))
|
||||
.finally(() => {
|
||||
if (--r.async == 0) {
|
||||
if (r.asyncDone == null) throw new Error("r.asyncDone == null");
|
||||
r.asyncDone();
|
||||
r.asyncDone = null;
|
||||
}
|
||||
});
|
||||
// This lie is checked with an assertion in `renderNode`
|
||||
return placeholder as [ResolvedNode];
|
||||
}
|
||||
if (!Array.isArray(node)) {
|
||||
throw new Error(`Invalid node type: ${inspect(node)}`);
|
||||
}
|
||||
const type = node[0];
|
||||
if (type === kElement) {
|
||||
const { 1: tag, 2: props } = node;
|
||||
if (typeof tag === "function") {
|
||||
currentRender = r;
|
||||
try {
|
||||
return resolveNode(r, tag(props));
|
||||
} catch (e) {
|
||||
const { 4: src } = node;
|
||||
if (e && typeof e === "object") {
|
||||
}
|
||||
} finally {
|
||||
currentRender = null;
|
||||
}
|
||||
}
|
||||
if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
|
||||
const children = props?.children;
|
||||
if (children) return [kElement, tag, props, resolveNode(r, children)];
|
||||
return node;
|
||||
}
|
||||
if (type === kDirectHtml) return node[1];
|
||||
return node.map((elem) => resolveNode(r, elem));
|
||||
}
|
||||
|
||||
export type ResolvedNode =
|
||||
| ResolvedNode[] // Concat
|
||||
| ResolvedElement // Render
|
||||
| string; // Direct HTML
|
||||
export type ResolvedElement = [
|
||||
tag: typeof kElement,
|
||||
type: string,
|
||||
props: Record<string, unknown>,
|
||||
children: ResolvedNode,
|
||||
];
|
||||
/**
|
||||
* Async rendering is done by creating an array of one item,
|
||||
* which is already a valid 'Node', but the element is written
|
||||
* once the data is available. The 'Render' contains a count
|
||||
* of how many async jobs are left.
|
||||
*/
|
||||
export type InsertionPoint = [null | ResolvedNode];
|
||||
|
||||
/**
|
||||
* Convert 'ResolvedNode' into HTML text. This operation happens after all
|
||||
* async work is settled. The HTML is emitted as concisely as possible.
|
||||
*/
|
||||
export function renderNode(node: ResolvedNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
ASSERT(node, "Unresolved Render Node");
|
||||
const type = node[0];
|
||||
if (type === kElement) {
|
||||
return renderElement(node as ResolvedElement);
|
||||
}
|
||||
node = node as ResolvedNode[]; // TS cannot infer.
|
||||
let out = type ? renderNode(type) : "";
|
||||
let len = node.length;
|
||||
for (let i = 1; i < len; i++) {
|
||||
const elem = node[i];
|
||||
if (elem) out += renderNode(elem);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function renderElement(element: ResolvedElement) {
|
||||
const { 1: tag, 2: props, 3: children } = element;
|
||||
let out = "<" + tag;
|
||||
let needSpace = true;
|
||||
for (const prop in props) {
|
||||
const value = props[prop];
|
||||
if (!value || typeof value === "function") continue;
|
||||
let attr;
|
||||
switch (prop) {
|
||||
default:
|
||||
attr = `${prop}=${quoteIfNeeded(escapeHtml(String(value)))}`;
|
||||
break;
|
||||
case "className":
|
||||
// Legacy React Compat
|
||||
case "class":
|
||||
attr = `class=${quoteIfNeeded(escapeHtml(clsx(value as ClsxInput)))}`;
|
||||
break;
|
||||
case "htmlFor":
|
||||
throw new Error("Do not use the `htmlFor` attribute. Use `for`");
|
||||
// Do not process these
|
||||
case "children":
|
||||
case "ref":
|
||||
case "dangerouslySetInnerHTML":
|
||||
case "key":
|
||||
continue;
|
||||
}
|
||||
if (needSpace) out += " ", needSpace = !attr.endsWith('"');
|
||||
out += attr;
|
||||
}
|
||||
out += ">";
|
||||
if (children) out += renderNode(children);
|
||||
if (
|
||||
tag !== "br" && tag !== "img" && tag !== "input" && tag !== "meta" &&
|
||||
tag !== "link" && tag !== "hr"
|
||||
) {
|
||||
out += `</${tag}>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function renderStyleAttribute(style: Record<string, string>) {
|
||||
let out = ``;
|
||||
for (const styleName in style) {
|
||||
if (out) out += ";";
|
||||
out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${
|
||||
escapeHtml(String(style[styleName]))
|
||||
}`;
|
||||
}
|
||||
return "style=" + quoteIfNeeded(out);
|
||||
}
|
||||
export function quoteIfNeeded(text: string) {
|
||||
if (text.includes(" ")) return '"' + text + '"';
|
||||
return text;
|
||||
}
|
||||
|
||||
// -- utility functions --
|
||||
|
||||
export function initRender(allowAsync: boolean, addon: Addons): Render {
|
||||
return {
|
||||
async: allowAsync ? 0 : -1,
|
||||
rejections: null,
|
||||
asyncDone: null,
|
||||
addon,
|
||||
};
|
||||
}
|
||||
|
||||
let currentRender: Render | null = null;
|
||||
export function getCurrentRender() {
|
||||
if (!currentRender) throw new Error("No Render Active");
|
||||
return currentRender;
|
||||
}
|
||||
export function setCurrentRender(r?: Render | null) {
|
||||
currentRender = r ?? null;
|
||||
}
|
||||
export function getUserData<T>(namespace: PropertyKey, def: () => T): T {
|
||||
return (getCurrentRender().addon[namespace] ??= def()) as T;
|
||||
}
|
||||
|
||||
export function inspect(object: unknown) {
|
||||
try {
|
||||
return require("node:util").inspect(object);
|
||||
} catch {
|
||||
return typeof object;
|
||||
}
|
||||
}
|
||||
|
||||
export type ClsxInput = string | Record<string, boolean | null> | ClsxInput[];
|
||||
export function clsx(mix: ClsxInput) {
|
||||
var k, y, str = "";
|
||||
if (typeof mix === "string") {
|
||||
return mix;
|
||||
} else if (typeof mix === "object") {
|
||||
if (Array.isArray(mix)) {
|
||||
for (k = 0; k < mix.length; k++) {
|
||||
if (mix[k] && (y = clsx(mix[k]))) {
|
||||
str && (str += " ");
|
||||
str += y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (k in mix) {
|
||||
if (mix[k]) {
|
||||
str && (str += " ");
|
||||
str += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export const escapeHtml = (unsafeText: string) =>
|
||||
String(unsafeText)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
|
||||
|
|
|
@ -1,40 +1,40 @@
|
|||
import { test } from "node:test";
|
||||
import { renderStreaming, Suspense } from "./suspense.ts";
|
||||
|
||||
test("sanity", async (t) => {
|
||||
let resolve: () => void = null!;
|
||||
|
||||
// @ts-expect-error
|
||||
async function AsyncComponent() {
|
||||
await new Promise<void>((done) => resolve = done);
|
||||
return <button>wow!</button>;
|
||||
}
|
||||
|
||||
const example = (
|
||||
<main>
|
||||
<h1>app shell</h1>
|
||||
<Suspense fallback="loading...">
|
||||
<AsyncComponent />
|
||||
</Suspense>
|
||||
<footer>(c) 2025</footer>
|
||||
</main>
|
||||
);
|
||||
|
||||
const iterator = renderStreaming(example);
|
||||
const assertContinue = (actual: unknown, value: unknown) =>
|
||||
t.assert.deepEqual(actual, { done: false, value });
|
||||
|
||||
assertContinue(
|
||||
await iterator.next(),
|
||||
"<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();
|
||||
assertContinue(
|
||||
await iterator.next(),
|
||||
"<button slot=suspended_1>wow!</button>",
|
||||
);
|
||||
t.assert.deepEqual(
|
||||
await iterator.next(),
|
||||
{ done: true, value: {} },
|
||||
);
|
||||
});
|
||||
import { test } from "node:test";
|
||||
import { renderStreaming, Suspense } from "./suspense.ts";
|
||||
|
||||
test("sanity", async (t) => {
|
||||
let resolve: () => void = null!;
|
||||
|
||||
// @ts-expect-error
|
||||
async function AsyncComponent() {
|
||||
await new Promise<void>((done) => resolve = done);
|
||||
return <button>wow!</button>;
|
||||
}
|
||||
|
||||
const example = (
|
||||
<main>
|
||||
<h1>app shell</h1>
|
||||
<Suspense fallback="loading...">
|
||||
<AsyncComponent />
|
||||
</Suspense>
|
||||
<footer>(c) 2025</footer>
|
||||
</main>
|
||||
);
|
||||
|
||||
const iterator = renderStreaming(example);
|
||||
const assertContinue = (actual: unknown, value: unknown) =>
|
||||
t.assert.deepEqual(actual, { done: false, value });
|
||||
|
||||
assertContinue(
|
||||
await iterator.next(),
|
||||
"<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();
|
||||
assertContinue(
|
||||
await iterator.next(),
|
||||
"<button slot=suspended_1>wow!</button>",
|
||||
);
|
||||
t.assert.deepEqual(
|
||||
await iterator.next(),
|
||||
{ done: true, value: {} },
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,102 +1,102 @@
|
|||
// This file implements out-of-order HTML streaming, mimicking the React
|
||||
// Suspense API. To use, place Suspense around an expensive async component
|
||||
// and render the page with 'renderStreaming'.
|
||||
//
|
||||
// Implementation of this article:
|
||||
// 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
|
||||
// are too verbose and not informative to what they actually do.
|
||||
const kState = Symbol("SuspenseState");
|
||||
|
||||
interface SuspenseProps {
|
||||
children: ssr.Node;
|
||||
fallback?: ssr.Node;
|
||||
}
|
||||
|
||||
interface State {
|
||||
nested: boolean;
|
||||
nextId: number;
|
||||
completed: number;
|
||||
pushChunk(name: string, node: ssr.ResolvedNode): void;
|
||||
}
|
||||
|
||||
export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
|
||||
const state = ssr.getUserData<State>(kState, () => {
|
||||
throw new Error("Can only use <Suspense> with 'renderStreaming'");
|
||||
});
|
||||
if (state.nested) throw new Error("<Suspense> cannot be nested");
|
||||
const parent = ssr.getCurrentRender()!;
|
||||
const r = ssr.initRender(true, { [kState]: { nested: true } });
|
||||
const resolved = ssr.resolveNode(r, children);
|
||||
if (r.async == 0) return ssr.html(resolved);
|
||||
const name = "suspended_" + (++state.nextId);
|
||||
state.nested = true;
|
||||
const ip: [ssr.ResolvedNode] = [
|
||||
[
|
||||
ssr.kElement,
|
||||
"slot",
|
||||
{ name },
|
||||
fallback ? ssr.resolveNode(parent, fallback) : "",
|
||||
],
|
||||
];
|
||||
state.nested = false;
|
||||
r.asyncDone = () => {
|
||||
const rejections = r.rejections;
|
||||
if (rejections && rejections.length > 0) throw new Error("TODO");
|
||||
state.pushChunk?.(name, ip[0] = resolved);
|
||||
};
|
||||
return ssr.html(ip);
|
||||
}
|
||||
|
||||
// TODO: add a User-Agent parameter, which is used to determine if a
|
||||
// fallback path must be used.
|
||||
// - Before ~2024 needs to use a JS implementation.
|
||||
// - IE should probably bail out entirely.
|
||||
export async function* renderStreaming<
|
||||
T extends ssr.Addons = Record<never, unknown>,
|
||||
>(
|
||||
node: ssr.Node,
|
||||
addon: T = {} as T,
|
||||
) {
|
||||
const {
|
||||
text: begin,
|
||||
addon: { [kState]: state, ...addonOutput },
|
||||
} = await ssr.ssrAsync(node, {
|
||||
...addon,
|
||||
[kState]: {
|
||||
nested: false,
|
||||
nextId: 0,
|
||||
completed: 0,
|
||||
pushChunk: () => {},
|
||||
} satisfies State as State,
|
||||
});
|
||||
if (state.nextId === 0) {
|
||||
yield begin;
|
||||
return addonOutput as unknown as T;
|
||||
}
|
||||
let resolve: (() => void) | null = null;
|
||||
let chunks: string[] = [];
|
||||
state.pushChunk = (slot, node) => {
|
||||
while (node.length === 1 && Array.isArray(node)) node = node[0];
|
||||
if (node[0] === ssr.kElement) {
|
||||
(node as ssr.ResolvedElement)[2].slot = slot;
|
||||
} else {
|
||||
node = [ssr.kElement, "clover-suspense", {
|
||||
style: "display:contents",
|
||||
slot,
|
||||
}, node];
|
||||
}
|
||||
chunks.push(ssr.renderNode(node));
|
||||
resolve?.();
|
||||
};
|
||||
yield `<template shadowrootmode=open>${begin}</template>`;
|
||||
do {
|
||||
await new Promise<void>((done) => resolve = done);
|
||||
yield* chunks;
|
||||
chunks = [];
|
||||
} while (state.nextId < state.completed);
|
||||
return addonOutput as unknown as T;
|
||||
}
|
||||
|
||||
import * as ssr from "./ssr.ts";
|
||||
// This file implements out-of-order HTML streaming, mimicking the React
|
||||
// Suspense API. To use, place Suspense around an expensive async component
|
||||
// and render the page with 'renderStreaming'.
|
||||
//
|
||||
// Implementation of this article:
|
||||
// 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
|
||||
// are too verbose and not informative to what they actually do.
|
||||
const kState = Symbol("SuspenseState");
|
||||
|
||||
interface SuspenseProps {
|
||||
children: ssr.Node;
|
||||
fallback?: ssr.Node;
|
||||
}
|
||||
|
||||
interface State {
|
||||
nested: boolean;
|
||||
nextId: number;
|
||||
completed: number;
|
||||
pushChunk(name: string, node: ssr.ResolvedNode): void;
|
||||
}
|
||||
|
||||
export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
|
||||
const state = ssr.getUserData<State>(kState, () => {
|
||||
throw new Error("Can only use <Suspense> with 'renderStreaming'");
|
||||
});
|
||||
if (state.nested) throw new Error("<Suspense> cannot be nested");
|
||||
const parent = ssr.getCurrentRender()!;
|
||||
const r = ssr.initRender(true, { [kState]: { nested: true } });
|
||||
const resolved = ssr.resolveNode(r, children);
|
||||
if (r.async == 0) return ssr.html(resolved);
|
||||
const name = "suspended_" + (++state.nextId);
|
||||
state.nested = true;
|
||||
const ip: [ssr.ResolvedNode] = [
|
||||
[
|
||||
ssr.kElement,
|
||||
"slot",
|
||||
{ name },
|
||||
fallback ? ssr.resolveNode(parent, fallback) : "",
|
||||
],
|
||||
];
|
||||
state.nested = false;
|
||||
r.asyncDone = () => {
|
||||
const rejections = r.rejections;
|
||||
if (rejections && rejections.length > 0) throw new Error("TODO");
|
||||
state.pushChunk?.(name, ip[0] = resolved);
|
||||
};
|
||||
return ssr.html(ip);
|
||||
}
|
||||
|
||||
// TODO: add a User-Agent parameter, which is used to determine if a
|
||||
// fallback path must be used.
|
||||
// - Before ~2024 needs to use a JS implementation.
|
||||
// - IE should probably bail out entirely.
|
||||
export async function* renderStreaming<
|
||||
T extends ssr.Addons = Record<never, unknown>,
|
||||
>(
|
||||
node: ssr.Node,
|
||||
addon: T = {} as T,
|
||||
) {
|
||||
const {
|
||||
text: begin,
|
||||
addon: { [kState]: state, ...addonOutput },
|
||||
} = await ssr.ssrAsync(node, {
|
||||
...addon,
|
||||
[kState]: {
|
||||
nested: false,
|
||||
nextId: 0,
|
||||
completed: 0,
|
||||
pushChunk: () => {},
|
||||
} satisfies State as State,
|
||||
});
|
||||
if (state.nextId === 0) {
|
||||
yield begin;
|
||||
return addonOutput as unknown as T;
|
||||
}
|
||||
let resolve: (() => void) | null = null;
|
||||
let chunks: string[] = [];
|
||||
state.pushChunk = (slot, node) => {
|
||||
while (node.length === 1 && Array.isArray(node)) node = node[0];
|
||||
if (node[0] === ssr.kElement) {
|
||||
(node as ssr.ResolvedElement)[2].slot = slot;
|
||||
} else {
|
||||
node = [ssr.kElement, "clover-suspense", {
|
||||
style: "display:contents",
|
||||
slot,
|
||||
}, node];
|
||||
}
|
||||
chunks.push(ssr.renderNode(node));
|
||||
resolve?.();
|
||||
};
|
||||
yield `<template shadowrootmode=open>${begin}</template>`;
|
||||
do {
|
||||
await new Promise<void>((done) => resolve = done);
|
||||
yield* chunks;
|
||||
chunks = [];
|
||||
} while (state.nextId < state.completed);
|
||||
return addonOutput as unknown as T;
|
||||
}
|
||||
|
||||
import * as ssr from "./ssr.ts";
|
||||
|
|
|
@ -1,79 +1,79 @@
|
|||
export function virtualFiles(
|
||||
map: Record<string, string | esbuild.OnLoadResult>,
|
||||
) {
|
||||
return {
|
||||
name: "clover vfs",
|
||||
setup(b) {
|
||||
b.onResolve(
|
||||
{
|
||||
filter: new RegExp(
|
||||
`^(?:${
|
||||
Object.keys(map).map((file) => string.escapeRegExp(file)).join(
|
||||
"|",
|
||||
)
|
||||
})\$`,
|
||||
),
|
||||
},
|
||||
({ path }) => ({ path, namespace: "vfs" }),
|
||||
);
|
||||
b.onLoad(
|
||||
{ filter: /./, namespace: "vfs" },
|
||||
({ path }) => {
|
||||
const entry = map[path];
|
||||
return ({
|
||||
resolveDir: ".",
|
||||
loader: "ts",
|
||||
...typeof entry === "string" ? { contents: entry } : entry,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
export function banFiles(
|
||||
files: string[],
|
||||
) {
|
||||
return {
|
||||
name: "clover vfs",
|
||||
setup(b) {
|
||||
b.onResolve(
|
||||
{
|
||||
filter: new RegExp(
|
||||
`^(?:${
|
||||
files.map((file) => string.escapeRegExp(file)).join("|")
|
||||
})\$`,
|
||||
),
|
||||
},
|
||||
({ path, importer }) => {
|
||||
throw new Error(
|
||||
`Loading ${path} (from ${importer}) is banned!`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
export function projectRelativeResolution(root = process.cwd() + "/src") {
|
||||
return {
|
||||
name: "project relative resolution ('@/' prefix)",
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^@\// }, ({ path: id }) => {
|
||||
return {
|
||||
path: path.resolve(root, id.slice(2)),
|
||||
};
|
||||
});
|
||||
b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
|
||||
return {
|
||||
path: hot.resolveFrom(importer, id),
|
||||
};
|
||||
});
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
import * as esbuild from "esbuild";
|
||||
import * as string from "#sitegen/string";
|
||||
import * as path from "node:path";
|
||||
import * as hot from "./hot.ts";
|
||||
export function virtualFiles(
|
||||
map: Record<string, string | esbuild.OnLoadResult>,
|
||||
) {
|
||||
return {
|
||||
name: "clover vfs",
|
||||
setup(b) {
|
||||
b.onResolve(
|
||||
{
|
||||
filter: new RegExp(
|
||||
`^(?:${
|
||||
Object.keys(map).map((file) => string.escapeRegExp(file)).join(
|
||||
"|",
|
||||
)
|
||||
})\$`,
|
||||
),
|
||||
},
|
||||
({ path }) => ({ path, namespace: "vfs" }),
|
||||
);
|
||||
b.onLoad(
|
||||
{ filter: /./, namespace: "vfs" },
|
||||
({ path }) => {
|
||||
const entry = map[path];
|
||||
return ({
|
||||
resolveDir: ".",
|
||||
loader: "ts",
|
||||
...typeof entry === "string" ? { contents: entry } : entry,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
export function banFiles(
|
||||
files: string[],
|
||||
) {
|
||||
return {
|
||||
name: "clover vfs",
|
||||
setup(b) {
|
||||
b.onResolve(
|
||||
{
|
||||
filter: new RegExp(
|
||||
`^(?:${
|
||||
files.map((file) => string.escapeRegExp(file)).join("|")
|
||||
})\$`,
|
||||
),
|
||||
},
|
||||
({ path, importer }) => {
|
||||
throw new Error(
|
||||
`Loading ${path} (from ${importer}) is banned!`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
export function projectRelativeResolution(root = process.cwd() + "/src") {
|
||||
return {
|
||||
name: "project relative resolution ('@/' prefix)",
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /^@\// }, ({ path: id }) => {
|
||||
return {
|
||||
path: path.resolve(root, id.slice(2)),
|
||||
};
|
||||
});
|
||||
b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
|
||||
return {
|
||||
path: hot.resolveFrom(importer, id),
|
||||
};
|
||||
});
|
||||
},
|
||||
} satisfies esbuild.Plugin;
|
||||
}
|
||||
|
||||
import * as esbuild from "esbuild";
|
||||
import * as string from "#sitegen/string";
|
||||
import * as path from "node:path";
|
||||
import * as hot from "./hot.ts";
|
||||
|
|
|
@ -96,7 +96,9 @@ Module._resolveFilename = (...args) => {
|
|||
try {
|
||||
return require.resolve(replacedPath, { paths: [projectSrc] });
|
||||
} 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,297 +1,299 @@
|
|||
const five_minutes = 5 * 60 * 1000;
|
||||
|
||||
interface QueueOptions<T, R> {
|
||||
name: string;
|
||||
fn: (item: T, spin: Spinner) => Promise<R>;
|
||||
getItemText?: (item: T) => string;
|
||||
maxJobs?: number;
|
||||
passive?: boolean;
|
||||
}
|
||||
|
||||
// Process multiple items in parallel, queue up as many.
|
||||
export class Queue<T, R> {
|
||||
#name: string;
|
||||
#fn: (item: T, spin: Spinner) => Promise<R>;
|
||||
#maxJobs: number;
|
||||
#getItemText: (item: T) => string;
|
||||
#passive: boolean;
|
||||
|
||||
#active: Spinner[] = [];
|
||||
#queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
|
||||
|
||||
#cachedProgress: Progress<{ active: Spinner[] }> | null = null;
|
||||
#done: number = 0;
|
||||
#total: number = 0;
|
||||
#onComplete: (() => void) | null = null;
|
||||
#estimate: number | null = null;
|
||||
#errors: unknown[] = [];
|
||||
|
||||
constructor(options: QueueOptions<T, R>) {
|
||||
this.#name = options.name;
|
||||
this.#fn = options.fn;
|
||||
this.#maxJobs = options.maxJobs ?? 5;
|
||||
this.#getItemText = options.getItemText ?? defaultGetItemText;
|
||||
this.#passive = options.passive ?? false;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const bar = this.#cachedProgress;
|
||||
bar?.stop();
|
||||
this.#queue = [];
|
||||
}
|
||||
|
||||
get bar() {
|
||||
const cached = this.#cachedProgress;
|
||||
if (!cached) {
|
||||
const bar = this.#cachedProgress = new Progress({
|
||||
spinner: null,
|
||||
text: ({ active }) => {
|
||||
const now = performance.now();
|
||||
let text = `[${this.#done}/${this.#total}] ${this.#name}`;
|
||||
let n = 0;
|
||||
for (const item of active) {
|
||||
let itemText = "- " + item.format(now);
|
||||
text += `\n` +
|
||||
itemText.slice(0, Math.max(0, process.stdout.columns - 1));
|
||||
if (n > 10) {
|
||||
text += `\n ... + ${active.length - n} more`;
|
||||
break;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
props: {
|
||||
active: [] as Spinner[],
|
||||
},
|
||||
});
|
||||
bar.value = 0;
|
||||
return bar;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
addReturn(args: T) {
|
||||
this.#total += 1;
|
||||
this.updateTotal();
|
||||
if (this.#active.length >= this.#maxJobs) {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<R>();
|
||||
this.#queue.push([args, resolve, reject]);
|
||||
return promise;
|
||||
}
|
||||
return this.#run(args);
|
||||
}
|
||||
|
||||
add(args: T) {
|
||||
return this.addReturn(args).then(() => {}, () => {});
|
||||
}
|
||||
|
||||
addMany(items: T[]) {
|
||||
this.#total += items.length;
|
||||
this.updateTotal();
|
||||
|
||||
const runNowCount = this.#maxJobs - this.#active.length;
|
||||
const runNow = items.slice(0, runNowCount);
|
||||
const runLater = items.slice(runNowCount);
|
||||
this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
|
||||
runNow.map((item) => this.#run(item).catch(() => {}));
|
||||
}
|
||||
|
||||
async #run(args: T): Promise<R> {
|
||||
const bar = this.bar;
|
||||
const itemText = this.#getItemText(args);
|
||||
const spinner = new Spinner(itemText);
|
||||
spinner.stop();
|
||||
(spinner as any).redraw = () => (bar as any).redraw();
|
||||
const active = this.#active;
|
||||
try {
|
||||
active.unshift(spinner);
|
||||
bar.props = { active };
|
||||
console.log(this.#name + ": " + itemText);
|
||||
const result = await this.#fn(args, spinner);
|
||||
this.#done++;
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object") {
|
||||
(err as any).job = itemText;
|
||||
}
|
||||
this.#errors.push(err);
|
||||
throw err;
|
||||
} finally {
|
||||
active.splice(active.indexOf(spinner), 1);
|
||||
bar.props = { active };
|
||||
bar.value = this.#done;
|
||||
|
||||
// Process next item
|
||||
const next = this.#queue.shift();
|
||||
if (next) {
|
||||
const args = next[0];
|
||||
this.#run(args)
|
||||
.then((result) => next[1]?.(result))
|
||||
.catch((err) => next[2]?.(err));
|
||||
} else if (this.#active.length === 0) {
|
||||
if (this.#passive) {
|
||||
this.bar.stop();
|
||||
this.#cachedProgress = null;
|
||||
}
|
||||
this.#onComplete?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTotal() {
|
||||
const bar = this.bar;
|
||||
bar.total = Math.max(this.#total, this.#estimate ?? 0);
|
||||
}
|
||||
|
||||
set estimate(e: number) {
|
||||
this.#estimate = e;
|
||||
if (this.#cachedProgress) {
|
||||
this.updateTotal();
|
||||
}
|
||||
}
|
||||
|
||||
async done(o?: { method: "success" | "stop" }) {
|
||||
if (this.#active.length === 0) {
|
||||
this.#end(o);
|
||||
return;
|
||||
}
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
this.#onComplete = resolve;
|
||||
await promise;
|
||||
this.#end(o);
|
||||
}
|
||||
|
||||
#end(
|
||||
{ method = this.#passive ? "stop" : "success" }: {
|
||||
method?: "success" | "stop";
|
||||
} = {},
|
||||
) {
|
||||
const bar = this.#cachedProgress;
|
||||
if (this.#errors.length > 0) {
|
||||
if (bar) bar.stop();
|
||||
throw new AggregateError(
|
||||
this.#errors,
|
||||
this.#errors.length + " jobs failed in '" + this.#name + "'",
|
||||
);
|
||||
}
|
||||
|
||||
if (bar) bar[method]();
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.#active.length !== 0;
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
if (this.active) {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
function defaultGetItemText(item: unknown) {
|
||||
let itemText = "";
|
||||
if (typeof item === "string") {
|
||||
itemText = item;
|
||||
} else if (typeof item === "object" && item !== null) {
|
||||
const { path, label, id } = item as any;
|
||||
itemText = label ?? path ?? id ?? JSON.stringify(item);
|
||||
} else {
|
||||
itemText = JSON.stringify(item);
|
||||
}
|
||||
|
||||
if (itemText.startsWith(cwd)) {
|
||||
itemText = path.relative(cwd, itemText);
|
||||
}
|
||||
return itemText;
|
||||
}
|
||||
|
||||
export class OnceMap<T> {
|
||||
private ongoing = new Map<string, Promise<T>>();
|
||||
|
||||
get(key: string, compute: () => Promise<T>) {
|
||||
if (this.ongoing.has(key)) {
|
||||
return this.ongoing.get(key)!;
|
||||
}
|
||||
|
||||
const result = compute();
|
||||
this.ongoing.set(key, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface ARCEValue<T> {
|
||||
value: T;
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export function RefCountedExpirable<T>(
|
||||
init: () => Promise<T>,
|
||||
deinit: (value: T) => void,
|
||||
expire: number = five_minutes,
|
||||
): () => Promise<ARCEValue<T>> {
|
||||
let refs = 0;
|
||||
let item: ARCEValue<T> | null = null;
|
||||
let loading: Promise<ARCEValue<T>> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function deref() {
|
||||
ASSERT(item !== null);
|
||||
if (--refs !== 0) return;
|
||||
ASSERT(timer === null);
|
||||
timer = setTimeout(() => {
|
||||
ASSERT(refs === 0);
|
||||
ASSERT(loading === null);
|
||||
ASSERT(item !== null);
|
||||
deinit(item.value);
|
||||
item = null;
|
||||
timer = null;
|
||||
}, expire);
|
||||
}
|
||||
|
||||
return async function () {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (item !== null) {
|
||||
refs++;
|
||||
return item;
|
||||
}
|
||||
if (loading !== null) {
|
||||
refs++;
|
||||
return loading;
|
||||
}
|
||||
const p = Promise.withResolvers<ARCEValue<T>>();
|
||||
loading = p.promise;
|
||||
try {
|
||||
const value = await init();
|
||||
item = { value, [Symbol.dispose]: deref };
|
||||
refs++;
|
||||
p.resolve(item);
|
||||
return item;
|
||||
} catch (e) {
|
||||
p.reject(e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function once<T>(fn: () => Promise<T>): () => Promise<T> {
|
||||
let result: T | Promise<T> | null = null;
|
||||
return async () => {
|
||||
if (result) return result;
|
||||
result = await fn();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
import { Progress } from "@paperclover/console/Progress";
|
||||
import { Spinner } from "@paperclover/console/Spinner";
|
||||
import * as path from "node:path";
|
||||
import process from "node:process";
|
||||
const five_minutes = 5 * 60 * 1000;
|
||||
|
||||
interface QueueOptions<T, R> {
|
||||
name: string;
|
||||
fn: (item: T, spin: Spinner) => Promise<R>;
|
||||
getItemText?: (item: T) => string;
|
||||
maxJobs?: number;
|
||||
passive?: boolean;
|
||||
}
|
||||
|
||||
// Process multiple items in parallel, queue up as many.
|
||||
export class Queue<T, R> {
|
||||
#name: string;
|
||||
#fn: (item: T, spin: Spinner) => Promise<R>;
|
||||
#maxJobs: number;
|
||||
#getItemText: (item: T) => string;
|
||||
#passive: boolean;
|
||||
|
||||
#active: Spinner[] = [];
|
||||
#queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
|
||||
|
||||
#cachedProgress: Progress<{ active: Spinner[] }> | null = null;
|
||||
#done: number = 0;
|
||||
#total: number = 0;
|
||||
#onComplete: (() => void) | null = null;
|
||||
#estimate: number | null = null;
|
||||
#errors: unknown[] = [];
|
||||
|
||||
constructor(options: QueueOptions<T, R>) {
|
||||
this.#name = options.name;
|
||||
this.#fn = options.fn;
|
||||
this.#maxJobs = options.maxJobs ?? 5;
|
||||
this.#getItemText = options.getItemText ?? defaultGetItemText;
|
||||
this.#passive = options.passive ?? false;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const bar = this.#cachedProgress;
|
||||
bar?.stop();
|
||||
this.#queue = [];
|
||||
}
|
||||
|
||||
get bar() {
|
||||
const cached = this.#cachedProgress;
|
||||
if (!cached) {
|
||||
const bar = this.#cachedProgress = new Progress({
|
||||
spinner: null,
|
||||
text: ({ active }) => {
|
||||
const now = performance.now();
|
||||
let text = `[${this.#done}/${this.#total}] ${this.#name}`;
|
||||
let n = 0;
|
||||
for (const item of active) {
|
||||
let itemText = "- " + item.format(now);
|
||||
text += `\n` +
|
||||
itemText.slice(0, Math.max(0, process.stdout.columns - 1));
|
||||
if (n > 10) {
|
||||
text += `\n ... + ${active.length - n} more`;
|
||||
break;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
props: {
|
||||
active: [] as Spinner[],
|
||||
},
|
||||
});
|
||||
bar.value = 0;
|
||||
return bar;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
addReturn(args: T) {
|
||||
this.#total += 1;
|
||||
this.updateTotal();
|
||||
if (this.#active.length >= this.#maxJobs) {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<R>();
|
||||
this.#queue.push([args, resolve, reject]);
|
||||
return promise;
|
||||
}
|
||||
return this.#run(args);
|
||||
}
|
||||
|
||||
add(args: T) {
|
||||
return this.addReturn(args).then(() => {}, () => {});
|
||||
}
|
||||
|
||||
addMany(items: T[]) {
|
||||
this.#total += items.length;
|
||||
this.updateTotal();
|
||||
|
||||
const runNowCount = this.#maxJobs - this.#active.length;
|
||||
const runNow = items.slice(0, runNowCount);
|
||||
const runLater = items.slice(runNowCount);
|
||||
this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
|
||||
runNow.map((item) => this.#run(item).catch(() => {}));
|
||||
}
|
||||
|
||||
async #run(args: T): Promise<R> {
|
||||
const bar = this.bar;
|
||||
const itemText = this.#getItemText(args);
|
||||
const spinner = new Spinner(itemText);
|
||||
spinner.stop();
|
||||
(spinner as any).redraw = () => (bar as any).redraw();
|
||||
const active = this.#active;
|
||||
try {
|
||||
active.unshift(spinner);
|
||||
bar.props = { active };
|
||||
// console.log(this.#name + ": " + itemText);
|
||||
const result = await this.#fn(args, spinner);
|
||||
this.#done++;
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err && typeof err === "object") {
|
||||
(err as any).job = itemText;
|
||||
}
|
||||
this.#errors.push(err);
|
||||
console.error(util.inspect(err, false, Infinity, true));
|
||||
throw err;
|
||||
} finally {
|
||||
active.splice(active.indexOf(spinner), 1);
|
||||
bar.props = { active };
|
||||
bar.value = this.#done;
|
||||
|
||||
// Process next item
|
||||
const next = this.#queue.shift();
|
||||
if (next) {
|
||||
const args = next[0];
|
||||
this.#run(args)
|
||||
.then((result) => next[1]?.(result))
|
||||
.catch((err) => next[2]?.(err));
|
||||
} else if (this.#active.length === 0) {
|
||||
if (this.#passive) {
|
||||
this.bar.stop();
|
||||
this.#cachedProgress = null;
|
||||
}
|
||||
this.#onComplete?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTotal() {
|
||||
const bar = this.bar;
|
||||
bar.total = Math.max(this.#total, this.#estimate ?? 0);
|
||||
}
|
||||
|
||||
set estimate(e: number) {
|
||||
this.#estimate = e;
|
||||
if (this.#cachedProgress) {
|
||||
this.updateTotal();
|
||||
}
|
||||
}
|
||||
|
||||
async done(o?: { method: "success" | "stop" }) {
|
||||
if (this.#active.length === 0) {
|
||||
this.#end(o);
|
||||
return;
|
||||
}
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
this.#onComplete = resolve;
|
||||
await promise;
|
||||
this.#end(o);
|
||||
}
|
||||
|
||||
#end(
|
||||
{ method = this.#passive ? "stop" : "success" }: {
|
||||
method?: "success" | "stop";
|
||||
} = {},
|
||||
) {
|
||||
const bar = this.#cachedProgress;
|
||||
if (this.#errors.length > 0) {
|
||||
if (bar) bar.stop();
|
||||
throw new AggregateError(
|
||||
this.#errors,
|
||||
this.#errors.length + " jobs failed in '" + this.#name + "'",
|
||||
);
|
||||
}
|
||||
|
||||
if (bar) bar[method]();
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.#active.length !== 0;
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
if (this.active) {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
function defaultGetItemText(item: unknown) {
|
||||
let itemText = "";
|
||||
if (typeof item === "string") {
|
||||
itemText = item;
|
||||
} else if (typeof item === "object" && item !== null) {
|
||||
const { path, label, id } = item as any;
|
||||
itemText = label ?? path ?? id ?? JSON.stringify(item);
|
||||
} else {
|
||||
itemText = JSON.stringify(item);
|
||||
}
|
||||
|
||||
if (itemText.startsWith(cwd)) {
|
||||
itemText = path.relative(cwd, itemText);
|
||||
}
|
||||
return itemText;
|
||||
}
|
||||
|
||||
export class OnceMap<T> {
|
||||
private ongoing = new Map<string, Promise<T>>();
|
||||
|
||||
get(key: string, compute: () => Promise<T>) {
|
||||
if (this.ongoing.has(key)) {
|
||||
return this.ongoing.get(key)!;
|
||||
}
|
||||
|
||||
const result = compute();
|
||||
this.ongoing.set(key, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
interface ARCEValue<T> {
|
||||
value: T;
|
||||
[Symbol.dispose]: () => void;
|
||||
}
|
||||
|
||||
export function RefCountedExpirable<T>(
|
||||
init: () => Promise<T>,
|
||||
deinit: (value: T) => void,
|
||||
expire: number = five_minutes,
|
||||
): () => Promise<ARCEValue<T>> {
|
||||
let refs = 0;
|
||||
let item: ARCEValue<T> | null = null;
|
||||
let loading: Promise<ARCEValue<T>> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function deref() {
|
||||
ASSERT(item !== null);
|
||||
if (--refs !== 0) return;
|
||||
ASSERT(timer === null);
|
||||
timer = setTimeout(() => {
|
||||
ASSERT(refs === 0);
|
||||
ASSERT(loading === null);
|
||||
ASSERT(item !== null);
|
||||
deinit(item.value);
|
||||
item = null;
|
||||
timer = null;
|
||||
}, expire);
|
||||
}
|
||||
|
||||
return async function () {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (item !== null) {
|
||||
refs++;
|
||||
return item;
|
||||
}
|
||||
if (loading !== null) {
|
||||
refs++;
|
||||
return loading;
|
||||
}
|
||||
const p = Promise.withResolvers<ARCEValue<T>>();
|
||||
loading = p.promise;
|
||||
try {
|
||||
const value = await init();
|
||||
item = { value, [Symbol.dispose]: deref };
|
||||
refs++;
|
||||
p.resolve(item);
|
||||
return item;
|
||||
} catch (e) {
|
||||
p.reject(e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function once<T>(fn: () => Promise<T>): () => Promise<T> {
|
||||
let result: T | Promise<T> | null = null;
|
||||
return async () => {
|
||||
if (result) return result;
|
||||
result = await fn();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
import { Progress } from "@paperclover/console/Progress";
|
||||
import { Spinner } from "@paperclover/console/Spinner";
|
||||
import * as path from "node:path";
|
||||
import process from "node:process";
|
||||
import * as util from "node:util";
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
export interface Meta {
|
||||
title: string;
|
||||
description?: string | undefined;
|
||||
openGraph?: OpenGraph;
|
||||
alternates?: Alternates;
|
||||
}
|
||||
export interface OpenGraph {
|
||||
title?: string;
|
||||
description?: string | undefined;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
export interface Alternates {
|
||||
canonical: string;
|
||||
types: { [mime: string]: AlternateType };
|
||||
}
|
||||
export interface AlternateType {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
export function renderMeta({ title }: Meta): string {
|
||||
return `<title>${esc(title)}</title>`;
|
||||
}
|
||||
import { escapeHtml as esc } from "../engine/ssr.ts";
|
||||
export interface Meta {
|
||||
title: string;
|
||||
description?: string | undefined;
|
||||
openGraph?: OpenGraph;
|
||||
alternates?: Alternates;
|
||||
}
|
||||
export interface OpenGraph {
|
||||
title?: string;
|
||||
description?: string | undefined;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
export interface Alternates {
|
||||
canonical: string;
|
||||
types: { [mime: string]: AlternateType };
|
||||
}
|
||||
export interface AlternateType {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
export function renderMeta({ title }: Meta): string {
|
||||
return `<title>${esc(title)}</title>`;
|
||||
}
|
||||
import { escapeHtml as esc } from "../engine/ssr.ts";
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
export function escapeRegExp(source: string) {
|
||||
return source.replace(/[\$\\]/g, "\\$&");
|
||||
}
|
||||
export function escapeRegExp(source: string) {
|
||||
return source.replace(/[\$\\]/g, "\\$&");
|
||||
}
|
||||
|
|
|
@ -1,100 +1,100 @@
|
|||
// This import is generated by code 'bundle.ts'
|
||||
export interface View {
|
||||
component: engine.Component;
|
||||
meta:
|
||||
| meta.Meta
|
||||
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
|
||||
layout?: engine.Component;
|
||||
inlineCss: string;
|
||||
scripts: Record<string, string>;
|
||||
}
|
||||
|
||||
let views: Record<string, View> = null!;
|
||||
let scripts: Record<string, string> = null!;
|
||||
|
||||
// An older version of the Clover Engine supported streaming suspense
|
||||
// boundaries, but those were never used. Pages will wait until they
|
||||
// are fully rendered before sending.
|
||||
export async function renderView(
|
||||
context: hono.Context,
|
||||
id: string,
|
||||
props: Record<string, unknown>,
|
||||
) {
|
||||
return context.html(await renderViewToString(id, { context, ...props }));
|
||||
}
|
||||
|
||||
export async function renderViewToString(
|
||||
id: string,
|
||||
props: Record<string, unknown>,
|
||||
) {
|
||||
views ?? ({ views, scripts } = require("$views"));
|
||||
// The view contains pre-bundled CSS and scripts, but keeps the scripts
|
||||
// separate for run-time dynamic scripts. For example, the file viewer
|
||||
// includes the canvas for the current page, but only the current page.
|
||||
const {
|
||||
component,
|
||||
inlineCss,
|
||||
layout,
|
||||
meta: metadata,
|
||||
}: View = UNWRAP(views[id], `Missing view ${id}`);
|
||||
|
||||
// -- metadata --
|
||||
const renderedMetaPromise = Promise.resolve(
|
||||
typeof metadata === "function" ? metadata(props) : metadata,
|
||||
).then((m) => meta.renderMeta(m));
|
||||
|
||||
// -- html --
|
||||
let page: engine.Element = [engine.kElement, component, props];
|
||||
if (layout) page = [engine.kElement, layout, { children: page }];
|
||||
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
|
||||
sitegen: sg.initRender(),
|
||||
});
|
||||
|
||||
// -- join document and send --
|
||||
return wrapDocument({
|
||||
body,
|
||||
head: await renderedMetaPromise,
|
||||
inlineCss,
|
||||
scripts: joinScripts(
|
||||
Array.from(
|
||||
sitegen.scripts,
|
||||
(id) => UNWRAP(scripts[id], `Missing script ${id}`),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function provideViewData(v: typeof views, s: typeof scripts) {
|
||||
views = v;
|
||||
scripts = s;
|
||||
}
|
||||
|
||||
export function joinScripts(scriptSources: string[]) {
|
||||
const { length } = scriptSources;
|
||||
if (length === 0) return "";
|
||||
if (length === 1) return scriptSources[0];
|
||||
return scriptSources.map((source) => `{${source}}`).join(";");
|
||||
}
|
||||
|
||||
export function wrapDocument({
|
||||
body,
|
||||
head,
|
||||
inlineCss,
|
||||
scripts,
|
||||
}: {
|
||||
head: string;
|
||||
body: string;
|
||||
inlineCss: string;
|
||||
scripts: string;
|
||||
}) {
|
||||
return `<!doctype html><html lang=en><head>${head}${
|
||||
inlineCss ? `<style>${inlineCss}</style>` : ""
|
||||
}</head><body>${body}${
|
||||
scripts ? `<script>${scripts}</script>` : ""
|
||||
}</body></html>`;
|
||||
}
|
||||
|
||||
import * as meta from "./meta.ts";
|
||||
import type * as hono from "#hono";
|
||||
import * as engine from "../engine/ssr.ts";
|
||||
import * as sg from "./sitegen.ts";
|
||||
// This import is generated by code 'bundle.ts'
|
||||
export interface View {
|
||||
component: engine.Component;
|
||||
meta:
|
||||
| meta.Meta
|
||||
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
|
||||
layout?: engine.Component;
|
||||
inlineCss: string;
|
||||
scripts: Record<string, string>;
|
||||
}
|
||||
|
||||
let views: Record<string, View> = null!;
|
||||
let scripts: Record<string, string> = null!;
|
||||
|
||||
// An older version of the Clover Engine supported streaming suspense
|
||||
// boundaries, but those were never used. Pages will wait until they
|
||||
// are fully rendered before sending.
|
||||
export async function renderView(
|
||||
context: hono.Context,
|
||||
id: string,
|
||||
props: Record<string, unknown>,
|
||||
) {
|
||||
return context.html(await renderViewToString(id, { context, ...props }));
|
||||
}
|
||||
|
||||
export async function renderViewToString(
|
||||
id: string,
|
||||
props: Record<string, unknown>,
|
||||
) {
|
||||
views ?? ({ views, scripts } = require("$views"));
|
||||
// The view contains pre-bundled CSS and scripts, but keeps the scripts
|
||||
// separate for run-time dynamic scripts. For example, the file viewer
|
||||
// includes the canvas for the current page, but only the current page.
|
||||
const {
|
||||
component,
|
||||
inlineCss,
|
||||
layout,
|
||||
meta: metadata,
|
||||
}: View = UNWRAP(views[id], `Missing view ${id}`);
|
||||
|
||||
// -- metadata --
|
||||
const renderedMetaPromise = Promise.resolve(
|
||||
typeof metadata === "function" ? metadata(props) : metadata,
|
||||
).then((m) => meta.renderMeta(m));
|
||||
|
||||
// -- html --
|
||||
let page: engine.Element = [engine.kElement, component, props];
|
||||
if (layout) page = [engine.kElement, layout, { children: page }];
|
||||
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
|
||||
sitegen: sg.initRender(),
|
||||
});
|
||||
|
||||
// -- join document and send --
|
||||
return wrapDocument({
|
||||
body,
|
||||
head: await renderedMetaPromise,
|
||||
inlineCss,
|
||||
scripts: joinScripts(
|
||||
Array.from(
|
||||
sitegen.scripts,
|
||||
(id) => UNWRAP(scripts[id], `Missing script ${id}`),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function provideViewData(v: typeof views, s: typeof scripts) {
|
||||
views = v;
|
||||
scripts = s;
|
||||
}
|
||||
|
||||
export function joinScripts(scriptSources: string[]) {
|
||||
const { length } = scriptSources;
|
||||
if (length === 0) return "";
|
||||
if (length === 1) return scriptSources[0];
|
||||
return scriptSources.map((source) => `{${source}}`).join(";");
|
||||
}
|
||||
|
||||
export function wrapDocument({
|
||||
body,
|
||||
head,
|
||||
inlineCss,
|
||||
scripts,
|
||||
}: {
|
||||
head: string;
|
||||
body: string;
|
||||
inlineCss: string;
|
||||
scripts: string;
|
||||
}) {
|
||||
return `<!doctype html><html lang=en><head>${head}${
|
||||
inlineCss ? `<style>${inlineCss}</style>` : ""
|
||||
}</head><body>${body}${
|
||||
scripts ? `<script>${scripts}</script>` : ""
|
||||
}</body></html>`;
|
||||
}
|
||||
|
||||
import * as meta from "./meta.ts";
|
||||
import type * as hono from "#hono";
|
||||
import * as engine from "../engine/ssr.ts";
|
||||
import * as sg from "./sitegen.ts";
|
||||
|
|
|
@ -1,198 +1,198 @@
|
|||
// File watcher and live reloading site generator
|
||||
|
||||
const debounceMilliseconds = 25;
|
||||
|
||||
export async function main() {
|
||||
let subprocess: child_process.ChildProcess | null = null;
|
||||
|
||||
// Catch up state by running a main build.
|
||||
const { incr } = await generate.main();
|
||||
// ...and watch the files that cause invals.
|
||||
const watch = new Watch(rebuild);
|
||||
watch.add(...incr.invals.keys());
|
||||
statusLine();
|
||||
// ... and then serve it!
|
||||
serve();
|
||||
|
||||
function serve() {
|
||||
if (subprocess) {
|
||||
subprocess.removeListener("close", onSubprocessClose);
|
||||
subprocess.kill();
|
||||
}
|
||||
subprocess = child_process.fork(".clover/out/server.js", [
|
||||
"--development",
|
||||
], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
subprocess.on("close", onSubprocessClose);
|
||||
}
|
||||
|
||||
function onSubprocessClose(code: number | null, signal: string | null) {
|
||||
subprocess = null;
|
||||
const status = code != null ? `code ${code}` : `signal ${signal}`;
|
||||
console.error(`Backend process exited with ${status}`);
|
||||
}
|
||||
|
||||
process.on("beforeExit", () => {
|
||||
subprocess?.removeListener("close", onSubprocessClose);
|
||||
});
|
||||
|
||||
function rebuild(files: string[]) {
|
||||
files = files.map((file) => path.relative(hot.projectRoot, file));
|
||||
const changed: string[] = [];
|
||||
for (const file of files) {
|
||||
let mtimeMs: number | null = null;
|
||||
try {
|
||||
mtimeMs = fs.statSync(file).mtimeMs;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (incr.updateStat(file, mtimeMs)) changed.push(file);
|
||||
}
|
||||
if (changed.length === 0) {
|
||||
console.warn("Files were modified but the 'modify' time did not change.");
|
||||
return;
|
||||
}
|
||||
withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
|
||||
text: "Rebuilding",
|
||||
successText: generate.successText,
|
||||
failureText: () => "sitegen FAIL",
|
||||
}, async (spinner) => {
|
||||
console.info("---");
|
||||
console.info(
|
||||
"Updated" +
|
||||
(changed.length === 1
|
||||
? " " + changed[0]
|
||||
: changed.map((file) => "\n- " + file)),
|
||||
);
|
||||
const result = await generate.sitegen(spinner, incr);
|
||||
incr.toDisk(); // Allows picking up this state again
|
||||
for (const file of watch.files) {
|
||||
const relative = path.relative(hot.projectRoot, file);
|
||||
if (!incr.invals.has(relative)) watch.remove(file);
|
||||
}
|
||||
return result;
|
||||
}).then((result) => {
|
||||
// Restart the server if it was changed or not running.
|
||||
if (
|
||||
!subprocess ||
|
||||
result.inserted.some(({ kind }) => kind === "backendReplace")
|
||||
) {
|
||||
serve();
|
||||
} else if (
|
||||
subprocess &&
|
||||
result.inserted.some(({ kind }) => kind === "asset")
|
||||
) {
|
||||
subprocess.send({ type: "clover.assets.reload" });
|
||||
}
|
||||
return result;
|
||||
}).catch((err) => {
|
||||
console.error(util.inspect(err));
|
||||
}).finally(statusLine);
|
||||
}
|
||||
|
||||
function statusLine() {
|
||||
console.info(
|
||||
`Watching ${incr.invals.size} files \x1b[36m[last change: ${
|
||||
new Date().toLocaleTimeString()
|
||||
}]\x1b[39m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Watch {
|
||||
files = new Set<string>();
|
||||
stale = new Set<string>();
|
||||
onChange: (files: string[]) => void;
|
||||
watchers: fs.FSWatcher[] = [];
|
||||
/** Has a trailing slash */
|
||||
roots: string[] = [];
|
||||
debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(onChange: Watch["onChange"]) {
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
add(...files: string[]) {
|
||||
const { roots, watchers } = this;
|
||||
let newRoots: string[] = [];
|
||||
for (let file of files) {
|
||||
file = path.resolve(file);
|
||||
if (this.files.has(file)) continue;
|
||||
this.files.add(file);
|
||||
// Find an existing watcher
|
||||
if (roots.some((root) => file.startsWith(root))) continue;
|
||||
if (newRoots.some((root) => file.startsWith(root))) continue;
|
||||
newRoots.push(path.dirname(file) + path.sep);
|
||||
}
|
||||
if (newRoots.length === 0) return;
|
||||
// Filter out directories that are already specified
|
||||
newRoots = newRoots
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.filter((dir, i, a) => {
|
||||
for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false;
|
||||
return true;
|
||||
});
|
||||
// Append Watches
|
||||
let i = roots.length;
|
||||
for (const root of newRoots) {
|
||||
this.watchers.push(fs.watch(
|
||||
root,
|
||||
{ recursive: true, encoding: "utf-8" },
|
||||
this.#handleEvent.bind(this, root),
|
||||
));
|
||||
this.roots.push(root);
|
||||
}
|
||||
// If any new roots shadow over and old one, delete it!
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const root = roots[i];
|
||||
if (newRoots.some((newRoot) => root.startsWith(newRoot))) {
|
||||
watchers.splice(i, 1)[0].close();
|
||||
roots.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(...files: string[]) {
|
||||
for (const file of files) this.files.delete(path.resolve(file));
|
||||
// Find watches that are covering no files
|
||||
const { roots, watchers } = this;
|
||||
const existingFiles = Array.from(this.files);
|
||||
let i = roots.length;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const root = roots[i];
|
||||
if (!existingFiles.some((file) => file.startsWith(root))) {
|
||||
watchers.splice(i, 1)[0].close();
|
||||
roots.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const w of this.watchers) w.close();
|
||||
}
|
||||
|
||||
#handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
|
||||
if (!subPath) return;
|
||||
const file = path.join(root, subPath);
|
||||
if (!this.files.has(file)) return;
|
||||
this.stale.add(file);
|
||||
const { debounce } = this;
|
||||
if (debounce !== null) clearTimeout(debounce);
|
||||
this.debounce = setTimeout(() => {
|
||||
this.debounce = null;
|
||||
this.onChange(Array.from(this.stale));
|
||||
this.stale.clear();
|
||||
}, debounceMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { withSpinner } from "@paperclover/console/Spinner";
|
||||
import * as generate from "./generate.ts";
|
||||
import * as path from "node:path";
|
||||
import * as util from "node:util";
|
||||
import * as hot from "./hot.ts";
|
||||
import * as child_process from "node:child_process";
|
||||
// File watcher and live reloading site generator
|
||||
|
||||
const debounceMilliseconds = 25;
|
||||
|
||||
export async function main() {
|
||||
let subprocess: child_process.ChildProcess | null = null;
|
||||
|
||||
// Catch up state by running a main build.
|
||||
const { incr } = await generate.main();
|
||||
// ...and watch the files that cause invals.
|
||||
const watch = new Watch(rebuild);
|
||||
watch.add(...incr.invals.keys());
|
||||
statusLine();
|
||||
// ... and then serve it!
|
||||
serve();
|
||||
|
||||
function serve() {
|
||||
if (subprocess) {
|
||||
subprocess.removeListener("close", onSubprocessClose);
|
||||
subprocess.kill();
|
||||
}
|
||||
subprocess = child_process.fork(".clover/out/server.js", [
|
||||
"--development",
|
||||
], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
subprocess.on("close", onSubprocessClose);
|
||||
}
|
||||
|
||||
function onSubprocessClose(code: number | null, signal: string | null) {
|
||||
subprocess = null;
|
||||
const status = code != null ? `code ${code}` : `signal ${signal}`;
|
||||
console.error(`Backend process exited with ${status}`);
|
||||
}
|
||||
|
||||
process.on("beforeExit", () => {
|
||||
subprocess?.removeListener("close", onSubprocessClose);
|
||||
});
|
||||
|
||||
function rebuild(files: string[]) {
|
||||
files = files.map((file) => path.relative(hot.projectRoot, file));
|
||||
const changed: string[] = [];
|
||||
for (const file of files) {
|
||||
let mtimeMs: number | null = null;
|
||||
try {
|
||||
mtimeMs = fs.statSync(file).mtimeMs;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (incr.updateStat(file, mtimeMs)) changed.push(file);
|
||||
}
|
||||
if (changed.length === 0) {
|
||||
console.warn("Files were modified but the 'modify' time did not change.");
|
||||
return;
|
||||
}
|
||||
withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
|
||||
text: "Rebuilding",
|
||||
successText: generate.successText,
|
||||
failureText: () => "sitegen FAIL",
|
||||
}, async (spinner) => {
|
||||
console.info("---");
|
||||
console.info(
|
||||
"Updated" +
|
||||
(changed.length === 1
|
||||
? " " + changed[0]
|
||||
: changed.map((file) => "\n- " + file)),
|
||||
);
|
||||
const result = await generate.sitegen(spinner, incr);
|
||||
incr.toDisk(); // Allows picking up this state again
|
||||
for (const file of watch.files) {
|
||||
const relative = path.relative(hot.projectRoot, file);
|
||||
if (!incr.invals.has(relative)) watch.remove(file);
|
||||
}
|
||||
return result;
|
||||
}).then((result) => {
|
||||
// Restart the server if it was changed or not running.
|
||||
if (
|
||||
!subprocess ||
|
||||
result.inserted.some(({ kind }) => kind === "backendReplace")
|
||||
) {
|
||||
serve();
|
||||
} else if (
|
||||
subprocess &&
|
||||
result.inserted.some(({ kind }) => kind === "asset")
|
||||
) {
|
||||
subprocess.send({ type: "clover.assets.reload" });
|
||||
}
|
||||
return result;
|
||||
}).catch((err) => {
|
||||
console.error(util.inspect(err));
|
||||
}).finally(statusLine);
|
||||
}
|
||||
|
||||
function statusLine() {
|
||||
console.info(
|
||||
`Watching ${incr.invals.size} files \x1b[36m[last change: ${
|
||||
new Date().toLocaleTimeString()
|
||||
}]\x1b[39m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Watch {
|
||||
files = new Set<string>();
|
||||
stale = new Set<string>();
|
||||
onChange: (files: string[]) => void;
|
||||
watchers: fs.FSWatcher[] = [];
|
||||
/** Has a trailing slash */
|
||||
roots: string[] = [];
|
||||
debounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(onChange: Watch["onChange"]) {
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
add(...files: string[]) {
|
||||
const { roots, watchers } = this;
|
||||
let newRoots: string[] = [];
|
||||
for (let file of files) {
|
||||
file = path.resolve(file);
|
||||
if (this.files.has(file)) continue;
|
||||
this.files.add(file);
|
||||
// Find an existing watcher
|
||||
if (roots.some((root) => file.startsWith(root))) continue;
|
||||
if (newRoots.some((root) => file.startsWith(root))) continue;
|
||||
newRoots.push(path.dirname(file) + path.sep);
|
||||
}
|
||||
if (newRoots.length === 0) return;
|
||||
// Filter out directories that are already specified
|
||||
newRoots = newRoots
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.filter((dir, i, a) => {
|
||||
for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false;
|
||||
return true;
|
||||
});
|
||||
// Append Watches
|
||||
let i = roots.length;
|
||||
for (const root of newRoots) {
|
||||
this.watchers.push(fs.watch(
|
||||
root,
|
||||
{ recursive: true, encoding: "utf-8" },
|
||||
this.#handleEvent.bind(this, root),
|
||||
));
|
||||
this.roots.push(root);
|
||||
}
|
||||
// If any new roots shadow over and old one, delete it!
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const root = roots[i];
|
||||
if (newRoots.some((newRoot) => root.startsWith(newRoot))) {
|
||||
watchers.splice(i, 1)[0].close();
|
||||
roots.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(...files: string[]) {
|
||||
for (const file of files) this.files.delete(path.resolve(file));
|
||||
// Find watches that are covering no files
|
||||
const { roots, watchers } = this;
|
||||
const existingFiles = Array.from(this.files);
|
||||
let i = roots.length;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const root = roots[i];
|
||||
if (!existingFiles.some((file) => file.startsWith(root))) {
|
||||
watchers.splice(i, 1)[0].close();
|
||||
roots.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const w of this.watchers) w.close();
|
||||
}
|
||||
|
||||
#handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
|
||||
if (!subPath) return;
|
||||
const file = path.join(root, subPath);
|
||||
if (!this.files.has(file)) return;
|
||||
this.stale.add(file);
|
||||
const { debounce } = this;
|
||||
if (debounce !== null) clearTimeout(debounce);
|
||||
this.debounce = setTimeout(() => {
|
||||
this.debounce = null;
|
||||
this.onChange(Array.from(this.stale));
|
||||
this.stale.clear();
|
||||
}, debounceMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { withSpinner } from "@paperclover/console/Spinner";
|
||||
import * as generate from "./generate.ts";
|
||||
import * as path from "node:path";
|
||||
import * as util from "node:util";
|
||||
import * as hot from "./hot.ts";
|
||||
import * as child_process from "node:child_process";
|
||||
|
|
40
readme.md
40
readme.md
|
@ -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.
|
||||
|
||||
- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
|
||||
- A more practical JSX runtime (`class` instead of `className`, built-in
|
||||
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
|
||||
- Integration with [Marko][1] for concisely written components.
|
||||
- TODO: MDX-like compiler for content-heavy pages like blogs.
|
||||
- Different languages can be used at the same time. Supports
|
||||
`async function` components, `<Suspense />`, and custom extensions.
|
||||
- A more practical JSX runtime (`class` instead of `className`, built-in
|
||||
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
|
||||
- Integration with [Marko][1] for concisely written components.
|
||||
- TODO: MDX-like compiler for content-heavy pages like blogs.
|
||||
- Different languages can be used at the same time. Supports `async function`
|
||||
components, `<Suspense />`, and custom extensions.
|
||||
- **Incremental static site generator and build system.**
|
||||
- Build entire production site at start, incremental updates when pages
|
||||
change; Build system state survives coding sessions.
|
||||
- The only difference in development and production mode is hidden
|
||||
source-maps and stripped `console.debug` calls. The site you
|
||||
see locally is the same site you see deployed.
|
||||
- (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
|
||||
only pages that use that component and re-lints only the changed file.
|
||||
- Build entire production site at start, incremental updates when pages
|
||||
change; Build system state survives coding sessions.
|
||||
- The only difference in development and production mode is hidden source-maps
|
||||
and stripped `console.debug` calls. The site you see locally is the same
|
||||
site you see deployed.
|
||||
- (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
|
||||
only pages that use that component and re-lints only the changed file.
|
||||
- **Integrated libraries for building complex, content heavy web sites.**
|
||||
- Static asset serving with ETag and build-time compression.
|
||||
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
|
||||
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
|
||||
- TODO: Meta and Open Graph generation. (`export const meta`)
|
||||
- TODO: Font subsetting tools to reduce bytes downloaded by fonts.
|
||||
- Static asset serving with ETag and build-time compression.
|
||||
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
|
||||
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
|
||||
- TODO: Meta and Open Graph generation. (`export const meta`)
|
||||
- TODO: Font subsetting tools to reduce bytes downloaded by fonts.
|
||||
- **Built on the battle-tested Node.js runtime.**
|
||||
|
||||
[1]: https://next.markojs.com
|
||||
|
@ -42,6 +42,7 @@ Included is `src`, which contains `paperclover.net`. Website highlights:
|
|||
## Development
|
||||
|
||||
minimum system requirements:
|
||||
|
||||
- a cpu with at least 1 core.
|
||||
- random access memory.
|
||||
- windows 7 or later, macos, or other operating system.
|
||||
|
@ -73,4 +74,3 @@ open a shell with all needed system dependencies.
|
|||
## Contributions
|
||||
|
||||
No contributions to `src` accepted, only `framework`.
|
||||
|
||||
|
|
2
run.js
2
run.js
|
@ -12,7 +12,7 @@ if (!zlib.zstdCompress) {
|
|||
: null;
|
||||
|
||||
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}${
|
||||
brand ? ` (${brand})` : ""
|
||||
}\n\n` +
|
||||
|
|
150
src/admin.ts
150
src/admin.ts
|
@ -1,75 +1,75 @@
|
|||
const cookieAge = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
let lastKnownToken: string | null = null;
|
||||
function compareToken(token: string) {
|
||||
if (token === lastKnownToken) return true;
|
||||
lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
|
||||
return token === lastKnownToken;
|
||||
}
|
||||
|
||||
export async function middleware(c: Context, next: Next) {
|
||||
if (c.req.path.startsWith("/admin")) {
|
||||
return adminInner(c, next);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
export function adminInner(c: Context, next: Next) {
|
||||
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
|
||||
|
||||
if (c.req.path === "/admin/login") {
|
||||
const key = c.req.query("key");
|
||||
if (key) {
|
||||
if (compareToken(key)) {
|
||||
return c.body(null, 303, {
|
||||
"Location": "/admin",
|
||||
"Set-Cookie":
|
||||
`admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
}
|
||||
return serveAsset(c, "/admin/login/fail", 403);
|
||||
}
|
||||
if (token && compareToken(token)) {
|
||||
return c.redirect("/admin", 303);
|
||||
}
|
||||
if (c.req.method === "POST") {
|
||||
return serveAsset(c, "/admin/login/fail", 403);
|
||||
} else {
|
||||
return serveAsset(c, "/admin/login", 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (c.req.path === "/admin/logout") {
|
||||
return c.body(null, 303, {
|
||||
"Location": "/admin/login",
|
||||
"Set-Cookie":
|
||||
`admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
|
||||
});
|
||||
}
|
||||
|
||||
if (token && compareToken(token)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.redirect("/admin/login", 303);
|
||||
}
|
||||
|
||||
export function hasAdminToken(c: Context) {
|
||||
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
|
||||
return token && compareToken(token);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const key = crypto.randomUUID();
|
||||
await fs.writeMkdir(".clover/admin-token.txt", key);
|
||||
const start = ({
|
||||
win32: "start",
|
||||
darwin: "open",
|
||||
} as Record<string, string>)[process.platform] ?? "xdg-open";
|
||||
child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`);
|
||||
}
|
||||
|
||||
import * as fs from "#sitegen/fs";
|
||||
import type { Context, Next } from "hono";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import * as child_process from "node:child_process";
|
||||
const cookieAge = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
let lastKnownToken: string | null = null;
|
||||
function compareToken(token: string) {
|
||||
if (token === lastKnownToken) return true;
|
||||
lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
|
||||
return token === lastKnownToken;
|
||||
}
|
||||
|
||||
export async function middleware(c: Context, next: Next) {
|
||||
if (c.req.path.startsWith("/admin")) {
|
||||
return adminInner(c, next);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
export function adminInner(c: Context, next: Next) {
|
||||
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
|
||||
|
||||
if (c.req.path === "/admin/login") {
|
||||
const key = c.req.query("key");
|
||||
if (key) {
|
||||
if (compareToken(key)) {
|
||||
return c.body(null, 303, {
|
||||
"Location": "/admin",
|
||||
"Set-Cookie":
|
||||
`admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
}
|
||||
return serveAsset(c, "/admin/login/fail", 403);
|
||||
}
|
||||
if (token && compareToken(token)) {
|
||||
return c.redirect("/admin", 303);
|
||||
}
|
||||
if (c.req.method === "POST") {
|
||||
return serveAsset(c, "/admin/login/fail", 403);
|
||||
} else {
|
||||
return serveAsset(c, "/admin/login", 200);
|
||||
}
|
||||
}
|
||||
|
||||
if (c.req.path === "/admin/logout") {
|
||||
return c.body(null, 303, {
|
||||
"Location": "/admin/login",
|
||||
"Set-Cookie":
|
||||
`admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
|
||||
});
|
||||
}
|
||||
|
||||
if (token && compareToken(token)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return c.redirect("/admin/login", 303);
|
||||
}
|
||||
|
||||
export function hasAdminToken(c: Context) {
|
||||
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
|
||||
return token && compareToken(token);
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const key = crypto.randomUUID();
|
||||
await fs.writeMkdir(".clover/admin-token.txt", key);
|
||||
const start = ({
|
||||
win32: "start",
|
||||
darwin: "open",
|
||||
} as Record<string, string>)[process.platform] ?? "xdg-open";
|
||||
child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`);
|
||||
}
|
||||
|
||||
import * as fs from "#sitegen/fs";
|
||||
import type { Context, Next } from "hono";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import * as child_process from "node:child_process";
|
||||
|
|
106
src/backend.ts
106
src/backend.ts
|
@ -1,53 +1,53 @@
|
|||
// This is the main file for the backend
|
||||
const app = new Hono();
|
||||
const logHttp = scoped("http", { color: "magenta" });
|
||||
|
||||
// Middleware
|
||||
app.use(trimTrailingSlash());
|
||||
app.use(removeDuplicateSlashes);
|
||||
app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
|
||||
app.use(admin.middleware);
|
||||
|
||||
// Backends
|
||||
app.route("", require("./q+a/backend.ts").app);
|
||||
app.route("", require("./file-viewer/backend.tsx").app);
|
||||
|
||||
// Asset middleware has least precedence
|
||||
app.use(assets.middleware);
|
||||
|
||||
// Handlers
|
||||
app.notFound(assets.notFound);
|
||||
if (process.argv.includes("--development")) {
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
// Get the custom response
|
||||
return err.getResponse();
|
||||
}
|
||||
|
||||
return c.text(util.inspect(err), 500);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
async function removeDuplicateSlashes(c: Context, next: Next) {
|
||||
const path = c.req.path;
|
||||
if (/\/\/+/.test(path)) {
|
||||
const normalizedPath = path.replace(/\/\/+/g, "/");
|
||||
const query = c.req.query();
|
||||
const queryString = Object.keys(query).length > 0
|
||||
? "?" + new URLSearchParams(query).toString()
|
||||
: "";
|
||||
return c.redirect(normalizedPath + queryString, 301);
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
import { type Context, Hono, type Next } from "#hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { logger } from "hono/logger";
|
||||
import { trimTrailingSlash } from "hono/trailing-slash";
|
||||
import * as assets from "#sitegen/assets";
|
||||
import * as admin from "./admin.ts";
|
||||
import { scoped } from "@paperclover/console";
|
||||
import * as util from "node:util";
|
||||
// This is the main file for the backend
|
||||
const app = new Hono();
|
||||
const logHttp = scoped("http", { color: "magenta" });
|
||||
|
||||
// Middleware
|
||||
app.use(trimTrailingSlash());
|
||||
app.use(removeDuplicateSlashes);
|
||||
app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
|
||||
app.use(admin.middleware);
|
||||
|
||||
// Backends
|
||||
app.route("", require("./q+a/backend.ts").app);
|
||||
app.route("", require("./file-viewer/backend.tsx").app);
|
||||
|
||||
// Asset middleware has least precedence
|
||||
app.use(assets.middleware);
|
||||
|
||||
// Handlers
|
||||
app.notFound(assets.notFound);
|
||||
if (process.argv.includes("--development")) {
|
||||
app.onError((err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
// Get the custom response
|
||||
return err.getResponse();
|
||||
}
|
||||
|
||||
return c.text(util.inspect(err), 500);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
async function removeDuplicateSlashes(c: Context, next: Next) {
|
||||
const path = c.req.path;
|
||||
if (/\/\/+/.test(path)) {
|
||||
const normalizedPath = path.replace(/\/\/+/g, "/");
|
||||
const query = c.req.query();
|
||||
const queryString = Object.keys(query).length > 0
|
||||
? "?" + new URLSearchParams(query).toString()
|
||||
: "";
|
||||
return c.redirect(normalizedPath + queryString, 301);
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
import { type Context, Hono, type Next } from "#hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { logger } from "hono/logger";
|
||||
import { trimTrailingSlash } from "hono/trailing-slash";
|
||||
import * as assets from "#sitegen/assets";
|
||||
import * as admin from "./admin.ts";
|
||||
import { scoped } from "@paperclover/console";
|
||||
import * as util from "node:util";
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
export function main() {
|
||||
const meows = MediaFile.db.prepare(`
|
||||
select * from media_files;
|
||||
`).as(MediaFile).array();
|
||||
console.log(meows);
|
||||
}
|
||||
|
||||
import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
|
||||
export function main() {
|
||||
const meows = MediaFile.db.prepare(`
|
||||
select * from media_files;
|
||||
`).as(MediaFile).array();
|
||||
console.log(meows);
|
||||
}
|
||||
|
||||
import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -135,10 +135,7 @@ function highlightLines({
|
|||
|
||||
export const getRegistry = async.once(async () => {
|
||||
const wasmBin = await fs.readFile(
|
||||
path.join(
|
||||
import.meta.dirname,
|
||||
"../node_modules/vscode-oniguruma/release/onig.wasm",
|
||||
),
|
||||
require.resolve("vscode-oniguruma/release/onig.wasm"),
|
||||
);
|
||||
await oniguruma.loadWASM(wasmBin);
|
||||
|
||||
|
|
|
@ -1,73 +1,73 @@
|
|||
const db = getDb("cache.sqlite");
|
||||
db.table(
|
||||
"asset_refs",
|
||||
/* SQL */ `
|
||||
create table if not exists asset_refs (
|
||||
id integer primary key autoincrement,
|
||||
key text not null UNIQUE,
|
||||
refs integer not null
|
||||
);
|
||||
create table if not exists asset_ref_files (
|
||||
file text not null,
|
||||
id integer not null,
|
||||
foreign key (id) references asset_refs(id)
|
||||
);
|
||||
create index asset_ref_files_id on asset_ref_files(id);
|
||||
`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Uncompressed files are read directly from the media store root. Derivied
|
||||
* assets like compressed files, optimized images, and streamable video are
|
||||
* stored in the `derived` folder. After scanning, the derived assets are
|
||||
* uploaded into the store (storage1/clofi-derived dataset on NAS). Since
|
||||
* 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
|
||||
* file is deleted, it should only decrement a reference count; deleting it
|
||||
* once all references are removed.
|
||||
*/
|
||||
export class AssetRef {
|
||||
/** Key which aws referenced */
|
||||
id!: number;
|
||||
key!: string;
|
||||
refs!: number;
|
||||
|
||||
unref() {
|
||||
decrementQuery.run(this.key);
|
||||
deleteUnreferencedQuery.run().changes > 0;
|
||||
}
|
||||
|
||||
addFiles(files: string[]) {
|
||||
for (const file of files) {
|
||||
addFileQuery.run({ id: this.id, file });
|
||||
}
|
||||
}
|
||||
|
||||
static get(key: string) {
|
||||
return getQuery.get(key);
|
||||
}
|
||||
|
||||
static putOrIncrement(key: string) {
|
||||
putOrIncrementQuery.get(key);
|
||||
return UNWRAP(AssetRef.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
const getQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
select * from asset_refs where key = ?;
|
||||
`).as(AssetRef);
|
||||
const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
insert into asset_refs (key, refs) values (?, 1)
|
||||
on conflict(key) do update set refs = refs + 1;
|
||||
`);
|
||||
const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
update asset_refs set refs = refs - 1 where key = ? and refs > 0;
|
||||
`);
|
||||
const deleteUnreferencedQuery = db.prepare(/* SQL */ `
|
||||
delete from asset_refs where refs <= 0;
|
||||
`);
|
||||
const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
|
||||
insert into asset_ref_files (id, file) values ($id, $file);
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
const db = getDb("cache.sqlite");
|
||||
db.table(
|
||||
"asset_refs",
|
||||
/* SQL */ `
|
||||
create table if not exists asset_refs (
|
||||
id integer primary key autoincrement,
|
||||
key text not null UNIQUE,
|
||||
refs integer not null
|
||||
);
|
||||
create table if not exists asset_ref_files (
|
||||
file text not null,
|
||||
id integer not null,
|
||||
foreign key (id) references asset_refs(id) ON DELETE CASCADE
|
||||
);
|
||||
create index asset_ref_files_id on asset_ref_files(id);
|
||||
`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Uncompressed files are read directly from the media store root. Derivied
|
||||
* assets like compressed files, optimized images, and streamable video are
|
||||
* stored in the `derived` folder. After scanning, the derived assets are
|
||||
* uploaded into the store (storage1/clofi-derived dataset on NAS). Since
|
||||
* 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
|
||||
* file is deleted, it should only decrement a reference count; deleting it
|
||||
* once all references are removed.
|
||||
*/
|
||||
export class AssetRef {
|
||||
/** Key which aws referenced */
|
||||
id!: number;
|
||||
key!: string;
|
||||
refs!: number;
|
||||
|
||||
unref() {
|
||||
decrementQuery.run(this.key);
|
||||
deleteUnreferencedQuery.run().changes > 0;
|
||||
}
|
||||
|
||||
addFiles(files: string[]) {
|
||||
for (const file of files) {
|
||||
addFileQuery.run({ id: this.id, file });
|
||||
}
|
||||
}
|
||||
|
||||
static get(key: string) {
|
||||
return getQuery.get(key);
|
||||
}
|
||||
|
||||
static putOrIncrement(key: string) {
|
||||
putOrIncrementQuery.get(key);
|
||||
return UNWRAP(AssetRef.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
const getQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
select * from asset_refs where key = ?;
|
||||
`).as(AssetRef);
|
||||
const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
insert into asset_refs (key, refs) values (?, 1)
|
||||
on conflict(key) do update set refs = refs + 1;
|
||||
`);
|
||||
const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
|
||||
update asset_refs set refs = refs - 1 where key = ? and refs > 0;
|
||||
`);
|
||||
const deleteUnreferencedQuery = db.prepare(/* SQL */ `
|
||||
delete from asset_refs where refs <= 0;
|
||||
`);
|
||||
const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
|
||||
insert into asset_ref_files (id, file) values ($id, $file);
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
|
|
|
@ -1,59 +1,59 @@
|
|||
const db = getDb("cache.sqlite");
|
||||
|
||||
db.table(
|
||||
"permissions",
|
||||
/* SQL */ `
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
allow INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
);
|
||||
export class FilePermissions {
|
||||
prefix!: string;
|
||||
/** Currently set to 1 always */
|
||||
allow!: number;
|
||||
|
||||
// -- static ops --
|
||||
static getByPrefix(filePath: string): number {
|
||||
return getByPrefixQuery.get(filePath)?.allow ?? 0;
|
||||
}
|
||||
|
||||
static getExact(filePath: string): number {
|
||||
return getExactQuery.get(filePath)?.allow ?? 0;
|
||||
}
|
||||
|
||||
static setPermissions(prefix: string, allow: number) {
|
||||
if (allow) {
|
||||
insertQuery.run({ prefix, allow });
|
||||
} else {
|
||||
deleteQuery.run(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getByPrefixQuery = db.prepare<
|
||||
[prefix: string],
|
||||
Pick<FilePermissions, "allow">
|
||||
>(/* SQL */ `
|
||||
SELECT allow
|
||||
FROM permissions
|
||||
WHERE ? GLOB prefix || '*'
|
||||
ORDER BY LENGTH(prefix) DESC
|
||||
LIMIT 1;
|
||||
`);
|
||||
const getExactQuery = db.prepare<
|
||||
[file: string],
|
||||
Pick<FilePermissions, "allow">
|
||||
>(/* SQL */ `
|
||||
SELECT allow FROM permissions WHERE ? == prefix
|
||||
`);
|
||||
|
||||
const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
|
||||
REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
|
||||
`);
|
||||
const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
|
||||
DELETE FROM permissions WHERE prefix = ?;
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
const db = getDb("cache.sqlite");
|
||||
|
||||
db.table(
|
||||
"permissions",
|
||||
/* SQL */ `
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
allow INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
);
|
||||
export class FilePermissions {
|
||||
prefix!: string;
|
||||
/** Currently set to 1 always */
|
||||
allow!: number;
|
||||
|
||||
// -- static ops --
|
||||
static getByPrefix(filePath: string): number {
|
||||
return getByPrefixQuery.get(filePath)?.allow ?? 0;
|
||||
}
|
||||
|
||||
static getExact(filePath: string): number {
|
||||
return getExactQuery.get(filePath)?.allow ?? 0;
|
||||
}
|
||||
|
||||
static setPermissions(prefix: string, allow: number) {
|
||||
if (allow) {
|
||||
insertQuery.run({ prefix, allow });
|
||||
} else {
|
||||
deleteQuery.run(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getByPrefixQuery = db.prepare<
|
||||
[prefix: string],
|
||||
Pick<FilePermissions, "allow">
|
||||
>(/* SQL */ `
|
||||
SELECT allow
|
||||
FROM permissions
|
||||
WHERE ? GLOB prefix || '*'
|
||||
ORDER BY LENGTH(prefix) DESC
|
||||
LIMIT 1;
|
||||
`);
|
||||
const getExactQuery = db.prepare<
|
||||
[file: string],
|
||||
Pick<FilePermissions, "allow">
|
||||
>(/* SQL */ `
|
||||
SELECT allow FROM permissions WHERE ? == prefix
|
||||
`);
|
||||
|
||||
const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
|
||||
REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
|
||||
`);
|
||||
const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
|
||||
DELETE FROM permissions WHERE prefix = ?;
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
|
|
|
@ -1,436 +1,436 @@
|
|||
const db = getDb("cache.sqlite");
|
||||
db.table(
|
||||
"media_files",
|
||||
/* SQL */ `
|
||||
create table media_files (
|
||||
id integer primary key autoincrement,
|
||||
parent_id integer,
|
||||
path text unique,
|
||||
kind integer not null,
|
||||
timestamp integer not null,
|
||||
timestamp_updated integer not null default current_timestamp,
|
||||
hash text not null,
|
||||
size integer not null,
|
||||
duration integer not null default 0,
|
||||
dimensions text not null default "",
|
||||
contents text not null,
|
||||
dirsort text,
|
||||
processed integer not null,
|
||||
processors text not null default "",
|
||||
foreign key (parent_id) references media_files(id)
|
||||
);
|
||||
-- index for quickly looking up files by path
|
||||
create index media_files_path on media_files (path);
|
||||
-- index for quickly looking up children
|
||||
create index media_files_parent_id on media_files (parent_id);
|
||||
-- index for quickly looking up recursive file children
|
||||
create index media_files_file_children on media_files (kind, path);
|
||||
-- index for finding directories that need to be processed
|
||||
create index media_files_directory_processed on media_files (kind, processed);
|
||||
`,
|
||||
);
|
||||
|
||||
export enum MediaFileKind {
|
||||
directory = 0,
|
||||
file = 1,
|
||||
}
|
||||
export class MediaFile {
|
||||
id!: number;
|
||||
parent_id!: number | null;
|
||||
/**
|
||||
* Has leading slash, does not have `/file` prefix.
|
||||
* @example "/2025/waterfalls/waterfalls.mp3"
|
||||
*/
|
||||
path!: string;
|
||||
kind!: MediaFileKind;
|
||||
private timestamp!: number;
|
||||
private timestamp_updated!: number;
|
||||
/** for mp3/mp4 files, measured in seconds */
|
||||
duration?: number;
|
||||
/** for images and videos, the dimensions. Two numbers split by `x` */
|
||||
dimensions?: string;
|
||||
/**
|
||||
* sha1 of
|
||||
* - files: the contents
|
||||
* - directories: the JSON array of strings + the content of `readme.txt`
|
||||
* this is used
|
||||
* - to inform changes in caching mechanisms (etag, page render cache)
|
||||
* - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
|
||||
*/
|
||||
hash!: string;
|
||||
/**
|
||||
* Depends on the file kind.
|
||||
*
|
||||
* - For directories, this is the contents of `readme.txt`, if it exists.
|
||||
* - Otherwise, it is an empty string.
|
||||
*/
|
||||
contents!: string;
|
||||
/**
|
||||
* For directories, if this is set, it is a JSON-encoded array of the explicit
|
||||
* sorting order. Derived off of `.dirsort` files.
|
||||
*/
|
||||
dirsort!: string | null;
|
||||
/** in bytes */
|
||||
size!: number;
|
||||
/**
|
||||
* 0 - not processed
|
||||
* non-zero - processed
|
||||
*
|
||||
* file: a bit-field of the processors.
|
||||
* directory: this is for re-indexing contents
|
||||
*/
|
||||
processed!: number;
|
||||
processors!: string;
|
||||
|
||||
// -- instance ops --
|
||||
get date() {
|
||||
return new Date(this.timestamp);
|
||||
}
|
||||
get lastUpdateDate() {
|
||||
return new Date(this.timestamp_updated);
|
||||
}
|
||||
parseDimensions() {
|
||||
const dimensions = this.dimensions;
|
||||
if (!dimensions) return null;
|
||||
const [width, height] = dimensions.split("x").map(Number);
|
||||
return { width, height };
|
||||
}
|
||||
get basename() {
|
||||
return path.basename(this.path);
|
||||
}
|
||||
get basenameWithoutExt() {
|
||||
return path.basename(this.path, path.extname(this.path));
|
||||
}
|
||||
get extension() {
|
||||
return path.extname(this.path);
|
||||
}
|
||||
getChildren() {
|
||||
return MediaFile.getChildren(this.id)
|
||||
.filter((file) => !file.basename.startsWith("."));
|
||||
}
|
||||
getPublicChildren() {
|
||||
const children = MediaFile.getChildren(this.id);
|
||||
if (FilePermissions.getByPrefix(this.path) == 0) {
|
||||
return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
getParent() {
|
||||
const dirPath = this.path;
|
||||
if (dirPath === "/") return null;
|
||||
const parentPath = path.dirname(dirPath);
|
||||
if (parentPath === dirPath) return null;
|
||||
const result = MediaFile.getByPath(parentPath);
|
||||
if (!result) return null;
|
||||
ASSERT(result.kind === MediaFileKind.directory);
|
||||
return result;
|
||||
}
|
||||
setProcessed(processed: number) {
|
||||
setProcessedQuery.run({ id: this.id, processed });
|
||||
this.processed = processed;
|
||||
}
|
||||
setProcessors(processed: number, processors: string) {
|
||||
setProcessorsQuery.run({ id: this.id, processed, processors });
|
||||
this.processed = processed;
|
||||
this.processors = processors;
|
||||
}
|
||||
setDuration(duration: number) {
|
||||
setDurationQuery.run({ id: this.id, duration });
|
||||
this.duration = duration;
|
||||
}
|
||||
setDimensions(dimensions: string) {
|
||||
setDimensionsQuery.run({ id: this.id, dimensions });
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
setContents(contents: string) {
|
||||
setContentsQuery.run({ id: this.id, contents });
|
||||
this.contents = contents;
|
||||
}
|
||||
getRecursiveFileChildren() {
|
||||
if (this.kind !== MediaFileKind.directory) return [];
|
||||
return getChildrenFilesRecursiveQuery.array(this.path + "/");
|
||||
}
|
||||
delete() {
|
||||
deleteCascadeQuery.run({ id: this.id });
|
||||
}
|
||||
|
||||
// -- static ops --
|
||||
static getByPath(filePath: string): MediaFile | null {
|
||||
const result = getByPathQuery.get(filePath);
|
||||
if (result) return result;
|
||||
if (filePath === "/") {
|
||||
return Object.assign(new MediaFile(), {
|
||||
id: 0,
|
||||
parent_id: null,
|
||||
path: "/",
|
||||
kind: MediaFileKind.directory,
|
||||
timestamp: 0,
|
||||
timestamp_updated: Date.now(),
|
||||
hash: "0".repeat(40),
|
||||
contents: "the file scanner has not been run yet",
|
||||
dirsort: null,
|
||||
size: 0,
|
||||
processed: 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static createFile({
|
||||
path: filePath,
|
||||
date,
|
||||
hash,
|
||||
size,
|
||||
duration,
|
||||
dimensions,
|
||||
contents,
|
||||
}: CreateFile) {
|
||||
ASSERT(
|
||||
!filePath.includes("\\") && filePath.startsWith("/"),
|
||||
`Invalid path: ${filePath}`,
|
||||
);
|
||||
return createFileQuery.getNonNull({
|
||||
path: filePath,
|
||||
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
|
||||
timestamp: date.getTime(),
|
||||
timestampUpdated: Date.now(),
|
||||
hash,
|
||||
size,
|
||||
duration,
|
||||
dimensions,
|
||||
contents,
|
||||
});
|
||||
}
|
||||
static getOrPutDirectoryId(filePath: string) {
|
||||
ASSERT(
|
||||
!filePath.includes("\\") && filePath.startsWith("/"),
|
||||
`Invalid path: ${filePath}`,
|
||||
);
|
||||
filePath = path.normalize(filePath);
|
||||
const row = getDirectoryIdQuery.get(filePath);
|
||||
if (row) return row.id;
|
||||
let current = filePath;
|
||||
let parts = [];
|
||||
let parentId: null | number = null;
|
||||
if (filePath === "/") {
|
||||
return createDirectoryQuery.getNonNull({
|
||||
path: filePath,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
// walk up the path until we find a directory that exists
|
||||
do {
|
||||
parts.unshift(path.basename(current));
|
||||
current = path.dirname(current);
|
||||
parentId = getDirectoryIdQuery.get(current)?.id ?? null;
|
||||
} while (parentId == undefined && current !== "/");
|
||||
if (parentId == undefined) {
|
||||
parentId = createDirectoryQuery.getNonNull({
|
||||
path: current,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
// walk back down the path, creating directories as needed
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part);
|
||||
ASSERT(parentId != undefined);
|
||||
parentId = createDirectoryQuery.getNonNull({
|
||||
path: current,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
static markDirectoryProcessed({
|
||||
id,
|
||||
timestamp,
|
||||
contents,
|
||||
size,
|
||||
hash,
|
||||
dirsort,
|
||||
}: MarkDirectoryProcessed) {
|
||||
markDirectoryProcessedQuery.get({
|
||||
id,
|
||||
timestamp: timestamp.getTime(),
|
||||
contents,
|
||||
dirsort: dirsort ? JSON.stringify(dirsort) : "",
|
||||
hash,
|
||||
size,
|
||||
});
|
||||
}
|
||||
static setProcessed(id: number, processed: number) {
|
||||
setProcessedQuery.run({ id, processed });
|
||||
}
|
||||
static createOrUpdateDirectory(dirPath: string) {
|
||||
const id = MediaFile.getOrPutDirectoryId(dirPath);
|
||||
return updateDirectoryQuery.get(id);
|
||||
}
|
||||
static getChildren(id: number) {
|
||||
return getChildrenQuery.array(id);
|
||||
}
|
||||
static db = db;
|
||||
}
|
||||
|
||||
// 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 exists and the hash is different, sets `compress` to 0.
|
||||
interface CreateFile {
|
||||
path: string;
|
||||
date: Date;
|
||||
hash: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
dimensions: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
// Set the `processed` flag true and update the metadata for a directory
|
||||
export interface MarkDirectoryProcessed {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
contents: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
dirsort: null | string[];
|
||||
}
|
||||
|
||||
export interface DirConfig {
|
||||
/** Overridden sorting */
|
||||
sort: string[];
|
||||
}
|
||||
|
||||
// -- queries --
|
||||
|
||||
// Get a directory ID by path, creating it if it doesn't exist
|
||||
const createDirectoryQuery = db.prepare<
|
||||
[{ path: string; parentId: number | null }],
|
||||
{ id: number }
|
||||
>(
|
||||
/* SQL */ `
|
||||
insert into media_files (
|
||||
path, parent_id, kind, timestamp, hash, size,
|
||||
duration, dimensions, contents, dirsort, processed)
|
||||
values (
|
||||
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
|
||||
0, '', '', '', 0)
|
||||
returning id;
|
||||
`,
|
||||
);
|
||||
const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
|
||||
SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
|
||||
`);
|
||||
const createFileQuery = db.prepare<[{
|
||||
path: string;
|
||||
parentId: number;
|
||||
timestamp: number;
|
||||
timestampUpdated: number;
|
||||
hash: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
dimensions: string;
|
||||
contents: string;
|
||||
}], void>(/* SQL */ `
|
||||
insert into media_files (
|
||||
path, parent_id, kind, timestamp, timestamp_updated, hash,
|
||||
size, duration, dimensions, contents, processed)
|
||||
values (
|
||||
$path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
|
||||
$hash, $size, $duration, $dimensions, $contents, 0)
|
||||
on conflict(path) do update set
|
||||
timestamp = excluded.timestamp,
|
||||
timestamp_updated = excluded.timestamp_updated,
|
||||
duration = excluded.duration,
|
||||
size = excluded.size,
|
||||
contents = excluded.contents,
|
||||
processed = case
|
||||
when media_files.hash != excluded.hash then 0
|
||||
else media_files.processed
|
||||
end
|
||||
returning *;
|
||||
`).as(MediaFile);
|
||||
const setProcessedQuery = db.prepare<[{
|
||||
id: number;
|
||||
processed: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set processed = $processed where id = $id;
|
||||
`);
|
||||
const setProcessorsQuery = db.prepare<[{
|
||||
id: number;
|
||||
processed: number;
|
||||
processors: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set
|
||||
processed = $processed,
|
||||
processors = $processors
|
||||
where id = $id;
|
||||
`);
|
||||
const setDurationQuery = db.prepare<[{
|
||||
id: number;
|
||||
duration: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set duration = $duration where id = $id;
|
||||
`);
|
||||
const setDimensionsQuery = db.prepare<[{
|
||||
id: number;
|
||||
dimensions: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set dimensions = $dimensions where id = $id;
|
||||
`);
|
||||
const setContentsQuery = db.prepare<[{
|
||||
id: number;
|
||||
contents: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set contents = $contents where id = $id;
|
||||
`);
|
||||
const getByPathQuery = db.prepare<[string]>(/* SQL */ `
|
||||
select * from media_files where path = ?;
|
||||
`).as(MediaFile);
|
||||
const markDirectoryProcessedQuery = db.prepare<[{
|
||||
timestamp: number;
|
||||
contents: string;
|
||||
dirsort: string;
|
||||
hash: string;
|
||||
size: number;
|
||||
id: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set
|
||||
processed = 1,
|
||||
timestamp = $timestamp,
|
||||
contents = $contents,
|
||||
dirsort = $dirsort,
|
||||
hash = $hash,
|
||||
size = $size
|
||||
where id = $id;
|
||||
`);
|
||||
const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
|
||||
update media_files set processed = 0 where id = ?;
|
||||
`);
|
||||
|
||||
const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
|
||||
select * from media_files where parent_id = ?;
|
||||
`).as(MediaFile);
|
||||
const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
|
||||
select * from media_files
|
||||
where path like ? || '%'
|
||||
and kind = ${MediaFileKind.file}
|
||||
`).as(MediaFile);
|
||||
const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
|
||||
with recursive items as (
|
||||
select id, parent_id from media_files where id = $id
|
||||
union all
|
||||
select p.id, p.parent_id
|
||||
from media_files p
|
||||
join items c on p.id = c.parent_id
|
||||
where p.parent_id is not null
|
||||
and not exists (
|
||||
select 1 from media_files child
|
||||
where child.parent_id = p.id
|
||||
and child.id <> c.id
|
||||
)
|
||||
)
|
||||
delete from media_files
|
||||
where id in (select id from items)
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
import * as path from "node:path/posix";
|
||||
import { FilePermissions } from "./FilePermissions.ts";
|
||||
const db = getDb("cache.sqlite");
|
||||
db.table(
|
||||
"media_files",
|
||||
/* SQL */ `
|
||||
create table media_files (
|
||||
id integer primary key autoincrement,
|
||||
parent_id integer,
|
||||
path text unique,
|
||||
kind integer not null,
|
||||
timestamp integer not null,
|
||||
timestamp_updated integer not null default current_timestamp,
|
||||
hash text not null,
|
||||
size integer not null,
|
||||
duration integer not null default 0,
|
||||
dimensions text not null default "",
|
||||
contents text not null,
|
||||
dirsort text,
|
||||
processed integer not null,
|
||||
processors text not null default "",
|
||||
foreign key (parent_id) references media_files(id)
|
||||
);
|
||||
-- index for quickly looking up files by path
|
||||
create index media_files_path on media_files (path);
|
||||
-- index for quickly looking up children
|
||||
create index media_files_parent_id on media_files (parent_id);
|
||||
-- index for quickly looking up recursive file children
|
||||
create index media_files_file_children on media_files (kind, path);
|
||||
-- index for finding directories that need to be processed
|
||||
create index media_files_directory_processed on media_files (kind, processed);
|
||||
`,
|
||||
);
|
||||
|
||||
export enum MediaFileKind {
|
||||
directory = 0,
|
||||
file = 1,
|
||||
}
|
||||
export class MediaFile {
|
||||
id!: number;
|
||||
parent_id!: number | null;
|
||||
/**
|
||||
* Has leading slash, does not have `/file` prefix.
|
||||
* @example "/2025/waterfalls/waterfalls.mp3"
|
||||
*/
|
||||
path!: string;
|
||||
kind!: MediaFileKind;
|
||||
private timestamp!: number;
|
||||
private timestamp_updated!: number;
|
||||
/** for mp3/mp4 files, measured in seconds */
|
||||
duration?: number;
|
||||
/** for images and videos, the dimensions. Two numbers split by `x` */
|
||||
dimensions?: string;
|
||||
/**
|
||||
* sha1 of
|
||||
* - files: the contents
|
||||
* - directories: the JSON array of strings + the content of `readme.txt`
|
||||
* this is used
|
||||
* - to inform changes in caching mechanisms (etag, page render cache)
|
||||
* - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
|
||||
*/
|
||||
hash!: string;
|
||||
/**
|
||||
* Depends on the file kind.
|
||||
*
|
||||
* - For directories, this is the contents of `readme.txt`, if it exists.
|
||||
* - Otherwise, it is an empty string.
|
||||
*/
|
||||
contents!: string;
|
||||
/**
|
||||
* For directories, if this is set, it is a JSON-encoded array of the explicit
|
||||
* sorting order. Derived off of `.dirsort` files.
|
||||
*/
|
||||
dirsort!: string | null;
|
||||
/** in bytes */
|
||||
size!: number;
|
||||
/**
|
||||
* 0 - not processed
|
||||
* non-zero - processed
|
||||
*
|
||||
* file: a bit-field of the processors.
|
||||
* directory: this is for re-indexing contents
|
||||
*/
|
||||
processed!: number;
|
||||
processors!: string;
|
||||
|
||||
// -- instance ops --
|
||||
get date() {
|
||||
return new Date(this.timestamp);
|
||||
}
|
||||
get lastUpdateDate() {
|
||||
return new Date(this.timestamp_updated);
|
||||
}
|
||||
parseDimensions() {
|
||||
const dimensions = this.dimensions;
|
||||
if (!dimensions) return null;
|
||||
const [width, height] = dimensions.split("x").map(Number);
|
||||
return { width, height };
|
||||
}
|
||||
get basename() {
|
||||
return path.basename(this.path);
|
||||
}
|
||||
get basenameWithoutExt() {
|
||||
return path.basename(this.path, path.extname(this.path));
|
||||
}
|
||||
get extension() {
|
||||
return path.extname(this.path);
|
||||
}
|
||||
getChildren() {
|
||||
return MediaFile.getChildren(this.id)
|
||||
.filter((file) => !file.basename.startsWith("."));
|
||||
}
|
||||
getPublicChildren() {
|
||||
const children = MediaFile.getChildren(this.id);
|
||||
if (FilePermissions.getByPrefix(this.path) == 0) {
|
||||
return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
getParent() {
|
||||
const dirPath = this.path;
|
||||
if (dirPath === "/") return null;
|
||||
const parentPath = path.dirname(dirPath);
|
||||
if (parentPath === dirPath) return null;
|
||||
const result = MediaFile.getByPath(parentPath);
|
||||
if (!result) return null;
|
||||
ASSERT(result.kind === MediaFileKind.directory);
|
||||
return result;
|
||||
}
|
||||
setProcessed(processed: number) {
|
||||
setProcessedQuery.run({ id: this.id, processed });
|
||||
this.processed = processed;
|
||||
}
|
||||
setProcessors(processed: number, processors: string) {
|
||||
setProcessorsQuery.run({ id: this.id, processed, processors });
|
||||
this.processed = processed;
|
||||
this.processors = processors;
|
||||
}
|
||||
setDuration(duration: number) {
|
||||
setDurationQuery.run({ id: this.id, duration });
|
||||
this.duration = duration;
|
||||
}
|
||||
setDimensions(dimensions: string) {
|
||||
setDimensionsQuery.run({ id: this.id, dimensions });
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
setContents(contents: string) {
|
||||
setContentsQuery.run({ id: this.id, contents });
|
||||
this.contents = contents;
|
||||
}
|
||||
getRecursiveFileChildren() {
|
||||
if (this.kind !== MediaFileKind.directory) return [];
|
||||
return getChildrenFilesRecursiveQuery.array(this.path + "/");
|
||||
}
|
||||
delete() {
|
||||
deleteCascadeQuery.run({ id: this.id });
|
||||
}
|
||||
|
||||
// -- static ops --
|
||||
static getByPath(filePath: string): MediaFile | null {
|
||||
const result = getByPathQuery.get(filePath);
|
||||
if (result) return result;
|
||||
if (filePath === "/") {
|
||||
return Object.assign(new MediaFile(), {
|
||||
id: 0,
|
||||
parent_id: null,
|
||||
path: "/",
|
||||
kind: MediaFileKind.directory,
|
||||
timestamp: 0,
|
||||
timestamp_updated: Date.now(),
|
||||
hash: "0".repeat(40),
|
||||
contents: "the file scanner has not been run yet",
|
||||
dirsort: null,
|
||||
size: 0,
|
||||
processed: 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
static createFile({
|
||||
path: filePath,
|
||||
date,
|
||||
hash,
|
||||
size,
|
||||
duration,
|
||||
dimensions,
|
||||
contents,
|
||||
}: CreateFile) {
|
||||
ASSERT(
|
||||
!filePath.includes("\\") && filePath.startsWith("/"),
|
||||
`Invalid path: ${filePath}`,
|
||||
);
|
||||
return createFileQuery.getNonNull({
|
||||
path: filePath,
|
||||
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
|
||||
timestamp: date.getTime(),
|
||||
timestampUpdated: Date.now(),
|
||||
hash,
|
||||
size,
|
||||
duration,
|
||||
dimensions,
|
||||
contents,
|
||||
});
|
||||
}
|
||||
static getOrPutDirectoryId(filePath: string) {
|
||||
ASSERT(
|
||||
!filePath.includes("\\") && filePath.startsWith("/"),
|
||||
`Invalid path: ${filePath}`,
|
||||
);
|
||||
filePath = path.normalize(filePath);
|
||||
const row = getDirectoryIdQuery.get(filePath);
|
||||
if (row) return row.id;
|
||||
let current = filePath;
|
||||
let parts = [];
|
||||
let parentId: null | number = null;
|
||||
if (filePath === "/") {
|
||||
return createDirectoryQuery.getNonNull({
|
||||
path: filePath,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
// walk up the path until we find a directory that exists
|
||||
do {
|
||||
parts.unshift(path.basename(current));
|
||||
current = path.dirname(current);
|
||||
parentId = getDirectoryIdQuery.get(current)?.id ?? null;
|
||||
} while (parentId == undefined && current !== "/");
|
||||
if (parentId == undefined) {
|
||||
parentId = createDirectoryQuery.getNonNull({
|
||||
path: current,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
// walk back down the path, creating directories as needed
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part);
|
||||
ASSERT(parentId != undefined);
|
||||
parentId = createDirectoryQuery.getNonNull({
|
||||
path: current,
|
||||
parentId,
|
||||
}).id;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
static markDirectoryProcessed({
|
||||
id,
|
||||
timestamp,
|
||||
contents,
|
||||
size,
|
||||
hash,
|
||||
dirsort,
|
||||
}: MarkDirectoryProcessed) {
|
||||
markDirectoryProcessedQuery.get({
|
||||
id,
|
||||
timestamp: timestamp.getTime(),
|
||||
contents,
|
||||
dirsort: dirsort ? JSON.stringify(dirsort) : "",
|
||||
hash,
|
||||
size,
|
||||
});
|
||||
}
|
||||
static setProcessed(id: number, processed: number) {
|
||||
setProcessedQuery.run({ id, processed });
|
||||
}
|
||||
static createOrUpdateDirectory(dirPath: string) {
|
||||
const id = MediaFile.getOrPutDirectoryId(dirPath);
|
||||
return updateDirectoryQuery.get(id);
|
||||
}
|
||||
static getChildren(id: number) {
|
||||
return getChildrenQuery.array(id);
|
||||
}
|
||||
static db = db;
|
||||
}
|
||||
|
||||
// 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 exists and the hash is different, sets `compress` to 0.
|
||||
interface CreateFile {
|
||||
path: string;
|
||||
date: Date;
|
||||
hash: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
dimensions: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
// Set the `processed` flag true and update the metadata for a directory
|
||||
export interface MarkDirectoryProcessed {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
contents: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
dirsort: null | string[];
|
||||
}
|
||||
|
||||
export interface DirConfig {
|
||||
/** Overridden sorting */
|
||||
sort: string[];
|
||||
}
|
||||
|
||||
// -- queries --
|
||||
|
||||
// Get a directory ID by path, creating it if it doesn't exist
|
||||
const createDirectoryQuery = db.prepare<
|
||||
[{ path: string; parentId: number | null }],
|
||||
{ id: number }
|
||||
>(
|
||||
/* SQL */ `
|
||||
insert into media_files (
|
||||
path, parent_id, kind, timestamp, hash, size,
|
||||
duration, dimensions, contents, dirsort, processed)
|
||||
values (
|
||||
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
|
||||
0, '', '', '', 0)
|
||||
returning id;
|
||||
`,
|
||||
);
|
||||
const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
|
||||
SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
|
||||
`);
|
||||
const createFileQuery = db.prepare<[{
|
||||
path: string;
|
||||
parentId: number;
|
||||
timestamp: number;
|
||||
timestampUpdated: number;
|
||||
hash: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
dimensions: string;
|
||||
contents: string;
|
||||
}], void>(/* SQL */ `
|
||||
insert into media_files (
|
||||
path, parent_id, kind, timestamp, timestamp_updated, hash,
|
||||
size, duration, dimensions, contents, processed)
|
||||
values (
|
||||
$path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
|
||||
$hash, $size, $duration, $dimensions, $contents, 0)
|
||||
on conflict(path) do update set
|
||||
timestamp = excluded.timestamp,
|
||||
timestamp_updated = excluded.timestamp_updated,
|
||||
duration = excluded.duration,
|
||||
size = excluded.size,
|
||||
contents = excluded.contents,
|
||||
processed = case
|
||||
when media_files.hash != excluded.hash then 0
|
||||
else media_files.processed
|
||||
end
|
||||
returning *;
|
||||
`).as(MediaFile);
|
||||
const setProcessedQuery = db.prepare<[{
|
||||
id: number;
|
||||
processed: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set processed = $processed where id = $id;
|
||||
`);
|
||||
const setProcessorsQuery = db.prepare<[{
|
||||
id: number;
|
||||
processed: number;
|
||||
processors: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set
|
||||
processed = $processed,
|
||||
processors = $processors
|
||||
where id = $id;
|
||||
`);
|
||||
const setDurationQuery = db.prepare<[{
|
||||
id: number;
|
||||
duration: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set duration = $duration where id = $id;
|
||||
`);
|
||||
const setDimensionsQuery = db.prepare<[{
|
||||
id: number;
|
||||
dimensions: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set dimensions = $dimensions where id = $id;
|
||||
`);
|
||||
const setContentsQuery = db.prepare<[{
|
||||
id: number;
|
||||
contents: string;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set contents = $contents where id = $id;
|
||||
`);
|
||||
const getByPathQuery = db.prepare<[string]>(/* SQL */ `
|
||||
select * from media_files where path = ?;
|
||||
`).as(MediaFile);
|
||||
const markDirectoryProcessedQuery = db.prepare<[{
|
||||
timestamp: number;
|
||||
contents: string;
|
||||
dirsort: string;
|
||||
hash: string;
|
||||
size: number;
|
||||
id: number;
|
||||
}]>(/* SQL */ `
|
||||
update media_files set
|
||||
processed = 1,
|
||||
timestamp = $timestamp,
|
||||
contents = $contents,
|
||||
dirsort = $dirsort,
|
||||
hash = $hash,
|
||||
size = $size
|
||||
where id = $id;
|
||||
`);
|
||||
const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
|
||||
update media_files set processed = 0 where id = ?;
|
||||
`);
|
||||
|
||||
const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
|
||||
select * from media_files where parent_id = ?;
|
||||
`).as(MediaFile);
|
||||
const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
|
||||
select * from media_files
|
||||
where path like ? || '%'
|
||||
and kind = ${MediaFileKind.file}
|
||||
`).as(MediaFile);
|
||||
const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
|
||||
with recursive items as (
|
||||
select id, parent_id from media_files where id = $id
|
||||
union all
|
||||
select p.id, p.parent_id
|
||||
from media_files p
|
||||
join items c on p.id = c.parent_id
|
||||
where p.parent_id is not null
|
||||
and not exists (
|
||||
select 1 from media_files child
|
||||
where child.parent_id = p.id
|
||||
and child.id <> c.id
|
||||
)
|
||||
)
|
||||
delete from media_files
|
||||
where id in (select id from items)
|
||||
`);
|
||||
|
||||
import { getDb } from "#sitegen/sqlite";
|
||||
import * as path from "node:path/posix";
|
||||
import { FilePermissions } from "./FilePermissions.ts";
|
||||
|
|
|
@ -1,34 +1,34 @@
|
|||
import { MediaFile } from "../models/MediaFile.ts";
|
||||
import { MediaPanel } from "../views/clofi.tsx";
|
||||
import { addScript } from "#sitegen";
|
||||
|
||||
export const theme = {
|
||||
bg: "#312652",
|
||||
fg: "#f0f0ff",
|
||||
primary: "#fabe32",
|
||||
};
|
||||
|
||||
export const meta = { title: "file not found" };
|
||||
|
||||
export default function CotyledonPage() {
|
||||
addScript("../scripts/canvas_cotyledon.client.ts");
|
||||
return (
|
||||
<div class="files ctld ctld-sb">
|
||||
<MediaPanel
|
||||
file={MediaFile.getByPath("/")!}
|
||||
isLast={false}
|
||||
activeFilename={null}
|
||||
hasCotyledonCookie={false}
|
||||
/>
|
||||
<div class="panel last">
|
||||
<div className="header"></div>
|
||||
<div className="content file-view notfound">
|
||||
<p>this file does not exist ...</p>
|
||||
<p>
|
||||
<a href="/file">return</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { MediaFile } from "../models/MediaFile.ts";
|
||||
import { MediaPanel } from "../views/clofi.tsx";
|
||||
import { addScript } from "#sitegen";
|
||||
|
||||
export const theme = {
|
||||
bg: "#312652",
|
||||
fg: "#f0f0ff",
|
||||
primary: "#fabe32",
|
||||
};
|
||||
|
||||
export const meta = { title: "file not found" };
|
||||
|
||||
export default function CotyledonPage() {
|
||||
addScript("../scripts/canvas_cotyledon.client.ts");
|
||||
return (
|
||||
<div class="files ctld ctld-sb">
|
||||
<MediaPanel
|
||||
file={MediaFile.getByPath("/")!}
|
||||
isLast={false}
|
||||
activeFilename={null}
|
||||
hasCotyledonCookie={false}
|
||||
/>
|
||||
<div class="panel last">
|
||||
<div className="header"></div>
|
||||
<div className="content file-view notfound">
|
||||
<p>this file does not exist ...</p>
|
||||
<p>
|
||||
<a href="/file">return</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,143 +1,147 @@
|
|||
// -- file extension rules --
|
||||
|
||||
/** Extensions that must have EXIF/etc data stripped */
|
||||
export const extScrubExif = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".mov",
|
||||
".mp4",
|
||||
".m4a",
|
||||
]);
|
||||
/** Extensions that rendered syntax-highlighted code */
|
||||
export const extsCode = new Map<string, highlight.Language>(Object.entries({
|
||||
".json": "json",
|
||||
".toml": "toml",
|
||||
".ts": "ts",
|
||||
".js": "ts",
|
||||
".tsx": "tsx",
|
||||
".jsx": "tsx",
|
||||
".css": "css",
|
||||
".py": "python",
|
||||
".lua": "lua",
|
||||
".sh": "shell",
|
||||
".bat": "dosbatch",
|
||||
".ps1": "powershell",
|
||||
".cmd": "dosbatch",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".zig": "zig",
|
||||
".astro": "astro",
|
||||
".mdx": "mdx",
|
||||
".xml": "xml",
|
||||
".jsonc": "json",
|
||||
".php": "php",
|
||||
".patch": "diff",
|
||||
".diff": "diff",
|
||||
}));
|
||||
/** These files show an audio embed. */
|
||||
export const extsAudio = new Set([
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
]);
|
||||
/** These files show a video embed. */
|
||||
export const extsVideo = new Set([
|
||||
".mp4",
|
||||
".mkv",
|
||||
".webm",
|
||||
".avi",
|
||||
".mov",
|
||||
]);
|
||||
/** These files show an image embed */
|
||||
export const extsImage = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".heic",
|
||||
".svg",
|
||||
]);
|
||||
|
||||
/** These files populate `duration` using `ffprobe` */
|
||||
export const extsDuration = new Set([...extsAudio, ...extsVideo]);
|
||||
/** These files populate `dimensions` using `ffprobe` */
|
||||
export const extsDimensions = new Set([...extsImage, ...extsVideo]);
|
||||
|
||||
/** These files read file contents into `contents`, as-is */
|
||||
export const extsReadContents = new Set([".txt", ".chat"]);
|
||||
|
||||
export const extsArchive = new Set([
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Formats which are already compression formats, meaning a pass
|
||||
* through zstd would offer little to negative benefits
|
||||
*/
|
||||
export const extsPreCompressed = new Set([
|
||||
...extsAudio,
|
||||
...extsVideo,
|
||||
...extsImage,
|
||||
...extsArchive,
|
||||
// TODO: are any of these NOT good for compression
|
||||
]);
|
||||
|
||||
export function fileIcon(
|
||||
file: Pick<MediaFile, "kind" | "basename" | "path">,
|
||||
dirOpen?: boolean,
|
||||
) {
|
||||
const { kind, basename } = file;
|
||||
if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
|
||||
|
||||
// -- special cases --
|
||||
if (file.path === "/2024/for everyone") return "snow";
|
||||
|
||||
// -- basename cases --
|
||||
if (basename === "readme.txt") return "readme";
|
||||
|
||||
// -- extension cases --
|
||||
const ext = path.extname(file.basename).toLowerCase();
|
||||
if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
|
||||
if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
|
||||
if (ext === ".blend") return "blend";
|
||||
if (ext === ".chat") return "chat";
|
||||
if (ext === ".html") return "webpage";
|
||||
if (ext === ".lnk") return "link";
|
||||
if (ext === ".txt" || ext === ".md") return "text";
|
||||
|
||||
// -- extension categories --
|
||||
if (extsVideo.has(ext)) return "video";
|
||||
if (extsAudio.has(ext)) return "audio";
|
||||
if (extsImage.has(ext)) return "image";
|
||||
if (extsArchive.has(ext)) return "archive";
|
||||
if (extsCode.has(ext)) return "code";
|
||||
|
||||
return "file";
|
||||
}
|
||||
|
||||
// -- viewer rules --
|
||||
const pathToCanvas = new Map<string, string>(Object.entries({
|
||||
"/2017": "2017",
|
||||
"/2018": "2018",
|
||||
"/2019": "2019",
|
||||
"/2020": "2020",
|
||||
"/2021": "2021",
|
||||
"/2022": "2022",
|
||||
"/2023": "2023",
|
||||
"/2024": "2024",
|
||||
}));
|
||||
|
||||
import type * as highlight from "./highlight.ts";
|
||||
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
|
||||
import * as path from "node:path";
|
||||
// -- file extension rules --
|
||||
|
||||
/** Extensions that must have EXIF/etc data stripped */
|
||||
export const extScrubExif = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".mov",
|
||||
".mp4",
|
||||
".m4a",
|
||||
]);
|
||||
/** Extensions that rendered syntax-highlighted code */
|
||||
export const extsCode = new Map<string, highlight.Language>(Object.entries({
|
||||
".json": "json",
|
||||
".toml": "toml",
|
||||
".ts": "ts",
|
||||
".js": "ts",
|
||||
".tsx": "tsx",
|
||||
".jsx": "tsx",
|
||||
".css": "css",
|
||||
".py": "python",
|
||||
".lua": "lua",
|
||||
".sh": "shell",
|
||||
".bat": "dosbatch",
|
||||
".ps1": "powershell",
|
||||
".cmd": "dosbatch",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".zig": "zig",
|
||||
".astro": "astro",
|
||||
".mdx": "mdx",
|
||||
".xml": "xml",
|
||||
".jsonc": "json",
|
||||
".php": "php",
|
||||
".patch": "diff",
|
||||
".diff": "diff",
|
||||
}));
|
||||
/** These files show an audio embed. */
|
||||
export const extsAudio = new Set([
|
||||
".mp3",
|
||||
".flac",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
]);
|
||||
/** These files show a video embed. */
|
||||
export const extsVideo = new Set([
|
||||
".mp4",
|
||||
".mkv",
|
||||
".webm",
|
||||
".avi",
|
||||
".mov",
|
||||
]);
|
||||
/** These files show an image embed */
|
||||
export const extsImage = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".avif",
|
||||
".heic",
|
||||
]);
|
||||
/** These files show an image embed, but aren't optimized */
|
||||
export const extsImageLike = new Set([
|
||||
...extsImage,
|
||||
".svg",
|
||||
".gif",
|
||||
]);
|
||||
|
||||
/** These files populate `duration` using `ffprobe` */
|
||||
export const extsDuration = new Set([...extsAudio, ...extsVideo]);
|
||||
/** These files populate `dimensions` using `ffprobe` */
|
||||
export const extsDimensions = new Set([...extsImage, ...extsVideo]);
|
||||
|
||||
/** These files read file contents into `contents`, as-is */
|
||||
export const extsReadContents = new Set([".txt", ".chat"]);
|
||||
|
||||
export const extsArchive = new Set([
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".bz2",
|
||||
".xz",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Formats which are already compression formats, meaning a pass
|
||||
* through zstd would offer little to negative benefits
|
||||
*/
|
||||
export const extsPreCompressed = new Set([
|
||||
...extsAudio,
|
||||
...extsVideo,
|
||||
...extsImage,
|
||||
...extsArchive,
|
||||
// TODO: are any of these NOT good for compression
|
||||
]);
|
||||
|
||||
export function fileIcon(
|
||||
file: Pick<MediaFile, "kind" | "basename" | "path">,
|
||||
dirOpen?: boolean,
|
||||
) {
|
||||
const { kind, basename } = file;
|
||||
if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
|
||||
|
||||
// -- special cases --
|
||||
if (file.path === "/2024/for everyone") return "snow";
|
||||
|
||||
// -- basename cases --
|
||||
if (basename === "readme.txt") return "readme";
|
||||
|
||||
// -- extension cases --
|
||||
const ext = path.extname(file.basename).toLowerCase();
|
||||
if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
|
||||
if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
|
||||
if (ext === ".blend") return "blend";
|
||||
if (ext === ".chat") return "chat";
|
||||
if (ext === ".html") return "webpage";
|
||||
if (ext === ".lnk") return "link";
|
||||
if (ext === ".txt" || ext === ".md") return "text";
|
||||
|
||||
// -- extension categories --
|
||||
if (extsVideo.has(ext)) return "video";
|
||||
if (extsAudio.has(ext)) return "audio";
|
||||
if (extsImage.has(ext)) return "image";
|
||||
if (extsArchive.has(ext)) return "archive";
|
||||
if (extsCode.has(ext)) return "code";
|
||||
|
||||
return "file";
|
||||
}
|
||||
|
||||
// -- viewer rules --
|
||||
const pathToCanvas = new Map<string, string>(Object.entries({
|
||||
"/2017": "2017",
|
||||
"/2018": "2018",
|
||||
"/2019": "2019",
|
||||
"/2020": "2020",
|
||||
"/2021": "2021",
|
||||
"/2022": "2022",
|
||||
"/2023": "2023",
|
||||
"/2024": "2024",
|
||||
}));
|
||||
|
||||
import type * as highlight from "./highlight.ts";
|
||||
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
|
||||
import * as path from "node:path";
|
||||
|
|
|
@ -1,58 +1,58 @@
|
|||
export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
|
||||
const children = dir.getPublicChildren();
|
||||
let readme: MediaFile | null = null;
|
||||
|
||||
const groups = {
|
||||
// years 2025 and onwards
|
||||
years: [] as MediaFile[],
|
||||
// named categories
|
||||
categories: [] as MediaFile[],
|
||||
// years 2017 to 2024
|
||||
cotyledon: [] as MediaFile[],
|
||||
};
|
||||
const colorMap: Record<string, string> = {
|
||||
years: "#a2ff91",
|
||||
categories: "#9c91ff",
|
||||
cotyledon: "#ff91ca",
|
||||
};
|
||||
for (const child of children) {
|
||||
const basename = child.basename;
|
||||
if (basename === "readme.txt") {
|
||||
readme = child;
|
||||
continue;
|
||||
}
|
||||
|
||||
const year = basename.match(/^(\d{4})/);
|
||||
if (year) {
|
||||
const n = parseInt(year[1]);
|
||||
if (n >= 2025) {
|
||||
groups.years.push(child);
|
||||
} else {
|
||||
groups.cotyledon.push(child);
|
||||
}
|
||||
} else {
|
||||
groups.categories.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
let sections = [];
|
||||
for (const [key, files] of Object.entries(groups)) {
|
||||
if (key === "cotyledon" && !hasCotyledonCookie) {
|
||||
continue;
|
||||
}
|
||||
if (key === "years" || key === "cotyledon") {
|
||||
files.sort((a, b) => {
|
||||
return b.basename.localeCompare(a.basename);
|
||||
});
|
||||
} else {
|
||||
files.sort((a, b) => {
|
||||
return a.basename.localeCompare(b.basename);
|
||||
});
|
||||
}
|
||||
sections.push({ key, titleColor: colorMap[key], files });
|
||||
}
|
||||
|
||||
return { readme, sections };
|
||||
}
|
||||
|
||||
import { MediaFile } from "./models/MediaFile.ts";
|
||||
export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
|
||||
const children = dir.getPublicChildren();
|
||||
let readme: MediaFile | null = null;
|
||||
|
||||
const groups = {
|
||||
// years 2025 and onwards
|
||||
years: [] as MediaFile[],
|
||||
// named categories
|
||||
categories: [] as MediaFile[],
|
||||
// years 2017 to 2024
|
||||
cotyledon: [] as MediaFile[],
|
||||
};
|
||||
const colorMap: Record<string, string> = {
|
||||
years: "#a2ff91",
|
||||
categories: "#9c91ff",
|
||||
cotyledon: "#ff91ca",
|
||||
};
|
||||
for (const child of children) {
|
||||
const basename = child.basename;
|
||||
if (basename === "readme.txt") {
|
||||
readme = child;
|
||||
continue;
|
||||
}
|
||||
|
||||
const year = basename.match(/^(\d{4})/);
|
||||
if (year) {
|
||||
const n = parseInt(year[1]);
|
||||
if (n >= 2025) {
|
||||
groups.years.push(child);
|
||||
} else {
|
||||
groups.cotyledon.push(child);
|
||||
}
|
||||
} else {
|
||||
groups.categories.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
let sections = [];
|
||||
for (const [key, files] of Object.entries(groups)) {
|
||||
if (key === "cotyledon" && !hasCotyledonCookie) {
|
||||
continue;
|
||||
}
|
||||
if (key === "years" || key === "cotyledon") {
|
||||
files.sort((a, b) => {
|
||||
return b.basename.localeCompare(a.basename);
|
||||
});
|
||||
} else {
|
||||
files.sort((a, b) => {
|
||||
return a.basename.localeCompare(b.basename);
|
||||
});
|
||||
}
|
||||
sections.push({ key, titleColor: colorMap[key], files });
|
||||
}
|
||||
|
||||
return { readme, sections };
|
||||
}
|
||||
|
||||
import { MediaFile } from "./models/MediaFile.ts";
|
||||
|
|
|
@ -99,14 +99,38 @@ export const imagePresets = [
|
|||
"6",
|
||||
],
|
||||
},
|
||||
// TODO: avif
|
||||
{
|
||||
ext: ".avif",
|
||||
args: [
|
||||
"-c:v",
|
||||
"libaom-av1",
|
||||
"-crf",
|
||||
"30",
|
||||
"-pix_fmt",
|
||||
"yuv420p10le",
|
||||
],
|
||||
},
|
||||
{
|
||||
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];
|
||||
|
||||
if (preset.codec === "av1") {
|
||||
|
|
|
@ -1,43 +1,43 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#lofi {
|
||||
padding: 32px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 3em;
|
||||
color: var(--primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
ul, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
ul {
|
||||
padding-right: 4em;
|
||||
}
|
||||
li a {
|
||||
display: block;
|
||||
color: white;
|
||||
line-height: 2em;
|
||||
padding: 0 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
li a:hover {
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
font-weight: bold;
|
||||
text-decoration: none!important;
|
||||
}
|
||||
.dir a {
|
||||
color: #99eeFF
|
||||
}
|
||||
.ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.meta {
|
||||
margin-left: 1em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#lofi {
|
||||
padding: 32px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 3em;
|
||||
color: var(--primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
ul, li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
ul {
|
||||
padding-right: 4em;
|
||||
}
|
||||
li a {
|
||||
display: block;
|
||||
color: white;
|
||||
line-height: 2em;
|
||||
padding: 0 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
li a:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
font-weight: bold;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.dir a {
|
||||
color: #99eeff;
|
||||
}
|
||||
.ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.meta {
|
||||
margin-left: 1em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
|
|
@ -1,75 +1,75 @@
|
|||
let friendPassword = "";
|
||||
try {
|
||||
friendPassword = require("./friends/hardcoded-password.ts").friendPassword;
|
||||
} catch {}
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
const cookieAge = 60 * 60 * 24 * 30; // 1 month
|
||||
|
||||
function checkFriendsCookie(c: Context) {
|
||||
const cookie = c.req.header("Cookie");
|
||||
if (!cookie) return false;
|
||||
const cookies = cookie.split("; ").map((x) => x.split("="));
|
||||
return cookies.some(
|
||||
(kv) =>
|
||||
kv[0].trim() === "friends_password" &&
|
||||
kv[1].trim() &&
|
||||
kv[1].trim() === friendPassword,
|
||||
);
|
||||
}
|
||||
|
||||
export function requireFriendAuth(c: Context) {
|
||||
const k = c.req.query("password") || c.req.query("k");
|
||||
if (k) {
|
||||
if (k === friendPassword) {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
"Set-Cookie":
|
||||
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
} else {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (checkFriendsCookie(c)) {
|
||||
return undefined;
|
||||
} else {
|
||||
return serveAsset(c, "/friends/auth", 403);
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/friends", (c) => {
|
||||
const friendAuthChallenge = requireFriendAuth(c);
|
||||
if (friendAuthChallenge) return friendAuthChallenge;
|
||||
return serveAsset(c, "/friends", 200);
|
||||
});
|
||||
|
||||
let incorrectMap: Record<string, boolean> = {};
|
||||
app.post("/friends", async (c) => {
|
||||
const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ??
|
||||
"unknown";
|
||||
if (incorrectMap[ip]) {
|
||||
return serveAsset(c, "/friends/auth/fail", 403);
|
||||
}
|
||||
const data = await c.req.formData();
|
||||
const k = data.get("password");
|
||||
if (k === friendPassword) {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
"Set-Cookie":
|
||||
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
}
|
||||
incorrectMap[ip] = true;
|
||||
await setTimeout(2500);
|
||||
incorrectMap[ip] = false;
|
||||
return serveAsset(c, "/friends/auth/fail", 403);
|
||||
});
|
||||
|
||||
import { type Context, Hono } from "hono";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { getConnInfo } from "#hono/conninfo";
|
||||
let friendPassword = "";
|
||||
try {
|
||||
friendPassword = require("./friends/hardcoded-password.ts").friendPassword;
|
||||
} catch {}
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
const cookieAge = 60 * 60 * 24 * 30; // 1 month
|
||||
|
||||
function checkFriendsCookie(c: Context) {
|
||||
const cookie = c.req.header("Cookie");
|
||||
if (!cookie) return false;
|
||||
const cookies = cookie.split("; ").map((x) => x.split("="));
|
||||
return cookies.some(
|
||||
(kv) =>
|
||||
kv[0].trim() === "friends_password" &&
|
||||
kv[1].trim() &&
|
||||
kv[1].trim() === friendPassword,
|
||||
);
|
||||
}
|
||||
|
||||
export function requireFriendAuth(c: Context) {
|
||||
const k = c.req.query("password") || c.req.query("k");
|
||||
if (k) {
|
||||
if (k === friendPassword) {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
"Set-Cookie":
|
||||
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
} else {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (checkFriendsCookie(c)) {
|
||||
return undefined;
|
||||
} else {
|
||||
return serveAsset(c, "/friends/auth", 403);
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/friends", (c) => {
|
||||
const friendAuthChallenge = requireFriendAuth(c);
|
||||
if (friendAuthChallenge) return friendAuthChallenge;
|
||||
return serveAsset(c, "/friends", 200);
|
||||
});
|
||||
|
||||
let incorrectMap: Record<string, boolean> = {};
|
||||
app.post("/friends", async (c) => {
|
||||
const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ??
|
||||
"unknown";
|
||||
if (incorrectMap[ip]) {
|
||||
return serveAsset(c, "/friends/auth/fail", 403);
|
||||
}
|
||||
const data = await c.req.formData();
|
||||
const k = data.get("password");
|
||||
if (k === friendPassword) {
|
||||
return c.body(null, 303, {
|
||||
Location: "/friends",
|
||||
"Set-Cookie":
|
||||
`friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
|
||||
});
|
||||
}
|
||||
incorrectMap[ip] = true;
|
||||
await setTimeout(2500);
|
||||
incorrectMap[ip] = false;
|
||||
return serveAsset(c, "/friends/auth/fail", 403);
|
||||
});
|
||||
|
||||
import { type Context, Hono } from "hono";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { getConnInfo } from "#hono/conninfo";
|
||||
|
|
|
@ -118,4 +118,3 @@ code {
|
|||
font-family: "rmo", monospace;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
body,html {
|
||||
overflow: hidden;
|
||||
}
|
||||
h1 {
|
||||
color: #f09;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.job {
|
||||
padding: 18px;
|
||||
margin: 1em -18px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
.job *, footer * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.job ul {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.job li {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.job header, footer {
|
||||
display: grid;
|
||||
grid-template-columns: auto max-content;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
footer {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
footer h2 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.job header > em, footer > em {
|
||||
margin-top: 2px;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
header h2, header em, footer h2, footer em {
|
||||
display: inline-block;
|
||||
}
|
||||
header em, footer em {
|
||||
margin-left: 16px!important;
|
||||
text-align: right;
|
||||
}
|
||||
body, html {
|
||||
overflow: hidden;
|
||||
}
|
||||
h1 {
|
||||
color: #f09;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.job {
|
||||
padding: 18px;
|
||||
margin: 1em -18px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
.job *, footer * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.job ul {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.job li {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
.job header, footer {
|
||||
display: grid;
|
||||
grid-template-columns: auto max-content;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
footer {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
footer h2 {
|
||||
font-size: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.job header > em, footer > em {
|
||||
margin-top: 2px;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
header h2, header em, footer h2, footer em {
|
||||
display: inline-block;
|
||||
}
|
||||
header em, footer em {
|
||||
margin-left: 16px !important;
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
@ -1,97 +1,97 @@
|
|||
// @ts-nocheck
|
||||
// manually obfuscated to make it very difficult to reverse engineer
|
||||
// if you want to decode what the email is, visit the page!
|
||||
// stops people from automatically scraping the email address
|
||||
//
|
||||
// Unfortunately this needs a rewrite to support Chrome without
|
||||
// hardware acceleration and some Linux stuff. I will probably
|
||||
// go with a proof of work alternative.
|
||||
requestAnimationFrame(() => {
|
||||
const hash = "SHA";
|
||||
const a = [
|
||||
{ parentElement: document.getElementById("subscribe") },
|
||||
function (b) {
|
||||
let c = 0, d = 0;
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
c = (c + b[i] ^ 0xF8) % 8;
|
||||
d = (c * b[i] ^ 0x82) % 193;
|
||||
}
|
||||
a[c + 1]()[c](d, b.buffer);
|
||||
},
|
||||
function () {
|
||||
const i = a[4](a[3]());
|
||||
const b = i.innerText = a.pop();
|
||||
if (a[b.indexOf("@") / 3]) {
|
||||
i.href = "mailto:" + b;
|
||||
}
|
||||
},
|
||||
function () {
|
||||
return a[a.length % 10];
|
||||
},
|
||||
function (x) {
|
||||
return x.parentElement;
|
||||
},
|
||||
function (b, c) {
|
||||
throw new Uint8Array(
|
||||
c,
|
||||
0,
|
||||
64,
|
||||
c.parentElement = this[8].call(b.call(this)).location,
|
||||
);
|
||||
},
|
||||
function (b, c) {
|
||||
this.width = 8;
|
||||
this.height = 16;
|
||||
b.clearColor(0.5, 0.7, 0.9, 1.0);
|
||||
b.clear(16408 ^ 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);
|
||||
let parent = a[this.width / 2](this);
|
||||
while (parent.tagName !== "BODY") {
|
||||
parent = a[2 * this.height / this.width](parent);
|
||||
}
|
||||
try {
|
||||
let d = [hash, e.length].join("-");
|
||||
const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
|
||||
[, d] = a;
|
||||
b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
|
||||
} catch (e) {
|
||||
fetch(e).then(a[5]).catch(a[2]);
|
||||
}
|
||||
},
|
||||
function (b, c) {
|
||||
const d = a.splice(
|
||||
9,
|
||||
1,
|
||||
[
|
||||
a[3]().parentElement.id,
|
||||
c.parentElement.hostname,
|
||||
].join(String.fromCharCode(b)),
|
||||
);
|
||||
var e = new Error();
|
||||
Object.defineProperty(e, "stack", {
|
||||
get() {
|
||||
a[9] = d;
|
||||
},
|
||||
});
|
||||
a[2].call(console.log(e));
|
||||
},
|
||||
function () {
|
||||
return this;
|
||||
},
|
||||
"[failed to verify your browser]",
|
||||
function (a) {
|
||||
a = a.parentElement.ownerDocument.defaultView;
|
||||
return { parentElement: a.navigator.webdriver || a.crypto };
|
||||
},
|
||||
];
|
||||
try {
|
||||
const c = document.querySelector("canvas");
|
||||
const g = c.getContext("webgl2") || c.getContext("webgl");
|
||||
a[0].parentElement.innerText = "[...loading...]";
|
||||
g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
|
||||
} catch {
|
||||
a.pop();
|
||||
fetch(":").then(a[5]).catch(a[2]);
|
||||
}
|
||||
});
|
||||
// @ts-nocheck
|
||||
// manually obfuscated to make it very difficult to reverse engineer
|
||||
// if you want to decode what the email is, visit the page!
|
||||
// stops people from automatically scraping the email address
|
||||
//
|
||||
// Unfortunately this needs a rewrite to support Chrome without
|
||||
// hardware acceleration and some Linux stuff. I will probably
|
||||
// go with a proof of work alternative.
|
||||
requestAnimationFrame(() => {
|
||||
const hash = "SHA";
|
||||
const a = [
|
||||
{ parentElement: document.getElementById("subscribe") },
|
||||
function (b) {
|
||||
let c = 0, d = 0;
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
c = (c + b[i] ^ 0xF8) % 8;
|
||||
d = (c * b[i] ^ 0x82) % 193;
|
||||
}
|
||||
a[c + 1]()[c](d, b.buffer);
|
||||
},
|
||||
function () {
|
||||
const i = a[4](a[3]());
|
||||
const b = i.innerText = a.pop();
|
||||
if (a[b.indexOf("@") / 3]) {
|
||||
i.href = "mailto:" + b;
|
||||
}
|
||||
},
|
||||
function () {
|
||||
return a[a.length % 10];
|
||||
},
|
||||
function (x) {
|
||||
return x.parentElement;
|
||||
},
|
||||
function (b, c) {
|
||||
throw new Uint8Array(
|
||||
c,
|
||||
0,
|
||||
64,
|
||||
c.parentElement = this[8].call(b.call(this)).location,
|
||||
);
|
||||
},
|
||||
function (b, c) {
|
||||
this.width = 8;
|
||||
this.height = 16;
|
||||
b.clearColor(0.5, 0.7, 0.9, 1.0);
|
||||
b.clear(16408 ^ 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);
|
||||
let parent = a[this.width / 2](this);
|
||||
while (parent.tagName !== "BODY") {
|
||||
parent = a[2 * this.height / this.width](parent);
|
||||
}
|
||||
try {
|
||||
let d = [hash, e.length].join("-");
|
||||
const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
|
||||
[, d] = a;
|
||||
b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
|
||||
} catch (e) {
|
||||
fetch(e).then(a[5]).catch(a[2]);
|
||||
}
|
||||
},
|
||||
function (b, c) {
|
||||
const d = a.splice(
|
||||
9,
|
||||
1,
|
||||
[
|
||||
a[3]().parentElement.id,
|
||||
c.parentElement.hostname,
|
||||
].join(String.fromCharCode(b)),
|
||||
);
|
||||
var e = new Error();
|
||||
Object.defineProperty(e, "stack", {
|
||||
get() {
|
||||
a[9] = d;
|
||||
},
|
||||
});
|
||||
a[2].call(console.log(e));
|
||||
},
|
||||
function () {
|
||||
return this;
|
||||
},
|
||||
"[failed to verify your browser]",
|
||||
function (a) {
|
||||
a = a.parentElement.ownerDocument.defaultView;
|
||||
return { parentElement: a.navigator.webdriver || a.crypto };
|
||||
},
|
||||
];
|
||||
try {
|
||||
const c = document.querySelector("canvas");
|
||||
const g = c.getContext("webgl2") || c.getContext("webgl");
|
||||
a[0].parentElement.innerText = "[...loading...]";
|
||||
g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
|
||||
} catch {
|
||||
a.pop();
|
||||
fetch(":").then(a[5]).catch(a[2]);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,89 +1,89 @@
|
|||
// 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
|
||||
// one canonical URL, which the questions page can refer to with `@id`.
|
||||
type Artifact = [title: string, url: string, type: ArtifactType];
|
||||
type ArtifactType = "music" | "game" | "project" | "video";
|
||||
export const artifactMap: Record<string, Artifact> = {
|
||||
// 2025
|
||||
"in-the-summer": ["in the summer", "", "music"],
|
||||
waterfalls: ["waterfalls", "/waterfalls", "music"],
|
||||
lolzip: ["lol.zip", "", "project"],
|
||||
"g-is-missing": ["g is missing", "", "music"],
|
||||
"im-18-now": ["i'm 18 now", "", "music"],
|
||||
"programming-comparison": [
|
||||
"thursday programming language comparison",
|
||||
"",
|
||||
"video",
|
||||
],
|
||||
aaaaaaaaa: ["aaaaaaaaa", "", "music"],
|
||||
"its-snowing": ["it's snowing", "", "video"],
|
||||
// 2023
|
||||
"iphone-15-review": [
|
||||
"iphone 15 review",
|
||||
"/file/2023/iphone%2015%20review/iphone-15-review.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2022
|
||||
mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
|
||||
"mystery-of-life": [
|
||||
"mystery of life",
|
||||
"/file/2022/mystery-of-life/mystery-of-life.mp4",
|
||||
"music",
|
||||
],
|
||||
// 2021
|
||||
"top-10000-bread": [
|
||||
"top 10000 bread",
|
||||
"https://paperclover.net/file/2021/top-10000-bread/output.mp4",
|
||||
"video",
|
||||
],
|
||||
"phoenix-write-soundtrack": [
|
||||
"Phoenix, WRITE! soundtrack",
|
||||
"/file/2021/phoenix-write/OST",
|
||||
"music",
|
||||
],
|
||||
"phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
|
||||
"money-visual-cover": [
|
||||
"money visual cover",
|
||||
"/file/2021/money-visual-cover/money-visual-cover.mp4",
|
||||
"video",
|
||||
],
|
||||
"i-got-this-thing": [
|
||||
"i got this thing",
|
||||
"/file/2021/i-got-this-thing/i-got-this-thing.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2020
|
||||
elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
"elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
// 2019
|
||||
"throw-soundtrack": [
|
||||
"throw soundtrack",
|
||||
"/file/2019/throw/soundtrack",
|
||||
"music",
|
||||
],
|
||||
"elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
|
||||
volar: [
|
||||
"volar visual cover",
|
||||
"/file/2019/volar-visual-cover/volar.mp4",
|
||||
"video",
|
||||
],
|
||||
wpm: [
|
||||
"how to read 500 words per minute",
|
||||
"/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
|
||||
"video",
|
||||
],
|
||||
"dice-roll": [
|
||||
"thursday dice roll",
|
||||
"/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
|
||||
"video",
|
||||
],
|
||||
"math-problem": [
|
||||
"thursday math problem",
|
||||
"/file/2019/thursday-math-problem/thursday-math-problem.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2018
|
||||
// 2017
|
||||
"hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
|
||||
"test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
|
||||
};
|
||||
// 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
|
||||
// one canonical URL, which the questions page can refer to with `@id`.
|
||||
type Artifact = [title: string, url: string, type: ArtifactType];
|
||||
type ArtifactType = "music" | "game" | "project" | "video";
|
||||
export const artifactMap: Record<string, Artifact> = {
|
||||
// 2025
|
||||
"in-the-summer": ["in the summer", "", "music"],
|
||||
waterfalls: ["waterfalls", "/waterfalls", "music"],
|
||||
lolzip: ["lol.zip", "", "project"],
|
||||
"g-is-missing": ["g is missing", "", "music"],
|
||||
"im-18-now": ["i'm 18 now", "", "music"],
|
||||
"programming-comparison": [
|
||||
"thursday programming language comparison",
|
||||
"",
|
||||
"video",
|
||||
],
|
||||
aaaaaaaaa: ["aaaaaaaaa", "", "music"],
|
||||
"its-snowing": ["it's snowing", "", "video"],
|
||||
// 2023
|
||||
"iphone-15-review": [
|
||||
"iphone 15 review",
|
||||
"/file/2023/iphone%2015%20review/iphone-15-review.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2022
|
||||
mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
|
||||
"mystery-of-life": [
|
||||
"mystery of life",
|
||||
"/file/2022/mystery-of-life/mystery-of-life.mp4",
|
||||
"music",
|
||||
],
|
||||
// 2021
|
||||
"top-10000-bread": [
|
||||
"top 10000 bread",
|
||||
"https://paperclover.net/file/2021/top-10000-bread/output.mp4",
|
||||
"video",
|
||||
],
|
||||
"phoenix-write-soundtrack": [
|
||||
"Phoenix, WRITE! soundtrack",
|
||||
"/file/2021/phoenix-write/OST",
|
||||
"music",
|
||||
],
|
||||
"phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
|
||||
"money-visual-cover": [
|
||||
"money visual cover",
|
||||
"/file/2021/money-visual-cover/money-visual-cover.mp4",
|
||||
"video",
|
||||
],
|
||||
"i-got-this-thing": [
|
||||
"i got this thing",
|
||||
"/file/2021/i-got-this-thing/i-got-this-thing.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2020
|
||||
elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
"elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
|
||||
// 2019
|
||||
"throw-soundtrack": [
|
||||
"throw soundtrack",
|
||||
"/file/2019/throw/soundtrack",
|
||||
"music",
|
||||
],
|
||||
"elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
|
||||
volar: [
|
||||
"volar visual cover",
|
||||
"/file/2019/volar-visual-cover/volar.mp4",
|
||||
"video",
|
||||
],
|
||||
wpm: [
|
||||
"how to read 500 words per minute",
|
||||
"/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
|
||||
"video",
|
||||
],
|
||||
"dice-roll": [
|
||||
"thursday dice roll",
|
||||
"/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
|
||||
"video",
|
||||
],
|
||||
"math-problem": [
|
||||
"thursday math problem",
|
||||
"/file/2019/thursday-math-problem/thursday-math-problem.mp4",
|
||||
"video",
|
||||
],
|
||||
// 2018
|
||||
// 2017
|
||||
"hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
|
||||
"test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
|
||||
};
|
||||
|
|
|
@ -1,228 +1,228 @@
|
|||
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
// Main page
|
||||
app.get("/q+a", async (c) => {
|
||||
if (hasAdminToken(c)) {
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
}
|
||||
return serveAsset(c, "/q+a", 200);
|
||||
});
|
||||
|
||||
// Submit form
|
||||
app.post("/q+a", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
let text = form.get("text");
|
||||
if (typeof text !== "string") {
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
text = text.trim();
|
||||
const input = {
|
||||
date: new Date(),
|
||||
prompt: text,
|
||||
sourceName: "unknown",
|
||||
sourceLocation: "unknown",
|
||||
sourceVPN: null,
|
||||
};
|
||||
|
||||
input.date.setMilliseconds(0);
|
||||
|
||||
if (text.length <= 0) {
|
||||
return questionFailure(c, 400, "Content is too short", text);
|
||||
}
|
||||
|
||||
if (text.length > 16000) {
|
||||
return questionFailure(c, 400, "Content is too long", text);
|
||||
}
|
||||
|
||||
// Ban patterns
|
||||
if (
|
||||
text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
|
||||
) {
|
||||
// To prevent known automatic spam-bots from noticing something automatic is
|
||||
// happening, pretend that the question was successfully submitted.
|
||||
return sendSuccess(c, new Date());
|
||||
}
|
||||
|
||||
const ipAddr = c.req.header("cf-connecting-ip");
|
||||
if (ipAddr) {
|
||||
input.sourceName = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
separator: "-",
|
||||
seed: ipAddr + PROXYCHECK_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
const cfIPCountry = c.req.header("cf-ipcountry");
|
||||
if (cfIPCountry) {
|
||||
input.sourceLocation = cfIPCountry;
|
||||
}
|
||||
|
||||
if (ipAddr && PROXYCHECK_API_KEY) {
|
||||
const proxyCheck = await fetch(
|
||||
`https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
|
||||
{
|
||||
method: "POST",
|
||||
body: "ips=" + ipAddr,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
if (ipAddr && proxyCheck[ipAddr]) {
|
||||
if (proxyCheck[ipAddr].proxy === "yes") {
|
||||
input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
|
||||
proxyCheck[ipAddr].organisation ??
|
||||
proxyCheck[ipAddr].provider ?? "unknown";
|
||||
}
|
||||
if (Number(proxyCheck[ipAddr].risk) > 72) {
|
||||
return questionFailure(
|
||||
c,
|
||||
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.",
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const date = Question.create(
|
||||
QuestionType.pending,
|
||||
JSON.stringify(input),
|
||||
input.date,
|
||||
);
|
||||
await sendSuccess(c, date);
|
||||
});
|
||||
async function sendSuccess(c: Context, date: Date) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "ok",
|
||||
date: date.getTime(),
|
||||
id: formatQuestionId(date),
|
||||
}, { status: 200 });
|
||||
}
|
||||
c.res = await renderView(c, "q+a/success", {
|
||||
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
|
||||
});
|
||||
}
|
||||
// Question Permalink
|
||||
app.get("/q+a/:id", async (c, next) => {
|
||||
// from deadname era, the seconds used to be in the url.
|
||||
// this was removed so that the url can be crafted by hand.
|
||||
let id = c.req.param("id");
|
||||
if (id.length === 12 && /^\d+$/.test(id)) {
|
||||
return c.redirect(`/q+a/${id.slice(0, 10)}`);
|
||||
}
|
||||
let image = false;
|
||||
if (id.endsWith(".png")) {
|
||||
image = true;
|
||||
id = id.slice(0, -4);
|
||||
}
|
||||
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
if (image) {
|
||||
return getQuestionImage(question, c.req.method === "HEAD");
|
||||
}
|
||||
return renderView(c, "q+a/permalink", { question });
|
||||
});
|
||||
|
||||
// Admin
|
||||
app.get("/admin/q+a", async (c) => {
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
});
|
||||
app.get("/admin/q+a/inbox", async (c) => {
|
||||
return renderView(c, "q+a/backend-inbox", {});
|
||||
});
|
||||
app.delete("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const deleteFull = c.req.header("X-Delete-Full") === "true";
|
||||
if (deleteFull) {
|
||||
Question.deleteByQmid(question.qmid);
|
||||
} else {
|
||||
Question.rejectByQmid(question.qmid);
|
||||
}
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.patch("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const form = await c.req.raw.json();
|
||||
if (typeof form.text !== "string" || typeof form.type !== "number") {
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
Question.updateByQmid(question.qmid, form.text, form.type);
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.get("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
let pendingInfo: null | PendingQuestionData = null;
|
||||
if (question.type === QuestionType.pending) {
|
||||
pendingInfo = JSON.parse(question.text) as PendingQuestionData;
|
||||
question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
|
||||
line.trim().length === 0 ? "" : `q: ${line.trim()}`
|
||||
).join("\n") + "\n\n";
|
||||
question.type = QuestionType.normal;
|
||||
}
|
||||
|
||||
return renderView(c, "q+a/editor", {
|
||||
pendingInfo,
|
||||
question,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/q+a/things/random", async (c) => {
|
||||
c.res = await renderView(c, "q+a/things-random", {});
|
||||
});
|
||||
|
||||
async function questionFailure(
|
||||
c: Context,
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
content?: string,
|
||||
) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({ success: false, message, id: null }, { status });
|
||||
}
|
||||
return await renderView(c, "q+a/fail", {
|
||||
error: message,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
import { type Context, Hono } from "#hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import {
|
||||
adjectives,
|
||||
animals,
|
||||
colors,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
import { hasAdminToken } from "../admin.ts";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import {
|
||||
PendingQuestion,
|
||||
PendingQuestionData,
|
||||
} from "./models/PendingQuestion.ts";
|
||||
import { Question, QuestionType } from "./models/Question.ts";
|
||||
import { renderView } from "#sitegen/view";
|
||||
import { getQuestionImage } from "./image.tsx";
|
||||
import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
|
||||
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
|
||||
|
||||
export const app = new Hono();
|
||||
|
||||
// Main page
|
||||
app.get("/q+a", async (c) => {
|
||||
if (hasAdminToken(c)) {
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
}
|
||||
return serveAsset(c, "/q+a", 200);
|
||||
});
|
||||
|
||||
// Submit form
|
||||
app.post("/q+a", async (c) => {
|
||||
const form = await c.req.formData();
|
||||
let text = form.get("text");
|
||||
if (typeof text !== "string") {
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
text = text.trim();
|
||||
const input = {
|
||||
date: new Date(),
|
||||
prompt: text,
|
||||
sourceName: "unknown",
|
||||
sourceLocation: "unknown",
|
||||
sourceVPN: null,
|
||||
};
|
||||
|
||||
input.date.setMilliseconds(0);
|
||||
|
||||
if (text.length <= 0) {
|
||||
return questionFailure(c, 400, "Content is too short", text);
|
||||
}
|
||||
|
||||
if (text.length > 16000) {
|
||||
return questionFailure(c, 400, "Content is too long", text);
|
||||
}
|
||||
|
||||
// Ban patterns
|
||||
if (
|
||||
text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
|
||||
) {
|
||||
// To prevent known automatic spam-bots from noticing something automatic is
|
||||
// happening, pretend that the question was successfully submitted.
|
||||
return sendSuccess(c, new Date());
|
||||
}
|
||||
|
||||
const ipAddr = c.req.header("cf-connecting-ip");
|
||||
if (ipAddr) {
|
||||
input.sourceName = uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
separator: "-",
|
||||
seed: ipAddr + PROXYCHECK_API_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
const cfIPCountry = c.req.header("cf-ipcountry");
|
||||
if (cfIPCountry) {
|
||||
input.sourceLocation = cfIPCountry;
|
||||
}
|
||||
|
||||
if (ipAddr && PROXYCHECK_API_KEY) {
|
||||
const proxyCheck = await fetch(
|
||||
`https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
|
||||
{
|
||||
method: "POST",
|
||||
body: "ips=" + ipAddr,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
).then((res) => res.json());
|
||||
|
||||
if (ipAddr && proxyCheck[ipAddr]) {
|
||||
if (proxyCheck[ipAddr].proxy === "yes") {
|
||||
input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
|
||||
proxyCheck[ipAddr].organisation ??
|
||||
proxyCheck[ipAddr].provider ?? "unknown";
|
||||
}
|
||||
if (Number(proxyCheck[ipAddr].risk) > 72) {
|
||||
return questionFailure(
|
||||
c,
|
||||
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.",
|
||||
text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const date = Question.create(
|
||||
QuestionType.pending,
|
||||
JSON.stringify(input),
|
||||
input.date,
|
||||
);
|
||||
await sendSuccess(c, date);
|
||||
});
|
||||
async function sendSuccess(c: Context, date: Date) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "ok",
|
||||
date: date.getTime(),
|
||||
id: formatQuestionId(date),
|
||||
}, { status: 200 });
|
||||
}
|
||||
c.res = await renderView(c, "q+a/success", {
|
||||
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
|
||||
});
|
||||
}
|
||||
// Question Permalink
|
||||
app.get("/q+a/:id", async (c, next) => {
|
||||
// from deadname era, the seconds used to be in the url.
|
||||
// this was removed so that the url can be crafted by hand.
|
||||
let id = c.req.param("id");
|
||||
if (id.length === 12 && /^\d+$/.test(id)) {
|
||||
return c.redirect(`/q+a/${id.slice(0, 10)}`);
|
||||
}
|
||||
let image = false;
|
||||
if (id.endsWith(".png")) {
|
||||
image = true;
|
||||
id = id.slice(0, -4);
|
||||
}
|
||||
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
if (image) {
|
||||
return getQuestionImage(question, c.req.method === "HEAD");
|
||||
}
|
||||
return renderView(c, "q+a/permalink", { question });
|
||||
});
|
||||
|
||||
// Admin
|
||||
app.get("/admin/q+a", async (c) => {
|
||||
return serveAsset(c, "/admin/q+a", 200);
|
||||
});
|
||||
app.get("/admin/q+a/inbox", async (c) => {
|
||||
return renderView(c, "q+a/backend-inbox", {});
|
||||
});
|
||||
app.delete("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const deleteFull = c.req.header("X-Delete-Full") === "true";
|
||||
if (deleteFull) {
|
||||
Question.deleteByQmid(question.qmid);
|
||||
} else {
|
||||
Question.rejectByQmid(question.qmid);
|
||||
}
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.patch("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
const form = await c.req.raw.json();
|
||||
if (typeof form.text !== "string" || typeof form.type !== "number") {
|
||||
return questionFailure(c, 400, "Bad Request");
|
||||
}
|
||||
Question.updateByQmid(question.qmid, form.text, form.type);
|
||||
return c.json({ success: true, message: "ok" });
|
||||
});
|
||||
app.get("/admin/q+a/:id", async (c, next) => {
|
||||
const id = c.req.param("id");
|
||||
const timestamp = questionIdToTimestamp(id);
|
||||
if (!timestamp) return next();
|
||||
const question = Question.getByDate(timestamp);
|
||||
if (!question) return next();
|
||||
|
||||
let pendingInfo: null | PendingQuestionData = null;
|
||||
if (question.type === QuestionType.pending) {
|
||||
pendingInfo = JSON.parse(question.text) as PendingQuestionData;
|
||||
question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
|
||||
line.trim().length === 0 ? "" : `q: ${line.trim()}`
|
||||
).join("\n") + "\n\n";
|
||||
question.type = QuestionType.normal;
|
||||
}
|
||||
|
||||
return renderView(c, "q+a/editor", {
|
||||
pendingInfo,
|
||||
question,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/q+a/things/random", async (c) => {
|
||||
c.res = await renderView(c, "q+a/things-random", {});
|
||||
});
|
||||
|
||||
async function questionFailure(
|
||||
c: Context,
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
content?: string,
|
||||
) {
|
||||
if (c.req.header("Accept")?.includes("application/json")) {
|
||||
return c.json({ success: false, message, id: null }, { status });
|
||||
}
|
||||
return await renderView(c, "q+a/fail", {
|
||||
error: message,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
import { type Context, Hono } from "#hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import {
|
||||
adjectives,
|
||||
animals,
|
||||
colors,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
import { hasAdminToken } from "../admin.ts";
|
||||
import { serveAsset } from "#sitegen/assets";
|
||||
import {
|
||||
PendingQuestion,
|
||||
PendingQuestionData,
|
||||
} from "./models/PendingQuestion.ts";
|
||||
import { Question, QuestionType } from "./models/Question.ts";
|
||||
import { renderView } from "#sitegen/view";
|
||||
import { getQuestionImage } from "./image.tsx";
|
||||
import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
|
||||
|
|
|
@ -1,39 +1,39 @@
|
|||
const dateFormat = new Intl.DateTimeFormat("sv", {
|
||||
timeZone: "EST",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
day: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
// YYYY-MM-DD HH:MM
|
||||
export function formatQuestionTimestamp(date: Date) {
|
||||
return dateFormat.format(date);
|
||||
}
|
||||
|
||||
// YYYY-MM-DDTHH:MM:00Z
|
||||
export function formatQuestionISOTimestamp(date: Date) {
|
||||
const str = dateFormat.format(date);
|
||||
return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
|
||||
}
|
||||
|
||||
// YYMMDDHHMM
|
||||
export function formatQuestionId(date: Date) {
|
||||
return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
|
||||
}
|
||||
|
||||
export function questionIdToTimestamp(id: string) {
|
||||
if (id.length !== 10 || !/^\d+$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(
|
||||
`20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
|
||||
id.slice(6, 8)
|
||||
}:${id.slice(8, 10)}:00 EST`,
|
||||
);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
const dateFormat = new Intl.DateTimeFormat("sv", {
|
||||
timeZone: "EST",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
day: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
// YYYY-MM-DD HH:MM
|
||||
export function formatQuestionTimestamp(date: Date) {
|
||||
return dateFormat.format(date);
|
||||
}
|
||||
|
||||
// YYYY-MM-DDTHH:MM:00Z
|
||||
export function formatQuestionISOTimestamp(date: Date) {
|
||||
const str = dateFormat.format(date);
|
||||
return `${str.slice(0, 10)}T${str.slice(11)}-05:00`;
|
||||
}
|
||||
|
||||
// YYMMDDHHMM
|
||||
export function formatQuestionId(date: Date) {
|
||||
return formatQuestionTimestamp(date).replace(/[^\d]/g, "").slice(2, 12);
|
||||
}
|
||||
|
||||
export function questionIdToTimestamp(id: string) {
|
||||
if (id.length !== 10 || !/^\d+$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(
|
||||
`20${id.slice(0, 2)}-${id.slice(2, 4)}-${id.slice(4, 6)} ${
|
||||
id.slice(6, 8)
|
||||
}:${id.slice(8, 10)}:00 EST`,
|
||||
);
|
||||
if (isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
|
|
@ -1,81 +1,81 @@
|
|||
const width = 768;
|
||||
const cacheImageDir = path.resolve(".clover/question_images");
|
||||
|
||||
// Cached browser session
|
||||
const getBrowser = RefCountedExpirable(
|
||||
() =>
|
||||
puppeteer.launch({
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
}),
|
||||
(b) => b.close(),
|
||||
);
|
||||
|
||||
export async function renderQuestionImage(question: Question) {
|
||||
const html = await renderViewToString("q+a/image-embed", { question });
|
||||
|
||||
// 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
|
||||
// symbol
|
||||
using sharedBrowser = await getBrowser();
|
||||
const b = sharedBrowser.value;
|
||||
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width, height: 400 });
|
||||
await p.setContent(html);
|
||||
try {
|
||||
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
|
||||
} catch (e) {}
|
||||
const height = await p.evaluate(() => {
|
||||
const e = document.querySelector("main")!;
|
||||
return e.getBoundingClientRect().height;
|
||||
});
|
||||
const buf = await p.screenshot({
|
||||
path: "screenshot.png",
|
||||
type: "png",
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
|
||||
});
|
||||
await p.close();
|
||||
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
export async function getQuestionImage(
|
||||
question: Question,
|
||||
headOnly: boolean,
|
||||
): Promise<Response> {
|
||||
const hash = crypto.createHash("sha1")
|
||||
.update(question.qmid + question.type + question.text)
|
||||
.digest("hex");
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
"ETag": `"${hash}"`,
|
||||
"Last-Modified": question.date.toUTCString(),
|
||||
};
|
||||
|
||||
if (headOnly) {
|
||||
return new Response(null, { headers });
|
||||
}
|
||||
|
||||
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await fs.readFile(cachedFilePath);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
buf = await renderQuestionImage(question);
|
||||
fs.writeMkdir(cachedFilePath, buf).catch(() => {});
|
||||
}
|
||||
|
||||
return new Response(buf, { headers });
|
||||
}
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "#sitegen/fs";
|
||||
import * as path from "node:path";
|
||||
import * as puppeteer from "puppeteer";
|
||||
import { Question } from "@/q+a/models/Question.ts";
|
||||
import { RefCountedExpirable } from "#sitegen/async";
|
||||
import { renderViewToString } from "#sitegen/view";
|
||||
const width = 768;
|
||||
const cacheImageDir = path.resolve(".clover/question_images");
|
||||
|
||||
// Cached browser session
|
||||
const getBrowser = RefCountedExpirable(
|
||||
() =>
|
||||
puppeteer.launch({
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
}),
|
||||
(b) => b.close(),
|
||||
);
|
||||
|
||||
export async function renderQuestionImage(question: Question) {
|
||||
const html = await renderViewToString("q+a/image-embed", { question });
|
||||
|
||||
// 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
|
||||
// symbol
|
||||
using sharedBrowser = await getBrowser();
|
||||
const b = sharedBrowser.value;
|
||||
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width, height: 400 });
|
||||
await p.setContent(html);
|
||||
try {
|
||||
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
|
||||
} catch (e) {}
|
||||
const height = await p.evaluate(() => {
|
||||
const e = document.querySelector("main")!;
|
||||
return e.getBoundingClientRect().height;
|
||||
});
|
||||
const buf = await p.screenshot({
|
||||
path: "screenshot.png",
|
||||
type: "png",
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
|
||||
});
|
||||
await p.close();
|
||||
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
export async function getQuestionImage(
|
||||
question: Question,
|
||||
headOnly: boolean,
|
||||
): Promise<Response> {
|
||||
const hash = crypto.createHash("sha1")
|
||||
.update(question.qmid + question.type + question.text)
|
||||
.digest("hex");
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
"ETag": `"${hash}"`,
|
||||
"Last-Modified": question.date.toUTCString(),
|
||||
};
|
||||
|
||||
if (headOnly) {
|
||||
return new Response(null, { headers });
|
||||
}
|
||||
|
||||
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await fs.readFile(cachedFilePath);
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
buf = await renderQuestionImage(question);
|
||||
fs.writeMkdir(cachedFilePath, buf).catch(() => {});
|
||||
}
|
||||
|
||||
return new Response(buf, { headers });
|
||||
}
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "#sitegen/fs";
|
||||
import * as path from "node:path";
|
||||
import * as puppeteer from "puppeteer";
|
||||
import { Question } from "@/q+a/models/Question.ts";
|
||||
import { RefCountedExpirable } from "#sitegen/async";
|
||||
import { renderViewToString } from "#sitegen/view";
|
||||
|
|
|
@ -1,116 +1,116 @@
|
|||
import { EditorState } from "@codemirror/state";
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { ssrSync } from "#ssr";
|
||||
import type { ScriptPayload } from "@/q+a/views/editor.marko";
|
||||
import QuestionRender from "@/q+a/tags/question.marko";
|
||||
|
||||
declare const payload: ScriptPayload;
|
||||
const date = new Date(payload.date);
|
||||
|
||||
const main = document.getElementById("edit-grid")! as HTMLDivElement;
|
||||
const preview = document.getElementById("preview")! as HTMLDivElement;
|
||||
|
||||
function updatePreview(text: string) {
|
||||
preview.innerHTML = ssrSync(
|
||||
<QuestionRender
|
||||
question={{
|
||||
id: payload.id,
|
||||
qmid: payload.qmid,
|
||||
text: text,
|
||||
type: payload.type,
|
||||
date,
|
||||
}}
|
||||
editor
|
||||
/>,
|
||||
).text;
|
||||
}
|
||||
updatePreview(payload.text);
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: payload.text,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
EditorView.darkTheme.of(true),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
updatePreview(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
],
|
||||
// selection: EditorSelection.create([
|
||||
// EditorSelection.cursor(0),
|
||||
// ], 0),
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state: startState,
|
||||
parent: document.getElementById("editor")!,
|
||||
});
|
||||
view.focus();
|
||||
|
||||
(globalThis as any).onCommitQuestion = wrapAction(async () => {
|
||||
const text = view.state.doc.toString();
|
||||
const res = await fetch(`/admin/q+a/${payload.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
type: payload.type,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to update question, status: " + res.status);
|
||||
}
|
||||
if (location.search.includes("return=inbox")) {
|
||||
location.href = "/admin/q+a/inbox";
|
||||
} else {
|
||||
location.href = "/q+a#q" + payload.id;
|
||||
}
|
||||
});
|
||||
(globalThis as any).onDelete = wrapAction(async () => {
|
||||
if (confirm("Are you sure you want to delete this question?")) {
|
||||
const res = await fetch(`/admin/q+a/${payload.id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete question, status: " + res.status);
|
||||
}
|
||||
location.href = document.referrer || "/admin/q+a";
|
||||
}
|
||||
});
|
||||
(globalThis as any).onTypeChange = () => {
|
||||
payload.type = parseInt(
|
||||
(document.getElementById("type") as HTMLSelectElement).value,
|
||||
);
|
||||
updatePreview(view.state.doc.toString());
|
||||
};
|
||||
|
||||
function wrapAction(cb: () => Promise<void>) {
|
||||
return async () => {
|
||||
main.style.opacity = "0.5";
|
||||
main.style.pointerEvents = "none";
|
||||
const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
|
||||
HTMLButtonElement
|
||||
>;
|
||||
inputs.forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
try {
|
||||
await cb();
|
||||
} catch (e: any) {
|
||||
main.style.opacity = "1";
|
||||
main.style.pointerEvents = "auto";
|
||||
inputs.forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { ssrSync } from "#ssr";
|
||||
import type { ScriptPayload } from "@/q+a/views/editor.marko";
|
||||
import QuestionRender from "@/q+a/tags/question.marko";
|
||||
|
||||
declare const payload: ScriptPayload;
|
||||
const date = new Date(payload.date);
|
||||
|
||||
const main = document.getElementById("edit-grid")! as HTMLDivElement;
|
||||
const preview = document.getElementById("preview")! as HTMLDivElement;
|
||||
|
||||
function updatePreview(text: string) {
|
||||
preview.innerHTML = ssrSync(
|
||||
<QuestionRender
|
||||
question={{
|
||||
id: payload.id,
|
||||
qmid: payload.qmid,
|
||||
text: text,
|
||||
type: payload.type,
|
||||
date,
|
||||
}}
|
||||
editor
|
||||
/>,
|
||||
).text;
|
||||
}
|
||||
updatePreview(payload.text);
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: payload.text,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
EditorView.darkTheme.of(true),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
updatePreview(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
],
|
||||
// selection: EditorSelection.create([
|
||||
// EditorSelection.cursor(0),
|
||||
// ], 0),
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state: startState,
|
||||
parent: document.getElementById("editor")!,
|
||||
});
|
||||
view.focus();
|
||||
|
||||
(globalThis as any).onCommitQuestion = wrapAction(async () => {
|
||||
const text = view.state.doc.toString();
|
||||
const res = await fetch(`/admin/q+a/${payload.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
type: payload.type,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to update question, status: " + res.status);
|
||||
}
|
||||
if (location.search.includes("return=inbox")) {
|
||||
location.href = "/admin/q+a/inbox";
|
||||
} else {
|
||||
location.href = "/q+a#q" + payload.id;
|
||||
}
|
||||
});
|
||||
(globalThis as any).onDelete = wrapAction(async () => {
|
||||
if (confirm("Are you sure you want to delete this question?")) {
|
||||
const res = await fetch(`/admin/q+a/${payload.id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete question, status: " + res.status);
|
||||
}
|
||||
location.href = document.referrer || "/admin/q+a";
|
||||
}
|
||||
});
|
||||
(globalThis as any).onTypeChange = () => {
|
||||
payload.type = parseInt(
|
||||
(document.getElementById("type") as HTMLSelectElement).value,
|
||||
);
|
||||
updatePreview(view.state.doc.toString());
|
||||
};
|
||||
|
||||
function wrapAction(cb: () => Promise<void>) {
|
||||
return async () => {
|
||||
main.style.opacity = "0.5";
|
||||
main.style.pointerEvents = "none";
|
||||
const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
|
||||
HTMLButtonElement
|
||||
>;
|
||||
inputs.forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
try {
|
||||
await cb();
|
||||
} catch (e: any) {
|
||||
main.style.opacity = "1";
|
||||
main.style.pointerEvents = "auto";
|
||||
inputs.forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,74 +1,74 @@
|
|||
// @ts-ignore
|
||||
globalThis.onReply = (id: string) => {
|
||||
location.href = `/admin/q+a/${id}?return=inbox`;
|
||||
};
|
||||
// @ts-ignore
|
||||
globalThis.onDelete = async (id: string) => {
|
||||
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
|
||||
if (!div) return alert("Question not found");
|
||||
|
||||
// Pending State
|
||||
div.style.opacity = "0.5";
|
||||
div.style.pointerEvents = "none";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/admin/q+a/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error("Failed to delete question, status: " + resp.status);
|
||||
}
|
||||
} catch (e: any) {
|
||||
div.style.opacity = "1";
|
||||
div.style.pointerEvents = "auto";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return alert(e.message);
|
||||
}
|
||||
|
||||
div.remove();
|
||||
};
|
||||
// @ts-ignore
|
||||
globalThis.onDeleteFull = async (id: string) => {
|
||||
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
|
||||
if (!div) return alert("Question not found");
|
||||
|
||||
// Confirmation
|
||||
if (!confirm("Are you sure you want to delete this question?")) return;
|
||||
|
||||
// Pending State
|
||||
div.style.opacity = "0.5";
|
||||
div.style.pointerEvents = "none";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/admin/q+a/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Delete-Full": "true",
|
||||
},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error("Failed to delete question, status: " + resp.status);
|
||||
}
|
||||
} catch (e: any) {
|
||||
div.style.opacity = "1";
|
||||
div.style.pointerEvents = "auto";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return alert(e.message);
|
||||
}
|
||||
|
||||
div.remove();
|
||||
};
|
||||
// @ts-ignore
|
||||
globalThis.onReply = (id: string) => {
|
||||
location.href = `/admin/q+a/${id}?return=inbox`;
|
||||
};
|
||||
// @ts-ignore
|
||||
globalThis.onDelete = async (id: string) => {
|
||||
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
|
||||
if (!div) return alert("Question not found");
|
||||
|
||||
// Pending State
|
||||
div.style.opacity = "0.5";
|
||||
div.style.pointerEvents = "none";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/admin/q+a/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error("Failed to delete question, status: " + resp.status);
|
||||
}
|
||||
} catch (e: any) {
|
||||
div.style.opacity = "1";
|
||||
div.style.pointerEvents = "auto";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return alert(e.message);
|
||||
}
|
||||
|
||||
div.remove();
|
||||
};
|
||||
// @ts-ignore
|
||||
globalThis.onDeleteFull = async (id: string) => {
|
||||
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
|
||||
if (!div) return alert("Question not found");
|
||||
|
||||
// Confirmation
|
||||
if (!confirm("Are you sure you want to delete this question?")) return;
|
||||
|
||||
// Pending State
|
||||
div.style.opacity = "0.5";
|
||||
div.style.pointerEvents = "none";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/admin/q+a/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Delete-Full": "true",
|
||||
},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error("Failed to delete question, status: " + resp.status);
|
||||
}
|
||||
} catch (e: any) {
|
||||
div.style.opacity = "1";
|
||||
div.style.pointerEvents = "auto";
|
||||
div?.querySelectorAll("button").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return alert(e.message);
|
||||
}
|
||||
|
||||
div.remove();
|
||||
};
|
||||
|
|
|
@ -1,39 +1,39 @@
|
|||
#edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80ch;
|
||||
grid-template-rows: 3rem 1fr;
|
||||
grid-gap: 1em;
|
||||
height: 100vh;
|
||||
|
||||
button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
main {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
#topleft, #topright {
|
||||
padding: 1rem;
|
||||
}
|
||||
#topleft {
|
||||
padding-right: 0rem;
|
||||
}
|
||||
#topright {
|
||||
padding-left: 0rem;
|
||||
}
|
||||
#preview {
|
||||
overflow-y: auto;
|
||||
e- {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
#editor {
|
||||
background-color: #303030;
|
||||
overflow-y: scroll;
|
||||
height: 100%;
|
||||
}
|
||||
.cm-scroller {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
#edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80ch;
|
||||
grid-template-rows: 3rem 1fr;
|
||||
grid-gap: 1em;
|
||||
height: 100vh;
|
||||
|
||||
button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
main {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
#topleft, #topright {
|
||||
padding: 1rem;
|
||||
}
|
||||
#topleft {
|
||||
padding-right: 0rem;
|
||||
}
|
||||
#topright {
|
||||
padding-left: 0rem;
|
||||
}
|
||||
#preview {
|
||||
overflow-y: auto;
|
||||
e- {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
#editor {
|
||||
background-color: #303030;
|
||||
overflow-y: scroll;
|
||||
height: 100%;
|
||||
}
|
||||
.cm-scroller {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
<title>paper clover</title>
|
||||
</head>
|
||||
<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>
|
||||
<p style="margin: 0.5rem 0">
|
||||
<a
|
||||
|
@ -56,7 +58,9 @@
|
|||
<font color="#FF8147">feed</font>
|
||||
</a>
|
||||
</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="#E8F4FF">clover</font>
|
||||
</h1>
|
||||
|
|
|
@ -1,58 +1,58 @@
|
|||
import "./video.css";
|
||||
import * as path from "node:path";
|
||||
import { addScript } from "#sitegen";
|
||||
import { PrecomputedBlurhash } from "./blurhash.tsx";
|
||||
|
||||
export namespace Video {
|
||||
export interface Props {
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sources: string[];
|
||||
downloads: string[];
|
||||
poster?: string;
|
||||
posterHash?: string;
|
||||
borderless?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function Video(
|
||||
{ title, sources, height, poster, posterHash, width, borderless }:
|
||||
Video.Props,
|
||||
) {
|
||||
addScript("./hls-polyfill.client.ts");
|
||||
return (
|
||||
<figure class={`video ${borderless ? "borderless" : ""}`}>
|
||||
<figcaption>{title}</figcaption>
|
||||
{posterHash && <PrecomputedBlurhash hash={posterHash} />}
|
||||
{poster && <img src={poster} alt="waterfalls" />}
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
style={`width:100%;background:transparent;aspect-ratio:${
|
||||
simplifyFraction(width, height)
|
||||
}`}
|
||||
poster="data:null"
|
||||
>
|
||||
{sources.map((src) => (
|
||||
<source
|
||||
src={src}
|
||||
type={contentTypeFromExt(src)}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
export function contentTypeFromExt(src: string) {
|
||||
if (src.endsWith(".m3u8")) return "application/x-mpegURL";
|
||||
if (src.endsWith(".webm")) return "video/webm";
|
||||
if (src.endsWith(".mp4")) return "video/mp4";
|
||||
if (src.endsWith(".ogg")) return "video/ogg";
|
||||
throw new Error("Unknown video extension: " + path.extname(src));
|
||||
}
|
||||
const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a;
|
||||
function simplifyFraction(n: number, d: number) {
|
||||
const divisor = gcd(n, d);
|
||||
return `${n / divisor}/${d / divisor}`;
|
||||
}
|
||||
import "./video.css";
|
||||
import * as path from "node:path";
|
||||
import { addScript } from "#sitegen";
|
||||
import { PrecomputedBlurhash } from "./blurhash.tsx";
|
||||
|
||||
export namespace Video {
|
||||
export interface Props {
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sources: string[];
|
||||
downloads: string[];
|
||||
poster?: string;
|
||||
posterHash?: string;
|
||||
borderless?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function Video(
|
||||
{ title, sources, height, poster, posterHash, width, borderless }:
|
||||
Video.Props,
|
||||
) {
|
||||
addScript("./hls-polyfill.client.ts");
|
||||
return (
|
||||
<figure class={`video ${borderless ? "borderless" : ""}`}>
|
||||
<figcaption>{title}</figcaption>
|
||||
{posterHash && <PrecomputedBlurhash hash={posterHash} />}
|
||||
{poster && <img src={poster} alt="waterfalls" />}
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
style={`width:100%;background:transparent;aspect-ratio:${
|
||||
simplifyFraction(width, height)
|
||||
}`}
|
||||
poster="data:null"
|
||||
>
|
||||
{sources.map((src) => (
|
||||
<source
|
||||
src={src}
|
||||
type={contentTypeFromExt(src)}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
export function contentTypeFromExt(src: string) {
|
||||
if (src.endsWith(".m3u8")) return "application/x-mpegURL";
|
||||
if (src.endsWith(".webm")) return "video/webm";
|
||||
if (src.endsWith(".mp4")) return "video/mp4";
|
||||
if (src.endsWith(".ogg")) return "video/ogg";
|
||||
throw new Error("Unknown video extension: " + path.extname(src));
|
||||
}
|
||||
const gcd = (a: number, b: number): number => b ? gcd(b, a % b) : a;
|
||||
function simplifyFraction(n: number, d: number) {
|
||||
const divisor = gcd(n, d);
|
||||
return `${n / divisor}/${d / divisor}`;
|
||||
}
|
||||
|
|
|
@ -1,25 +1,24 @@
|
|||
.video {
|
||||
border: 4px solid var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video > img,
|
||||
.video > span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.video figcaption {
|
||||
background-color: var(--fg);
|
||||
color: var(--bg);
|
||||
width: 100%;
|
||||
margin-top: -1px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.video {
|
||||
border: 4px solid var(--fg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video > img,
|
||||
.video > span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.video figcaption {
|
||||
background-color: var(--fg);
|
||||
color: var(--bg);
|
||||
width: 100%;
|
||||
margin-top: -1px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue