| 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,7 +8,9 @@ const server = serve({ |
| 8 | fetch: app.default.fetch, | 8 | fetch: app.default.fetch, |
| 9 | port: Number(process.env.PORT ?? 3000), | 9 | port: Number(process.env.PORT ?? 3000), |
| 10 | }, ({ address, port }) => { | 10 | }, ({ address, port }) => { |
| 11 | if (address === "::") address = "localhost"; | 11 | if (address === "::" || address === "127.0.0.1") { |
| 12 | address = os.hostname().replace(/\.local$/, ""); | ||
| 13 | } | ||
| 12 | console.info(url.format({ | 14 | console.info(url.format({ |
| 13 | protocol, | 15 | protocol, |
| 14 | hostname: address, | 16 | hostname: address, |
| ... | @@ -36,6 +38,7 @@ declare global { | ... | @@ -36,6 +38,7 @@ declare global { |
| 36 | var CLOVER_SERVER_ENTRY: string; | 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 | import { serve } from "@hono/node-server"; | 43 | import { serve } from "@hono/node-server"; |
| 41 | import process from "node:process"; | 44 | import process from "node:process"; |
framework/bundle.ts+14-1| ... | @@ -39,6 +39,18 @@ export async function bundleClientJavaScript( | ... | @@ -39,6 +39,18 @@ export async function bundleClientJavaScript( |
| 39 | const clientPlugins: esbuild.Plugin[] = [ | 39 | const clientPlugins: esbuild.Plugin[] = [ |
| 40 | projectRelativeResolution(), | 40 | projectRelativeResolution(), |
| 41 | markoViaBuildCache(), | 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 | using _ = io.start(`bundle ${entryPoints.length} files`); | 56 | using _ = io.start(`bundle ${entryPoints.length} files`); |
| ... | @@ -60,6 +72,7 @@ export async function bundleClientJavaScript( | ... | @@ -60,6 +72,7 @@ export async function bundleClientJavaScript( |
| 60 | outdir: "out!", | 72 | outdir: "out!", |
| 61 | plugins: clientPlugins, | 73 | plugins: clientPlugins, |
| 62 | write: false, | 74 | write: false, |
| 75 | external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"], | ||
| 63 | define: { | 76 | define: { |
| 64 | ASSERT: "console.assert", | 77 | ASSERT: "console.assert", |
| 65 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), | 78 | MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText), |
| ... | @@ -290,7 +303,7 @@ export async function bundleServerJavaScript({ | ... | @@ -290,7 +303,7 @@ export async function bundleServerJavaScript({ |
| 290 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), | 303 | "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry), |
| 291 | }, | 304 | }, |
| 292 | external: Object.keys(pkg.dependencies).filter( | 305 | external: Object.keys(pkg.dependencies).filter( |
| 293 | (x) => !x.startsWith("@paperclover"), | 306 | (x) => x !== "lib", |
| 294 | ), | 307 | ), |
| 295 | }); | 308 | }); |
| 296 | await trackEsbuild(io, metafile); | 309 | await trackEsbuild(io, metafile); |
framework/engine/marko-runtime.ts-1| ... | @@ -27,7 +27,6 @@ export const _template: typeof marko._template = ( | ... | @@ -27,7 +27,6 @@ export const _template: typeof marko._template = ( |
| 27 | return markoResult.then(render.raw); | 27 | return markoResult.then(render.raw); |
| 28 | } | 28 | } |
| 29 | const rr = markoResult.toString(); | 29 | const rr = markoResult.toString(); |
| 30 | console.log({ rr }); | ||
| 31 | return render.raw(rr); | 30 | return render.raw(rr); |
| 32 | } | 31 | } |
| 33 | wrap.render = renderFn; | 32 | wrap.render = renderFn; |
framework/engine/render.ts+5-4| ... | @@ -6,6 +6,8 @@ | ... | @@ -6,6 +6,8 @@ |
| 6 | // Add-ons to the rendering engine can provide opaque data, And retrieve it | 6 | // Add-ons to the rendering engine can provide opaque data, And retrieve it |
| 7 | // within component calls with 'getAddonData'. For example, 'sitegen' uses this | 7 | // within component calls with 'getAddonData'. For example, 'sitegen' uses this |
| 8 | // to track needed client scripts without introducing patches to the engine. | 8 | // to track needed client scripts without introducing patches to the engine. |
| 9 | // | ||
| 10 | // This module is not production ready | ||
| 9 | export let current: State | null = null; | 11 | export let current: State | null = null; |
| 10 | export function setCurrent(r: State | null) { | 12 | export function setCurrent(r: State | null) { |
| 11 | current = r ?? null; | 13 | current = r ?? null; |
| ... | @@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string { | ... | @@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string { |
| 212 | } | 214 | } |
| 213 | function stringifyElement(element: ResolvedElement) { | 215 | function stringifyElement(element: ResolvedElement) { |
| 214 | const { 1: tag, 2: props, 3: children } = element; | 216 | const { 1: tag, 2: props, 3: children } = element; |
| 215 | let out = "<" + tag; | 217 | let out = "<" + tag + " "; |
| 216 | let needSpace = true; | ||
| 217 | for (const prop in props) { | 218 | for (const prop in props) { |
| 218 | const value = props[prop]; | 219 | const value = props[prop]; |
| 219 | if (!value || typeof value === "function") continue; | 220 | if (!value || typeof value === "function") continue; |
| ... | @@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) { | ... | @@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) { |
| 249 | case "key": | 250 | case "key": |
| 250 | continue; | 251 | continue; |
| 251 | } | 252 | } |
| 252 | if (needSpace) ((out += " "), (needSpace = !attr.endsWith('"'))); | 253 | if (!out.endsWith('"')) out += " "; |
| 253 | out += attr; | 254 | out += attr; |
| 254 | } | 255 | } |
| 255 | out += ">"; | 256 | out = out.trimEnd() + ">"; |
| 256 | if (children) out += stringifyNode(children); | 257 | if (children) out += stringifyNode(children); |
| 257 | if ( | 258 | if ( |
| 258 | tag !== "br" && | 259 | tag !== "br" && |
framework/generate.ts+2-4| ... | @@ -369,10 +369,8 @@ export async function assembleAndWritePage( | ... | @@ -369,10 +369,8 @@ export async function assembleAndWritePage( |
| 369 | 369 | ||
| 370 | pageNode.passive = false; | 370 | pageNode.passive = false; |
| 371 | 371 | ||
| 372 | const scripts = scriptIds | 372 | const scripts = sg.joinScripts(scriptIds |
| 373 | .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`)) | 373 | .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))); |
| 374 | .map((x) => `{${x}}`) | ||
| 375 | .join("\n"); | ||
| 376 | 374 | ||
| 377 | const buffer = sg.wrapDocument({ | 375 | const buffer = sg.wrapDocument({ |
| 378 | body: html, | 376 | body: html, |
framework/incremental.ts+1-1| ... | @@ -496,7 +496,7 @@ export class Io { | ... | @@ -496,7 +496,7 @@ export class Io { |
| 496 | return resolved; | 496 | return resolved; |
| 497 | } | 497 | } |
| 498 | async readFile(file: string) { | 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 | async readJson<T>(file: string) { | 501 | async readJson<T>(file: string) { |
| 502 | return JSON.parse(await this.readFile(file)) as T; | 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,7 +94,8 @@ export class Lru<K, V> extends Map<K, V> { |
| 94 | this.#used += size; | 94 | this.#used += size; |
| 95 | try { | 95 | try { |
| 96 | this.ensureUnusedCapacity(0); | 96 | this.ensureUnusedCapacity(0); |
| 97 | } catch { | 97 | } catch (err) { |
| 98 | console.error(err); | ||
| 98 | ASSERT(false, "capacity is already verified"); | 99 | ASSERT(false, "capacity is already verified"); |
| 99 | } | 100 | } |
| 100 | super.set(key, value); | 101 | super.set(key, value); |
framework/lib/meta.ts+1-1| ... | @@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string { | ... | @@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string { |
| 214 | if (denyRobots) { | 214 | if (denyRobots) { |
| 215 | out += `<meta name=robots content=noindex,nofollow>`; | 215 | out += `<meta name=robots content=noindex,nofollow>`; |
| 216 | } | 216 | } |
| 217 | if (description) { | 217 | if (viewport) { |
| 218 | out += `<meta name=viewport content=${attr(viewport)}>`; | 218 | out += `<meta name=viewport content=${attr(viewport)}>`; |
| 219 | } | 219 | } |
| 220 | if (canonical) { | 220 | if (canonical) { |
framework/lib/mime.ts+1-1| ... | @@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); | ... | @@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); |
| 18 | export function contentTypeFor(file: string) { | 18 | export function contentTypeFor(file: string) { |
| 19 | const slash = file.indexOf("/"); | 19 | const slash = file.indexOf("/"); |
| 20 | if (slash !== -1) file = file.slice(slash + 1); | 20 | if (slash !== -1) file = file.slice(slash + 1); |
| 21 | const dot = file.indexOf("."); | 21 | const dot = file.lastIndexOf("."); |
| 22 | if (dot === -1) file = "." + file; | 22 | if (dot === -1) file = "." + file; |
| 23 | else if (dot > 0) { | 23 | else if (dot > 0) { |
| 24 | let entry = fullNames.get(file); | 24 | let entry = fullNames.get(file); |
framework/lib/mime.txt+1-1| ... | @@ -42,7 +42,7 @@ | ... | @@ -42,7 +42,7 @@ |
| 42 | .jpg image/jpeg | 42 | .jpg image/jpeg |
| 43 | .jpgv video/jpeg | 43 | .jpgv video/jpeg |
| 44 | .jxl image/jxl | 44 | .jxl image/jxl |
| 45 | .js application/javascript | 45 | .js application/javascript;charset=utf-8 |
| 46 | .json application/json | 46 | .json application/json |
| 47 | .latex application/x-latex | 47 | .latex application/x-latex |
| 48 | .list text/plain | 48 | .list text/plain |
framework/lib/sitegen.ts+10-4| ... | @@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>; | ... | @@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>; |
| 50 | export type FontVariableAxis = number | [min: number, max: number]; | 50 | export type FontVariableAxis = number | [min: number, max: number]; |
| 51 | export type FontRange = string | number | { start: number; end: number }; | 51 | export type FontRange = string | number | { start: number; end: number }; |
| 52 | 52 | ||
| 53 | export const userData = render.userData<SitegenRender>(() => { | 53 | export const userData = render.userData<SitegenRender>(() => initRender()); |
| 54 | throw new Error("This function can only be used in a page (static or view)"); | ||
| 55 | }); | ||
| 56 | 54 | ||
| 57 | export interface SitegenRender { | 55 | export interface SitegenRender { |
| 58 | scripts: Set<string>; | 56 | scripts: Set<string>; |
| ... | @@ -78,13 +76,21 @@ export function wrapDocument({ | ... | @@ -78,13 +76,21 @@ export function wrapDocument({ |
| 78 | inlineCss: string; | 76 | inlineCss: string; |
| 79 | scripts: string; | 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 | inlineCss ? `<style>${inlineCss}</style>` : "" | 80 | inlineCss ? `<style>${inlineCss}</style>` : "" |
| 83 | }</head><body>${body}${ | 81 | }</head><body>${body}${ |
| 84 | scripts ? `<script>${scripts}</script>` : "" | 82 | scripts ? `<script>${scripts}</script>` : "" |
| 85 | }</body></html>`; | 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 | import * as render from "#engine/render"; | 94 | import * as render from "#engine/render"; |
| 89 | import type * as meta from "./meta.ts"; | 95 | import type * as meta from "./meta.ts"; |
| 90 | import type * as css from "../css.ts"; | 96 | import type * as css from "../css.ts"; |
framework/lib/subprocess.ts+56-52| ... | @@ -3,76 +3,80 @@ | ... | @@ -3,76 +3,80 @@ |
| 3 | 3 | ||
| 4 | const execFileRaw = util.promisify(child_process.execFile); | 4 | const execFileRaw = util.promisify(child_process.execFile); |
| 5 | export const exec: typeof execFileRaw = (( | 5 | export const exec: typeof execFileRaw = (( |
| 6 | ...args: Parameters<typeof execFileRaw> | 6 | ...args: Parameters<typeof execFileRaw> |
| 7 | ) => | 7 | ) => |
| 8 | execFileRaw(...args).catch((e: any) => { | 8 | execFileRaw(...args).catch((e: any) => { |
| 9 | if (e?.message?.startsWith?.("Command failed")) { | 9 | if (e?.message?.startsWith?.("Command failed")) { |
| 10 | if (e.code > 2 ** 31) e.code |= 0; | 10 | if (e.code > 2 ** 31) e.code |= 0; |
| 11 | const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`; | 11 | const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`; |
| 12 | e.message = `${e.cmd.split(" ")[0]} failed with ${code}`; | 12 | e.message = `${e.cmd.split(" ")[0]} failed with ${code}`; |
| 13 | } | 13 | } |
| 14 | throw e; | 14 | throw e; |
| 15 | })) as any; | 15 | })) as any; |
| 16 | 16 | ||
| 17 | const activePids = new Set<number>(); | 17 | // const activePids = new Set<number>(); |
| 18 | 18 | ||
| 19 | export interface SpawnOptions extends child_process.SpawnOptions { | 19 | export interface SpawnOptions extends child_process.SpawnOptions { |
| 20 | cmd: string[]; | 20 | cmd: string[]; |
| 21 | } | 21 | } |
| 22 | export function spawn({ cmd, ...rest }: SpawnOptions) { | 22 | export function spawn({ cmd, ...rest }: SpawnOptions) { |
| 23 | rest.detached ??= true; | 23 | // rest.detached ??= true; |
| 24 | const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest); | 24 | const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest); |
| 25 | const pid = rest.detached && process.pid ? -process.pid : process.pid; | 25 | // const pid = rest.detached && process.pid ? -process.pid : process.pid; |
| 26 | if (pid) { | 26 | // if (pid) { |
| 27 | activePids.add(pid); | 27 | // activePids.add(pid); |
| 28 | process.on("exit", () => void activePids.delete(pid)); | 28 | // process.on("exit", () => void activePids.delete(pid)); |
| 29 | } | 29 | // } |
| 30 | return process; | 30 | return process; |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | export interface ForkOptions extends child_process.ForkOptions { | 33 | export interface ForkOptions extends child_process.ForkOptions { |
| 34 | cmd: string[]; | 34 | cmd: string[]; |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | export function fork({ cmd, ...rest }: ForkOptions) { | 37 | export function fork({ cmd, ...rest }: ForkOptions) { |
| 38 | rest.detached ??= true; | 38 | // rest.detached ??= true; |
| 39 | const process = child_process.fork(cmd[0]!, cmd.slice(1), rest); | 39 | const process = child_process.fork(cmd[0]!, cmd.slice(1), rest); |
| 40 | const pid = rest.detached && process.pid ? -process.pid : process.pid; | 40 | // const pid = rest.detached && process.pid ? -process.pid : process.pid; |
| 41 | if (pid) { | 41 | // if (pid) { |
| 42 | activePids.add(pid); | 42 | // activePids.add(pid); |
| 43 | process.on("exit", () => void activePids.delete(pid)); | 43 | // process.on("exit", () => void activePids.delete(pid)); |
| 44 | } | 44 | // } |
| 45 | return process; | 45 | return process; |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | export async function spawnAndWait(opts: SpawnOptions) { | 48 | export async function spawnAndWait(opts: SpawnOptions) { |
| 49 | opts.stdio ??= ["ignore", "inherit", "inherit"]; | 49 | opts.stdio ??= ["ignore", "inherit", "inherit"]; |
| 50 | const process = spawn(opts); | 50 | const process = spawn(opts); |
| 51 | const output = Promise.all([ | 51 | const output = Promise.all([ |
| 52 | process.stdout ? Array.fromAsync(process.stdout).then(Buffer.concat) : null, | 52 | process.stdout |
| 53 | process.stderr ? Array.fromAsync(process.stderr).then(Buffer.concat) : null, | 53 | ? Array.fromAsync(process.stdout).then(Buffer.concat) |
| 54 | ]); | 54 | : null, |
| 55 | let [code, signal] = await events.EventEmitter.once(process, "exit") as [ | 55 | process.stderr |
| 56 | number | null, | 56 | ? Array.fromAsync(process.stderr).then(Buffer.concat) |
| 57 | string | null, | 57 | : null, |
| 58 | ]; | 58 | ]); |
| 59 | if (code !== 0) { | 59 | let [code, signal] = (await events.EventEmitter.once(process, "exit")) as [ |
| 60 | if (code !== null && code > 2 ** 31) code |= 0; | 60 | number | null, |
| 61 | const exit = signal ? `signal ${signal}` : `code ${code}`; | 61 | string | null, |
| 62 | throw new Error(`${opts.cmd[0]} failed with ${exit}`); | 62 | ]; |
| 63 | } | 63 | if (code !== 0) { |
| 64 | const [stdout, stderr] = await output; | 64 | if (code !== null && code > 2 ** 31) code |= 0; |
| 65 | return { stdout, stderr }; | 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", () => { | 72 | // process.on("exit", () => { |
| 69 | for (const pid of activePids) { | 73 | // for (const pid of activePids) { |
| 70 | console.info(`Kill ${Math.abs(pid)}`); | 74 | // console.info(`Kill ${Math.abs(pid)}`); |
| 71 | process.kill(pid, 9); | 75 | // process.kill(pid, 9); |
| 72 | } | 76 | // } |
| 73 | }); | 77 | // }); |
| 74 | 78 | ||
| 75 | import * as process from "node:process"; | 79 | // import * as process from "node:process"; |
| 76 | import * as events from "node:events"; | 80 | import * as events from "node:events"; |
| 77 | import * as util from "node:util"; | 81 | import * as util from "node:util"; |
| 78 | import * as child_process from "node:child_process"; | 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,7 +83,7 @@ export async function renderToString<K extends Key>( |
| 83 | body, | 83 | body, |
| 84 | head: await renderedMetaPromise, | 84 | head: await renderedMetaPromise, |
| 85 | inlineCss, | 85 | inlineCss, |
| 86 | scripts: joinScripts( | 86 | scripts: sg.joinScripts( |
| 87 | Array.from( | 87 | Array.from( |
| 88 | sitegen!.scripts, | 88 | sitegen!.scripts, |
| 89 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), | 89 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), |
| ... | @@ -108,13 +108,6 @@ export function regenerate(tag: RegenKey) { | ... | @@ -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 | import * as meta from "./meta.ts"; | 111 | import * as meta from "./meta.ts"; |
| 119 | import type * as hono from "#hono"; | 112 | import type * as hono from "#hono"; |
| 120 | import * as render from "#engine/render"; | 113 | import * as render from "#engine/render"; |
run.js+18-4| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | // This file allows using Node.js in combination with | 1 | // This file allows using Node.js in combination with |
| 2 | // all available plugins. Usage: "node run <script>" | 2 | // all available plugins. Usage: "node run <script>" |
| 3 | import * as util from "node:util"; | ||
| 4 | import * as zlib from "node:zlib"; | 3 | import * as zlib from "node:zlib"; |
| 5 | import * as url from "node:url"; | 4 | import * as url from "node:url"; |
| 5 | import * as fs from "node:fs"; | ||
| 6 | import * as os from "node:os"; | ||
| 6 | import process from "node:process"; | 7 | import process from "node:process"; |
| 7 | 8 | ||
| 8 | // Disable experimental warnings (Type Stripping, etc) | 9 | // Disable experimental warnings (Type Stripping, etc) |
| ... | @@ -23,7 +24,7 @@ try { | ... | @@ -23,7 +24,7 @@ try { |
| 23 | 24 | ||
| 24 | // seems to fail in bun | 25 | // seems to fail in bun |
| 25 | (await import("source-map-support")).install({ hookRequire: true }); | 26 | (await import("source-map-support")).install({ hookRequire: true }); |
| 26 | } catch { | 27 | } catch (e) { |
| 27 | const brand = process.versions.bun | 28 | const brand = process.versions.bun |
| 28 | ? `bun ${process.versions.bun}` | 29 | ? `bun ${process.versions.bun}` |
| 29 | : process.versions.deno | 30 | : process.versions.deno |
| ... | @@ -33,8 +34,9 @@ try { | ... | @@ -33,8 +34,9 @@ try { |
| 33 | globalThis.console.error( | 34 | globalThis.console.error( |
| 34 | `sitegen depends on a node.js v24. your runtime is missing features.\n` + | 35 | `sitegen depends on a node.js v24. your runtime is missing features.\n` + |
| 35 | `this is node.js version ${process.version}${brandText}\n\n` + | 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 | process.exit(1); | 40 | process.exit(1); |
| 39 | } | 41 | } |
| 40 | 42 | ||
| ... | @@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => { | ... | @@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => { |
| 53 | process.exit(1); | 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 | // Load with hooks | 67 | // Load with hooks |
| 57 | if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | 68 | if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 58 | if (process.argv.length == 2) { | 69 | if (process.argv.length == 2) { |
| ... | @@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | ... | @@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 82 | serve( | 93 | serve( |
| 83 | { | 94 | { |
| 84 | fetch: mod.default.fetch, | 95 | fetch: mod.default.fetch, |
| 96 | port: mod.port ?? 3000, | ||
| 85 | }, | 97 | }, |
| 86 | ({ address, port }) => { | 98 | ({ address, port }) => { |
| 87 | if (address === "::") address = "::1"; | 99 | if (address === "::" || address === "127.0.0.1") { |
| 100 | address = os.hostname().replace(/\.local$/, ""); | ||
| 101 | } | ||
| 88 | log.info( | 102 | log.info( |
| 89 | url.format({ | 103 | url.format({ |
| 90 | protocol, | 104 | protocol, |
src/admin.ts+1-3| ... | @@ -12,9 +12,7 @@ function compareToken(token: string) { | ... | @@ -12,9 +12,7 @@ function compareToken(token: string) { |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | export async function middleware(c: Context, next: Next) { | 14 | export async function middleware(c: Context, next: Next) { |
| 15 | if (c.req.path.startsWith("/admin")) { | 15 | if (c.req.path.startsWith("/admin")) return adminInner(c, next); |
| 16 | return adminInner(c, next); | ||
| 17 | } | ||
| 18 | return next(); | 16 | return next(); |
| 19 | } | 17 | } |
| 20 | 18 |
src/backend.ts+1| ... | @@ -11,6 +11,7 @@ app.use(admin.middleware); | ... | @@ -11,6 +11,7 @@ app.use(admin.middleware); |
| 11 | // Backends | 11 | // Backends |
| 12 | app.route("", require("./q+a/backend.ts").app); | 12 | app.route("", require("./q+a/backend.ts").app); |
| 13 | app.route("", require("./file-viewer/backend.tsx").app); | 13 | app.route("", require("./file-viewer/backend.tsx").app); |
| 14 | app.route("", require("./friend-auth.ts").app); | ||
| 14 | 15 | ||
| 15 | // Asset middleware has least precedence | 16 | // Asset middleware has least precedence |
| 16 | app.use(assets.middleware); | 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,7 +66,10 @@ app.get("/file/*", async (c, next) => { |
| 66 | if (c.req.query("dl") != null) viewMode = "download"; | 66 | if (c.req.query("dl") != null) viewMode = "download"; |
| 67 | if (lofi) viewMode = file.extension === ".html" ? "embed" : "download"; | 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 | // cache.prefetch(file); | 73 | // cache.prefetch(file); |
| 71 | c.res = await view.serve(c, "file-viewer/clofi", { | 74 | c.res = await view.serve(c, "file-viewer/clofi", { |
| 72 | file, | 75 | file, |
| ... | @@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => { | ... | @@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => { |
| 156 | 159 | ||
| 157 | const found = await cache.read({ | 160 | const found = await cache.read({ |
| 158 | file, | 161 | file, |
| 159 | hash, | 162 | size, |
| 160 | derived: derivedKey, | 163 | derived: derivedKey, |
| 164 | hash, | ||
| 161 | start, | 165 | start, |
| 162 | end, | 166 | end, |
| 163 | }); | 167 | }); |
| 164 | if (!found) { | 168 | if (!found) { |
| 165 | return Response.json({ | 169 | return c.json({ |
| 166 | error: "localOnly is enabled and this file is not available", | 170 | error: "localOnly is enabled and this file is not available", |
| 167 | }, 500); | 171 | }, 500); |
| 168 | } | 172 | } |
| ... | @@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) { | ... | @@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) { |
| 265 | return c.json({ error: "File not found" }, 404); | 269 | return c.json({ error: "File not found" }, 404); |
| 266 | } | 270 | } |
| 267 | 271 | ||
| 268 | let root = MediaPanel({ | 272 | const r = render.init(false, {}); |
| 269 | file, | 273 | let root = render.resolveNode( |
| 270 | isLast: true, | 274 | r, |
| 271 | activeFilename: null, | 275 | <MediaPanel |
| 272 | hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), | 276 | file={file} |
| 273 | }); | 277 | isLast |
| 278 | activeFilename={null} | ||
| 279 | hasCotyledonCookie={rawFilePath === "" && checkCotyledonCookie(c)} | ||
| 280 | />, | ||
| 281 | ); | ||
| 274 | // Remove the root element, it's created client side! | 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; | 285 | return c.html(render.stringifyNode(root)); |
| 278 | return c.html(html); | ||
| 279 | } | 286 | } |
| 280 | 287 | ||
| 281 | import { type Context, Hono } from "hono"; | 288 | import { type Context, Hono } from "hono"; |
src/file-viewer/bin/scan3.ts+3-2| ... | @@ -160,7 +160,7 @@ export async function main() { | ... | @@ -160,7 +160,7 @@ export async function main() { |
| 160 | ); | 160 | ); |
| 161 | dependantJob.needs -= 1; | 161 | dependantJob.needs -= 1; |
| 162 | if (dependantJob.needs == 0) { | 162 | if (dependantJob.needs == 0) { |
| 163 | queueFileProcessor(dependantJob); | 163 | promises.push(queueFileProcessor(dependantJob)); |
| 164 | } | 164 | } |
| 165 | } | 165 | } |
| 166 | }, (job) => ({ cores: job.processor.cores ?? 0 })); | 166 | }, (job) => ({ cores: job.processor.cores ?? 0 })); |
| ... | @@ -281,6 +281,7 @@ export async function main() { | ... | @@ -281,6 +281,7 @@ export async function main() { |
| 281 | using dirMetaNode = progress.start("update directory metadata"); | 281 | using dirMetaNode = progress.start("update directory metadata"); |
| 282 | const dirs = MediaFile.getDirectoriesToReindex() | 282 | const dirs = MediaFile.getDirectoriesToReindex() |
| 283 | .sort((a, b) => b.path.length - a.path.length); | 283 | .sort((a, b) => b.path.length - a.path.length); |
| 284 | |||
| 284 | for (const dir of dirs) { | 285 | for (const dir of dirs) { |
| 285 | using _ = dirMetaNode.start(dir.path); | 286 | using _ = dirMetaNode.start(dir.path); |
| 286 | const children = dir.getChildren(); | 287 | const children = dir.getChildren(); |
| ... | @@ -376,7 +377,7 @@ export async function main() { | ... | @@ -376,7 +377,7 @@ export async function main() { |
| 376 | }); | 377 | }); |
| 377 | if (sotToken) { | 378 | if (sotToken) { |
| 378 | const res = await fetch("https://db.paperclover.net/reload", { | 379 | const res = await fetch("https://db.paperclover.net/reload", { |
| 379 | method: "post", | 380 | method: "POST", |
| 380 | headers: { | 381 | headers: { |
| 381 | Authorization: sotToken, | 382 | Authorization: sotToken, |
| 382 | }, | 383 | }, |
src/file-viewer/cache.ts+155-64| ... | @@ -33,7 +33,7 @@ export type Source = | ... | @@ -33,7 +33,7 @@ export type Source = |
| 33 | const token = process.env.CLOVER_SOT_KEY ?? null; | 33 | const token = process.env.CLOVER_SOT_KEY ?? null; |
| 34 | const sotUrl = new URL( | 34 | const sotUrl = new URL( |
| 35 | process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net", | 35 | process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net", |
| 36 | ); | 36 | ).toString().replace(/\/+$/g, ""); |
| 37 | if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files"); | 37 | if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files"); |
| 38 | 38 | ||
| 39 | const diskCacheRoot = openDiskCache(); | 39 | const diskCacheRoot = openDiskCache(); |
| ... | @@ -94,6 +94,7 @@ export interface Options { | ... | @@ -94,6 +94,7 @@ export interface Options { |
| 94 | file: MediaFile; | 94 | file: MediaFile; |
| 95 | /** If set, fetches the derived asset instead. */ | 95 | /** If set, fetches the derived asset instead. */ |
| 96 | derived: string | null; | 96 | derived: string | null; |
| 97 | size: number; | ||
| 97 | hash: string; | 98 | hash: string; |
| 98 | start: number; | 99 | start: number; |
| 99 | /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */ | 100 | /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */ |
| ... | @@ -107,7 +108,7 @@ export interface Options { | ... | @@ -107,7 +108,7 @@ export interface Options { |
| 107 | * `null` if a derived asset does not exist | 108 | * `null` if a derived asset does not exist |
| 108 | */ | 109 | */ |
| 109 | export async function read( | 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 | ): Promise<{ stream: ReadableStream; src: Source; size: number } | null> { | 112 | ): Promise<{ stream: ReadableStream; src: Source; size: number } | null> { |
| 112 | const rel = derived ? `${file.path}$/${derived}` : file.path; | 113 | const rel = derived ? `${file.path}$/${derived}` : file.path; |
| 113 | 114 | ||
| ... | @@ -115,33 +116,33 @@ export async function read( | ... | @@ -115,33 +116,33 @@ export async function read( |
| 115 | const localFile = derived | 116 | const localFile = derived |
| 116 | ? derivedFileRoot?.join(derived) | 117 | ? derivedFileRoot?.join(derived) |
| 117 | : rawFileRoot?.join(file.path); | 118 | : rawFileRoot?.join(file.path); |
| 118 | if (localFile) { | 119 | local: { |
| 119 | local: { | 120 | if (!localFile) break local; |
| 120 | let size: number; | 121 | if (process.env.CLOVER_SOT_FORCE) break local; |
| 121 | try { | 122 | let size: number; |
| 122 | ({ size } = await localFile.stat()); | 123 | try { |
| 123 | } catch (err) { | 124 | ({ size } = await localFile.stat()); |
| 124 | if (error.code(err) === "ENOENT") { | 125 | } catch (err) { |
| 125 | console.warn(`missing local file: ${localFile}`); | 126 | if (error.code(err) === "ENOENT") { |
| 126 | break local; | 127 | console.warn(`missing local file: ${localFile}`); |
| 127 | } | 128 | break local; |
| 128 | throw err; | ||
| 129 | } | 129 | } |
| 130 | const fileStream = await readStream(localFile, { start, end }); | 130 | throw err; |
| 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 | }; | ||
| 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 | // Disk cache | 144 | // Disk cache |
| 144 | const diskPath = diskCacheRoot.join(key); | 145 | const diskPath = diskCacheRoot.join(hash); |
| 145 | let diskFile = diskCache.get(diskPath); | 146 | let diskFile = diskCache.get(diskPath); |
| 146 | if (diskFile) { | 147 | if (diskFile) { |
| 147 | const lock = diskCache.lock(diskPath); | 148 | const lock = diskCache.lock(diskPath); |
| ... | @@ -165,67 +166,154 @@ export async function read( | ... | @@ -165,67 +166,154 @@ export async function read( |
| 165 | } | 166 | } |
| 166 | 167 | ||
| 167 | // Fetch the source of truth | 168 | // Fetch the source of truth |
| 168 | const url = `${sotUrl}/file${file.path}`; | 169 | const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : ""; |
| 169 | let response; | 170 | const url = `${sotUrl}/file${file.path}${suffix}`; |
| 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); | ||
| 194 | 171 | ||
| 195 | // Do not cache large assets | 172 | // Do not cache large assets |
| 196 | if (size > 1_000_000_000) { | 173 | if (size > 1_000_000_000) { |
| 197 | console.info(`skipping disk cache due to large asset: ${rel}`); | 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 | // Bail out of the disk cache if it is contended | 181 | // Bail out of the disk cache if it is contended |
| 201 | if (diskCache.unlockedCapacity < size) { | 182 | if (diskCache.unlockedCapacity < size) { |
| 202 | console.info(`skipping disk cache due to contention: ${rel}`); | 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 | // IPs which request too many cache misses are temporarily | 190 | // IPs which request too many cache misses are temporarily |
| 206 | // banned from writing into the disk cache to avoid abuse. | 191 | // banned from writing into the disk cache to avoid abuse. |
| 207 | if (noWrite) { | 192 | if (noWrite) { |
| 208 | console.info(`skipping disk cache due to config: ${rel}`); | 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 | // Create a disk cache entry; | 201 | // Create a disk cache entry; |
| 213 | // Tee the stream to both the request and the filesystem. | 202 | // Tee the stream to both the request and the filesystem. |
| 214 | diskFile = { | 203 | diskFile = { size, written: new async.Watch(0), memory: new Map() }; |
| 215 | size, | ||
| 216 | written: new async.Watch(0), | ||
| 217 | memory: new Map(), | ||
| 218 | }; | ||
| 219 | diskCache.set(diskPath, diskFile); | 204 | diskCache.set(diskPath, diskFile); |
| 220 | |||
| 221 | const lock = diskCache.lock(diskPath); | 205 | const lock = diskCache.lock(diskPath); |
| 222 | const writer = await writeStream(diskPath, response, diskFile, lock); | 206 | return fetchAndStore(rel, url, size, diskPath, diskFile, start, end, lock); |
| 223 | writer.on("error", () => { | 207 | } |
| 224 | diskCache.delete(diskPath); | 208 | |
| 225 | diskFile.written.value = Infinity; | 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 | interface DiskFile { | 319 | interface DiskFile { |
| ... | @@ -236,7 +324,10 @@ interface DiskFile { | ... | @@ -236,7 +324,10 @@ interface DiskFile { |
| 236 | 324 | ||
| 237 | function httpGet(url: string, options: https.RequestOptions) { | 325 | function httpGet(url: string, options: https.RequestOptions) { |
| 238 | return new Promise<http.IncomingMessage>((resolve, reject) => { | 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 | request.on("response", resolve); | 331 | request.on("response", resolve); |
| 241 | request.on("error", reject); | 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,9 +3,9 @@ const db = getDb("cache.sqlite"); |
| 3 | db.table( | 3 | db.table( |
| 4 | "permissions", | 4 | "permissions", |
| 5 | /* SQL */ ` | 5 | /* SQL */ ` |
| 6 | CREATE TABLE IF NOT EXISTS permissions ( | 6 | create table if not exists permissions ( |
| 7 | prefix TEXT PRIMARY KEY, | 7 | prefix text primary key, |
| 8 | allow INTEGER NOT NULL | 8 | allow integer not null |
| 9 | ); | 9 | ); |
| 10 | `, | 10 | `, |
| 11 | ); | 11 | ); |
| ... | @@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare< | ... | @@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare< |
| 36 | [prefix: string], | 36 | [prefix: string], |
| 37 | Pick<FilePermissions, "allow"> | 37 | Pick<FilePermissions, "allow"> |
| 38 | >(/* SQL */ ` | 38 | >(/* SQL */ ` |
| 39 | SELECT allow | 39 | select allow |
| 40 | FROM permissions | 40 | from permissions |
| 41 | WHERE ? GLOB prefix || '*' | 41 | where ? glob prefix || '*' |
| 42 | ORDER BY LENGTH(prefix) DESC | 42 | order by length(prefix) desc |
| 43 | LIMIT 1; | 43 | limit 1; |
| 44 | `); | 44 | `); |
| 45 | const getExactQuery = db.prepare< | 45 | const getExactQuery = db.prepare< |
| 46 | [file: string], | 46 | [file: string], |
| 47 | Pick<FilePermissions, "allow"> | 47 | Pick<FilePermissions, "allow"> |
| 48 | >(/* SQL */ ` | 48 | >(/* SQL */ ` |
| 49 | SELECT allow FROM permissions WHERE ? == prefix | 49 | select allow from permissions where ? == prefix |
| 50 | `); | 50 | `); |
| 51 | 51 | ||
| 52 | const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ ` | 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 | const deleteQuery = db.prepare<[file: string]>(/* SQL */ ` | 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 | import { getDb } from "#sitegen/sqlite"; | 59 | import { getDb } from "#sitegen/sqlite"; |
src/file-viewer/models/MediaFile.ts+2-3| ... | @@ -113,12 +113,11 @@ export class MediaFile { | ... | @@ -113,12 +113,11 @@ export class MediaFile { |
| 113 | return ext; | 113 | return ext; |
| 114 | } | 114 | } |
| 115 | getChildren() { | 115 | getChildren() { |
| 116 | return MediaFile.getChildren(this.id) | 116 | return MediaFile.getChildren(this.id); |
| 117 | .filter((file) => !file.basename.startsWith(".")); | ||
| 118 | } | 117 | } |
| 119 | getPublicChildren() { | 118 | getPublicChildren() { |
| 120 | const children = MediaFile.getChildren(this.id).filter((x) => | 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 | if (FilePermissions.getByPrefix(this.path) == 0) { | 122 | if (FilePermissions.getByPrefix(this.path) == 0) { |
| 124 | return children.filter(({ path }) => FilePermissions.getExact(path) == 0); | 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,7 +70,7 @@ export const extsImageLike = new Set([ |
| 70 | /** These files populate `duration` using `ffprobe` */ | 70 | /** These files populate `duration` using `ffprobe` */ |
| 71 | export const extsDuration = new Set([...extsAudio, ...extsVideo]); | 71 | export const extsDuration = new Set([...extsAudio, ...extsVideo]); |
| 72 | /** These files populate `dimensions` using `ffprobe` */ | 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 | /** These files read file contents into `contents`, as-is */ | 75 | /** These files read file contents into `contents`, as-is */ |
| 76 | export const extsReadContents = new Set([".txt", ".chat", ".dirsort"]); | 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,7 +139,7 @@ function ensureCanvasReady(id: string): Promise<CanvasFn> { |
| 139 | promise = new Promise<CanvasFn>((r) => resolve = r); | 139 | promise = new Promise<CanvasFn>((r) => resolve = r); |
| 140 | fetchCanvas.set(id, promise); | 140 | fetchCanvas.set(id, promise); |
| 141 | const script = document.createElement("script"); | 141 | const script = document.createElement("script"); |
| 142 | script.src = `/js/canvas/${id}.js`; | 142 | script.src = `/js/file-viewer/scripts/canvas_${id}.js`; |
| 143 | script.async = true; | 143 | script.async = true; |
| 144 | script.onload = () => { | 144 | script.onload = () => { |
| 145 | func = (globalThis as any)["canvas_" + id]; | 145 | func = (globalThis as any)["canvas_" + id]; |
| ... | @@ -216,6 +216,11 @@ class Panel { | ... | @@ -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 | const ul = panel.querySelector("ul"); | 224 | const ul = panel.querySelector("ul"); |
| 220 | if (!ul) { | 225 | if (!ul) { |
| 221 | this.width = 0; | 226 | this.width = 0; |
src/file-viewer/views/clofi.tsx+57-65| ... | @@ -70,8 +70,6 @@ export default function MediaList({ | ... | @@ -70,8 +70,6 @@ export default function MediaList({ |
| 70 | file: MediaFile; | 70 | file: MediaFile; |
| 71 | hasCotyledonCookie: boolean; | 71 | hasCotyledonCookie: boolean; |
| 72 | }) { | 72 | }) { |
| 73 | addScript("./clofi.client.ts"); | ||
| 74 | |||
| 75 | const dirs: MediaFile[] = []; | 73 | const dirs: MediaFile[] = []; |
| 76 | let dir: MediaFile | null = file; | 74 | let dir: MediaFile | null = file; |
| 77 | assert(dir); | 75 | assert(dir); |
| ... | @@ -115,43 +113,43 @@ export function MediaPanel({ | ... | @@ -115,43 +113,43 @@ export function MediaPanel({ |
| 115 | activeFilename: string | null; | 113 | activeFilename: string | null; |
| 116 | hasCotyledonCookie: boolean; | 114 | hasCotyledonCookie: boolean; |
| 117 | }) { | 115 | }) { |
| 116 | addScript("./clofi.client.ts"); | ||
| 117 | addScript("../../tags/clover-video.client.ts"); | ||
| 118 | const showsActions = file.path !== "/2024/for everyone"; | 118 | const showsActions = file.path !== "/2024/for everyone"; |
| 119 | const label = file.path === "/" | 119 | const label = file.path === "/" |
| 120 | ? ( | 120 | ? ( |
| 121 | "clo's files" | 121 | "clo's files" |
| 122 | ) | 122 | ) |
| 123 | : ( | 123 | : ( |
| 124 | <> | 124 | file.kind === MediaFileKind.directory |
| 125 | {file.kind === MediaFileKind.directory | 125 | ? ( |
| 126 | ? ( | 126 | <span> |
| 127 | <span> | 127 | {file.basename} |
| 128 | {file.basename} | 128 | <span class="bold-slash">/</span> |
| 129 | <span class="bold-slash">/</span> | 129 | </span> |
| 130 | </span> | 130 | ) |
| 131 | ) | 131 | : ( |
| 132 | : ( | 132 | <> |
| 133 | <> | 133 | {file.basename} |
| 134 | {file.basename} | 134 | <div style="flex:1" /> |
| 135 | <div style="flex:1" /> | 135 | {showsActions && ( |
| 136 | {showsActions && ( | 136 | <div class="actions"> |
| 137 | <div class="actions"> | 137 | <a |
| 138 | <a | 138 | class="custom full-screen" |
| 139 | class="custom full-screen" | 139 | href={`/file${escapeUri(file.path)}?view=embed`} |
| 140 | href={`/file${escapeUri(file.path)}?view=embed`} | 140 | aria-label="full screen" |
| 141 | aria-label="full screen" | 141 | > |
| 142 | > | 142 | </a> |
| 143 | </a> | 143 | <a |
| 144 | <a | 144 | class="custom download" |
| 145 | class="custom download" | 145 | href={`/file${escapeUri(file.path)}?dl`} |
| 146 | href={`/file${escapeUri(file.path)}?dl`} | 146 | aria-label="download" |
| 147 | aria-label="download" | 147 | > |
| 148 | > | 148 | </a> |
| 149 | </a> | 149 | </div> |
| 150 | </div> | 150 | )} |
| 151 | )} | 151 | </> |
| 152 | </> | 152 | ) |
| 153 | )} | ||
| 154 | </> | ||
| 155 | ); | 153 | ); |
| 156 | let View = specialCaseViewers[file.path] ?? | 154 | let View = specialCaseViewers[file.path] ?? |
| 157 | (extensionToViewer[file.extension.toLowerCase()] as any) ?? | 155 | (extensionToViewer[file.extension.toLowerCase()] as any) ?? |
| ... | @@ -166,18 +164,16 @@ export function MediaPanel({ | ... | @@ -166,18 +164,16 @@ export function MediaPanel({ |
| 166 | /> | 164 | /> |
| 167 | ); | 165 | ); |
| 168 | const canvases: Record<string, string> = { | 166 | const canvases: Record<string, string> = { |
| 169 | "/2017": "2017", | 167 | "/2017": () => addScript("../scripts/canvas_2017.client.ts"), |
| 170 | "/2018": "2018", | 168 | "/2018": () => addScript("../scripts/canvas_2018.client.ts"), |
| 171 | "/2019": "2019", | 169 | "/2019": () => addScript("../scripts/canvas_2019.client.ts"), |
| 172 | "/2020": "2020", | 170 | "/2020": () => addScript("../scripts/canvas_2020.client.ts"), |
| 173 | "/2021": "2021", | 171 | "/2021": () => addScript("../scripts/canvas_2021.client.ts"), |
| 174 | "/2022": "2022", | 172 | "/2022": () => addScript("../scripts/canvas_2022.client.ts"), |
| 175 | "/2023": "2023", | 173 | "/2023": () => addScript("../scripts/canvas_2023.client.ts"), |
| 176 | "/2024": "2024", | 174 | "/2024": () => addScript("../scripts/canvas_2024.client.ts"), |
| 177 | }; | 175 | }; |
| 178 | if (canvases[file.path]) { | 176 | canvases[file.path]?.(); |
| 179 | addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`); | ||
| 180 | } | ||
| 181 | return ( | 177 | return ( |
| 182 | <div | 178 | <div |
| 183 | class={(isLast ? "panel last" : "panel") + | 179 | class={(isLast ? "panel last" : "panel") + |
| ... | @@ -186,7 +182,7 @@ export function MediaPanel({ | ... | @@ -186,7 +182,7 @@ export function MediaPanel({ |
| 186 | {canvases[file.path] && ( | 182 | {canvases[file.path] && ( |
| 187 | <canvas | 183 | <canvas |
| 188 | class="fullscreen-canvas" | 184 | class="fullscreen-canvas" |
| 189 | data-canvas={canvases[file.path]} | 185 | data-canvas={file.path.slice(1).split("/")![0]} |
| 190 | > | 186 | > |
| 191 | </canvas> | 187 | </canvas> |
| 192 | )} | 188 | )} |
| ... | @@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) { | ... | @@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) { |
| 680 | function VideoView({ file }: { file: MediaFile }) { | 676 | function VideoView({ file }: { file: MediaFile }) { |
| 681 | const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 }; | 677 | const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 }; |
| 682 | return ( | 678 | return ( |
| 683 | <> | 679 | <CloverVideo |
| 684 | <video | 680 | minimal |
| 685 | controls | 681 | file={file} |
| 686 | preload | 682 | style={`background:transparent;aspect-ratio:${ |
| 687 | style={`background:transparent;aspect-ratio:${ | 683 | simplifyFraction( |
| 688 | simplifyFraction( | 684 | dimensions.width, |
| 689 | dimensions.width, | 685 | dimensions.height, |
| 690 | dimensions.height, | 686 | ) |
| 691 | ) | 687 | }`} |
| 692 | }`} | 688 | /> |
| 693 | > | ||
| 694 | <source | ||
| 695 | src={"/file" + file.path} | ||
| 696 | type={mime.contentTypeFor(file.path)} | ||
| 697 | /> | ||
| 698 | </video> | ||
| 699 | </> | ||
| 700 | ); | 689 | ); |
| 701 | } | 690 | } |
| 702 | 691 | ||
| ... | @@ -748,10 +737,11 @@ function AudioView({ | ... | @@ -748,10 +737,11 @@ function AudioView({ |
| 748 | 737 | ||
| 749 | function ImageView({ file }: { file: MediaFile }) { | 738 | function ImageView({ file }: { file: MediaFile }) { |
| 750 | return ( | 739 | return ( |
| 751 | <img | 740 | <Image |
| 752 | src={"/file" + file.path} | 741 | src={file} |
| 753 | alt={file.basename} | 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,4 +925,6 @@ import { |
| 935 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; | 925 | import { ForEveryone } from "@/file-viewer/cotyledon.tsx"; |
| 936 | import * as mime from "#sitegen/mime"; | 926 | import * as mime from "#sitegen/mime"; |
| 937 | import * as sort from "../sort.ts"; | 927 | import * as sort from "../sort.ts"; |
| 928 | import CloverVideo from "@/tags/clover-video.marko"; | ||
| 929 | import Image from "@/tags/image.tsx"; | ||
| 938 | import * as render from "#engine/render"; | 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,7 +22,10 @@ import "./in-the-summer.css"; |
| 22 | </p> | 22 | </p> |
| 23 | </div> | 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 | <@header><strong>music video</>: in the summer</> | 29 | <@header><strong>music video</>: in the summer</> |
| 27 | </> | 30 | </> |
| 28 | 31 | ||
| ... | @@ -34,7 +37,7 @@ import "./in-the-summer.css"; | ... | @@ -34,7 +37,7 @@ import "./in-the-summer.css"; |
| 34 | <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li> | 37 | <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li> |
| 35 | </ul> | 38 | </ul> |
| 36 | <h2>mentions on the q&a</h2> | 39 | <h2>mentions on the q&a</h2> |
| 37 | // <QuestionsFor artifact='in-the-summer'/> | 40 | <questions-embed="in-the-summer" /> |
| 38 | </div> | 41 | </div> |
| 39 | </main> | 42 | </main> |
| 40 | 43 |
src/pages/index.marko+2-5| ... | @@ -21,10 +21,7 @@ export const meta = { | ... | @@ -21,10 +21,7 @@ export const meta = { |
| 21 | <p><a href="/file">file browser</a></p> | 21 | <p><a href="/file">file browser</a></p> |
| 22 | <p><a href="/subscribe">mailing list</a></p> | 22 | <p><a href="/subscribe">mailing list</a></p> |
| 23 | <p><a href="/donate">donate</a></p> | 23 | <p><a href="/donate">donate</a></p> |
| 24 | <p><a href="/rss.xml">rss feed</a></p> | 24 | <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p> |
| 25 | <p style="position: relative; z-index: 10"><a href="/discord">discord</a></p> | 25 | <h1>paper clover</h1> |
| 26 | <h1> | ||
| 27 | paper clover | ||
| 28 | </h1> | ||
| 29 | </div> | 26 | </div> |
| 30 | </main> | 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,4 +1,4 @@ |
| 1 | #subscribe[href] { | 1 | #subscribe { |
| 2 | display: flex; | 2 | display: flex; |
| 3 | justify-content: center; | 3 | justify-content: center; |
| 4 | align-items: center; | 4 | align-items: center; |
| ... | @@ -6,9 +6,6 @@ | ... | @@ -6,9 +6,6 @@ |
| 6 | font-weight: bold; | 6 | font-weight: bold; |
| 7 | text-align: center; | 7 | text-align: center; |
| 8 | } | 8 | } |
| 9 | #subscribe:not([href]) { | ||
| 10 | opacity: 0.5; | ||
| 11 | } | ||
| 12 | .flex { | 9 | .flex { |
| 13 | display: flex; | 10 | display: flex; |
| 14 | justify-content: center; | 11 | justify-content: center; |
src/pages/subscribe.marko+2-9| ... | @@ -4,8 +4,6 @@ export const meta = { | ... | @@ -4,8 +4,6 @@ export const meta = { |
| 4 | description: "subscribe to the mailing list", | 4 | description: "subscribe to the mailing list", |
| 5 | }; | 5 | }; |
| 6 | 6 | ||
| 7 | client import "./subscribe.client.ts"; | ||
| 8 | |||
| 9 | <main> | 7 | <main> |
| 10 | <h1>mailing list</h1> | 8 | <h1>mailing list</h1> |
| 11 | <canvas style="display: none;"></canvas> | 9 | <canvas style="display: none;"></canvas> |
| ... | @@ -17,19 +15,14 @@ client import "./subscribe.client.ts"; | ... | @@ -17,19 +15,14 @@ client import "./subscribe.client.ts"; |
| 17 | the list is currently managed manually. to get added, please email anything | 15 | the list is currently managed manually. to get added, please email anything |
| 18 | with the word "subscribe" in the subject or body to the following address: | 16 | with the word "subscribe" in the subject or body to the following address: |
| 19 | </p> | 17 | </p> |
| 20 | <code class="flex"> | 18 | <code id="subscribe" class="flex"> |
| 21 | <a id="subscribe"> | 19 | subscribe at paper clover dot net |
| 22 | [please enable javascript, this is an anti-spam/scraping measure] | ||
| 23 | </a> | ||
| 24 | </code> | 20 | </code> |
| 25 | <p> | 21 | <p> |
| 26 | to unsubscribe, send an email asking to unsubscribe. for those who are close | 22 | to unsubscribe, send an email asking to unsubscribe. for those who are close |
| 27 | friends with me, add your name and i'll add you to the 'friends' list, where i | 23 | friends with me, add your name and i'll add you to the 'friends' list, where i |
| 28 | send life updates in addition to new content (1-2 times per month). | 24 | send life updates in addition to new content (1-2 times per month). |
| 29 | </p> | 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 | <br> | 26 | <br> |
| 34 | <br> | 27 | <br> |
| 35 | <br> | 28 | <br> |
src/pages/waterfalls.marko+3| ... | @@ -42,6 +42,9 @@ export const theme = { | ... | @@ -42,6 +42,9 @@ export const theme = { |
| 42 | <br /> | 42 | <br /> |
| 43 | <a href="/q+a">q&a page</a> | 43 | <a href="/q+a">q&a page</a> |
| 44 | </p> | 44 | </p> |
| 45 | |||
| 46 | <h2>mentions on the q&a</h2> | ||
| 47 | <questions-embed="waterfalls" /> | ||
| 45 | </div> | 48 | </div> |
| 46 | </main> | 49 | </main> |
| 47 | </main> | 50 | </main> |
src/q+a/artifacts.ts+26-77| ... | @@ -3,87 +3,36 @@ | ... | @@ -3,87 +3,36 @@ |
| 3 | // one canonical URL, which the questions page can refer to with `@id`. | 3 | // one canonical URL, which the questions page can refer to with `@id`. |
| 4 | type Artifact = [title: string, url: string, type: ArtifactType]; | 4 | type Artifact = [title: string, url: string, type: ArtifactType]; |
| 5 | type ArtifactType = "music" | "game" | "project" | "video"; | 5 | type ArtifactType = "music" | "game" | "project" | "video"; |
| 6 | |||
| 7 | // deno-fmt-ignore | ||
| 6 | export const artifactMap: Record<string, Artifact> = { | 8 | export const artifactMap: Record<string, Artifact> = { |
| 7 | // 2025 | 9 | // 2025 |
| 8 | "in-the-summer": ["in the summer", "", "music"], | 10 | "in-the-summer": ["in the summer", "/in-the-summer", "music"], |
| 9 | waterfalls: ["waterfalls", "/waterfalls", "music"], | 11 | "waterfalls": ["waterfalls", "/waterfalls", "music"], |
| 10 | lolzip: ["lol.zip", "", "project"], | 12 | |
| 11 | "g-is-missing": ["g is missing", "", "music"], | 13 | // cotyledon |
| 12 | "im-18-now": ["i'm 18 now", "", "music"], | 14 | "aaaaaaaaa": ["aaaaaaaaa", "", "music"], |
| 13 | "programming-comparison": [ | 15 | "dice-roll": [ "thursday dice roll", "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4", "video"], |
| 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"], | ||
| 57 | "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"], | 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 | "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"], | 17 | "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"], |
| 65 | volar: [ | 18 | "elemental4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"], |
| 66 | "volar visual cover", | 19 | "g-is-missing": ["g is missing", "https://paperclover.net/file/2023/g%20is%20missing/g%20is%20missing.mp4", "music"], |
| 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 | ||
| 87 | "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"], | 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 | "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"], | 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,7 +61,7 @@ customRules.insertBefore("paragraph", { |
| 61 | customRules.insertBefore("url", { | 61 | customRules.insertBefore("url", { |
| 62 | name: "autolink_mention", | 62 | name: "autolink_mention", |
| 63 | match: inlineRegex( | 63 | match: inlineRegex( |
| 64 | /^https?:\/\/(?:paper(?:dave|clover)\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/, | 64 | /^https?:\/\/(?:paperclover\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/, |
| 65 | ), | 65 | ), |
| 66 | parse(capture) { | 66 | parse(capture) { |
| 67 | const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/); | 67 | const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/); |
| ... | @@ -72,6 +72,17 @@ customRules.insertBefore("url", { | ... | @@ -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 | return { | 86 | return { |
| 76 | type: "link", | 87 | type: "link", |
| 77 | content: [ | 88 | content: [ |
src/q+a/models/Question.ts+8-1| ... | @@ -2,7 +2,7 @@ const db = getDb("questions.sqlite"); | ... | @@ -2,7 +2,7 @@ const db = getDb("questions.sqlite"); |
| 2 | db.table( | 2 | db.table( |
| 3 | "questions", | 3 | "questions", |
| 4 | ` | 4 | ` |
| 5 | create table questions ( | 5 | create table if not exists questions ( |
| 6 | qmid integer not null, | 6 | qmid integer not null, |
| 7 | type integer not null, | 7 | type integer not null, |
| 8 | text text not null, | 8 | text text not null, |
| ... | @@ -68,6 +68,9 @@ export class Question { | ... | @@ -68,6 +68,9 @@ export class Question { |
| 68 | static updateByQmid(qmid: number, text: string, type: QuestionType) { | 68 | static updateByQmid(qmid: number, text: string, type: QuestionType) { |
| 69 | return updateByQmidQuery.run({ text, type, qmid }); | 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 | // Create a new question with a unique QMID | 76 | // Create a new question with a unique QMID |
| ... | @@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare< | ... | @@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare< |
| 114 | UPDATE questions SET text = $text, type = $type WHERE qmid = $qmid | 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 | import { formatQuestionId } from "../format.ts"; | 124 | import { formatQuestionId } from "../format.ts"; |
| 118 | import { getDb } from "#sitegen/sqlite"; | 125 | import { getDb } from "#sitegen/sqlite"; |
src/q+a/tags/question.marko+3-1| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import "../questions.css"; | ||
| 2 | |||
| 1 | // Renders a `Question` entry including its markdown body. | 3 | // Renders a `Question` entry including its markdown body. |
| 2 | export interface Input { | 4 | export interface Input { |
| 3 | question: Question; | 5 | question: Question; |
| ... | @@ -12,7 +14,7 @@ export const transitionDate = 1735639200000; | ... | @@ -12,7 +14,7 @@ export const transitionDate = 1735639200000; |
| 12 | <const/{ id, date } = question/> | 14 | <const/{ id, date } = question/> |
| 13 | 15 | ||
| 14 | <${"e-"} | 16 | <${"e-"} |
| 15 | f=(date > transitionDate ? true : undefined) | 17 | f=(date > transitionDate ? true : null) |
| 16 | id=admin ? `q${id}` : undefined | 18 | id=admin ? `q${id}` : undefined |
| 17 | > | 19 | > |
| 18 | <if=admin> | 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,24 +1,27 @@ |
| 1 | // The "source of truth" server is the canonical storage for | 1 | /** @module |
| 2 | // paper clover's files. This is technically needed because | 2 | * The "source of truth" server is the canonical storage for |
| 3 | // the VPS she uses can only store about 20gb of content, where | 3 | * paper clover's files. This is technically needed because |
| 4 | // the contents of /file is about 48gb as of writing; only a | 4 | * the VPS she uses can only store about 20gb of content, where |
| 5 | // limited amount of data can be cached. | 5 | * the contents of /file is about 48gb as of writing; only a |
| 6 | // | 6 | * limited amount of data can be cached. |
| 7 | // What's great about this system is it also allows scaling the | 7 | * |
| 8 | // website up into multiple servers, if that is ever desired. | 8 | * What's great about this system is it also allows scaling the |
| 9 | // When that happens, mutations to "q+a" will be moved here, | 9 | * website up into multiple servers, if that is ever desired. |
| 10 | // and the SQLite synchronization mechanism will apply to both | 10 | * When that happens, mutations to "q+a" will be moved here, |
| 11 | // of those databases. | 11 | * and the SQLite synchronization mechanism will apply to both |
| 12 | // | 12 | * of those databases. |
| 13 | // An alternative to this would have been to use a SMB client, | 13 | * |
| 14 | // but I've read that the systems used for caching don't work | 14 | * An alternative to this would have been to use a SMB client, |
| 15 | // like the HTTP Cache-Control header, where you can say a file | 15 | * but I've read that the systems used for caching don't work |
| 16 | // is valid for up to a certain amount of time. If we seriously | 16 | * like the HTTP Cache-Control header, where you can say a file |
| 17 | // need cache busts (paper clover does not), the proper way | 17 | * is valid for up to a certain amount of time. If we seriously |
| 18 | // would be to push a message to all VPS nodes instead of | 18 | * need cache busts (paper clover does not), the proper way |
| 19 | // checking upstream if a file changed every time. | 19 | * would be to push a message to all VPS nodes instead of |
| 20 | * checking upstream if a file changed every time. | ||
| 21 | */ | ||
| 20 | const app = new Hono(); | 22 | const app = new Hono(); |
| 21 | export default app; | 23 | export default app; |
| 24 | export const port = 4000; | ||
| 22 | 25 | ||
| 23 | const token = UNWRAP(process.env.CLOVER_SOT_KEY); | 26 | const token = UNWRAP(process.env.CLOVER_SOT_KEY); |
| 24 | 27 | ||
| ... | @@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) { | ... | @@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) { |
| 32 | // Re-use file descriptors if the same file is being read twice. | 35 | // Re-use file descriptors if the same file is being read twice. |
| 33 | const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>(); | 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 | app.get("/file/*", async (c) => { | 42 | app.get("/file/*", async (c) => { |
| 36 | const fullQuery = c.req.path.slice("/file".length); | 43 | const fullQuery = c.req.path.slice("/file".length); |
| 37 | const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/"); | 44 | const [filePath, derivedAsset, ...invalid] = fullQuery.split(":/"); |
| 38 | ASSERT(filePath != null); | 45 | ASSERT(filePath != null); |
| 39 | if (invalid.length > 0) return c.notFound(); | 46 | if (invalid.length > 0) return c.notFound(); |
| 40 | if (filePath.length <= 1) return c.notFound(); | 47 | if (filePath.length <= 1) return c.notFound(); |
| 41 | const permissions = FilePermissions.getByPrefix(filePath); | 48 | const permissions = FilePermissions.getByPrefix(filePath); |
| 42 | if (permissions !== 0) { | 49 | if (permissions !== 0 && c.req.header("Authorization") !== token) { |
| 43 | if (c.req.header("Authorization") !== token) { | 50 | return c.json({ error: "invalid authorization header" }, 401); |
| 44 | return c.json({ error: "invalid authorization header" }, 401); | ||
| 45 | } | ||
| 46 | } | 51 | } |
| 47 | const file = MediaFile.getByPath(filePath); | 52 | const file = MediaFile.getByPath(filePath); |
| 48 | if (!file || file.kind === MediaFileKind.directory) { | 53 | if (!file || file.kind === MediaFileKind.directory) return c.notFound(); |
| 49 | return c.notFound(); | ||
| 50 | } | ||
| 51 | const fullPath = derivedAsset | 54 | const fullPath = derivedAsset |
| 52 | ? path.join(derivedFileRoot, file.hash, derivedAsset) | 55 | ? path.join(derivedFileRoot, file.hash, derivedAsset) |
| 53 | : path.join(rawFileRoot, file.path); | 56 | : path.join(rawFileRoot, file.path); |
| ... | @@ -66,11 +69,10 @@ app.get("/file/*", async (c) => { | ... | @@ -66,11 +69,10 @@ app.get("/file/*", async (c) => { |
| 66 | } | 69 | } |
| 67 | handle.refs += 1; | 70 | handle.refs += 1; |
| 68 | } catch (err: any) { | 71 | } catch (err: any) { |
| 69 | if (err.code === "ENOENT") { | 72 | if (err.code === "ENOENT") return c.notFound(); |
| 70 | return c.notFound(); | ||
| 71 | } | ||
| 72 | throw err; | 73 | throw err; |
| 73 | } | 74 | } |
| 75 | c.header("Content-Length", (await fstat(handle.fd)).size); | ||
| 74 | const nodeStream = fs.createReadStream(fullPath, { | 76 | const nodeStream = fs.createReadStream(fullPath, { |
| 75 | fd: handle.fd, | 77 | fd: handle.fd, |
| 76 | fs: { | 78 | fs: { |
| ... | @@ -86,9 +88,6 @@ app.get("/file/*", async (c) => { | ... | @@ -86,9 +88,6 @@ app.get("/file/*", async (c) => { |
| 86 | }, | 88 | }, |
| 87 | }); | 89 | }); |
| 88 | c.header("Content-Type", mime.contentTypeFor(fullPath)); | 90 | c.header("Content-Type", mime.contentTypeFor(fullPath)); |
| 89 | if (!derivedAsset) { | ||
| 90 | c.header("Content-Length", file.size.toString()); | ||
| 91 | } | ||
| 92 | return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream); | 91 | return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream); |
| 93 | }); | 92 | }); |
| 94 | 93 | ||
| ... | @@ -102,6 +101,7 @@ app.post("/reload", async (c) => { | ... | @@ -102,6 +101,7 @@ app.post("/reload", async (c) => { |
| 102 | 101 | ||
| 103 | const openFile = util.promisify(fsCallbacks.open); | 102 | const openFile = util.promisify(fsCallbacks.open); |
| 104 | const closeFile = util.promisify(fsCallbacks.close); | 103 | const closeFile = util.promisify(fsCallbacks.close); |
| 104 | const fstat = util.promisify(fsCallbacks.fstat); | ||
| 105 | 105 | ||
| 106 | import { Hono } from "#hono"; | 106 | import { Hono } from "#hono"; |
| 107 | import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; | 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,6 +2,7 @@ |
| 2 | 2 | ||
| 3 | // Polyfill for old browsers. | 3 | // Polyfill for old browsers. |
| 4 | type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none"; | 4 | type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none"; |
| 5 | let mode: Promise<PlaybackMode> | null = null; | ||
| 5 | let hls: Promise<typeof import("hls.js")> | null = null; | 6 | let hls: Promise<typeof import("hls.js")> | null = null; |
| 6 | 7 | ||
| 7 | Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => | 8 | Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => |
| ... | @@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => | ... | @@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) => |
| 12 | (reason) => ({ status: "rejected" as const, reason }), | 13 | (reason) => ({ status: "rejected" as const, reason }), |
| 13 | ))); | 14 | ))); |
| 14 | 15 | ||
| 15 | decidePlaybackMode().then((mode) => | 16 | globalThis.hydrateVideo = function hydrateVideo(container) { |
| 16 | Array.from( | 17 | mode ??= decidePlaybackMode(); |
| 17 | document.querySelectorAll<HTMLElement>(".clover-video"), | 18 | mode.then((mode) => hydrateVideoInner(container, mode)); |
| 18 | (container) => hydrateVideo(container, mode), | 19 | }; |
| 19 | ) | 20 | |
| 21 | Array.from( | ||
| 22 | document.querySelectorAll<HTMLElement>(".clover-video"), | ||
| 23 | (container) => hydrateVideo(container), | ||
| 20 | ); | 24 | ); |
| 21 | 25 | ||
| 22 | function decidePlaybackMode(): Promise<PlaybackMode> { | 26 | function decidePlaybackMode(): Promise<PlaybackMode> { |
| ... | @@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> { | ... | @@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> { |
| 39 | return "hls-native"; | 43 | return "hls-native"; |
| 40 | } | 44 | } |
| 41 | 45 | ||
| 42 | return (hls ??= import("hls.js")) | 46 | return (hls ??= import("/js/scripts/vendor/hls.js")) |
| 43 | .then((hls): PlaybackMode => { | 47 | .then((hls): PlaybackMode => { |
| 44 | // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8) | 48 | // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8) |
| 45 | // TODO: ES modules have a minimum of Chrome 63 or Firefox 60 | 49 | // TODO: ES modules have a minimum of Chrome 63 or Firefox 60 |
| ... | @@ -55,8 +59,16 @@ function decidePlaybackMode(): Promise<PlaybackMode> { | ... | @@ -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 | const video = container.querySelector("video")!; | 70 | const video = container.querySelector("video")!; |
| 71 | if (video.hasAttribute("controls")) return; | ||
| 60 | let src = video.getAttribute("data-src"); | 72 | let src = video.getAttribute("data-src"); |
| 61 | if (!src) return; | 73 | if (!src) return; |
| 62 | src = `/file${src}`; | 74 | src = `/file${src}`; |
| ... | @@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) { | ... | @@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) { |
| 65 | const hlsFile = `${src}:/master.m3u8`; | 77 | const hlsFile = `${src}:/master.m3u8`; |
| 66 | 78 | ||
| 67 | if (mode === "av1") { | 79 | if (mode === "av1") { |
| 68 | console.info(`Playback: av1, opus, mpeg-dash`); | 80 | import("/js/scripts/vendor/dash.js").then((dashjs) => { |
| 69 | import("dashjs").then((dashjs) => { | ||
| 70 | const player = dashjs.MediaPlayer().create(); | 81 | const player = dashjs.MediaPlayer().create(); |
| 71 | // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay | 82 | // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay |
| 72 | let mainSource: HTMLSourceElement | null = null; | 83 | let mainSource: HTMLSourceElement | null = null; |
src/tags/clover-video.css+5-1| ... | @@ -3,6 +3,9 @@ | ... | @@ -3,6 +3,9 @@ |
| 3 | display: flex; | 3 | display: flex; |
| 4 | flex-direction: column; | 4 | flex-direction: column; |
| 5 | } | 5 | } |
| 6 | .clover-video.minimal { | ||
| 7 | border: none; | ||
| 8 | } | ||
| 6 | .clover-video video { | 9 | .clover-video video { |
| 7 | background-color: transparent; | 10 | background-color: transparent; |
| 8 | } | 11 | } |
| ... | @@ -11,6 +14,7 @@ | ... | @@ -11,6 +14,7 @@ |
| 11 | overflow: hidden; | 14 | overflow: hidden; |
| 12 | display: flex; | 15 | display: flex; |
| 13 | flex-direction: column; | 16 | flex-direction: column; |
| 17 | background-color: black; | ||
| 14 | } | 18 | } |
| 15 | .clover-video video { | 19 | .clover-video video { |
| 16 | width: 100%; | 20 | width: 100%; |
| ... | @@ -33,7 +37,7 @@ | ... | @@ -33,7 +37,7 @@ |
| 33 | } | 37 | } |
| 34 | .clover-video figcaption { | 38 | .clover-video figcaption { |
| 35 | background-color: var(--fg); | 39 | background-color: var(--fg); |
| 36 | color: white; | 40 | color: var(--bg); |
| 37 | width: 100%; | 41 | width: 100%; |
| 38 | margin-top: -1px; | 42 | margin-top: -1px; |
| 39 | padding-bottom: 2px; | 43 | padding-bottom: 2px; |
src/tags/clover-video.marko+10-7| ... | @@ -4,9 +4,10 @@ export interface Input { | ... | @@ -4,9 +4,10 @@ export interface Input { |
| 4 | file: MediaFile | string; | 4 | file: MediaFile | string; |
| 5 | poster?: MediaFile | string; | 5 | poster?: MediaFile | string; |
| 6 | header?: Marko.AttrTag<{ content: Marko.Body }>; | 6 | header?: Marko.AttrTag<{ content: Marko.Body }>; |
| 7 | minimal?: boolean; | ||
| 7 | } | 8 | } |
| 8 | 9 | ||
| 9 | <const/{ header } = input /> | 10 | <const/{ header, minimal } = input /> |
| 10 | <const/file = typeof UNWRAP(input.file) === 'object' | 11 | <const/file = typeof UNWRAP(input.file) === 'object' |
| 11 | ? input.file | 12 | ? input.file |
| 12 | : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) /> | 13 | : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) /> |
| ... | @@ -18,12 +19,14 @@ export interface Input { | ... | @@ -18,12 +19,14 @@ export interface Input { |
| 18 | 19 | ||
| 19 | <const/{ width, height } = file.parseDimensions() /> | 20 | <const/{ width, height } = file.parseDimensions() /> |
| 20 | 21 | ||
| 21 | <figure.clover-video> | 22 | <figure.clover-video class={ minimal }> |
| 22 | <figcaption> | 23 | <if=!minimal> |
| 23 | <if=header><${header.content}/></><else> | 24 | <figcaption> |
| 24 | ${file.basename} | 25 | <if=header><${header.content}/></><else> |
| 25 | </else> | 26 | ${file.basename} |
| 26 | </figcaption> | 27 | </else> |
| 28 | </figcaption> | ||
| 29 | </> | ||
| 27 | <div.stack style=`aspect-ratio:${width}/${height}`> | 30 | <div.stack style=`aspect-ratio:${width}/${height}`> |
| 28 | <video | 31 | <video |
| 29 | data-src=file.path | 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"; | ||