| author | |
| committer | |
| log | 83f7d1c67f60bc08f85e3878875374d3198e74fe |
| tree | 29dad5cf67ae6a28a9344685505de7a641c3cd12 |
| parent | b490ba62dedb3b43b9ee832e8c3630d017557b69 |
| signature | Commit is signed but in an unrecognized format. |
48 files changed, 787 insertions(+), 645 deletions(-)
framework/backend/entry-node.ts+5-2| ... | ... | @@ -8,7 +8,9 @@ const server = serve({ |
| 8 | 8 | fetch: app.default.fetch, |
| 9 | 9 | port: Number(process.env.PORT ?? 3000), |
| 10 | 10 | }, ({ address, port }) => { |
| 11 | if (address === "::") address = "localhost"; | |
| 11 | if (address === "::" || address === "127.0.0.1") { | |
| 12 | address = os.hostname().replace(/\.local$/, ""); | |
| 13 | } | |
| 12 | 14 | console.info(url.format({ |
| 13 | 15 | protocol, |
| 14 | 16 | hostname: address, |
| ... | ... | @@ -36,6 +38,7 @@ declare global { |
| 36 | 38 | var CLOVER_SERVER_ENTRY: string; |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | import url from "node:url"; | |
| 41 | import * as os from "os"; | |
| 42 | import * as url from "node:url"; | |
| 40 | 43 | import { serve } from "@hono/node-server"; |
| 41 | 44 | import process from "node:process"; |
framework/bundle.ts+14-1| ... | ... | @@ -39,6 +39,18 @@ export async function bundleClientJavaScript( |
| 39 | 39 | const clientPlugins: esbuild.Plugin[] = [ |
| 40 | 40 | projectRelativeResolution(), |
| 41 | 41 | 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 | }, | |
| 42 | 54 | ]; |
| 43 | 55 | |
| 44 | 56 | using _ = io.start(`bundle ${entryPoints.length} files`); |
| ... | ... | @@ -60,6 +72,7 @@ export async function bundleClientJavaScript( |
| 60 | 72 | outdir: "out!", |
| 61 | 73 | plugins: clientPlugins, |
| 62 | 74 | write: false, |
| 75 | external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"], | |
| 63 | 76 | define: { |
| 64 | 77 | ASSERT: "console.assert", |
| 65 | 78 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), |
| ... | ... | @@ -290,7 +303,7 @@ export async function bundleServerJavaScript({ |
| 290 | 303 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), |
| 291 | 304 | }, |
| 292 | 305 | external: Object.keys(pkg.dependencies).filter( |
| 293 | (x) => !x.startsWith("@paperclover"), | |
| 306 | (x) => x !== "lib", | |
| 294 | 307 | ), |
| 295 | 308 | }); |
| 296 | 309 | await trackEsbuild(io, metafile); |
framework/engine/marko-runtime.ts-1| ... | ... | @@ -27,7 +27,6 @@ export const _template: typeof marko._template = ( |
| 27 | 27 | return markoResult.then(render.raw); |
| 28 | 28 | } |
| 29 | 29 | const rr = markoResult.toString(); |
| 30 | console.log({ rr }); | |
| 31 | 30 | return render.raw(rr); |
| 32 | 31 | } |
| 33 | 32 | wrap.render = renderFn; |
framework/engine/render.ts+5-4| ... | ... | @@ -6,6 +6,8 @@ |
| 6 | 6 | // Add-ons to the rendering engine can provide opaque data, And retrieve it |
| 7 | 7 | // within component calls with 'getAddonData'. For example, 'sitegen' uses this |
| 8 | 8 | // to track needed client scripts without introducing patches to the engine. |
| 9 | // | |
| 10 | // This module is not production ready | |
| 9 | 11 | export let current: State | null = null; |
| 10 | 12 | export function setCurrent(r: State | null) { |
| 11 | 13 | current = r ?? null; |
| ... | ... | @@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string { |
| 212 | 214 | } |
| 213 | 215 | function stringifyElement(element: ResolvedElement) { |
| 214 | 216 | const { 1: tag, 2: props, 3: children } = element; |
| 215 | let out = "<" + tag; | |
| 216 | let needSpace = true; | |
| 217 | let out = "<" + tag + " "; | |
| 217 | 218 | for (const prop in props) { |
| 218 | 219 | const value = props[prop]; |
| 219 | 220 | if (!value || typeof value === "function") continue; |
| ... | ... | @@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) { |
| 249 | 250 | case "key": |
| 250 | 251 | continue; |
| 251 | 252 | } |
| 252 | if (needSpace) ((out += " "), (needSpace = !attr.endsWith('"'))); | |
| 253 | if (!out.endsWith('"')) out += " "; | |
| 253 | 254 | out += attr; |
| 254 | 255 | } |
| 255 | out += ">"; | |
| 256 | out = out.trimEnd() + ">"; | |
| 256 | 257 | if (children) out += stringifyNode(children); |
| 257 | 258 | if ( |
| 258 | 259 | tag !== "br" && |
framework/generate.ts+2-4| ... | ... | @@ -369,10 +369,8 @@ export async function assembleAndWritePage( |
| 369 | 369 | |
| 370 | 370 | pageNode.passive = false; |
| 371 | 371 | |
| 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}`))); | |
| 376 | 374 | |
| 377 | 375 | const buffer = sg.wrapDocument({ |
| 378 | 376 | body: html, |
framework/incremental.ts+1-1| ... | ... | @@ -496,7 +496,7 @@ export class Io { |
| 496 | 496 | return resolved; |
| 497 | 497 | } |
| 498 | 498 | async readFile(file: string) { |
| 499 | return fs.readFile(await this.trackFile(file), "utf-8"); | |
| 499 | return fs.readFile(await this.trackFile(file)); | |
| 500 | 500 | } |
| 501 | 501 | async readJson<T>(file: string) { |
| 502 | 502 | 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> { |
| 94 | 94 | this.#used += size; |
| 95 | 95 | try { |
| 96 | 96 | this.ensureUnusedCapacity(0); |
| 97 | } catch { | |
| 97 | } catch (err) { | |
| 98 | console.error(err); | |
| 98 | 99 | ASSERT(false, "capacity is already verified"); |
| 99 | 100 | } |
| 100 | 101 | super.set(key, value); |
framework/lib/meta.ts+1-1| ... | ... | @@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string { |
| 214 | 214 | if (denyRobots) { |
| 215 | 215 | out += `<meta name=robots content=noindex,nofollow>`; |
| 216 | 216 | } |
| 217 | if (description) { | |
| 217 | if (viewport) { | |
| 218 | 218 | out += `<meta name=viewport content=${attr(viewport)}>`; |
| 219 | 219 | } |
| 220 | 220 | if (canonical) { |
framework/lib/mime.ts+1-1| ... | ... | @@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); |
| 18 | 18 | export function contentTypeFor(file: string) { |
| 19 | 19 | const slash = file.indexOf("/"); |
| 20 | 20 | if (slash !== -1) file = file.slice(slash + 1); |
| 21 | const dot = file.indexOf("."); | |
| 21 | const dot = file.lastIndexOf("."); | |
| 22 | 22 | if (dot === -1) file = "." + file; |
| 23 | 23 | else if (dot > 0) { |
| 24 | 24 | let entry = fullNames.get(file); |
framework/lib/mime.txt+1-1| ... | ... | @@ -42,7 +42,7 @@ |
| 42 | 42 | .jpg image/jpeg |
| 43 | 43 | .jpgv video/jpeg |
| 44 | 44 | .jxl image/jxl |
| 45 | .js application/javascript | |
| 45 | .js application/javascript;charset=utf-8 | |
| 46 | 46 | .json application/json |
| 47 | 47 | .latex application/x-latex |
| 48 | 48 | .list text/plain |
framework/lib/sitegen.ts+10-4| ... | ... | @@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>; |
| 50 | 50 | export type FontVariableAxis = number | [min: number, max: number]; |
| 51 | 51 | export type FontRange = string | number | { start: number; end: number }; |
| 52 | 52 | |
| 53 | export const userData = render.userData<SitegenRender>(() => { | |
| 54 | throw new Error("This function can only be used in a page (static or view)"); | |
| 55 | }); | |
| 53 | export const userData = render.userData<SitegenRender>(() => initRender()); | |
| 56 | 54 | |
| 57 | 55 | export interface SitegenRender { |
| 58 | 56 | scripts: Set<string>; |
| ... | ... | @@ -78,13 +76,21 @@ export function wrapDocument({ |
| 78 | 76 | inlineCss: string; |
| 79 | 77 | scripts: string; |
| 80 | 78 | }) { |
| 81 | return `<!doctype html><html lang=en><head>${head}${ | |
| 79 | return `<!doctype html><html lang=en><head><meta charset=utf8>${head}${ | |
| 82 | 80 | inlineCss ? `<style>${inlineCss}</style>` : "" |
| 83 | 81 | }</head><body>${body}${ |
| 84 | 82 | scripts ? `<script>${scripts}</script>` : "" |
| 85 | 83 | }</body></html>`; |
| 86 | 84 | } |
| 87 | 85 | |
| 86 | export 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 | ||
| 88 | 94 | import * as render from "#engine/render"; |
| 89 | 95 | import type * as meta from "./meta.ts"; |
| 90 | 96 | import type * as css from "../css.ts"; |
framework/lib/subprocess.ts+56-52| ... | ... | @@ -3,76 +3,80 @@ |
| 3 | 3 | |
| 4 | 4 | const execFileRaw = util.promisify(child_process.execFile); |
| 5 | 5 | export const exec: typeof execFileRaw = (( |
| 6 | ...args: Parameters<typeof execFileRaw> | |
| 6 | ...args: Parameters<typeof execFileRaw> | |
| 7 | 7 | ) => |
| 8 | execFileRaw(...args).catch((e: any) => { | |
| 9 | if (e?.message?.startsWith?.("Command failed")) { | |
| 10 | if (e.code > 2 ** 31) e.code |= 0; | |
| 11 | const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`; | |
| 12 | e.message = `${e.cmd.split(" ")[0]} failed with ${code}`; | |
| 13 | } | |
| 14 | throw e; | |
| 15 | })) as any; | |
| 8 | execFileRaw(...args).catch((e: any) => { | |
| 9 | if (e?.message?.startsWith?.("Command failed")) { | |
| 10 | if (e.code > 2 ** 31) e.code |= 0; | |
| 11 | const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`; | |
| 12 | e.message = `${e.cmd.split(" ")[0]} failed with ${code}`; | |
| 13 | } | |
| 14 | throw e; | |
| 15 | })) as any; | |
| 16 | 16 | |
| 17 | const activePids = new Set<number>(); | |
| 17 | // const activePids = new Set<number>(); | |
| 18 | 18 | |
| 19 | 19 | export interface SpawnOptions extends child_process.SpawnOptions { |
| 20 | cmd: string[]; | |
| 20 | cmd: string[]; | |
| 21 | 21 | } |
| 22 | 22 | export function spawn({ cmd, ...rest }: SpawnOptions) { |
| 23 | rest.detached ??= true; | |
| 24 | const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest); | |
| 25 | const pid = rest.detached && process.pid ? -process.pid : process.pid; | |
| 26 | if (pid) { | |
| 27 | activePids.add(pid); | |
| 28 | process.on("exit", () => void activePids.delete(pid)); | |
| 29 | } | |
| 30 | return process; | |
| 23 | // rest.detached ??= true; | |
| 24 | const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest); | |
| 25 | // const pid = rest.detached && process.pid ? -process.pid : process.pid; | |
| 26 | // if (pid) { | |
| 27 | // activePids.add(pid); | |
| 28 | // process.on("exit", () => void activePids.delete(pid)); | |
| 29 | // } | |
| 30 | return process; | |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | export interface ForkOptions extends child_process.ForkOptions { |
| 34 | cmd: string[]; | |
| 34 | cmd: string[]; | |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | export function fork({ cmd, ...rest }: ForkOptions) { |
| 38 | rest.detached ??= true; | |
| 39 | const process = child_process.fork(cmd[0]!, cmd.slice(1), rest); | |
| 40 | const pid = rest.detached && process.pid ? -process.pid : process.pid; | |
| 41 | if (pid) { | |
| 42 | activePids.add(pid); | |
| 43 | process.on("exit", () => void activePids.delete(pid)); | |
| 44 | } | |
| 45 | return process; | |
| 38 | // rest.detached ??= true; | |
| 39 | const process = child_process.fork(cmd[0]!, cmd.slice(1), rest); | |
| 40 | // const pid = rest.detached && process.pid ? -process.pid : process.pid; | |
| 41 | // if (pid) { | |
| 42 | // activePids.add(pid); | |
| 43 | // process.on("exit", () => void activePids.delete(pid)); | |
| 44 | // } | |
| 45 | return process; | |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | export async function spawnAndWait(opts: SpawnOptions) { |
| 49 | opts.stdio ??= ["ignore", "inherit", "inherit"]; | |
| 50 | const process = spawn(opts); | |
| 51 | const output = Promise.all([ | |
| 52 | process.stdout ? 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 }; | |
| 66 | 70 | } |
| 67 | 71 | |
| 68 | process.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 | // }); | |
| 74 | 78 | |
| 75 | import * as process from "node:process"; | |
| 79 | // import * as process from "node:process"; | |
| 76 | 80 | import * as events from "node:events"; |
| 77 | 81 | import * as util from "node:util"; |
| 78 | 82 | import * as child_process from "node:child_process"; |
framework/lib/view.ts+1-8| ... | ... | @@ -83,7 +83,7 @@ export async function renderToString<K extends Key>( |
| 83 | 83 | body, |
| 84 | 84 | head: await renderedMetaPromise, |
| 85 | 85 | inlineCss, |
| 86 | scripts: joinScripts( | |
| 86 | scripts: sg.joinScripts( | |
| 87 | 87 | Array.from( |
| 88 | 88 | sitegen!.scripts, |
| 89 | 89 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), |
| ... | ... | @@ -108,13 +108,6 @@ export function regenerate(tag: RegenKey) { |
| 108 | 108 | } |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | function 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 | ||
| 118 | 111 | import * as meta from "./meta.ts"; |
| 119 | 112 | import type * as hono from "#hono"; |
| 120 | 113 | import * as render from "#engine/render"; |
run.js+18-4| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | // This file allows using Node.js in combination with |
| 2 | 2 | // all available plugins. Usage: "node run <script>" |
| 3 | import * as util from "node:util"; | |
| 4 | 3 | import * as zlib from "node:zlib"; |
| 5 | 4 | import * as url from "node:url"; |
| 5 | import * as fs from "node:fs"; | |
| 6 | import * as os from "node:os"; | |
| 6 | 7 | import process from "node:process"; |
| 7 | 8 | |
| 8 | 9 | // Disable experimental warnings (Type Stripping, etc) |
| ... | ... | @@ -23,7 +24,7 @@ try { |
| 23 | 24 | |
| 24 | 25 | // seems to fail in bun |
| 25 | 26 | (await import("source-map-support")).install({ hookRequire: true }); |
| 26 | } catch { | |
| 27 | } catch (e) { | |
| 27 | 28 | const brand = process.versions.bun |
| 28 | 29 | ? `bun ${process.versions.bun}` |
| 29 | 30 | : process.versions.deno |
| ... | ... | @@ -33,8 +34,9 @@ try { |
| 33 | 34 | globalThis.console.error( |
| 34 | 35 | `sitegen depends on a node.js v24. your runtime is missing features.\n` + |
| 35 | 36 | `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`, | |
| 37 | 38 | ); |
| 39 | console.error(e); | |
| 38 | 40 | process.exit(1); |
| 39 | 41 | } |
| 40 | 42 | |
| ... | ... | @@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => { |
| 53 | 55 | process.exit(1); |
| 54 | 56 | }); |
| 55 | 57 | |
| 58 | if (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 | ||
| 56 | 67 | // Load with hooks |
| 57 | 68 | if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 58 | 69 | if (process.argv.length == 2) { |
| ... | ... | @@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 82 | 93 | serve( |
| 83 | 94 | { |
| 84 | 95 | fetch: mod.default.fetch, |
| 96 | port: mod.port ?? 3000, | |
| 85 | 97 | }, |
| 86 | 98 | ({ address, port }) => { |
| 87 | if (address === "::") address = "::1"; | |
| 99 | if (address === "::" || address === "127.0.0.1") { | |
| 100 | address = os.hostname().replace(/\.local$/, ""); | |
| 101 | } | |
| 88 | 102 | log.info( |
| 89 | 103 | url.format({ |
| 90 | 104 | protocol, |
src/admin.ts+1-3| ... | ... | @@ -12,9 +12,7 @@ function compareToken(token: string) { |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | 14 | export 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); | |
| 18 | 16 | return next(); |
| 19 | 17 | } |
| 20 | 18 |
src/backend.ts+1| ... | ... | @@ -11,6 +11,7 @@ app.use(admin.middleware); |
| 11 | 11 | // Backends |
| 12 | 12 | app.route("", require("./q+a/backend.ts").app); |
| 13 | 13 | app.route("", require("./file-viewer/backend.tsx").app); |
| 14 | app.route("", require("./friend-auth.ts").app); | |
| 14 | 15 | |
| 15 | 16 | // Asset middleware has least precedence |
| 16 | 17 | app.use(assets.middleware); |
src/deploy.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | export 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 | ||
| 46 | import * as incr from "../framework/incremental.ts"; | |
| 47 | import { generate } from "../framework/generate.ts"; | |
| 48 | import * as progress from "lib/log/progress.ts"; | |
| 49 | import * as subprocess from "#sitegen/subprocess"; | |
| 50 | import * as rsync from "./file-viewer/rsync.ts"; | |
| 51 | import { Path } from "#sitegen/path"; |
src/file-viewer/backend.tsx+19-12| ... | ... | @@ -66,7 +66,10 @@ app.get("/file/*", async (c, next) => { |
| 66 | 66 | if (c.req.query("dl") != null) viewMode = "download"; |
| 67 | 67 | if (lofi) viewMode = file.extension === ".html" ? "embed" : "download"; |
| 68 | 68 | |
| 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 | ) { | |
| 70 | 73 | // cache.prefetch(file); |
| 71 | 74 | c.res = await view.serve(c, "file-viewer/clofi", { |
| 72 | 75 | file, |
| ... | ... | @@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => { |
| 156 | 159 | |
| 157 | 160 | const found = await cache.read({ |
| 158 | 161 | file, |
| 159 | hash, | |
| 162 | size, | |
| 160 | 163 | derived: derivedKey, |
| 164 | hash, | |
| 161 | 165 | start, |
| 162 | 166 | end, |
| 163 | 167 | }); |
| 164 | 168 | if (!found) { |
| 165 | return Response.json({ | |
| 169 | return c.json({ | |
| 166 | 170 | error: "localOnly is enabled and this file is not available", |
| 167 | 171 | }, 500); |
| 168 | 172 | } |
| ... | ... | @@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 265 | 269 | return c.json({ error: "File not found" }, 404); |
| 266 | 270 | } |
| 267 | 271 | |
| 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 | ); | |
| 274 | 282 | // Remove the root element, it's created client side! |
| 275 | root = root[2].children as render.Element; | |
| 283 | root = (root as render.ResolvedElement)[3]; | |
| 276 | 284 | |
| 277 | const html = render.sync(root).text; | |
| 278 | return c.html(html); | |
| 285 | return c.html(render.stringifyNode(root)); | |
| 279 | 286 | } |
| 280 | 287 | |
| 281 | 288 | import { type Context, Hono } from "hono"; |
src/file-viewer/bin/scan3.ts+3-2| ... | ... | @@ -160,7 +160,7 @@ export async function main() { |
| 160 | 160 | ); |
| 161 | 161 | dependantJob.needs -= 1; |
| 162 | 162 | if (dependantJob.needs == 0) { |
| 163 | queueFileProcessor(dependantJob); | |
| 163 | promises.push(queueFileProcessor(dependantJob)); | |
| 164 | 164 | } |
| 165 | 165 | } |
| 166 | 166 | }, (job) => ({ cores: job.processor.cores ?? 0 })); |
| ... | ... | @@ -281,6 +281,7 @@ export async function main() { |
| 281 | 281 | using dirMetaNode = progress.start("update directory metadata"); |
| 282 | 282 | const dirs = MediaFile.getDirectoriesToReindex() |
| 283 | 283 | .sort((a, b) => b.path.length - a.path.length); |
| 284 | ||
| 284 | 285 | for (const dir of dirs) { |
| 285 | 286 | using _ = dirMetaNode.start(dir.path); |
| 286 | 287 | const children = dir.getChildren(); |
| ... | ... | @@ -376,7 +377,7 @@ export async function main() { |
| 376 | 377 | }); |
| 377 | 378 | if (sotToken) { |
| 378 | 379 | const res = await fetch("https://db.paperclover.net/reload", { |
| 379 | method: "post", | |
| 380 | method: "POST", | |
| 380 | 381 | headers: { |
| 381 | 382 | Authorization: sotToken, |
| 382 | 383 | }, |
src/file-viewer/cache.ts+155-64| ... | ... | @@ -33,7 +33,7 @@ export type Source = |
| 33 | 33 | const token = process.env.CLOVER_SOT_KEY ?? null; |
| 34 | 34 | const sotUrl = new URL( |
| 35 | 35 | process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net", |
| 36 | ); | |
| 36 | ).toString().replace(/\/+$/g, ""); | |
| 37 | 37 | if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files"); |
| 38 | 38 | |
| 39 | 39 | const diskCacheRoot = openDiskCache(); |
| ... | ... | @@ -94,6 +94,7 @@ export interface Options { |
| 94 | 94 | file: MediaFile; |
| 95 | 95 | /** If set, fetches the derived asset instead. */ |
| 96 | 96 | derived: string | null; |
| 97 | size: number; | |
| 97 | 98 | hash: string; |
| 98 | 99 | start: number; |
| 99 | 100 | /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */ |
| ... | ... | @@ -107,7 +108,7 @@ export interface Options { |
| 107 | 108 | * `null` if a derived asset does not exist |
| 108 | 109 | */ |
| 109 | 110 | export async function read( |
| 110 | { file, derived, hash, start, end, noWrite, localOnly }: Options, | |
| 111 | { file, derived, hash, size, start, end, noWrite, localOnly }: Options, | |
| 111 | 112 | ): Promise<{ stream: ReadableStream; src: Source; size: number } | null> { |
| 112 | 113 | const rel = derived ? `${file.path}$/${derived}` : file.path; |
| 113 | 114 | |
| ... | ... | @@ -115,33 +116,33 @@ export async function read( |
| 115 | 116 | const localFile = derived |
| 116 | 117 | ? derivedFileRoot?.join(derived) |
| 117 | 118 | : 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; | |
| 129 | 129 | } |
| 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; | |
| 138 | 131 | } |
| 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 | }; | |
| 139 | 140 | } |
| 140 | 141 | |
| 141 | if (!localOnly) return null; | |
| 142 | if (localOnly) return null; | |
| 142 | 143 | |
| 143 | 144 | // Disk cache |
| 144 | const diskPath = diskCacheRoot.join(key); | |
| 145 | const diskPath = diskCacheRoot.join(hash); | |
| 145 | 146 | let diskFile = diskCache.get(diskPath); |
| 146 | 147 | if (diskFile) { |
| 147 | 148 | const lock = diskCache.lock(diskPath); |
| ... | ... | @@ -165,67 +166,154 @@ export async function read( |
| 165 | 166 | } |
| 166 | 167 | |
| 167 | 168 | // 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}`; | |
| 194 | 171 | |
| 195 | 172 | // Do not cache large assets |
| 196 | 173 | if (size > 1_000_000_000) { |
| 197 | 174 | 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 | }; | |
| 199 | 180 | } |
| 200 | 181 | // Bail out of the disk cache if it is contended |
| 201 | 182 | if (diskCache.unlockedCapacity < size) { |
| 202 | 183 | 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 | }; | |
| 204 | 189 | } |
| 205 | 190 | // IPs which request too many cache misses are temporarily |
| 206 | 191 | // banned from writing into the disk cache to avoid abuse. |
| 207 | 192 | if (noWrite) { |
| 208 | 193 | 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 | }; | |
| 210 | 199 | } |
| 211 | 200 | |
| 212 | 201 | // Create a disk cache entry; |
| 213 | 202 | // 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() }; | |
| 219 | 204 | diskCache.set(diskPath, diskFile); |
| 220 | ||
| 221 | 205 | 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 | ||
| 209 | async 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 | ||
| 242 | async 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 | ||
| 262 | async 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 | ||
| 274 | export 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 | }, | |
| 226 | 312 | }); |
| 227 | 313 | |
| 228 | return { stream: stream.Readable.toWeb(response), size, src: "miss" }; | |
| 314 | readable.on("error", (err) => transform.destroy(err)); | |
| 315 | ||
| 316 | return readable.pipe(transform); | |
| 229 | 317 | } |
| 230 | 318 | |
| 231 | 319 | interface DiskFile { |
| ... | ... | @@ -236,7 +324,10 @@ interface DiskFile { |
| 236 | 324 | |
| 237 | 325 | function httpGet(url: string, options: https.RequestOptions) { |
| 238 | 326 | 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 | ); | |
| 240 | 331 | request.on("response", resolve); |
| 241 | 332 | request.on("error", reject); |
| 242 | 333 | }); |
src/file-viewer/models/FilePermissions.ts+11-11| ... | ... | @@ -3,9 +3,9 @@ const db = getDb("cache.sqlite"); |
| 3 | 3 | db.table( |
| 4 | 4 | "permissions", |
| 5 | 5 | /* 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 | |
| 9 | 9 | ); |
| 10 | 10 | `, |
| 11 | 11 | ); |
| ... | ... | @@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare< |
| 36 | 36 | [prefix: string], |
| 37 | 37 | Pick<FilePermissions, "allow"> |
| 38 | 38 | >(/* 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; | |
| 44 | 44 | `); |
| 45 | 45 | const getExactQuery = db.prepare< |
| 46 | 46 | [file: string], |
| 47 | 47 | Pick<FilePermissions, "allow"> |
| 48 | 48 | >(/* SQL */ ` |
| 49 | SELECT allow FROM permissions WHERE ? == prefix | |
| 49 | select allow from permissions where ? == prefix | |
| 50 | 50 | `); |
| 51 | 51 | |
| 52 | 52 | const 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); | |
| 54 | 54 | `); |
| 55 | 55 | const deleteQuery = db.prepare<[file: string]>(/* SQL */ ` |
| 56 | DELETE FROM permissions WHERE prefix = ?; | |
| 56 | delete from permissions where prefix = ?; | |
| 57 | 57 | `); |
| 58 | 58 | |
| 59 | 59 | import { getDb } from "#sitegen/sqlite"; |
src/file-viewer/models/MediaFile.ts+2-3| ... | ... | @@ -113,12 +113,11 @@ export class MediaFile { |
| 113 | 113 | return ext; |
| 114 | 114 | } |
| 115 | 115 | getChildren() { |
| 116 | return MediaFile.getChildren(this.id) | |
| 117 | .filter((file) => !file.basename.startsWith(".")); | |
| 116 | return MediaFile.getChildren(this.id); | |
| 118 | 117 | } |
| 119 | 118 | getPublicChildren() { |
| 120 | 119 | const children = MediaFile.getChildren(this.id).filter((x) => |
| 121 | !x.basename.startsWith("_unlisted") | |
| 120 | !x.basename.startsWith(".") && !x.basename.startsWith("_unlisted") | |
| 122 | 121 | ); |
| 123 | 122 | if (FilePermissions.getByPrefix(this.path) == 0) { |
| 124 | 123 | return children.filter(({ path }) => FilePermissions.getExact(path) == 0); |
src/file-viewer/rules.ts+1-1| ... | ... | @@ -70,7 +70,7 @@ export const extsImageLike = new Set([ |
| 70 | 70 | /** These files populate `duration` using `ffprobe` */ |
| 71 | 71 | export const extsDuration = new Set([...extsAudio, ...extsVideo]); |
| 72 | 72 | /** These files populate `dimensions` using `ffprobe` */ |
| 73 | export const extsDimensions = new Set([...extsImage, ...extsVideo]); | |
| 73 | export const extsDimensions = new Set([...extsImage, ...extsVideo, ".gif"]); | |
| 74 | 74 | |
| 75 | 75 | /** These files read file contents into `contents`, as-is */ |
| 76 | 76 | export 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> { |
| 139 | 139 | promise = new Promise<CanvasFn>((r) => resolve = r); |
| 140 | 140 | fetchCanvas.set(id, promise); |
| 141 | 141 | const script = document.createElement("script"); |
| 142 | script.src = `/js/canvas/${id}.js`; | |
| 142 | script.src = `/js/file-viewer/scripts/canvas_${id}.js`; | |
| 143 | 143 | script.async = true; |
| 144 | 144 | script.onload = () => { |
| 145 | 145 | func = (globalThis as any)["canvas_" + id]; |
| ... | ... | @@ -216,6 +216,11 @@ class Panel { |
| 216 | 216 | } |
| 217 | 217 | } |
| 218 | 218 | |
| 219 | Array.from( | |
| 220 | panel.querySelectorAll<HTMLElement>(".clover-video"), | |
| 221 | (container) => hydrateVideo(container), | |
| 222 | ); | |
| 223 | ||
| 219 | 224 | const ul = panel.querySelector("ul"); |
| 220 | 225 | if (!ul) { |
| 221 | 226 | this.width = 0; |
src/file-viewer/views/clofi.tsx+57-65| ... | ... | @@ -70,8 +70,6 @@ export default function MediaList({ |
| 70 | 70 | file: MediaFile; |
| 71 | 71 | hasCotyledonCookie: boolean; |
| 72 | 72 | }) { |
| 73 | addScript("./clofi.client.ts"); | |
| 74 | ||
| 75 | 73 | const dirs: MediaFile[] = []; |
| 76 | 74 | let dir: MediaFile | null = file; |
| 77 | 75 | assert(dir); |
| ... | ... | @@ -115,43 +113,43 @@ export function MediaPanel({ |
| 115 | 113 | activeFilename: string | null; |
| 116 | 114 | hasCotyledonCookie: boolean; |
| 117 | 115 | }) { |
| 116 | addScript("./clofi.client.ts"); | |
| 117 | addScript("../../tags/clover-video.client.ts"); | |
| 118 | 118 | const showsActions = file.path !== "/2024/for everyone"; |
| 119 | 119 | const label = file.path === "/" |
| 120 | 120 | ? ( |
| 121 | 121 | "clo's files" |
| 122 | 122 | ) |
| 123 | 123 | : ( |
| 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 | ) | |
| 155 | 153 | ); |
| 156 | 154 | let View = specialCaseViewers[file.path] ?? |
| 157 | 155 | (extensionToViewer[file.extension.toLowerCase()] as any) ?? |
| ... | ... | @@ -166,18 +164,16 @@ export function MediaPanel({ |
| 166 | 164 | /> |
| 167 | 165 | ); |
| 168 | 166 | 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"), | |
| 177 | 175 | }; |
| 178 | if (canvases[file.path]) { | |
| 179 | addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`); | |
| 180 | } | |
| 176 | canvases[file.path]?.(); | |
| 181 | 177 | return ( |
| 182 | 178 | <div |
| 183 | 179 | class={(isLast ? "panel last" : "panel") + |
| ... | ... | @@ -186,7 +182,7 @@ export function MediaPanel({ |
| 186 | 182 | {canvases[file.path] && ( |
| 187 | 183 | <canvas |
| 188 | 184 | class="fullscreen-canvas" |
| 189 | data-canvas={canvases[file.path]} | |
| 185 | data-canvas={file.path.slice(1).split("/")![0]} | |
| 190 | 186 | > |
| 191 | 187 | </canvas> |
| 192 | 188 | )} |
| ... | ... | @@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) { |
| 680 | 676 | function VideoView({ file }: { file: MediaFile }) { |
| 681 | 677 | const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 }; |
| 682 | 678 | 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 | /> | |
| 700 | 689 | ); |
| 701 | 690 | } |
| 702 | 691 | |
| ... | ... | @@ -748,10 +737,11 @@ function AudioView({ |
| 748 | 737 | |
| 749 | 738 | function ImageView({ file }: { file: MediaFile }) { |
| 750 | 739 | return ( |
| 751 | <img | |
| 752 | src={"/file" + file.path} | |
| 740 | <Image | |
| 741 | src={file} | |
| 753 | 742 | 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;" | |
| 755 | 745 | /> |
| 756 | 746 | ); |
| 757 | 747 | } |
| ... | ... | @@ -935,4 +925,6 @@ import { |
| 935 | 925 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; |
| 936 | 926 | import * as mime from "#sitegen/mime"; |
| 937 | 927 | import * as sort from "../sort.ts"; |
| 928 | import CloverVideo from "@/tags/clover-video.marko"; | |
| 929 | import Image from "@/tags/image.tsx"; | |
| 938 | 930 | import * as render from "#engine/render"; |
src/pages/in-the-summer.marko+5-2| ... | ... | @@ -22,7 +22,10 @@ import "./in-the-summer.css"; |
| 22 | 22 | </p> |
| 23 | 23 | </div> |
| 24 | 24 | |
| 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 | > | |
| 26 | 29 | <@header><strong>music video</>: in the summer</> |
| 27 | 30 | </> |
| 28 | 31 | |
| ... | ... | @@ -34,7 +37,7 @@ import "./in-the-summer.css"; |
| 34 | 37 | <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li> |
| 35 | 38 | </ul> |
| 36 | 39 | <h2>mentions on the q&a</h2> |
| 37 | // <QuestionsFor artifact='in-the-summer'/> | |
| 40 | <questions-embed="in-the-summer" /> | |
| 38 | 41 | </div> |
| 39 | 42 | </main> |
| 40 | 43 |
src/pages/index.marko+2-5| ... | ... | @@ -21,10 +21,7 @@ export const meta = { |
| 21 | 21 | <p><a href="/file">file browser</a></p> |
| 22 | 22 | <p><a href="/subscribe">mailing list</a></p> |
| 23 | 23 | <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> | |
| 29 | 26 | </div> |
| 30 | 27 | </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. | |
| 9 | requestAnimationFrame(() => { | |
| 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 { | |
| 2 | 2 | display: flex; |
| 3 | 3 | justify-content: center; |
| 4 | 4 | align-items: center; |
| ... | ... | @@ -6,9 +6,6 @@ |
| 6 | 6 | font-weight: bold; |
| 7 | 7 | text-align: center; |
| 8 | 8 | } |
| 9 | #subscribe:not([href]) { | |
| 10 | opacity: 0.5; | |
| 11 | } | |
| 12 | 9 | .flex { |
| 13 | 10 | display: flex; |
| 14 | 11 | justify-content: center; |
src/pages/subscribe.marko+2-9| ... | ... | @@ -4,8 +4,6 @@ export const meta = { |
| 4 | 4 | description: "subscribe to the mailing list", |
| 5 | 5 | }; |
| 6 | 6 | |
| 7 | client import "./subscribe.client.ts"; | |
| 8 | ||
| 9 | 7 | <main> |
| 10 | 8 | <h1>mailing list</h1> |
| 11 | 9 | <canvas style="display: none;"></canvas> |
| ... | ... | @@ -17,19 +15,14 @@ client import "./subscribe.client.ts"; |
| 17 | 15 | the list is currently managed manually. to get added, please email anything |
| 18 | 16 | with the word "subscribe" in the subject or body to the following address: |
| 19 | 17 | </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 | |
| 24 | 20 | </code> |
| 25 | 21 | <p> |
| 26 | 22 | to unsubscribe, send an email asking to unsubscribe. for those who are close |
| 27 | 23 | friends with me, add your name and i'll add you to the 'friends' list, where i |
| 28 | 24 | send life updates in addition to new content (1-2 times per month). |
| 29 | 25 | </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> | |
| 33 | 26 | <br> |
| 34 | 27 | <br> |
| 35 | 28 | <br> |
src/pages/waterfalls.marko+3| ... | ... | @@ -42,6 +42,9 @@ export const theme = { |
| 42 | 42 | <br /> |
| 43 | 43 | <a href="/q+a">q&a page</a> |
| 44 | 44 | </p> |
| 45 | ||
| 46 | <h2>mentions on the q&a</h2> | |
| 47 | <questions-embed="waterfalls" /> | |
| 45 | 48 | </div> |
| 46 | 49 | </main> |
| 47 | 50 | </main> |
src/q+a/artifacts.ts+26-77| ... | ... | @@ -3,87 +3,36 @@ |
| 3 | 3 | // one canonical URL, which the questions page can refer to with `@id`. |
| 4 | 4 | type Artifact = [title: string, url: string, type: ArtifactType]; |
| 5 | 5 | type ArtifactType = "music" | "game" | "project" | "video"; |
| 6 | ||
| 7 | // deno-fmt-ignore | |
| 6 | 8 | export const artifactMap: Record<string, Artifact> = { |
| 7 | 9 | // 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"], | |
| 57 | 16 | "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 | ], | |
| 64 | 17 | "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"], | |
| 87 | 20 | "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"], | |
| 88 | 33 | "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"], | |
| 89 | 38 | }; |
src/q+a/clover-markdown.tsx+12-1| ... | ... | @@ -61,7 +61,7 @@ customRules.insertBefore("paragraph", { |
| 61 | 61 | customRules.insertBefore("url", { |
| 62 | 62 | name: "autolink_mention", |
| 63 | 63 | match: inlineRegex( |
| 64 | /^https?:\/\/(?:paper(?:dave|clover)\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/, | |
| 64 | /^https?:\/\/(?:paperclover\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/, | |
| 65 | 65 | ), |
| 66 | 66 | parse(capture) { |
| 67 | 67 | const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/); |
| ... | ... | @@ -72,6 +72,17 @@ customRules.insertBefore("url", { |
| 72 | 72 | }; |
| 73 | 73 | } |
| 74 | 74 | |
| 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 | ||
| 75 | 86 | return { |
| 76 | 87 | type: "link", |
| 77 | 88 | content: [ |
src/q+a/models/Question.ts+8-1| ... | ... | @@ -2,7 +2,7 @@ const db = getDb("questions.sqlite"); |
| 2 | 2 | db.table( |
| 3 | 3 | "questions", |
| 4 | 4 | ` |
| 5 | create table questions ( | |
| 5 | create table if not exists questions ( | |
| 6 | 6 | qmid integer not null, |
| 7 | 7 | type integer not null, |
| 8 | 8 | text text not null, |
| ... | ... | @@ -68,6 +68,9 @@ export class Question { |
| 68 | 68 | static updateByQmid(qmid: number, text: string, type: QuestionType) { |
| 69 | 69 | return updateByQmidQuery.run({ text, type, qmid }); |
| 70 | 70 | } |
| 71 | static search(ref: string) { | |
| 72 | return searchQuery.array(`%@${ref}%`, `%https://paperclover.net/${ref}%`); | |
| 73 | } | |
| 71 | 74 | } |
| 72 | 75 | |
| 73 | 76 | // Create a new question with a unique QMID |
| ... | ... | @@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare< |
| 114 | 117 | UPDATE questions SET text = $text, type = $type WHERE qmid = $qmid |
| 115 | 118 | `); |
| 116 | 119 | |
| 120 | const searchQuery = db.prepare<[string, string]>( | |
| 121 | `select * from questions where type = ${QuestionType.normal} and (text like ? or text like ?)`, | |
| 122 | ).as(Question); | |
| 123 | ||
| 117 | 124 | import { formatQuestionId } from "../format.ts"; |
| 118 | 125 | import { getDb } from "#sitegen/sqlite"; |
src/q+a/tags/question.marko+3-1| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import "../questions.css"; | |
| 2 | ||
| 1 | 3 | // Renders a `Question` entry including its markdown body. |
| 2 | 4 | export interface Input { |
| 3 | 5 | question: Question; |
| ... | ... | @@ -12,7 +14,7 @@ export const transitionDate = 1735639200000; |
| 12 | 14 | <const/{ id, date } = question/> |
| 13 | 15 | |
| 14 | 16 | <${"e-"} |
| 15 | f=(date > transitionDate ? true : undefined) | |
| 17 | f=(date > transitionDate ? true : null) | |
| 16 | 18 | id=admin ? `q${id}` : undefined |
| 17 | 19 | > |
| 18 | 20 | <if=admin> |
src/scripts/vendor/dash.client.ts created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | export * from "dashjs"; |
src/scripts/vendor/hls.client.ts created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | export * 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 | */ | |
| 20 | 22 | const app = new Hono(); |
| 21 | 23 | export default app; |
| 24 | export const port = 4000; | |
| 22 | 25 | |
| 23 | 26 | const token = UNWRAP(process.env.CLOVER_SOT_KEY); |
| 24 | 27 | |
| ... | ... | @@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) { |
| 32 | 35 | // Re-use file descriptors if the same file is being read twice. |
| 33 | 36 | const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>(); |
| 34 | 37 | |
| 38 | app.get("/", (c) => { | |
| 39 | return c.text("everything you love, loves you back."); | |
| 40 | }); | |
| 41 | ||
| 35 | 42 | app.get("/file/*", async (c) => { |
| 36 | 43 | const fullQuery = c.req.path.slice("/file".length); |
| 37 | const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/"); | |
| 44 | const [filePath, derivedAsset, ...invalid] = fullQuery.split(":/"); | |
| 38 | 45 | ASSERT(filePath != null); |
| 39 | 46 | if (invalid.length > 0) return c.notFound(); |
| 40 | 47 | if (filePath.length <= 1) return c.notFound(); |
| 41 | 48 | 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); | |
| 46 | 51 | } |
| 47 | 52 | 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(); | |
| 51 | 54 | const fullPath = derivedAsset |
| 52 | 55 | ? path.join(derivedFileRoot, file.hash, derivedAsset) |
| 53 | 56 | : path.join(rawFileRoot, file.path); |
| ... | ... | @@ -66,11 +69,10 @@ app.get("/file/*", async (c) => { |
| 66 | 69 | } |
| 67 | 70 | handle.refs += 1; |
| 68 | 71 | } catch (err: any) { |
| 69 | if (err.code === "ENOENT") { | |
| 70 | return c.notFound(); | |
| 71 | } | |
| 72 | if (err.code === "ENOENT") return c.notFound(); | |
| 72 | 73 | throw err; |
| 73 | 74 | } |
| 75 | c.header("Content-Length", (await fstat(handle.fd)).size); | |
| 74 | 76 | const nodeStream = fs.createReadStream(fullPath, { |
| 75 | 77 | fd: handle.fd, |
| 76 | 78 | fs: { |
| ... | ... | @@ -86,9 +88,6 @@ app.get("/file/*", async (c) => { |
| 86 | 88 | }, |
| 87 | 89 | }); |
| 88 | 90 | c.header("Content-Type", mime.contentTypeFor(fullPath)); |
| 89 | if (!derivedAsset) { | |
| 90 | c.header("Content-Length", file.size.toString()); | |
| 91 | } | |
| 92 | 91 | return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream); |
| 93 | 92 | }); |
| 94 | 93 | |
| ... | ... | @@ -102,6 +101,7 @@ app.post("/reload", async (c) => { |
| 102 | 101 | |
| 103 | 102 | const openFile = util.promisify(fsCallbacks.open); |
| 104 | 103 | const closeFile = util.promisify(fsCallbacks.close); |
| 104 | const fstat = util.promisify(fsCallbacks.fstat); | |
| 105 | 105 | |
| 106 | 106 | import { Hono } from "#hono"; |
| 107 | 107 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; |
src/tags/PhotoGrid.css deleted-16| ... | ... | @@ -1,16 +0,0 @@ |
| 1 | photo-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 @@ |
| 1 | import "./PhotoGrid.css"; | |
| 2 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | |
| 3 | const { isArray } = Array; | |
| 4 | ||
| 5 | export interface PhotoGridProps { | |
| 6 | style: any; | |
| 7 | base: string; | |
| 8 | width: number; | |
| 9 | widths?: string[]; | |
| 10 | heights?: number[]; | |
| 11 | items: Item[]; | |
| 12 | } | |
| 13 | ||
| 14 | type Item = [img: string, options?: ItemOptions]; | |
| 15 | ||
| 16 | interface ItemOptions { | |
| 17 | w?: number; | |
| 18 | h?: number; | |
| 19 | align?: keyof typeof alignments; | |
| 20 | } | |
| 21 | ||
| 22 | const 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 | ||
| 33 | const typicalPageWidth = 768; | |
| 34 | ||
| 35 | export 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 | ||
| 116 | function 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 | ||
| 128 | function mapSizes(sizes: Array<string | number>) { | |
| 129 | return sizes.map(mapSize); | |
| 130 | } | |
| 131 | function mapSize(size: string | number) { | |
| 132 | return typeof size === "number" ? size + "px" : size; | |
| 133 | } | |
| 134 | ||
| 135 | import * as path from "node:path"; |
src/tags/clover-video.client.ts+20-9| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | // Polyfill for old browsers. |
| 4 | 4 | type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none"; |
| 5 | let mode: Promise<PlaybackMode> | null = null; | |
| 5 | 6 | let hls: Promise<typeof import("hls.js")> | null = null; |
| 6 | 7 | |
| 7 | 8 | Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => |
| ... | ... | @@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => |
| 12 | 13 | (reason) => ({ status: "rejected" as const, reason }), |
| 13 | 14 | ))); |
| 14 | 15 | |
| 15 | decidePlaybackMode().then((mode) => | |
| 16 | Array.from( | |
| 17 | document.querySelectorAll<HTMLElement>(".clover-video"), | |
| 18 | (container) => hydrateVideo(container, mode), | |
| 19 | ) | |
| 16 | globalThis.hydrateVideo = function hydrateVideo(container) { | |
| 17 | mode ??= decidePlaybackMode(); | |
| 18 | mode.then((mode) => hydrateVideoInner(container, mode)); | |
| 19 | }; | |
| 20 | ||
| 21 | Array.from( | |
| 22 | document.querySelectorAll<HTMLElement>(".clover-video"), | |
| 23 | (container) => hydrateVideo(container), | |
| 20 | 24 | ); |
| 21 | 25 | |
| 22 | 26 | function decidePlaybackMode(): Promise<PlaybackMode> { |
| ... | ... | @@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> { |
| 39 | 43 | return "hls-native"; |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | return (hls ??= import("hls.js")) | |
| 46 | return (hls ??= import("/js/scripts/vendor/hls.js")) | |
| 43 | 47 | .then((hls): PlaybackMode => { |
| 44 | 48 | // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8) |
| 45 | 49 | // TODO: ES modules have a minimum of Chrome 63 or Firefox 60 |
| ... | ... | @@ -55,8 +59,16 @@ function decidePlaybackMode(): Promise<PlaybackMode> { |
| 55 | 59 | }); |
| 56 | 60 | } |
| 57 | 61 | |
| 58 | function hydrateVideo(container: HTMLElement, mode: PlaybackMode) { | |
| 62 | declare global { | |
| 63 | function hydrateVideo(container: HTMLElement): void; | |
| 64 | } | |
| 65 | ||
| 66 | function hydrateVideoInner( | |
| 67 | container: HTMLElement, | |
| 68 | mode: PlaybackMode, | |
| 69 | ) { | |
| 59 | 70 | const video = container.querySelector("video")!; |
| 71 | if (video.hasAttribute("controls")) return; | |
| 60 | 72 | let src = video.getAttribute("data-src"); |
| 61 | 73 | if (!src) return; |
| 62 | 74 | src = `/file${src}`; |
| ... | ... | @@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) { |
| 65 | 77 | const hlsFile = `${src}:/master.m3u8`; |
| 66 | 78 | |
| 67 | 79 | 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) => { | |
| 70 | 81 | const player = dashjs.MediaPlayer().create(); |
| 71 | 82 | // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay |
| 72 | 83 | let mainSource: HTMLSourceElement | null = null; |
src/tags/clover-video.css+5-1| ... | ... | @@ -3,6 +3,9 @@ |
| 3 | 3 | display: flex; |
| 4 | 4 | flex-direction: column; |
| 5 | 5 | } |
| 6 | .clover-video.minimal { | |
| 7 | border: none; | |
| 8 | } | |
| 6 | 9 | .clover-video video { |
| 7 | 10 | background-color: transparent; |
| 8 | 11 | } |
| ... | ... | @@ -11,6 +14,7 @@ |
| 11 | 14 | overflow: hidden; |
| 12 | 15 | display: flex; |
| 13 | 16 | flex-direction: column; |
| 17 | background-color: black; | |
| 14 | 18 | } |
| 15 | 19 | .clover-video video { |
| 16 | 20 | width: 100%; |
| ... | ... | @@ -33,7 +37,7 @@ |
| 33 | 37 | } |
| 34 | 38 | .clover-video figcaption { |
| 35 | 39 | background-color: var(--fg); |
| 36 | color: white; | |
| 40 | color: var(--bg); | |
| 37 | 41 | width: 100%; |
| 38 | 42 | margin-top: -1px; |
| 39 | 43 | padding-bottom: 2px; |
src/tags/clover-video.marko+10-7| ... | ... | @@ -4,9 +4,10 @@ export interface Input { |
| 4 | 4 | file: MediaFile | string; |
| 5 | 5 | poster?: MediaFile | string; |
| 6 | 6 | header?: Marko.AttrTag<{ content: Marko.Body }>; |
| 7 | minimal?: boolean; | |
| 7 | 8 | } |
| 8 | 9 | |
| 9 | <const/{ header } = input /> | |
| 10 | <const/{ header, minimal } = input /> | |
| 10 | 11 | <const/file = typeof UNWRAP(input.file) === 'object' |
| 11 | 12 | ? input.file |
| 12 | 13 | : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) /> |
| ... | ... | @@ -18,12 +19,14 @@ export interface Input { |
| 18 | 19 | |
| 19 | 20 | <const/{ width, height } = file.parseDimensions() /> |
| 20 | 21 | |
| 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 | </> | |
| 27 | 30 | <div.stack style=`aspect-ratio:${width}/${height}`> |
| 28 | 31 | <video |
| 29 | 32 | data-src=file.path |
src/tags/image.tsx created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | interface Props { | |
| 2 | src: MediaFile | string; | |
| 3 | alt: string; | |
| 4 | displayWidthEstimate?: number | string; | |
| 5 | objectFit?: string; | |
| 6 | ||
| 7 | style?: string; | |
| 8 | className?: string; | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | ||
| 53 | import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; | |
| 54 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | |
| 55 | import { escapeUri } from "@/file-viewer/format.ts"; |
src/tags/photo-grid.css created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | photo-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 @@ |
| 1 | const { isArray } = Array; | |
| 2 | ||
| 3 | export interface PhotoGridProps { | |
| 4 | style: any; | |
| 5 | base: string; | |
| 6 | width: number; | |
| 7 | widths?: string[]; | |
| 8 | heights?: number[]; | |
| 9 | items: Item[]; | |
| 10 | } | |
| 11 | ||
| 12 | type Item = [img: string, options?: ItemOptions]; | |
| 13 | ||
| 14 | interface ItemOptions { | |
| 15 | w?: number; | |
| 16 | h?: number; | |
| 17 | align?: keyof typeof alignments; | |
| 18 | } | |
| 19 | ||
| 20 | const 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 | ||
| 31 | const typicalPageWidth = 768; | |
| 32 | ||
| 33 | export 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 | ||
| 114 | function 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 | ||
| 125 | function mapSizes(sizes: Array<string | number>) { | |
| 126 | return sizes.map(mapSize); | |
| 127 | } | |
| 128 | function mapSize(size: string | number) { | |
| 129 | return typeof size === "number" ? size + "px" : size; | |
| 130 | } | |
| 131 | ||
| 132 | import "./photo-grid.css"; | |
| 133 | import { MediaFile } from "@/file-viewer/models/MediaFile.ts"; | |
| 134 | import Image from "./image.tsx"; | |
| 135 | import * 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 @@ |
| 1 | import "./questions-embed.css"; | |
| 2 | ||
| 3 | export 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 | ||
| 15 | import QuestionRender from "@/q+a/tags/question.marko"; | |
| 16 | import { Question } from "@/q+a/models/Question.ts"; |