authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-03 11:56:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:48-07:00
log83f7d1c67f60bc08f85e3878875374d3198e74fe
tree29dad5cf67ae6a28a9344685505de7a641c3cd12
parentb490ba62dedb3b43b9ee832e8c3630d017557b69
signature Commit is signed but in an unrecognized format.

site: finish main deploymnet


48 files changed, 787 insertions(+), 645 deletions(-)

framework/backend/entry-node.ts+5-2
......@@ -8,7 +8,9 @@ const server = serve({
88 fetch: app.default.fetch,
99 port: Number(process.env.PORT ?? 3000),
1010}, ({ address, port }) => {
11 if (address === "::") address = "localhost";
11 if (address === "::" || address === "127.0.0.1") {
12 address = os.hostname().replace(/\.local$/, "");
13 }
1214 console.info(url.format({
1315 protocol,
1416 hostname: address,
......@@ -36,6 +38,7 @@ declare global {
3638 var CLOVER_SERVER_ENTRY: string;
3739}
3840
39import url from "node:url";
41import * as os from "os";
42import * as url from "node:url";
4043import { serve } from "@hono/node-server";
4144import process from "node:process";
framework/bundle.ts+14-1
......@@ -39,6 +39,18 @@ export async function bundleClientJavaScript(
3939 const clientPlugins: esbuild.Plugin[] = [
4040 projectRelativeResolution(),
4141 markoViaBuildCache(),
42 {
43 name: "mark css external",
44 setup(b) {
45 b.onResolve({ filter: /\.css$/ }, () => ({
46 path: ".",
47 namespace: "dropped",
48 }));
49 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
50 contents: "",
51 }));
52 },
53 },
4254 ];
4355
4456 using _ = io.start(`bundle ${entryPoints.length} files`);
......@@ -60,6 +72,7 @@ export async function bundleClientJavaScript(
6072 outdir: "out!",
6173 plugins: clientPlugins,
6274 write: false,
75 external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"],
6376 define: {
6477 ASSERT: "console.assert",
6578 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
......@@ -290,7 +303,7 @@ export async function bundleServerJavaScript({
290303 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
291304 },
292305 external: Object.keys(pkg.dependencies).filter(
293 (x) => !x.startsWith("@paperclover"),
306 (x) => x !== "lib",
294307 ),
295308 });
296309 await trackEsbuild(io, metafile);
framework/engine/marko-runtime.ts-1
......@@ -27,7 +27,6 @@ export const _template: typeof marko._template = (
2727 return markoResult.then(render.raw);
2828 }
2929 const rr = markoResult.toString();
30 console.log({ rr });
3130 return render.raw(rr);
3231 }
3332 wrap.render = renderFn;
framework/engine/render.ts+5-4
......@@ -6,6 +6,8 @@
66// Add-ons to the rendering engine can provide opaque data, And retrieve it
77// within component calls with 'getAddonData'. For example, 'sitegen' uses this
88// to track needed client scripts without introducing patches to the engine.
9//
10// This module is not production ready
911export let current: State | null = null;
1012export function setCurrent(r: State | null) {
1113 current = r ?? null;
......@@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string {
212214}
213215function stringifyElement(element: ResolvedElement) {
214216 const { 1: tag, 2: props, 3: children } = element;
215 let out = "<" + tag;
216 let needSpace = true;
217 let out = "<" + tag + " ";
217218 for (const prop in props) {
218219 const value = props[prop];
219220 if (!value || typeof value === "function") continue;
......@@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) {
249250 case "key":
250251 continue;
251252 }
252 if (needSpace) ((out += " "), (needSpace = !attr.endsWith('"')));
253 if (!out.endsWith('"')) out += " ";
253254 out += attr;
254255 }
255 out += ">";
256 out = out.trimEnd() + ">";
256257 if (children) out += stringifyNode(children);
257258 if (
258259 tag !== "br" &&
framework/generate.ts+2-4
......@@ -369,10 +369,8 @@ export async function assembleAndWritePage(
369369
370370 pageNode.passive = false;
371371
372 const scripts = scriptIds
373 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))
374 .map((x) => `{${x}}`)
375 .join("\n");
372 const scripts = sg.joinScripts(scriptIds
373 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`)));
376374
377375 const buffer = sg.wrapDocument({
378376 body: html,
framework/incremental.ts+1-1
......@@ -496,7 +496,7 @@ export class Io {
496496 return resolved;
497497 }
498498 async readFile(file: string) {
499 return fs.readFile(await this.trackFile(file), "utf-8");
499 return fs.readFile(await this.trackFile(file));
500500 }
501501 async readJson<T>(file: string) {
502502 return JSON.parse(await this.readFile(file)) as T;
framework/lib/lru.ts+2-1
......@@ -94,7 +94,8 @@ export class Lru<K, V> extends Map<K, V> {
9494 this.#used += size;
9595 try {
9696 this.ensureUnusedCapacity(0);
97 } catch {
97 } catch (err) {
98 console.error(err);
9899 ASSERT(false, "capacity is already verified");
99100 }
100101 super.set(key, value);
framework/lib/meta.ts+1-1
......@@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string {
214214 if (denyRobots) {
215215 out += `<meta name=robots content=noindex,nofollow>`;
216216 }
217 if (description) {
217 if (viewport) {
218218 out += `<meta name=viewport content=${attr(viewport)}>`;
219219 }
220220 if (canonical) {
framework/lib/mime.ts+1-1
......@@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));
1818export function contentTypeFor(file: string) {
1919 const slash = file.indexOf("/");
2020 if (slash !== -1) file = file.slice(slash + 1);
21 const dot = file.indexOf(".");
21 const dot = file.lastIndexOf(".");
2222 if (dot === -1) file = "." + file;
2323 else if (dot > 0) {
2424 let entry = fullNames.get(file);
framework/lib/mime.txt+1-1
......@@ -42,7 +42,7 @@
4242.jpg image/jpeg
4343.jpgv video/jpeg
4444.jxl image/jxl
45.js application/javascript
45.js application/javascript;charset=utf-8
4646.json application/json
4747.latex application/x-latex
4848.list text/plain
framework/lib/sitegen.ts+10-4
......@@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>;
5050export type FontVariableAxis = number | [min: number, max: number];
5151export type FontRange = string | number | { start: number; end: number };
5252
53export const userData = render.userData<SitegenRender>(() => {
54 throw new Error("This function can only be used in a page (static or view)");
55});
53export const userData = render.userData<SitegenRender>(() => initRender());
5654
5755export interface SitegenRender {
5856 scripts: Set<string>;
......@@ -78,13 +76,21 @@ export function wrapDocument({
7876 inlineCss: string;
7977 scripts: string;
8078}) {
81 return `<!doctype html><html lang=en><head>${head}${
79 return `<!doctype html><html lang=en><head><meta charset=utf8>${head}${
8280 inlineCss ? `<style>${inlineCss}</style>` : ""
8381 }</head><body>${body}${
8482 scripts ? `<script>${scripts}</script>` : ""
8583 }</body></html>`;
8684}
8785
86export function joinScripts(scriptSources: string[]) {
87 const { length } = scriptSources;
88 if (scriptSources[0] == null) return "";
89 if (length === 1) return UNWRAP(scriptSources[0]);
90 return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",") +
91 "].map(e=>e())";
92}
93
8894import * as render from "#engine/render";
8995import type * as meta from "./meta.ts";
9096import type * as css from "../css.ts";
framework/lib/subprocess.ts+56-52
......@@ -3,76 +3,80 @@
33
44const execFileRaw = util.promisify(child_process.execFile);
55export const exec: typeof execFileRaw = ((
6 ...args: Parameters<typeof execFileRaw>
6 ...args: Parameters<typeof execFileRaw>
77) =>
8 execFileRaw(...args).catch((e: any) => {
9 if (e?.message?.startsWith?.("Command failed")) {
10 if (e.code > 2 ** 31) e.code |= 0;
11 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
12 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
13 }
14 throw e;
15 })) as any;
8 execFileRaw(...args).catch((e: any) => {
9 if (e?.message?.startsWith?.("Command failed")) {
10 if (e.code > 2 ** 31) e.code |= 0;
11 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
12 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
13 }
14 throw e;
15 })) as any;
1616
17const activePids = new Set<number>();
17// const activePids = new Set<number>();
1818
1919export interface SpawnOptions extends child_process.SpawnOptions {
20 cmd: string[];
20 cmd: string[];
2121}
2222export function spawn({ cmd, ...rest }: SpawnOptions) {
23 rest.detached ??= true;
24 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
25 const pid = rest.detached && process.pid ? -process.pid : process.pid;
26 if (pid) {
27 activePids.add(pid);
28 process.on("exit", () => void activePids.delete(pid));
29 }
30 return process;
23 // rest.detached ??= true;
24 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
25 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
26 // if (pid) {
27 // activePids.add(pid);
28 // process.on("exit", () => void activePids.delete(pid));
29 // }
30 return process;
3131}
3232
3333export interface ForkOptions extends child_process.ForkOptions {
34 cmd: string[];
34 cmd: string[];
3535}
3636
3737export function fork({ cmd, ...rest }: ForkOptions) {
38 rest.detached ??= true;
39 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);
40 const pid = rest.detached && process.pid ? -process.pid : process.pid;
41 if (pid) {
42 activePids.add(pid);
43 process.on("exit", () => void activePids.delete(pid));
44 }
45 return process;
38 // rest.detached ??= true;
39 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);
40 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
41 // if (pid) {
42 // activePids.add(pid);
43 // process.on("exit", () => void activePids.delete(pid));
44 // }
45 return process;
4646}
4747
4848export async function spawnAndWait(opts: SpawnOptions) {
49 opts.stdio ??= ["ignore", "inherit", "inherit"];
50 const process = spawn(opts);
51 const output = Promise.all([
52 process.stdout ? Array.fromAsync(process.stdout).then(Buffer.concat) : null,
53 process.stderr ? Array.fromAsync(process.stderr).then(Buffer.concat) : null,
54 ]);
55 let [code, signal] = await events.EventEmitter.once(process, "exit") as [
56 number | null,
57 string | null,
58 ];
59 if (code !== 0) {
60 if (code !== null && code > 2 ** 31) code |= 0;
61 const exit = signal ? `signal ${signal}` : `code ${code}`;
62 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
63 }
64 const [stdout, stderr] = await output;
65 return { stdout, stderr };
49 opts.stdio ??= ["ignore", "inherit", "inherit"];
50 const process = spawn(opts);
51 const output = Promise.all([
52 process.stdout
53 ? Array.fromAsync(process.stdout).then(Buffer.concat)
54 : null,
55 process.stderr
56 ? Array.fromAsync(process.stderr).then(Buffer.concat)
57 : null,
58 ]);
59 let [code, signal] = (await events.EventEmitter.once(process, "exit")) as [
60 number | null,
61 string | null,
62 ];
63 if (code !== 0) {
64 if (code !== null && code > 2 ** 31) code |= 0;
65 const exit = signal ? `signal ${signal}` : `code ${code}`;
66 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
67 }
68 const [stdout, stderr] = await output;
69 return { stdout, stderr };
6670}
6771
68process.on("exit", () => {
69 for (const pid of activePids) {
70 console.info(`Kill ${Math.abs(pid)}`);
71 process.kill(pid, 9);
72 }
73});
72// process.on("exit", () => {
73// for (const pid of activePids) {
74// console.info(`Kill ${Math.abs(pid)}`);
75// process.kill(pid, 9);
76// }
77// });
7478
75import * as process from "node:process";
79// import * as process from "node:process";
7680import * as events from "node:events";
7781import * as util from "node:util";
7882import * as child_process from "node:child_process";
framework/lib/view.ts+1-8
......@@ -83,7 +83,7 @@ export async function renderToString<K extends Key>(
8383 body,
8484 head: await renderedMetaPromise,
8585 inlineCss,
86 scripts: joinScripts(
86 scripts: sg.joinScripts(
8787 Array.from(
8888 sitegen!.scripts,
8989 (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
......@@ -108,13 +108,6 @@ export function regenerate(tag: RegenKey) {
108108 }
109109}
110110
111function joinScripts(scriptSources: string[]) {
112 const { length } = scriptSources;
113 if (length === 0) return "";
114 if (0 in scriptSources) return scriptSources[0];
115 return scriptSources.map((source) => `{${source}}`).join(";");
116}
117
118111import * as meta from "./meta.ts";
119112import type * as hono from "#hono";
120113import * as render from "#engine/render";
run.js+18-4
......@@ -1,8 +1,9 @@
11// This file allows using Node.js in combination with
22// all available plugins. Usage: "node run <script>"
3import * as util from "node:util";
43import * as zlib from "node:zlib";
54import * as url from "node:url";
5import * as fs from "node:fs";
6import * as os from "node:os";
67import process from "node:process";
78
89// Disable experimental warnings (Type Stripping, etc)
......@@ -23,7 +24,7 @@ try {
2324
2425 // seems to fail in bun
2526 (await import("source-map-support")).install({ hookRequire: true });
26} catch {
27} catch (e) {
2728 const brand = process.versions.bun
2829 ? `bun ${process.versions.bun}`
2930 : process.versions.deno
......@@ -33,8 +34,9 @@ try {
3334 globalThis.console.error(
3435 `sitegen depends on a node.js v24. your runtime is missing features.\n` +
3536 `this is node.js version ${process.version}${brandText}\n\n` +
36 `get node.js --> https://nodejs.org/en/download/current`,
37 `get node.js --> https://nodejs.org/en/download/current\n`,
3738 );
39 console.error(e);
3840 process.exit(1);
3941}
4042
......@@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => {
5355 process.exit(1);
5456});
5557
58if (fs.existsSync(".env")) {
59 const env = fs.readFileSync(".env", "utf-8").split("\n").map((x) =>
60 x.trim().split("=")
61 );
62 for (const [k, v] of env) {
63 process.env[k] = v;
64 }
65}
66
5667// Load with hooks
5768if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
5869 if (process.argv.length == 2) {
......@@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
8293 serve(
8394 {
8495 fetch: mod.default.fetch,
96 port: mod.port ?? 3000,
8597 },
8698 ({ address, port }) => {
87 if (address === "::") address = "::1";
99 if (address === "::" || address === "127.0.0.1") {
100 address = os.hostname().replace(/\.local$/, "");
101 }
88102 log.info(
89103 url.format({
90104 protocol,
src/admin.ts+1-3
......@@ -12,9 +12,7 @@ function compareToken(token: string) {
1212}
1313
1414export async function middleware(c: Context, next: Next) {
15 if (c.req.path.startsWith("/admin")) {
16 return adminInner(c, next);
17 }
15 if (c.req.path.startsWith("/admin")) return adminInner(c, next);
1816 return next();
1917}
2018
src/backend.ts+1
......@@ -11,6 +11,7 @@ app.use(admin.middleware);
1111// Backends
1212app.route("", require("./q+a/backend.ts").app);
1313app.route("", require("./file-viewer/backend.tsx").app);
14app.route("", require("./friend-auth.ts").app);
1415
1516// Asset middleware has least precedence
1617app.use(assets.middleware);
src/deploy.ts created+51
......@@ -0,0 +1,51 @@
1export async function main() {
2 if (!process.argv.includes("-f")) await incr.restore();
3 await incr.compile(generate);
4
5 await rsync.spawn({
6 cwd: Path.resolve(import.meta.dirname).parent!.toString(),
7 args: [
8 "-a",
9 "-r",
10 "--progress",
11 "--delete",
12 "--exclude=node_modules",
13 "--exclude=.clover",
14 "package.json",
15 "package-lock.json",
16 ".clover/o/",
17 "paperclover.net:~/paperclover.net/",
18 ],
19 progress: progress.start("copy site files"),
20 });
21 const cache = Path.resolve(".clover/cache.sqlite").ifExistsSync();
22 if (cache) {
23 await rsync.spawn({
24 cwd: Path.resolve(import.meta.dirname).parent!.toString(),
25 args: [
26 "-a",
27 "--progress",
28 cache.toString(),
29 "paperclover.net:~/paperclover.net/.clover/cache.sqlite",
30 ],
31 progress: progress.start("copy file viewer cache"),
32 });
33 }
34 await subprocess.spawnAndWait({
35 stdio: "inherit",
36 cmd: [
37 "ssh",
38 "paperclover.net",
39 "cd paperclover.net && " +
40 "npm i &&" +
41 "pm2 restart site",
42 ],
43 });
44}
45
46import * as incr from "../framework/incremental.ts";
47import { generate } from "../framework/generate.ts";
48import * as progress from "lib/log/progress.ts";
49import * as subprocess from "#sitegen/subprocess";
50import * as rsync from "./file-viewer/rsync.ts";
51import { Path } from "#sitegen/path";
src/file-viewer/backend.tsx+19-12
......@@ -66,7 +66,10 @@ app.get("/file/*", async (c, next) => {
6666 if (c.req.query("dl") != null) viewMode = "download";
6767 if (lofi) viewMode = file.extension === ".html" ? "embed" : "download";
6868
69 if (viewMode == null && c.req.header("Accept")?.includes("text/html")) {
69 if (
70 viewMode == null && !derivedKey &&
71 c.req.header("Accept")?.includes("text/html")
72 ) {
7073 // cache.prefetch(file);
7174 c.res = await view.serve(c, "file-viewer/clofi", {
7275 file,
......@@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => {
156159
157160 const found = await cache.read({
158161 file,
159 hash,
162 size,
160163 derived: derivedKey,
164 hash,
161165 start,
162166 end,
163167 });
164168 if (!found) {
165 return Response.json({
169 return c.json({
166170 error: "localOnly is enabled and this file is not available",
167171 }, 500);
168172 }
......@@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) {
265269 return c.json({ error: "File not found" }, 404);
266270 }
267271
268 let root = MediaPanel({
269 file,
270 isLast: true,
271 activeFilename: null,
272 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
273 });
272 const r = render.init(false, {});
273 let root = render.resolveNode(
274 r,
275 <MediaPanel
276 file={file}
277 isLast
278 activeFilename={null}
279 hasCotyledonCookie={rawFilePath === "" && checkCotyledonCookie(c)}
280 />,
281 );
274282 // Remove the root element, it's created client side!
275 root = root[2].children as render.Element;
283 root = (root as render.ResolvedElement)[3];
276284
277 const html = render.sync(root).text;
278 return c.html(html);
285 return c.html(render.stringifyNode(root));
279286}
280287
281288import { type Context, Hono } from "hono";
src/file-viewer/bin/scan3.ts+3-2
......@@ -160,7 +160,7 @@ export async function main() {
160160 );
161161 dependantJob.needs -= 1;
162162 if (dependantJob.needs == 0) {
163 queueFileProcessor(dependantJob);
163 promises.push(queueFileProcessor(dependantJob));
164164 }
165165 }
166166 }, (job) => ({ cores: job.processor.cores ?? 0 }));
......@@ -281,6 +281,7 @@ export async function main() {
281281 using dirMetaNode = progress.start("update directory metadata");
282282 const dirs = MediaFile.getDirectoriesToReindex()
283283 .sort((a, b) => b.path.length - a.path.length);
284
284285 for (const dir of dirs) {
285286 using _ = dirMetaNode.start(dir.path);
286287 const children = dir.getChildren();
......@@ -376,7 +377,7 @@ export async function main() {
376377 });
377378 if (sotToken) {
378379 const res = await fetch("https://db.paperclover.net/reload", {
379 method: "post",
380 method: "POST",
380381 headers: {
381382 Authorization: sotToken,
382383 },
src/file-viewer/cache.ts+155-64
......@@ -33,7 +33,7 @@ export type Source =
3333const token = process.env.CLOVER_SOT_KEY ?? null;
3434const sotUrl = new URL(
3535 process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net",
36);
36).toString().replace(/\/+$/g, "");
3737if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files");
3838
3939const diskCacheRoot = openDiskCache();
......@@ -94,6 +94,7 @@ export interface Options {
9494 file: MediaFile;
9595 /** If set, fetches the derived asset instead. */
9696 derived: string | null;
97 size: number;
9798 hash: string;
9899 start: number;
99100 /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */
......@@ -107,7 +108,7 @@ export interface Options {
107108 * `null` if a derived asset does not exist
108109 */
109110export async function read(
110 { file, derived, hash, start, end, noWrite, localOnly }: Options,
111 { file, derived, hash, size, start, end, noWrite, localOnly }: Options,
111112): Promise<{ stream: ReadableStream; src: Source; size: number } | null> {
112113 const rel = derived ? `${file.path}$/${derived}` : file.path;
113114
......@@ -115,33 +116,33 @@ export async function read(
115116 const localFile = derived
116117 ? derivedFileRoot?.join(derived)
117118 : rawFileRoot?.join(file.path);
118 if (localFile) {
119 local: {
120 let size: number;
121 try {
122 ({ size } = await localFile.stat());
123 } catch (err) {
124 if (error.code(err) === "ENOENT") {
125 console.warn(`missing local file: ${localFile}`);
126 break local;
127 }
128 throw err;
119 local: {
120 if (!localFile) break local;
121 if (process.env.CLOVER_SOT_FORCE) break local;
122 let size: number;
123 try {
124 ({ size } = await localFile.stat());
125 } catch (err) {
126 if (error.code(err) === "ENOENT") {
127 console.warn(`missing local file: ${localFile}`);
128 break local;
129129 }
130 const fileStream = await readStream(localFile, { start, end });
131 if (!fileStream) break local;
132 console.info(`from local: ${rel} ` + localFile);
133 return {
134 stream: stream.Readable.toWeb(fileStream),
135 size,
136 src: "local",
137 };
130 throw err;
138131 }
132 const fileStream = await readStream(localFile, { start, end });
133 if (!fileStream) break local;
134 console.info(`from local: ${rel} ` + localFile);
135 return {
136 stream: stream.Readable.toWeb(fileStream),
137 size,
138 src: "local",
139 };
139140 }
140141
141 if (!localOnly) return null;
142 if (localOnly) return null;
142143
143144 // Disk cache
144 const diskPath = diskCacheRoot.join(key);
145 const diskPath = diskCacheRoot.join(hash);
145146 let diskFile = diskCache.get(diskPath);
146147 if (diskFile) {
147148 const lock = diskCache.lock(diskPath);
......@@ -165,67 +166,154 @@ export async function read(
165166 }
166167
167168 // Fetch the source of truth
168 const url = `${sotUrl}/file${file.path}`;
169 let response;
170 {
171 let promise = parallelRequests.get(url);
172 if (promise) {
173 console.info(`from other response: ${rel}`);
174 response = await promise;
175 } else {
176 console.info(`cache miss: ${rel}`);
177 promise = httpGet(url, {
178 headers: token ? { Authorization: token } : {},
179 });
180 parallelRequests.set(url, promise);
181 response = await promise;
182 parallelRequests.delete(url);
183 }
184 }
185 const { statusCode, headers } = response;
186 if (statusCode !== 200) {
187 const text = await new Response(response as unknown as Blob).text();
188 const msg = `Source of Truth responded with status ${statusCode}: ${text}`;
189 console.error(msg);
190 throw new Error("Source of Truth is unavailable");
191 }
192 const size = Number(headers["content-length"]);
193 ASSERT(!Number.isNaN(size) && size >= 0);
169 const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
170 const url = `${sotUrl}/file${file.path}${suffix}`;
194171
195172 // Do not cache large assets
196173 if (size > 1_000_000_000) {
197174 console.info(`skipping disk cache due to large asset: ${rel}`);
198 return { stream: stream.Readable.toWeb(response), size, src: "ignored" };
175 return {
176 stream: fetchAndSlice(rel, url, start, end),
177 size,
178 src: "ignored",
179 };
199180 }
200181 // Bail out of the disk cache if it is contended
201182 if (diskCache.unlockedCapacity < size) {
202183 console.info(`skipping disk cache due to contention: ${rel}`);
203 return { stream: stream.Readable.toWeb(response), size, src: "contended" };
184 return {
185 stream: fetchAndSlice(rel, url, start, end),
186 size,
187 src: "contended",
188 };
204189 }
205190 // IPs which request too many cache misses are temporarily
206191 // banned from writing into the disk cache to avoid abuse.
207192 if (noWrite) {
208193 console.info(`skipping disk cache due to config: ${rel}`);
209 return { stream: stream.Readable.toWeb(response), size, src: "ratelimit" };
194 return {
195 stream: fetchAndSlice(rel, url, start, end),
196 size,
197 src: "ratelimit",
198 };
210199 }
211200
212201 // Create a disk cache entry;
213202 // Tee the stream to both the request and the filesystem.
214 diskFile = {
215 size,
216 written: new async.Watch(0),
217 memory: new Map(),
218 };
203 diskFile = { size, written: new async.Watch(0), memory: new Map() };
219204 diskCache.set(diskPath, diskFile);
220
221205 const lock = diskCache.lock(diskPath);
222 const writer = await writeStream(diskPath, response, diskFile, lock);
223 writer.on("error", () => {
224 diskCache.delete(diskPath);
225 diskFile.written.value = Infinity;
206 return fetchAndStore(rel, url, size, diskPath, diskFile, start, end, lock);
207}
208
209async function fetchAndStore(
210 rel: string,
211 url: string,
212 size: number,
213 diskPath: Path,
214 diskFile: DiskFile,
215 start: number,
216 end: number,
217 lock: Lock,
218) {
219 try {
220 let response = await fetch(rel, url);
221 const { statusCode } = response;
222 if (statusCode !== 200) {
223 const text = await new Response(response as unknown as Blob).text();
224 const msg =
225 `Source of Truth responded with status ${statusCode}: ${text}`;
226 console.error(msg);
227 throw new Error("Source of Truth is unavailable");
228 }
229 const writer = await writeStream(diskPath, response, diskFile, lock);
230 writer.on("error", () => {
231 diskCache.delete(diskPath);
232 diskFile.written.value = Infinity;
233 });
234
235 return { stream: sliceStream(response, start, end), size, src: "miss" };
236 } catch (e) {
237 lock();
238 throw e;
239 }
240}
241
242async function fetch(rel: string, url: string, headers?: {}) {
243 let promise = !headers ? parallelRequests.get(url) : null;
244 if (promise) {
245 console.info(`from other response: ${rel}`);
246 return await promise;
247 } else {
248 console.info(`cache miss: ${rel}`);
249 promise = httpGet(url, {
250 headers: {
251 ...token ? { Authorization: token } : null,
252 ...headers,
253 },
254 });
255 if (!headers) parallelRequests.set(url, promise);
256 const response = await promise;
257 if (!headers) parallelRequests.delete(url);
258 return response;
259 }
260}
261
262async function fetchAndSlice(
263 rel: string,
264 url: string,
265 start: number,
266 end: number,
267) {
268 const headers = start !== 0 || end !== Infinity
269 ? { "Clover-Start": String(start), "Clover-End": String(end) }
270 : null;
271 return stream.Readable.toWeb(await fetch(rel, url, headers));
272}
273
274export function sliceStream(
275 readable: stream.Readable,
276 start: number,
277 end: number,
278): Readable {
279 const length = end === Infinity ? Infinity : end - start;
280 let recieved = 0;
281 let sent = 0;
282
283 const transform = new stream.Transform({
284 transform(chunk: Buffer, _, callback) {
285 const chunkEnd = recieved + chunk.length;
286
287 if (chunkEnd <= start) {
288 recieved = chunkEnd;
289 callback();
290 return;
291 }
292
293 if (sent >= length) {
294 callback();
295 return;
296 }
297
298 const sliceStart = Math.max(0, start - recieved);
299 const remaining = length - sent;
300 const sliceEnd = end === Infinity
301 ? chunk.length
302 : Math.min(chunk.length, sliceStart + remaining);
303
304 const slice = chunk.slice(sliceStart, sliceEnd);
305 sent += slice.length;
306 recieved = chunkEnd;
307
308 this.push(slice);
309
310 callback();
311 },
226312 });
227313
228 return { stream: stream.Readable.toWeb(response), size, src: "miss" };
314 readable.on("error", (err) => transform.destroy(err));
315
316 return readable.pipe(transform);
229317}
230318
231319interface DiskFile {
......@@ -236,7 +324,10 @@ interface DiskFile {
236324
237325function httpGet(url: string, options: https.RequestOptions) {
238326 return new Promise<http.IncomingMessage>((resolve, reject) => {
239 const request = https.get(url, options);
327 const request = (url.startsWith("https://") ? https : http).get(
328 url,
329 options,
330 );
240331 request.on("response", resolve);
241332 request.on("error", reject);
242333 });
src/file-viewer/models/FilePermissions.ts+11-11
......@@ -3,9 +3,9 @@ const db = getDb("cache.sqlite");
33db.table(
44 "permissions",
55 /* SQL */ `
6 CREATE TABLE IF NOT EXISTS permissions (
7 prefix TEXT PRIMARY KEY,
8 allow INTEGER NOT NULL
6 create table if not exists permissions (
7 prefix text primary key,
8 allow integer not null
99 );
1010`,
1111);
......@@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare<
3636 [prefix: string],
3737 Pick<FilePermissions, "allow">
3838>(/* SQL */ `
39 SELECT allow
40 FROM permissions
41 WHERE ? GLOB prefix || '*'
42 ORDER BY LENGTH(prefix) DESC
43 LIMIT 1;
39 select allow
40 from permissions
41 where ? glob prefix || '*'
42 order by length(prefix) desc
43 limit 1;
4444`);
4545const getExactQuery = db.prepare<
4646 [file: string],
4747 Pick<FilePermissions, "allow">
4848>(/* SQL */ `
49 SELECT allow FROM permissions WHERE ? == prefix
49 select allow from permissions where ? == prefix
5050`);
5151
5252const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
53 REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
53 replace into permissions(prefix, allow) values($prefix, $allow);
5454`);
5555const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
56 DELETE FROM permissions WHERE prefix = ?;
56 delete from permissions where prefix = ?;
5757`);
5858
5959import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+2-3
......@@ -113,12 +113,11 @@ export class MediaFile {
113113 return ext;
114114 }
115115 getChildren() {
116 return MediaFile.getChildren(this.id)
117 .filter((file) => !file.basename.startsWith("."));
116 return MediaFile.getChildren(this.id);
118117 }
119118 getPublicChildren() {
120119 const children = MediaFile.getChildren(this.id).filter((x) =>
121 !x.basename.startsWith("_unlisted")
120 !x.basename.startsWith(".") && !x.basename.startsWith("_unlisted")
122121 );
123122 if (FilePermissions.getByPrefix(this.path) == 0) {
124123 return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
src/file-viewer/rules.ts+1-1
......@@ -70,7 +70,7 @@ export const extsImageLike = new Set([
7070/** These files populate `duration` using `ffprobe` */
7171export const extsDuration = new Set([...extsAudio, ...extsVideo]);
7272/** These files populate `dimensions` using `ffprobe` */
73export const extsDimensions = new Set([...extsImage, ...extsVideo]);
73export const extsDimensions = new Set([...extsImage, ...extsVideo, ".gif"]);
7474
7575/** These files read file contents into `contents`, as-is */
7676export const extsReadContents = new Set([".txt", ".chat", ".dirsort"]);
src/file-viewer/views/clofi.client.ts+6-1
......@@ -139,7 +139,7 @@ function ensureCanvasReady(id: string): Promise<CanvasFn> {
139139 promise = new Promise<CanvasFn>((r) => resolve = r);
140140 fetchCanvas.set(id, promise);
141141 const script = document.createElement("script");
142 script.src = `/js/canvas/${id}.js`;
142 script.src = `/js/file-viewer/scripts/canvas_${id}.js`;
143143 script.async = true;
144144 script.onload = () => {
145145 func = (globalThis as any)["canvas_" + id];
......@@ -216,6 +216,11 @@ class Panel {
216216 }
217217 }
218218
219 Array.from(
220 panel.querySelectorAll<HTMLElement>(".clover-video"),
221 (container) => hydrateVideo(container),
222 );
223
219224 const ul = panel.querySelector("ul");
220225 if (!ul) {
221226 this.width = 0;
src/file-viewer/views/clofi.tsx+57-65
......@@ -70,8 +70,6 @@ export default function MediaList({
7070 file: MediaFile;
7171 hasCotyledonCookie: boolean;
7272}) {
73 addScript("./clofi.client.ts");
74
7573 const dirs: MediaFile[] = [];
7674 let dir: MediaFile | null = file;
7775 assert(dir);
......@@ -115,43 +113,43 @@ export function MediaPanel({
115113 activeFilename: string | null;
116114 hasCotyledonCookie: boolean;
117115}) {
116 addScript("./clofi.client.ts");
117 addScript("../../tags/clover-video.client.ts");
118118 const showsActions = file.path !== "/2024/for everyone";
119119 const label = file.path === "/"
120120 ? (
121121 "clo's files"
122122 )
123123 : (
124 <>
125 {file.kind === MediaFileKind.directory
126 ? (
127 <span>
128 {file.basename}
129 <span class="bold-slash">/</span>
130 </span>
131 )
132 : (
133 <>
134 {file.basename}
135 <div style="flex:1" />
136 {showsActions && (
137 <div class="actions">
138 <a
139 class="custom full-screen"
140 href={`/file${escapeUri(file.path)}?view=embed`}
141 aria-label="full screen"
142 >
143 </a>
144 <a
145 class="custom download"
146 href={`/file${escapeUri(file.path)}?dl`}
147 aria-label="download"
148 >
149 </a>
150 </div>
151 )}
152 </>
153 )}
154 </>
124 file.kind === MediaFileKind.directory
125 ? (
126 <span>
127 {file.basename}
128 <span class="bold-slash">/</span>
129 </span>
130 )
131 : (
132 <>
133 {file.basename}
134 <div style="flex:1" />
135 {showsActions && (
136 <div class="actions">
137 <a
138 class="custom full-screen"
139 href={`/file${escapeUri(file.path)}?view=embed`}
140 aria-label="full screen"
141 >
142 </a>
143 <a
144 class="custom download"
145 href={`/file${escapeUri(file.path)}?dl`}
146 aria-label="download"
147 >
148 </a>
149 </div>
150 )}
151 </>
152 )
155153 );
156154 let View = specialCaseViewers[file.path] ??
157155 (extensionToViewer[file.extension.toLowerCase()] as any) ??
......@@ -166,18 +164,16 @@ export function MediaPanel({
166164 />
167165 );
168166 const canvases: Record<string, string> = {
169 "/2017": "2017",
170 "/2018": "2018",
171 "/2019": "2019",
172 "/2020": "2020",
173 "/2021": "2021",
174 "/2022": "2022",
175 "/2023": "2023",
176 "/2024": "2024",
167 "/2017": () => addScript("../scripts/canvas_2017.client.ts"),
168 "/2018": () => addScript("../scripts/canvas_2018.client.ts"),
169 "/2019": () => addScript("../scripts/canvas_2019.client.ts"),
170 "/2020": () => addScript("../scripts/canvas_2020.client.ts"),
171 "/2021": () => addScript("../scripts/canvas_2021.client.ts"),
172 "/2022": () => addScript("../scripts/canvas_2022.client.ts"),
173 "/2023": () => addScript("../scripts/canvas_2023.client.ts"),
174 "/2024": () => addScript("../scripts/canvas_2024.client.ts"),
177175 };
178 if (canvases[file.path]) {
179 addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
180 }
176 canvases[file.path]?.();
181177 return (
182178 <div
183179 class={(isLast ? "panel last" : "panel") +
......@@ -186,7 +182,7 @@ export function MediaPanel({
186182 {canvases[file.path] && (
187183 <canvas
188184 class="fullscreen-canvas"
189 data-canvas={canvases[file.path]}
185 data-canvas={file.path.slice(1).split("/")![0]}
190186 >
191187 </canvas>
192188 )}
......@@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) {
680676function VideoView({ file }: { file: MediaFile }) {
681677 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
682678 return (
683 <>
684 <video
685 controls
686 preload
687 style={`background:transparent;aspect-ratio:${
688 simplifyFraction(
689 dimensions.width,
690 dimensions.height,
691 )
692 }`}
693 >
694 <source
695 src={"/file" + file.path}
696 type={mime.contentTypeFor(file.path)}
697 />
698 </video>
699 </>
679 <CloverVideo
680 minimal
681 file={file}
682 style={`background:transparent;aspect-ratio:${
683 simplifyFraction(
684 dimensions.width,
685 dimensions.height,
686 )
687 }`}
688 />
700689 );
701690}
702691
......@@ -748,10 +737,11 @@ function AudioView({
748737
749738function ImageView({ file }: { file: MediaFile }) {
750739 return (
751 <img
752 src={"/file" + file.path}
740 <Image
741 src={file}
753742 alt={file.basename}
754 style="max-width:100%;max-height:100%;object-fit:contain;margin:auto;display:block;"
743 objectFit="contain"
744 style="max-width:100%;max-height:100%;margin:auto;display:block;"
755745 />
756746 );
757747}
......@@ -935,4 +925,6 @@ import {
935925import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
936926import * as mime from "#sitegen/mime";
937927import * as sort from "../sort.ts";
928import CloverVideo from "@/tags/clover-video.marko";
929import Image from "@/tags/image.tsx";
938930import * as render from "#engine/render";
src/pages/in-the-summer.marko+5-2
......@@ -22,7 +22,10 @@ import "./in-the-summer.css";
2222 </p>
2323</div>
2424
25<clover-video file="/2025/in the summer/in the summer.mp4">
25<clover-video
26 file="/2025/in the summer/in the summer.mp4"
27 poster="/2025/in the summer/thumbnail.jpeg"
28>
2629 <@header><strong>music video</>: in the summer</>
2730</>
2831
......@@ -34,7 +37,7 @@ import "./in-the-summer.css";
3437 <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li>
3538 </ul>
3639 <h2>mentions on the q&a</h2>
37 // <QuestionsFor artifact='in-the-summer'/>
40 <questions-embed="in-the-summer" />
3841</div>
3942</main>
4043
src/pages/index.marko+2-5
......@@ -21,10 +21,7 @@ export const meta = {
2121 <p><a href="/file">file browser</a></p>
2222 <p><a href="/subscribe">mailing list</a></p>
2323 <p><a href="/donate">donate</a></p>
24 <p><a href="/rss.xml">rss feed</a></p>
25 <p style="position: relative; z-index: 10"><a href="/discord">discord</a></p>
26 <h1>
27 paper clover
28 </h1>
24 <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p>
25 <h1>paper clover</h1>
2926 </div>
3027</main>
src/pages/subscribe.client.ts deleted-97
......@@ -1,97 +0,0 @@
1// @ts-nocheck
2// manually obfuscated to make it very difficult to reverse engineer
3// if you want to decode what the email is, visit the page!
4// stops people from automatically scraping the email address
5//
6// Unfortunately this needs a rewrite to support Chrome without
7// hardware acceleration and some Linux stuff. I will probably
8// go with a proof of work alternative.
9requestAnimationFrame(() => {
10 const hash = "SHA";
11 const a = [
12 { parentElement: document.getElementById("subscribe") },
13 function (b) {
14 let c = 0, d = 0;
15 for (let i = 0; i < b.length; i++) {
16 c = (c + b[i] ^ 0xF8) % 8;
17 d = (c * b[i] ^ 0x82) % 193;
18 }
19 a[c + 1]()[c](d, b.buffer);
20 },
21 function () {
22 const i = a[4](a[3]());
23 const b = i.innerText = a.pop();
24 if (a[b.indexOf("@") / 3]) {
25 i.href = "mailto:" + b;
26 }
27 },
28 function () {
29 return a[a.length % 10];
30 },
31 function (x) {
32 return x.parentElement;
33 },
34 function (b, c) {
35 throw new Uint8Array(
36 c,
37 0,
38 64,
39 c.parentElement = this[8].call(b.call(this)).location,
40 );
41 },
42 function (b, c) {
43 this.width = 8;
44 this.height = 16;
45 b.clearColor(0.5, 0.7, 0.9, 1.0);
46 b.clear(16408 ^ this.width ^ this.height);
47 const e = new Uint8Array(4 * this.width * this.height);
48 b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e);
49 let parent = a[this.width / 2](this);
50 while (parent.tagName !== "BODY") {
51 parent = a[2 * this.height / this.width](parent);
52 }
53 try {
54 let d = [hash, e.length].join("-");
55 const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
56 [, d] = a;
57 b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
58 } catch (e) {
59 fetch(e).then(a[5]).catch(a[2]);
60 }
61 },
62 function (b, c) {
63 const d = a.splice(
64 9,
65 1,
66 [
67 a[3]().parentElement.id,
68 c.parentElement.hostname,
69 ].join(String.fromCharCode(b)),
70 );
71 var e = new Error();
72 Object.defineProperty(e, "stack", {
73 get() {
74 a[9] = d;
75 },
76 });
77 a[2].call(console.log(e));
78 },
79 function () {
80 return this;
81 },
82 "[failed to verify your browser]",
83 function (a) {
84 a = a.parentElement.ownerDocument.defaultView;
85 return { parentElement: a.navigator.webdriver || a.crypto };
86 },
87 ];
88 try {
89 const c = document.querySelector("canvas");
90 const g = c.getContext("webgl2") || c.getContext("webgl");
91 a[0].parentElement.innerText = "[...loading...]";
92 g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
93 } catch {
94 a.pop();
95 fetch(":").then(a[5]).catch(a[2]);
96 }
97});
src/pages/subscribe.css+1-4
......@@ -1,4 +1,4 @@
1#subscribe[href] {
1#subscribe {
22 display: flex;
33 justify-content: center;
44 align-items: center;
......@@ -6,9 +6,6 @@
66 font-weight: bold;
77 text-align: center;
88}
9#subscribe:not([href]) {
10 opacity: 0.5;
11}
129.flex {
1310 display: flex;
1411 justify-content: center;
src/pages/subscribe.marko+2-9
......@@ -4,8 +4,6 @@ export const meta = {
44 description: "subscribe to the mailing list",
55};
66
7client import "./subscribe.client.ts";
8
97<main>
108<h1>mailing list</h1>
119<canvas style="display: none;"></canvas>
......@@ -17,19 +15,14 @@ client import "./subscribe.client.ts";
1715 the list is currently managed manually. to get added, please email anything
1816 with the word "subscribe" in the subject or body to the following address:
1917</p>
20<code class="flex">
21 <a id="subscribe">
22 [please enable javascript, this is an anti-spam/scraping measure]
23 </a>
18<code id="subscribe" class="flex">
19 subscribe at paper clover dot net
2420</code>
2521<p>
2622 to unsubscribe, send an email asking to unsubscribe. for those who are close
2723 friends with me, add your name and i'll add you to the 'friends' list, where i
2824 send life updates in addition to new content (1-2 times per month).
2925</p>
30<p>
31 alternatively, you can join <a href="/discord">the discord</a> to receive mentions in the <code>#new</code> channel.
32</p>
3326<br>
3427<br>
3528<br>
src/pages/waterfalls.marko+3
......@@ -42,6 +42,9 @@ export const theme = {
4242 <br />
4343 <a href="/q+a">q&a page</a>
4444 </p>
45
46 <h2>mentions on the q&a</h2>
47 <questions-embed="waterfalls" />
4548 </div>
4649 </main>
4750</main>
src/q+a/artifacts.ts+26-77
......@@ -3,87 +3,36 @@
33// one canonical URL, which the questions page can refer to with `@id`.
44type Artifact = [title: string, url: string, type: ArtifactType];
55type ArtifactType = "music" | "game" | "project" | "video";
6
7// deno-fmt-ignore
68export const artifactMap: Record<string, Artifact> = {
79 // 2025
8 "in-the-summer": ["in the summer", "", "music"],
9 waterfalls: ["waterfalls", "/waterfalls", "music"],
10 lolzip: ["lol.zip", "", "project"],
11 "g-is-missing": ["g is missing", "", "music"],
12 "im-18-now": ["i'm 18 now", "", "music"],
13 "programming-comparison": [
14 "thursday programming language comparison",
15 "",
16 "video",
17 ],
18 aaaaaaaaa: ["aaaaaaaaa", "", "music"],
19 "its-snowing": ["it's snowing", "", "video"],
20 // 2023
21 "iphone-15-review": [
22 "iphone 15 review",
23 "/file/2023/iphone%2015%20review/iphone-15-review.mp4",
24 "video",
25 ],
26 // 2022
27 mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "mystery-of-life": [
29 "mystery of life",
30 "/file/2022/mystery-of-life/mystery-of-life.mp4",
31 "music",
32 ],
33 // 2021
34 "top-10000-bread": [
35 "top 10000 bread",
36 "https://paperclover.net/file/2021/top-10000-bread/output.mp4",
37 "video",
38 ],
39 "phoenix-write-soundtrack": [
40 "Phoenix, WRITE! soundtrack",
41 "/file/2021/phoenix-write/OST",
42 "music",
43 ],
44 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
45 "money-visual-cover": [
46 "money visual cover",
47 "/file/2021/money-visual-cover/money-visual-cover.mp4",
48 "video",
49 ],
50 "i-got-this-thing": [
51 "i got this thing",
52 "/file/2021/i-got-this-thing/i-got-this-thing.mp4",
53 "video",
54 ],
55 // 2020
56 elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
10 "in-the-summer": ["in the summer", "/in-the-summer", "music"],
11 "waterfalls": ["waterfalls", "/waterfalls", "music"],
12
13 // cotyledon
14 "aaaaaaaaa": ["aaaaaaaaa", "", "music"],
15 "dice-roll": [ "thursday dice roll", "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4", "video"],
5716 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
58 // 2019
59 "throw-soundtrack": [
60 "throw soundtrack",
61 "/file/2019/throw/soundtrack",
62 "music",
63 ],
6417 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
65 volar: [
66 "volar visual cover",
67 "/file/2019/volar-visual-cover/volar.mp4",
68 "video",
69 ],
70 wpm: [
71 "how to read 500 words per minute",
72 "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
73 "video",
74 ],
75 "dice-roll": [
76 "thursday dice roll",
77 "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
78 "video",
79 ],
80 "math-problem": [
81 "thursday math problem",
82 "/file/2019/thursday-math-problem/thursday-math-problem.mp4",
83 "video",
84 ],
85 // 2018
86 // 2017
18 "elemental4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
19 "g-is-missing": ["g is missing", "https://paperclover.net/file/2023/g%20is%20missing/g%20is%20missing.mp4", "music"],
8720 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
21 "i-got-this-thing": [ "i got this thing", "/file/2021/i-got-this-thing/i-got-this-thing.mp4", "video"],
22 "im-18-now": ["i'm 18 now", "", "music"],
23 "iphone-15-review": [ "iphone 15 review", "/file/2023/iphone%2015%20review/iphone-15-review.mp4", "video"],
24 "its-snowing": ["it's snowing", "https://paperclover.net/file/2022/its-snowing/it's%20snowing.mp4", "video"],
25 "lolzip": ["lol.zip", "", "project"],
26 "math-problem": [ "thursday math problem", "/file/2019/thursday-math-problem/thursday-math-problem.mp4", "video"],
27 "mayday": ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "money-visual-cover": [ "money visual cover", "/file/2021/money-visual-cover/money-visual-cover.mp4", "video"],
29 "mystery-of-life": [ "mystery of life", "/file/2022/mystery-of-life/mystery-of-life.mp4", "music"],
30 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
31 "phoenix-write-soundtrack": [ "Phoenix, WRITE! soundtrack", "/file/2021/phoenix-write/OST", "music"],
32 "programming-comparison": [ "thursday programming language comparison", "", "video"],
8833 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
34 "throw-soundtrack": [ "throw soundtrack", "/file/2019/throw/soundtrack", "music"],
35 "top-10000-bread": [ "top 10000 bread", "https://paperclover.net/file/2021/top-10000-bread/output.mp4", "video"],
36 "volar": [ "volar visual cover", "/file/2019/volar-visual-cover/volar.mp4", "video" ],
37 "wpm": [ "how to read 500 words per minute", "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4", "video"],
8938};
src/q+a/clover-markdown.tsx+12-1
......@@ -61,7 +61,7 @@ customRules.insertBefore("paragraph", {
6161customRules.insertBefore("url", {
6262 name: "autolink_mention",
6363 match: inlineRegex(
64 /^https?:\/\/(?:paper(?:dave|clover)\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/,
64 /^https?:\/\/(?:paperclover\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/,
6565 ),
6666 parse(capture) {
6767 const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/);
......@@ -72,6 +72,17 @@ customRules.insertBefore("url", {
7272 };
7373 }
7474
75 const fileMatch = capture[1].match(/^file\/([^#?]*)/);
76 if (fileMatch) {
77 // const file = MediaFile.getByPath(decodeURIComponent(fileMatch[1]));
78 // if (file) {
79 // return {
80 // type: "file",
81 // file,
82 // };
83 // }
84 }
85
7586 return {
7687 type: "link",
7788 content: [
src/q+a/models/Question.ts+8-1
......@@ -2,7 +2,7 @@ const db = getDb("questions.sqlite");
22db.table(
33 "questions",
44 `
5 create table questions (
5 create table if not exists questions (
66 qmid integer not null,
77 type integer not null,
88 text text not null,
......@@ -68,6 +68,9 @@ export class Question {
6868 static updateByQmid(qmid: number, text: string, type: QuestionType) {
6969 return updateByQmidQuery.run({ text, type, qmid });
7070 }
71 static search(ref: string) {
72 return searchQuery.array(`%@${ref}%`, `%https://paperclover.net/${ref}%`);
73 }
7174}
7275
7376// Create a new question with a unique QMID
......@@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare<
114117 UPDATE questions SET text = $text, type = $type WHERE qmid = $qmid
115118`);
116119
120const searchQuery = db.prepare<[string, string]>(
121 `select * from questions where type = ${QuestionType.normal} and (text like ? or text like ?)`,
122).as(Question);
123
117124import { formatQuestionId } from "../format.ts";
118125import { getDb } from "#sitegen/sqlite";
src/q+a/tags/question.marko+3-1
......@@ -1,3 +1,5 @@
1import "../questions.css";
2
13// Renders a `Question` entry including its markdown body.
24export interface Input {
35 question: Question;
......@@ -12,7 +14,7 @@ export const transitionDate = 1735639200000;
1214<const/{ id, date } = question/>
1315
1416<${"e-"}
15 f=(date > transitionDate ? true : undefined)
17 f=(date > transitionDate ? true : null)
1618 id=admin ? `q${id}` : undefined
1719>
1820 <if=admin>
src/scripts/vendor/dash.client.ts created+1
......@@ -0,0 +1 @@
1export * from "dashjs";
src/scripts/vendor/hls.client.ts created+1
......@@ -0,0 +1 @@
1export * from "hls.js";
src/source-of-truth.ts+33-33
......@@ -1,24 +1,27 @@
1// The "source of truth" server is the canonical storage for
2// paper clover's files. This is technically needed because
3// the VPS she uses can only store about 20gb of content, where
4// the contents of /file is about 48gb as of writing; only a
5// limited amount of data can be cached.
6//
7// What's great about this system is it also allows scaling the
8// website up into multiple servers, if that is ever desired.
9// When that happens, mutations to "q+a" will be moved here,
10// and the SQLite synchronization mechanism will apply to both
11// of those databases.
12//
13// An alternative to this would have been to use a SMB client,
14// but I've read that the systems used for caching don't work
15// like the HTTP Cache-Control header, where you can say a file
16// is valid for up to a certain amount of time. If we seriously
17// need cache busts (paper clover does not), the proper way
18// would be to push a message to all VPS nodes instead of
19// checking upstream if a file changed every time.
1/** @module
2 * The "source of truth" server is the canonical storage for
3 * paper clover's files. This is technically needed because
4 * the VPS she uses can only store about 20gb of content, where
5 * the contents of /file is about 48gb as of writing; only a
6 * limited amount of data can be cached.
7 *
8 * What's great about this system is it also allows scaling the
9 * website up into multiple servers, if that is ever desired.
10 * When that happens, mutations to "q+a" will be moved here,
11 * and the SQLite synchronization mechanism will apply to both
12 * of those databases.
13 *
14 * An alternative to this would have been to use a SMB client,
15 * but I've read that the systems used for caching don't work
16 * like the HTTP Cache-Control header, where you can say a file
17 * is valid for up to a certain amount of time. If we seriously
18 * need cache busts (paper clover does not), the proper way
19 * would be to push a message to all VPS nodes instead of
20 * checking upstream if a file changed every time.
21 */
2022const app = new Hono();
2123export default app;
24export const port = 4000;
2225
2326const token = UNWRAP(process.env.CLOVER_SOT_KEY);
2427
......@@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) {
3235// Re-use file descriptors if the same file is being read twice.
3336const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();
3437
38app.get("/", (c) => {
39 return c.text("everything you love, loves you back.");
40});
41
3542app.get("/file/*", async (c) => {
3643 const fullQuery = c.req.path.slice("/file".length);
37 const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/");
44 const [filePath, derivedAsset, ...invalid] = fullQuery.split(":/");
3845 ASSERT(filePath != null);
3946 if (invalid.length > 0) return c.notFound();
4047 if (filePath.length <= 1) return c.notFound();
4148 const permissions = FilePermissions.getByPrefix(filePath);
42 if (permissions !== 0) {
43 if (c.req.header("Authorization") !== token) {
44 return c.json({ error: "invalid authorization header" }, 401);
45 }
49 if (permissions !== 0 && c.req.header("Authorization") !== token) {
50 return c.json({ error: "invalid authorization header" }, 401);
4651 }
4752 const file = MediaFile.getByPath(filePath);
48 if (!file || file.kind === MediaFileKind.directory) {
49 return c.notFound();
50 }
53 if (!file || file.kind === MediaFileKind.directory) return c.notFound();
5154 const fullPath = derivedAsset
5255 ? path.join(derivedFileRoot, file.hash, derivedAsset)
5356 : path.join(rawFileRoot, file.path);
......@@ -66,11 +69,10 @@ app.get("/file/*", async (c) => {
6669 }
6770 handle.refs += 1;
6871 } catch (err: any) {
69 if (err.code === "ENOENT") {
70 return c.notFound();
71 }
72 if (err.code === "ENOENT") return c.notFound();
7273 throw err;
7374 }
75 c.header("Content-Length", (await fstat(handle.fd)).size);
7476 const nodeStream = fs.createReadStream(fullPath, {
7577 fd: handle.fd,
7678 fs: {
......@@ -86,9 +88,6 @@ app.get("/file/*", async (c) => {
8688 },
8789 });
8890 c.header("Content-Type", mime.contentTypeFor(fullPath));
89 if (!derivedAsset) {
90 c.header("Content-Length", file.size.toString());
91 }
9291 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);
9392});
9493
......@@ -102,6 +101,7 @@ app.post("/reload", async (c) => {
102101
103102const openFile = util.promisify(fsCallbacks.open);
104103const closeFile = util.promisify(fsCallbacks.close);
104const fstat = util.promisify(fsCallbacks.fstat);
105105
106106import { Hono } from "#hono";
107107import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
src/tags/PhotoGrid.css deleted-16
......@@ -1,16 +0,0 @@
1photo-grid {
2 display: grid;
3 gap: 0.5rem;
4 div {
5 position: relative;
6 width: 100%;
7 height: 100%;
8 border: 2px solid #0009;
9 }
10 img {
11 position: absolute;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15 }
16}
src/tags/PhotoGrid.tsx deleted-135
......@@ -1,135 +0,0 @@
1import "./PhotoGrid.css";
2import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
3const { isArray } = Array;
4
5export interface PhotoGridProps {
6 style: any;
7 base: string;
8 width: number;
9 widths?: string[];
10 heights?: number[];
11 items: Item[];
12}
13
14type Item = [img: string, options?: ItemOptions];
15
16interface ItemOptions {
17 w?: number;
18 h?: number;
19 align?: keyof typeof alignments;
20}
21
22const alignments = {
23 bottom: "50% 80%",
24 "bottom-end": "50% 100%",
25 top: "50% 30%",
26 "top-end": "50% 0%",
27 left: "30% 50%",
28 "left-end": "0% 50%",
29 right: "80% 50%",
30 "right-end": "100% 50%",
31};
32
33const typicalPageWidth = 768;
34
35export function PhotoGrid({
36 style,
37 base,
38 width,
39 widths,
40 heights,
41 items,
42}: PhotoGridProps) {
43 return;
44 if (!base.endsWith("/")) base += "/";
45 let rows: boolean[][] = [];
46 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
47 const anyCollide = (x: number, y: number, w: number, h: number) => {
48 for (let yy = 0; yy < h; yy++) {
49 const r = row(y + yy);
50 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
51 }
52 return false;
53 };
54
55 const firstRowHeight = heights?.[0] ?? 325;
56
57 let x = 0;
58 let y = 0;
59 let contents = [];
60 for (const item of items) {
61 const [img, { w = 1, h = 1, align } = {}] = isArray(item) ? item : [item];
62 if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`);
63
64 while (anyCollide(x, y, w, h)) {
65 x += 1;
66 if (x > width - w) x = 0, y += 1;
67 }
68 for (let yy = 0; yy < h; yy++) {
69 const r = row(y + yy);
70 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
71 }
72
73 const info = fileInfo(base, img);
74 contents.push(
75 <div
76 style={{
77 ...x === 0 && y === 0 &&
78 {
79 "aspect-ratio": ((w / width) * typicalPageWidth) / firstRowHeight,
80 },
81 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
82 }}
83 >
84 <img
85 {...info}
86 style={{
87 "object-position": align
88 ? (alignments as Record<string, string>)[align] ?? align
89 : "50% 50%",
90 }}
91 />
92 </div>,
93 );
94 }
95
96 const height = rows.length;
97 return (
98 <photo-grid
99 style={{
100 ...style,
101 "grid-template-columns": widths
102 ? mapSizes(widths).join(" ")
103 : `repeat(${width}, 1fr)`,
104 "grid-template-rows": heights
105 ? heights.map((h) => (h / firstRowHeight) + "fr")
106 .concat(new Array(height - heights.length).fill("1fr"))
107 .join(" ")
108 : `repeat(${height}, 1fr)`,
109 }}
110 >
111 {contents}
112 </photo-grid>
113 );
114}
115
116function fileInfo(base: string, img: string) {
117 const filePath = path.join(base, img);
118 const file = MediaFile.getByPath(filePath);
119 const dimensions = file?.parseDimensions();
120 if (!dimensions) {
121 throw new Error(`File does not exist or has no dimensions: ${filePath}`);
122 }
123 const src = "/file" + base + encodeURIComponent(img);
124 const alt = path.basename(img, path.extname(img));
125 return { src, alt, width: dimensions.width, height: dimensions.height };
126}
127
128function mapSizes(sizes: Array<string | number>) {
129 return sizes.map(mapSize);
130}
131function mapSize(size: string | number) {
132 return typeof size === "number" ? size + "px" : size;
133}
134
135import * as path from "node:path";
src/tags/clover-video.client.ts+20-9
......@@ -2,6 +2,7 @@
22
33// Polyfill for old browsers.
44type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
5let mode: Promise<PlaybackMode> | null = null;
56let hls: Promise<typeof import("hls.js")> | null = null;
67
78Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
......@@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
1213 (reason) => ({ status: "rejected" as const, reason }),
1314 )));
1415
15decidePlaybackMode().then((mode) =>
16 Array.from(
17 document.querySelectorAll<HTMLElement>(".clover-video"),
18 (container) => hydrateVideo(container, mode),
19 )
16globalThis.hydrateVideo = function hydrateVideo(container) {
17 mode ??= decidePlaybackMode();
18 mode.then((mode) => hydrateVideoInner(container, mode));
19};
20
21Array.from(
22 document.querySelectorAll<HTMLElement>(".clover-video"),
23 (container) => hydrateVideo(container),
2024);
2125
2226function decidePlaybackMode(): Promise<PlaybackMode> {
......@@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
3943 return "hls-native";
4044 }
4145
42 return (hls ??= import("hls.js"))
46 return (hls ??= import("/js/scripts/vendor/hls.js"))
4347 .then((hls): PlaybackMode => {
4448 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
4549 // TODO: ES modules have a minimum of Chrome 63 or Firefox 60
......@@ -55,8 +59,16 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
5559 });
5660}
5761
58function hydrateVideo(container: HTMLElement, mode: PlaybackMode) {
62declare global {
63 function hydrateVideo(container: HTMLElement): void;
64}
65
66function hydrateVideoInner(
67 container: HTMLElement,
68 mode: PlaybackMode,
69) {
5970 const video = container.querySelector("video")!;
71 if (video.hasAttribute("controls")) return;
6072 let src = video.getAttribute("data-src");
6173 if (!src) return;
6274 src = `/file${src}`;
......@@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) {
6577 const hlsFile = `${src}:/master.m3u8`;
6678
6779 if (mode === "av1") {
68 console.info(`Playback: av1, opus, mpeg-dash`);
69 import("dashjs").then((dashjs) => {
80 import("/js/scripts/vendor/dash.js").then((dashjs) => {
7081 const player = dashjs.MediaPlayer().create();
7182 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
7283 let mainSource: HTMLSourceElement | null = null;
src/tags/clover-video.css+5-1
......@@ -3,6 +3,9 @@
33 display: flex;
44 flex-direction: column;
55}
6.clover-video.minimal {
7 border: none;
8}
69.clover-video video {
710 background-color: transparent;
811}
......@@ -11,6 +14,7 @@
1114 overflow: hidden;
1215 display: flex;
1316 flex-direction: column;
17 background-color: black;
1418}
1519.clover-video video {
1620 width: 100%;
......@@ -33,7 +37,7 @@
3337}
3438.clover-video figcaption {
3539 background-color: var(--fg);
36 color: white;
40 color: var(--bg);
3741 width: 100%;
3842 margin-top: -1px;
3943 padding-bottom: 2px;
src/tags/clover-video.marko+10-7
......@@ -4,9 +4,10 @@ export interface Input {
44 file: MediaFile | string;
55 poster?: MediaFile | string;
66 header?: Marko.AttrTag<{ content: Marko.Body }>;
7 minimal?: boolean;
78}
89
9<const/{ header } = input />
10<const/{ header, minimal } = input />
1011<const/file = typeof UNWRAP(input.file) === 'object'
1112 ? input.file
1213 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />
......@@ -18,12 +19,14 @@ export interface Input {
1819
1920<const/{ width, height } = file.parseDimensions() />
2021
21<figure.clover-video>
22 <figcaption>
23 <if=header><${header.content}/></><else>
24 ${file.basename}
25 </else>
26 </figcaption>
22<figure.clover-video class={ minimal }>
23 <if=!minimal>
24 <figcaption>
25 <if=header><${header.content}/></><else>
26 ${file.basename}
27 </else>
28 </figcaption>
29 </>
2730 <div.stack style=`aspect-ratio:${width}/${height}`>
2831 <video
2932 data-src=file.path
src/tags/image.tsx created+55
......@@ -0,0 +1,55 @@
1interface Props {
2 src: MediaFile | string;
3 alt: string;
4 displayWidthEstimate?: number | string;
5 objectFit?: string;
6
7 style?: string;
8 className?: string;
9}
10
11export default function Image(
12 { src, class: className, objectFit, style, displayWidthEstimate, ...rest }:
13 Props,
14) {
15 src = typeof src === "object"
16 ? src
17 : UNWRAP(MediaFile.getByPath(src), `No file ${JSON.stringify(src)}`);
18
19 const base = `/file${escapeUri(src.path)}`;
20 if (src.extension === ".gif") {
21 return <img src={base} class={className} style={style} {...rest} />;
22 }
23
24 const { width } = UNWRAP(src.parseDimensions());
25 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
26
27 const sizes = displayWidthEstimate
28 ? typeof displayWidthEstimate === "number"
29 ? `${displayWidthEstimate}px`
30 : displayWidthEstimate
31 : `100vw`;
32
33 return (
34 <picture class={className} style={style}>
35 {["jxl", "avif", "webp"].map((ext) => (
36 <source
37 type={`image/${ext}`}
38 srcset={targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)}
39 sizes={sizes}
40 />
41 ))}
42 <img
43 src={base}
44 {...rest}
45 style={objectFit
46 ? { "object-fit": objectFit, width: "100%", height: "100%" }
47 : null}
48 />
49 </picture>
50 );
51}
52
53import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
54import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
55import { escapeUri } from "@/file-viewer/format.ts";
src/tags/photo-grid.css created+16
......@@ -0,0 +1,16 @@
1photo-grid {
2 display: grid;
3 gap: 0.5rem;
4 div {
5 position: relative;
6 width: 100%;
7 height: 100%;
8 border: 2px solid #0009;
9 }
10 img {
11 position: absolute;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15 }
16}
src/tags/photo-grid.tsx created+135
......@@ -0,0 +1,135 @@
1const { isArray } = Array;
2
3export interface PhotoGridProps {
4 style: any;
5 base: string;
6 width: number;
7 widths?: string[];
8 heights?: number[];
9 items: Item[];
10}
11
12type Item = [img: string, options?: ItemOptions];
13
14interface ItemOptions {
15 w?: number;
16 h?: number;
17 align?: keyof typeof alignments;
18}
19
20const alignments = {
21 bottom: "50% 80%",
22 "bottom-end": "50% 100%",
23 top: "50% 30%",
24 "top-end": "50% 0%",
25 left: "30% 50%",
26 "left-end": "0% 50%",
27 right: "80% 50%",
28 "right-end": "100% 50%",
29};
30
31const typicalPageWidth = 768;
32
33export function PhotoGrid({
34 style,
35 base,
36 width,
37 widths,
38 heights,
39 items,
40}: PhotoGridProps) {
41 if (!base.endsWith("/")) base += "/";
42 let rows: boolean[][] = [];
43 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
44 const anyCollide = (x: number, y: number, w: number, h: number) => {
45 for (let yy = 0; yy < h; yy++) {
46 const r = row(y + yy);
47 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
48 }
49 return false;
50 };
51
52 const firstRowHeight = heights?.[0] ?? 325;
53
54 let x = 0;
55 let y = 0;
56 let contents = [];
57 for (const item of items) {
58 const [img, { w = 1, h = 1, align } = {}] = isArray(item) ? item : [item];
59 if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`);
60
61 while (anyCollide(x, y, w, h)) {
62 x += 1;
63 if (x > width - w) x = 0, y += 1;
64 }
65 for (let yy = 0; yy < h; yy++) {
66 const r = row(y + yy);
67 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
68 }
69
70 const info = fileInfo(base, img);
71 contents.push(
72 <div
73 style={{
74 ...x === 0 && y === 0 &&
75 {
76 "aspect-ratio": ((w / width) * typicalPageWidth) / firstRowHeight,
77 },
78 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
79 }}
80 >
81 <Image
82 {...info}
83 displayWidthEstimate={Math.ceil((w / width) * 736)}
84 style={{
85 "object-position": align
86 ? (alignments as Record<string, string>)[align] ?? align
87 : "50% 50%",
88 }}
89 />
90 </div>,
91 );
92 }
93
94 const height = rows.length;
95 return (
96 <photo-grid
97 style={{
98 ...style,
99 "grid-template-columns": widths
100 ? mapSizes(widths).join(" ")
101 : `repeat(${width}, 1fr)`,
102 "grid-template-rows": heights
103 ? heights.map((h) => (h / firstRowHeight) + "fr")
104 .concat(new Array(height - heights.length).fill("1fr"))
105 .join(" ")
106 : `repeat(${height}, 1fr)`,
107 }}
108 >
109 {contents}
110 </photo-grid>
111 );
112}
113
114function fileInfo(base: string, img: string) {
115 const filePath = path.join(base, img);
116 const file = MediaFile.getByPath(filePath);
117 const dimensions = file?.parseDimensions();
118 if (!dimensions) {
119 throw new Error(`File does not exist or has no dimensions: ${filePath}`);
120 }
121 const alt = path.basename(img, path.extname(img));
122 return { src: file, alt, width: dimensions.width, height: dimensions.height };
123}
124
125function mapSizes(sizes: Array<string | number>) {
126 return sizes.map(mapSize);
127}
128function mapSize(size: string | number) {
129 return typeof size === "number" ? size + "px" : size;
130}
131
132import "./photo-grid.css";
133import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
134import Image from "./image.tsx";
135import * as path from "node:path";
src/tags/questions-embed.css created+9
......@@ -0,0 +1,9 @@
1.questions-embed {
2 --fg: #f0f0f0;
3 --bg: #1a1a1a;
4 --primary: #f5a9b8;
5 background-color: var(--bg);
6 margin: -1rem;
7 margin-top: 1rem;
8 padding: 1rem;
9}
src/tags/questions-embed.marko created+16
......@@ -0,0 +1,16 @@
1import "./questions-embed.css";
2
3export interface Input {
4 value: string;
5}
6
7<div.questions-embed.qa>
8 <a href="/q+a">ask your own question / view all questions</a>
9 <br />
10 <for|question| of=Question.search(input.value)>
11 <QuestionRender ...{ question } />
12 </>
13</div>
14
15import QuestionRender from "@/q+a/tags/question.marko";
16import { Question } from "@/q+a/models/Question.ts";