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