authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-04-02 23:01:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-04-06 01:17:01-07:00
log22ebf23fcec9685339e4fa903ed984d374f06948
tree47315d7b1cf8918f9f15d381c82b0cebd3624077
parentec1448f737d8aed1c7f7fdd8a02fd6232b19eff8
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: marko formatting

sad to see prettier but its whatever

44 files changed, 847 insertions(+), 689 deletions(-)

dprint.jsonc+7-7
......@@ -1,14 +1,13 @@
11{
2 "typescript": {
3 },
4 "json": {
5 },
62 "markdown": {
73 "textWrap": "always",
84 },
9 "malva": {
10 },
11 "markup": {
5 "exec": {
6 "cwd": "${configDir}",
7 "commands": [{
8 "command": "node_modules/.bin/prettier --plugin prettier-plugin-marko {{file_path}}",
9 "exts": ["marko"],
10 }],
1211 },
1312 "excludes": [
1413 "**/node_modules",
......@@ -20,5 +19,6 @@
2019 "https://plugins.dprint.dev/markdown-0.21.1.wasm",
2120 "https://plugins.dprint.dev/g-plane/malva-v0.15.2.wasm",
2221 "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm",
22 "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463",
2323 ],
2424}
framework/lib/add-script.marko+1-1
......@@ -1 +1 @@
1<const/_=$global.cloverScripts.add(input.value) />
1<const/_=$global.cloverScripts.add(input.value)>
framework/sitegen-wrap.marko+2-2
......@@ -1,2 +1,2 @@
1<const/_=$global.cloverScripts=input.cloverScripts />
2<${input.page} ...input.attrs />
1<const/_=$global.cloverScripts = input.cloverScripts>
2<${input.page} ...input.attrs/>
package-lock.json+26
......@@ -19,6 +19,8 @@
1919 "marko": "5.38.33",
2020 "msgpackr": "1.11.5",
2121 "playwright": "1.58.2",
22 "prettier": "^3.8.1",
23 "prettier-plugin-marko": "^4.0.9",
2224 "source-map-support": "0.5.21",
2325 "typescript": "6.0.2",
2426 "unique-names-generator": "4.7.1",
......@@ -2178,6 +2180,30 @@
21782180 "node": "^10 || ^12 || >=14"
21792181 }
21802182 },
2183 "node_modules/prettier": {
2184 "version": "3.8.1",
2185 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
2186 "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
2187 "license": "MIT",
2188 "bin": {
2189 "prettier": "bin/prettier.cjs"
2190 },
2191 "engines": {
2192 "node": ">=14"
2193 },
2194 "funding": {
2195 "url": "https://github.com/prettier/prettier?sponsor=1"
2196 }
2197 },
2198 "node_modules/prettier-plugin-marko": {
2199 "version": "4.0.9",
2200 "resolved": "https://registry.npmjs.org/prettier-plugin-marko/-/prettier-plugin-marko-4.0.9.tgz",
2201 "integrity": "sha512-njqNk0oF6jolUMXNUOSlvRe6Dd50DN4NvQk3EY/6hUrU4tJw/HSwvhDF1uTYVs5KjOY5lH+x5C13C1Av9jajWA==",
2202 "license": "MIT",
2203 "dependencies": {
2204 "htmljs-parser": "^5.10.2"
2205 }
2206 },
21812207 "node_modules/raptor-async": {
21822208 "version": "1.1.3",
21832209 "resolved": "https://registry.npmjs.org/raptor-async/-/raptor-async-1.1.3.tgz",
package.json+2
......@@ -15,6 +15,8 @@
1515 "marko": "5.38.33",
1616 "msgpackr": "1.11.5",
1717 "playwright": "1.58.2",
18 "prettier": "^3.8.1",
19 "prettier-plugin-marko": "^4.0.9",
1820 "source-map-support": "0.5.21",
1921 "typescript": "6.0.2",
2022 "unique-names-generator": "4.7.1",
src/blog/tags/blog-layout.marko+52-53
......@@ -6,7 +6,7 @@ export const theme = {
66};
77export const components = {
88 heading: Heading,
9 codeBlock: CodeBlock
9 codeBlock: CodeBlock,
1010};
1111
1212export interface Module {
......@@ -35,68 +35,67 @@ export interface Context {
3535
3636export const markoOnly = true;
3737
38<const/module: Module = input.module />
39<const/{ date, translation } = module />
40<const/{ title, description, keywords, canonical } = module.meta />
41<const/slug = canonical.replace(/^\/blog\/|\.[a-zA-Z_-]+$/g, '') />
38<const/module: Module=input.module>
39<const/{ date, translation }=module>
40<const/{ title, description, keywords, canonical }=module.meta>
41<const/slug=canonical.replace(/^\/blog\/|\.[a-zA-Z_-]+$/g, "")>
4242
43<const/translations=
44 Path.resolve(import.meta.dirname, "../pages/" + slug).readDirSync()
43<const/translations=(
44 Path.resolve(import.meta.dirname, "../pages/" + slug)
45 .readDirSync()
4546 .filter((x) => x.ext === ".mdo" && x.baseWithoutExt !== "en")
46 .map((x) => x.baseWithoutExt) />
47<const/t = UNWRAP(localization.languages[translation?.lang ?? "en"]) />
47 .map((x) => x.baseWithoutExt)
48)>
49<const/t=UNWRAP(localization.languages[translation?.lang ?? "en"])>
4850
4951<const/_=(() => {
5052 $global.cloverOutline = input.outline;
51})() />
53})()>
5254
5355<main>
54<header>
55<a href="/">${t.return}</a>
56 <header>
57 <a href="/">${t.return}</a>
58 <h1 style={ "max-width": "30ch" }>${title}</h1>
59 <p class="description"><em>${description}</em></p>
5660
57<h1 style={ 'max-width': "30ch" }>${title}</>
58<p class="description"><em>${description}</em></p>
61 <div.meta>
62 <a class="meta-author" href="/">${t.writtenByClover}</a><span>•</span>
63 <if=translation>
64 <a.meta-translate href=translation.href>
65 ${t.translatedBy(translation)}
66 </a>
67 <span>•</span>
68 </if>
69 <span.meta-date>${date}</span>
70 </div>
71 <div.tag-list>
72 <if=translations.length>
73 <a
74 class=["custom tag lang lang-original", { active: !translation }]
75 href=`/blog/${slug}`
76 >
77 ${localization.languages.en.title}
78 </a>
79 <for|code| of=translations>
80 <a
81 class=["custom tag lang", { active: code === translation?.lang }]
82 href=`/blog/${slug}.${code}`
83 >
84 ${UNWRAP(localization.languages[code]).title}
85 </a>
86 </for>
87 <a.custom.tag.square href="/blog/community-translations">${"+"}</a>
88 <span.bar/>
89 </if>
90 <for|tag| of=keywords>
91 <div.tag>${tag}</div>
92 </for>
93 </div>
5994
60<div.meta>
61 <a class="meta-author" href="/">${t.writtenByClover}</a>
62 <span>•</span>
63 <if=translation>
64 <a.meta-translate href=translation.href>
65 ${t.translatedBy(translation)}
66 </a>
67 <span>•</span>
68 </>
69 <span.meta-date>${date}</span>
70</div>
71<div.tag-list>
72 <if=translations.length>
73 <a
74 class=["custom tag lang lang-original", { active: !translation }]
75 href=`/blog/${slug}`
76 >
77 ${localization.languages.en.title}
78 </a>
79 <for|code| of=translations>
80 <a
81 class=["custom tag lang", { active: code === translation?.lang }]
82 href=`/blog/${slug}.${code}`
83 >
84 ${UNWRAP(localization.languages[code]).title}
85 </a>
86 </>
87 <a.custom.tag.square href="/blog/community-translations">${"+"}</a>
88 <span.bar />
89 </>
90 <for|tag| of=keywords>
91 <div.tag>${tag}</div>
92 </>
93</div>
94
95<hr />
96</header>
97
98<${input.content} />
95 <hr>
96 </header>
9997
98 <${input.content}/>
10099</main>
101100
102101import * as localization from "../localization.ts";
src/blog/tags/code-block.marko+14-9
......@@ -1,15 +1,20 @@
1<const/{ filename, language, tint, whitespace, content } = input />
1<const/{ filename, language, tint, whitespace, content }=input>
22
3<const/highlightedPromise = ["tsx", "jsx", "json", "html", "ts", "js"].includes(language)
4 ? highlight.highlightCode(content.trim(), language)
5 : content.trim()
6/>
3<const/highlightedPromise=(
4 ["tsx", "jsx", "json", "html", "ts", "js"].includes(language)
5 ? highlight.highlightCode(content.trim(), language)
6 : content.trim()
7)>
78
89<figure class=["code", tint]>
9<if=filename><figcaption>${filename}</figcaption></>
10<await|highlighted|=highlightedPromise>
11 <pre style=whitespace ? { "white-space": whitespace } : {}><code>$!{highlighted}</code></pre>
12</>
10 <if=filename>
11 <figcaption>${filename}</figcaption>
12 </if>
13 <await|highlighted|=highlightedPromise>
14 <pre style=whitespace ? { "white-space": whitespace } : {}><code>$!{
15 highlighted
16 }</code></pre>
17 </await>
1318</figure>
1419
1520import * as highlight from "#src/file-viewer/highlight.ts";
src/blog/tags/heading.marko+3-3
......@@ -1,5 +1,5 @@
1<const/{ level, content, ...attrs } = input />
2<${'h' + level} ...attrs>
1<const/{ level, content, ...attrs }=input>
2<${"h" + level} ...attrs>
33 <a.return href="#toc">§</a>
4 <${content} />
4 <${content}/>
55</>
src/blog/tags/table-of-contents.marko+18-10
......@@ -5,25 +5,33 @@ export interface Node {
55 children: Node[];
66}
77
8<const/outline = $global.cloverOutline />
9<const/groups = markodown.outlineToTree(outline) />
8<const/outline=$global.cloverOutline>
9<const/groups=markodown.outlineToTree(outline)>
1010
1111<define/Recurse|input: markodown.HeadingTree|>
12 <a href=`#${input.id}`><${input.content} /></a>
12 <a href=`#${input.id}`>
13 <${input.content}/>
14 </a>
1315 <if=input.children.length>
1416 <ul>
1517 <for|item| of=input.children>
16 <li><Recurse ...item /></li>
17 </>
18 <li>
19 <Recurse ...item/>
20 </li>
21 </for>
1822 </ul>
19 </>
20</>
23 </if>
24</define>
2125
2226<div#toc>
2327 <h2>Contents:</h2>
24 <ul><for|item| of=groups>
25 <li><Recurse ...item /></li>
26 </></ul>
28 <ul>
29 <for|item| of=groups>
30 <li>
31 <Recurse ...item/>
32 </li>
33 </for>
34 </ul>
2735</div>
2836
2937import * as markodown from "@clo/markodown";
src/file-viewer/bin/file-scan.ts+42-54
......@@ -15,8 +15,7 @@ const sotToken = process.env.CLOVER_SOT_KEY;
1515export async function main() {
1616 const start = performance.now();
1717 using _ = log.startWidget({
18 format: ({ now }) =>
19 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
18 format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
2019 });
2120
2221 const promises = new async.PromiseAggregator();
......@@ -63,10 +62,10 @@ export async function main() {
6362 }
6463
6564 if (
66 !mediaFile || // All processes must be performed if there is no file.
65 !mediaFile // All processes must be performed if there is no file.
6766 // Rerun all processors if it changed
68 stat.size !== mediaFile.size ||
69 stat.mtime.getTime() !== mediaFile.date.getTime()
67 || stat.size !== mediaFile.size
68 || stat.mtime.getTime() !== mediaFile.date.getTime()
7069 ) {
7170 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));
7271 } else {
......@@ -101,9 +100,9 @@ export async function main() {
101100 });
102101 let date = stat.mtime;
103102 if (
104 mediaFile &&
105 mediaFile.date.getTime() < stat.mtime.getTime() &&
106 Date.now() - stat.mtime.getTime() < monthMilliseconds
103 mediaFile
104 && mediaFile.date.getTime() < stat.mtime.getTime()
105 && Date.now() - stat.mtime.getTime() < monthMilliseconds
107106 ) {
108107 date = mediaFile.date;
109108 console.warn(
......@@ -137,7 +136,7 @@ export async function main() {
137136 },
138137 () => ({ priority: -1 }),
139138 );
140 const queueFileProcessor = queue.wrap(async function ({
139 const queueFileProcessor = queue.wrap(async function({
141140 path,
142141 stat,
143142 mediaFile,
......@@ -190,9 +189,7 @@ export async function main() {
190189 node.passive = true;
191190
192191 const ext = mediaFile.extensionNonEmpty.toLowerCase();
193 let possible = processors.filter((p) =>
194 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
195 );
192 let possible = processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext));
196193 if (possible.length === 0) return;
197194 ASSERT(possible.length < 16, "too many bits");
198195
......@@ -227,9 +224,7 @@ export async function main() {
227224 .join(";"),
228225 );
229226 } else {
230 possible = order.map(({ id }) =>
231 UNWRAP(possible.find((p) => p.id === id))
232 );
227 possible = order.map(({ id }) => UNWRAP(possible.find((p) => p.id === id)));
233228 }
234229
235230 // Queue needed processors.
......@@ -294,14 +289,11 @@ export async function main() {
294289 const children = dir.getChildren();
295290
296291 // readme.txt
297 const readmeContent = children.find((x) =>
298 x.basename === "readme.txt"
299 )?.contents ?? "";
292 const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? "";
300293
301294 // dirsort
302295 let dirsort: string[] | null = null;
303 const dirSortRaw =
304 children.find((x) => x.basename === ".dirsort")?.contents ?? "";
296 const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? "";
305297 if (dirSortRaw) {
306298 dirsort = dirSortRaw
307299 .split("\n")
......@@ -417,9 +409,9 @@ export async function main() {
417409 } else console.warn("Missing SOT token");
418410
419411 console.info(
420 "Updated file viewer index in \x1b[1m" +
421 ((performance.now() - start) / 1000).toFixed(1) +
422 "s\x1b[0m",
412 "Updated file viewer index in \x1b[1m"
413 + ((performance.now() - start) / 1000).toFixed(1)
414 + "s\x1b[0m",
423415 );
424416
425417 const { duration, count } = MediaFile.db
......@@ -442,12 +434,12 @@ export async function main() {
442434
443435 console.info();
444436 console.info(
445 "Global Stats:\n" +
446 `- File Count: \x1b[1m${count}\x1b[0m\n` +
447 `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` +
448 `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` +
449 `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` +
450 `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
437 "Global Stats:\n"
438 + `- File Count: \x1b[1m${count}\x1b[0m\n`
439 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`
440 + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n`
441 + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n`
442 + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
451443 );
452444}
453445
......@@ -837,8 +829,8 @@ const processors = [
837829 crypto
838830 .createHash("sha1")
839831 .update(
840 process.run.toString() +
841 (process.version ? String(process.version) : ""),
832 process.run.toString()
833 + (process.version ? String(process.version) : ""),
842834 )
843835 .digest().buffer,
844836 ).reduce((a, b) => a ^ b),
......@@ -888,12 +880,12 @@ function skipBasename(basename: string): boolean {
888880 if (basename === ".date") return false;
889881
890882 return (
891 basename.startsWith(".") ||
883 basename.startsWith(".")
892884 // basename.startsWith("._") ||
893885 // basename.startsWith(".tmp") ||
894886 // basename === ".DS_Store" ||
895 basename.toLowerCase() === "thumbs.db" ||
896 basename.toLowerCase() === "desktop.ini"
887 || basename.toLowerCase() === "thumbs.db"
888 || basename.toLowerCase() === "desktop.ini"
897889 );
898890}
899891
......@@ -946,8 +938,8 @@ async function scrubLocationMetadata(
946938 "-s",
947939 path.toString(),
948940 ]);
949 hasLocation = videoCheck.includes("GPS") ||
950 videoCheck.includes("Location");
941 hasLocation = videoCheck.includes("GPS")
942 || videoCheck.includes("Location");
951943 args = [
952944 "-gps:all=",
953945 "-xmp:all=",
......@@ -963,10 +955,10 @@ async function scrubLocationMetadata(
963955 "-s",
964956 path.toString(),
965957 ]);
966 hasLocation = m4aCheck.includes("GPS") ||
967 m4aCheck.includes("Location") ||
968 m4aCheck.includes("Filename") ||
969 m4aCheck.includes("Title");
958 hasLocation = m4aCheck.includes("GPS")
959 || m4aCheck.includes("Location")
960 || m4aCheck.includes("Filename")
961 || m4aCheck.includes("Title");
970962
971963 if (hasLocation) {
972964 args = [
......@@ -1028,13 +1020,13 @@ async function scrubLocationMetadata(
10281020
10291021const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
10301022
1031import * as async from "@clo/lib/async";
10321023import * as fs from "#sitegen/fs";
1033import * as subprocess from "@clo/lib/subprocess";
10341024import { Path } from "#sitegen/path";
1035import * as queue from "@clo/lib/queue";
1025import * as async from "@clo/lib/async";
10361026import * as log from "@clo/lib/log";
10371027import * as progress from "@clo/lib/progress";
1028import * as queue from "@clo/lib/queue";
1029import * as subprocess from "@clo/lib/subprocess";
10381030import * as ts from "@clo/lib/ts";
10391031
10401032import * as child_process from "node:child_process";
......@@ -1044,19 +1036,15 @@ import * as path from "node:path";
10441036import * as stream from "node:stream";
10451037import * as zlib from "node:zlib";
10461038
1047import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
1048import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
1049import {
1050 formatDate,
1051 formatDurationLong,
1052 formatSize,
1053} from "#src/file-viewer/format.ts";
1054import * as rules from "#src/file-viewer/rules.ts";
1039import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.ts";
10551040import * as highlight from "#src/file-viewer/highlight.ts";
1056import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
1057import * as rsync from "#src/file-viewer/rsync.ts";
10581041import * as derived from "#src/file-viewer/models/derived.ts";
1042import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
1043import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
1044import * as rsync from "#src/file-viewer/rsync.ts";
1045import * as rules from "#src/file-viewer/rules.ts";
10591046import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
1047import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
10601048
1061import { rawFileRoot as root } from "../paths.ts";
10621049import { ASSERT, UNWRAP } from "@clo/lib/assert";
1050import { rawFileRoot as root } from "../paths.ts";
src/file-viewer/bin/file-trim.ts+1-2
......@@ -1,8 +1,7 @@
11export async function main() {
22 const start = performance.now();
33 using _ = log.startWidget({
4 format: (now) =>
5 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
4 format: (now) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
65 });
76
87 const orphaned = derived.findOrphanedRoots();
src/file-viewer/transcode-rules.test.ts+8-19
......@@ -226,15 +226,10 @@ test("dense sweep never produces mismatched ladder aspect ratios", () => {
226226 }
227227});
228228
229import { test } from "vitest";
230import assert from "node:assert";
231229import { Path } from "#sitegen/path";
232import {
233 getAv1VideoArgs,
234 getConstantAspectScaleFilter,
235 getMpegDashArgs,
236 videoFormats,
237} from "./transcode-rules.ts";
230import assert from "node:assert";
231import { test } from "vitest";
232import { getAv1VideoArgs, getConstantAspectScaleFilter, getMpegDashArgs, videoFormats } from "./transcode-rules.ts";
238233
239234function assertString(value: string | undefined | null): string {
240235 assert.equal(typeof value, "string");
......@@ -275,9 +270,7 @@ const EXACT_ERROR_THRESHOLD = 0.0005;
275270function getFeasibleCandidates(source: { width: number; height: number }) {
276271 const caps = [
277272 source.height,
278 ...videoFormats.flatMap((preset) =>
279 preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : []
280 ),
273 ...videoFormats.flatMap((preset) => preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : []),
281274 ];
282275 const sourceRatio = source.width / source.height;
283276 const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2));
......@@ -300,16 +293,14 @@ function getFeasibleCandidates(source: { width: number; height: number }) {
300293 if (seen.has(key)) continue;
301294 seen.add(key);
302295
303 const dims = videoFormats.map((preset) =>
304 getExpectedDimensionsForPreset(source, candidate, preset.maxHeight)
305 );
296 const dims = videoFormats.map((preset) => getExpectedDimensionsForPreset(source, candidate, preset.maxHeight));
306297 if (dims.some((value) => value.width <= 0 || value.height <= 0)) continue;
307298
308299 const retainedArea = dims
309300 .map((value) => value.width * value.height)
310301 .reduce((sum, value) => sum + value, 0);
311 const error = Math.abs((candidate.width / candidate.height) - sourceRatio) /
312 sourceRatio;
302 const error = Math.abs((candidate.width / candidate.height) - sourceRatio)
303 / sourceRatio;
313304 candidates.push({ ...candidate, error, retainedArea });
314305 }
315306
......@@ -337,9 +328,7 @@ function getCandidateScore(
337328 source: { width: number; height: number },
338329 ratio: { width: number; height: number },
339330) {
340 const dims = videoFormats.map((preset) =>
341 getExpectedDimensionsForPreset(source, ratio, preset.maxHeight)
342 );
331 const dims = videoFormats.map((preset) => getExpectedDimensionsForPreset(source, ratio, preset.maxHeight));
343332 if (dims.some((value) => value.width <= 0 || value.height <= 0)) return null;
344333 const sourceRatio = source.width / source.height;
345334 return {
src/file-viewer/transcode-rules.ts+42-31
......@@ -202,9 +202,7 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
202202 if (seen.has(key)) continue;
203203 seen.add(key);
204204
205 const dimensions = caps.map((cap) =>
206 getExactSquarePixelDimensions(source, aspect, cap)
207 );
205 const dimensions = caps.map((cap) => getExactSquarePixelDimensions(source, aspect, cap));
208206 if (dimensions.some((value) => value == null)) continue;
209207
210208 const retainedArea = dimensions
......@@ -215,11 +213,11 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
215213
216214 if (error <= exactErrorThreshold) {
217215 if (
218 !bestWithinThreshold ||
219 retainedArea > bestWithinThreshold.retainedArea ||
220 (
221 retainedArea === bestWithinThreshold.retainedArea &&
222 error < bestWithinThreshold.error
216 !bestWithinThreshold
217 || retainedArea > bestWithinThreshold.retainedArea
218 || (
219 retainedArea === bestWithinThreshold.retainedArea
220 && error < bestWithinThreshold.error
223221 )
224222 ) {
225223 bestWithinThreshold = candidate;
......@@ -228,9 +226,9 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
228226 }
229227
230228 if (
231 !bestFallback ||
232 error < bestFallback.error ||
233 (error === bestFallback.error && retainedArea > bestFallback.retainedArea)
229 !bestFallback
230 || error < bestFallback.error
231 || (error === bestFallback.error && retainedArea > bestFallback.retainedArea)
234232 ) {
235233 bestFallback = candidate;
236234 }
......@@ -247,8 +245,8 @@ export function getConstantAspectScaleFilter(
247245 const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight);
248246 if (!dimensions) return null;
249247 if (
250 dimensions.width === source.width &&
251 dimensions.height === source.height
248 dimensions.width === source.width
249 && dimensions.height === source.height
252250 ) return null;
253251 return `scale=${dimensions.width}:${dimensions.height}`;
254252}
......@@ -336,9 +334,7 @@ export function getMpegDashArgs(
336334 if (audios.length > 0) {
337335 const audioId = videos.length > 0 ? 1 : 0;
338336 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
339 adaptationSets += `${
340 adaptationSets ? " " : ""
341 }id=${audioId},streams=${audioIndices}`;
337 adaptationSets += `${adaptationSets ? " " : ""}id=${audioId},streams=${audioIndices}`;
342338 }
343339
344340 cmd.push("-adaptation_sets", adaptationSets);
......@@ -378,11 +374,16 @@ export function getH264HlsArgs(
378374 const aIdx = i * 2 + 1;
379375 // deno-fmt-ignore
380376 cmd.push(
381 `-c:${vIdx}`, "libx264",
382 `-b:v:${vIdx}`, preset.videoBitrate,
383 `-filter:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
384 `-c:${aIdx}`, "aac",
385 `-b:a:${aIdx}`, preset.audioBitrate,
377 `-c:${vIdx}`,
378 "libx264",
379 `-b:v:${vIdx}`,
380 preset.videoBitrate,
381 `-filter:${vIdx}`,
382 `scale=-2:min(${preset.maxHeight}\\,ih)`,
383 `-c:${aIdx}`,
384 "aac",
385 `-b:a:${aIdx}`,
386 preset.audioBitrate,
386387 );
387388 });
388389
......@@ -399,9 +400,12 @@ export function getH264HlsArgs(
399400 hlsPresets.forEach((preset, i) => {
400401 // deno-fmt-ignore
401402 cmd.push(
402 `-c:v:${i}`, "libx264",
403 `-b:v:${i}`, preset.videoBitrate,
404 `-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,
403 `-c:v:${i}`,
404 "libx264",
405 `-b:v:${i}`,
406 preset.videoBitrate,
407 `-filter:v:${i}`,
408 `scale=-2:min(${preset.maxHeight}\\,ih)`,
405409 );
406410 });
407411
......@@ -413,13 +417,20 @@ export function getH264HlsArgs(
413417
414418 // deno-fmt-ignore
415419 cmd.push(
416 "-f", "hls",
417 "-hls_time", "2",
418 "-hls_list_size", "0",
419 "-hls_segment_type", "fmp4",
420 "-hls_allow_cache", "1",
421 "-master_pl_name", "master.m3u8",
422 "-hls_segment_filename", "hls.%v.%03d.ts",
420 "-f",
421 "hls",
422 "-hls_time",
423 "2",
424 "-hls_list_size",
425 "0",
426 "-hls_segment_type",
427 "fmp4",
428 "-hls_allow_cache",
429 "1",
430 "-master_pl_name",
431 "master.m3u8",
432 "-hls_segment_filename",
433 "hls.%v.%03d.ts",
423434 "-y",
424435 outDir.join("hls.%v.m3u8").toString(),
425436 );
src/file-viewer/views/canvas.marko+6-5
......@@ -3,15 +3,16 @@ export interface Input {
33}
44export const meta = { title: "canvas" };
55
6<const/{ script } = input />
6<const/{ script }=input>
77
8<canvas id="canvas"
8<canvas
9 id="canvas"
910 data-canvas=script
1011 data-standalone="true"
1112 style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
12></canvas>
13<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts" />
13/>
14<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts"/>
1415
1516client import "./canvas.client.ts";
1617
17import { addScript as AddScript } from '#sitegen';
18import { addScript as AddScript } from "#sitegen";
src/file-viewer/views/lofi.marko+73-52
......@@ -5,66 +5,87 @@ export interface Input {
55}
66export { meta, theme } from "./clofi.tsx";
77
8<const/{ file: dir, hasCotyledonCookie } = input />
9<const/{ path: fullPath, dirname, basename, kind } = dir />
10<const/isRoot = fullPath == '/'>
8<const/{ file: dir, hasCotyledonCookie }=input>
9<const/{ path: fullPath, dirname, basename, kind }=dir>
10<const/isRoot=fullPath == "/">
1111
1212<div#lofi>
13 <define/ListItem|{ value: file }|>
14 <const/dir=file.kind === MediaFileKind.directory>
15 <const/meta=(
16 dir
17 ? formatSize(file.size)
18 : (file.duration ?? 0) > 0
19 ? formatDuration(file.duration!)
20 : null
21 )>
22 <li class={ dir }>
23 <a href=`/file${escapeUri(file.path)}`>
24 <code>${formatDate(file.date)}</code>${" "}
25 <span>${file.basenameWithoutExt}</span>
26 <if=file.extension><span class="ext">${file.extension}</span></if>
27 <if=dir>/</if>
28 <if=meta><span class="meta">&nbsp;&nbsp;&nbsp;(${meta})</span></if>
29 </a>
30 </li>
31 </define>
1332
14<define/ListItem|{ value: file }|>
15 <const/dir=file.kind === MediaFileKind.directory/>
16 <const/meta=(
17 dir
18 ? formatSize(file.size)
19 : (file.duration ?? 0) > 0
20 ? formatDuration(file.duration!)
21 : null
22 )/>
23 <li class={ dir }>
24 <a href=`/file${escapeUri(file.path)}` >
25 <code>${formatDate(file.date)}</>${" "}<span>${file.basenameWithoutExt}</span><if=file.extension><span class="ext">${file.extension}</></><if=dir>/</>
26 <if=meta><span class='meta'>&nbsp;&nbsp;&nbsp;(${meta})</></>
27 </a>
28 </li>
29</define>
33 <h1>
34 <if=isRoot>clo's files</if>
35 <else>${fullPath}</else>
36 </h1>
3037
31<h1>
32 <if=isRoot>clo's files</>
33 <else>${fullPath}</>
34</h1>
35
36<if=isRoot>
37 <const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />
38 <if=readme><ul><ListItem=readme/></ul></>
39 <for|{key, files, titleColor }| of=sections>
40 <h2 style={color: titleColor }>${key}</>
38 <if=isRoot>
39 <const/{ readme, sections }=sort.splitRootDirFiles(dir, hasCotyledonCookie)>
40 <if=readme>
41 <ul>
42 <ListItem=readme/>
43 </ul>
44 </if>
45 <for|{ key, files, titleColor }| of=sections>
46 <h2 style={ color: titleColor }>${key}</h2>
47 <ul>
48 <for|item| of=files>
49 <ListItem=item/>
50 </for>
51 </ul>
52 </for>
53 // <if=!hasCotyledonCookie>
54 // <br><br><br><br><br><br>
55 // <p style={ opacity: 0.3 }>
56 // would you like to
57 // <a
58 // href="/file/cotyledon"
59 // style={
60 // color: 'white',
61 // 'text-decoration': 'underline',
62 // }
63 // >dive deeper?</a>
64 // </p>
65 // </>
66 </if>
67 <else>
4168 <ul>
42 <for|item| of=files><ListItem=item /></>
69 <li>
70 <a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>
71 [up one...]
72 </a>
73 </li>
74 <for|item| of=dir.getChildren()>
75 ${" "}
76 <ListItem=item/>
77 ${" "}
78 </for>
4379 </ul>
44 </>
45 // <if=!hasCotyledonCookie>
46 // <br><br><br><br><br><br>
47 // <p style={ opacity: 0.3 }>
48 // would you like to
49 // <a
50 // href="/file/cotyledon"
51 // style={
52 // color: 'white',
53 // 'text-decoration': 'underline',
54 // }
55 // >dive deeper?</a>
56 // </p>
57 // </>
58</><else>
59 <ul>
60 <li><a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>[up one...]</a></li>
61 <for|item| of=dir.getChildren()> <ListItem=item /> </>
62 </ul>
63</>
64
80 </else>
6581</div>
6682
6783import * as path from "node:path";
68import { escapeUri, formatDuration, formatSize, formatDate } from "#src/file-viewer/format.ts";
84import {
85 escapeUri,
86 formatDuration,
87 formatSize,
88 formatDate,
89} from "#src/file-viewer/format.ts";
6990import { MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
7091import * as sort from "#src/file-viewer/sort.ts";
src/file-viewer/views/lofideon.marko+19-15
......@@ -1,22 +1,26 @@
11export interface Input {
2 stage: number
2 stage: number;
33}
4export const meta = { title: 'C O T Y L E D O N' };
4export const meta = { title: "C O T Y L E D O N" };
55export const theme = {
6 bg: '#ff00ff',
7 fg: '#000000',
6 bg: "#ff00ff",
7 fg: "#000000",
88};
99
1010<h1>co<br>ty<br>le<br>don</h1>
1111
12<if=input.stage==0>
13
14<p>
15this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.
16</p><p>
17by entering our museum, you agree not to use your camera. flash off isn't enough; the bits and bytes are alergic even to a camera's sensor
18</p><p>
19<sub>(in english: please do not store downloads after you're done viewing them)</sub>
20</p>
21
22</>
12<if=input.stage == 0>
13 <p>
14 this place is sacred, but dangerous. i have to keep visitors to an absolute
15 minimum; you'll get dust on all the artifacts.
16 </p>
17 <p>
18 by entering our museum, you agree not to use your camera. flash off isn't
19 enough; the bits and bytes are alergic even to a camera's sensor
20 </p>
21 <p>
22 <sub>
23 (in english: please do not store downloads after you're done viewing them)
24 </sub>
25 </p>
26</if>
src/pages/admin/login.fail.marko+1-1
......@@ -1,4 +1,4 @@
1export const meta = { title: 'paper clover admin' };
1export const meta = { title: "paper clover admin" };
22
33<main>
44 <h1>login</h1>
src/pages/admin/login.marko+1-1
......@@ -1,4 +1,4 @@
1export const meta = { title: 'paper clover admin' };
1export const meta = { title: "paper clover admin" };
22
33<main>
44 <h1>login</h1>
src/pages/friends/auth.fail.marko+1-1
......@@ -5,7 +5,7 @@ export const meta = { title: "password required" };
55 <p>please contact clover</p>
66 <form method="post">
77 <h1>try again</h1>
8 <input type="password" name="password" placeholder="password" />
8 <input type="password" name="password" placeholder="password">
99 <button type="submit">enter</button>
1010 </form>
1111</main>
src/pages/friends/auth.marko+1-1
......@@ -3,7 +3,7 @@ export const meta = { title: "password required" };
33<main>
44 <form method="post">
55 <h1>what's the password</h1>
6 <input type="password" name="password" placeholder="password"/>
6 <input type="password" name="password" placeholder="password">
77 <button type="submit">enter</button>
88 <p>(clover will give the password to you if you're a friend)</p>
99 </form>
src/pages/friends/misconfigure.marko+1-1
......@@ -3,7 +3,7 @@ export const meta = { title: "password required" };
33<main>
44 <form method="post" action="/friends">
55 <h1>what's the password</h1>
6 <input type="password" name="password" placeholder="password" disabled />
6 <input type="password" name="password" placeholder="password" disabled>
77 <button type="submit" disabled>enter</button>
88 <p style="color:red">this page's password is not configured</p>
99 </form>
src/pages/history-of-japan-reanimated.marko+142-53
......@@ -4,97 +4,186 @@ client import "./history-of-japan-reanimated.client.ts";
44
55export const meta = {
66 title: "history of japan reanimated",
7 description: "17 artists and musicians come together to remake bill wurtz's legendary \"history of japan\" to celebrate its 10 year anniversary.",
7 description:
8 '17 artists and musicians come together to remake bill wurtz\'s legendary "history of japan" to celebrate its 10 year anniversary.',
89 embed: {
9 thumbnail: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
10 thumbnail:
11 "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg",
1012 },
1113 canonical: "/history-of-japan-reanimated",
1214 twitter: {
13 image: "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg"
15 image:
16 "https://paperclover.net/file/2026/history%20of%20japan%20reanimated/thumbnail.jpeg",
1417 },
1518};
1619export const theme = {
1720 fg: "#fff",
1821 bg: "#442233",
19 primary: "#ff8b47"
22 primary: "#ff8b47",
2023};
2124
2225<main>
2326 <nav><a href="/">back to home</a></nav>
2427 <h1>history of japan reanimated</h1>
2528 <p>
26 me and 16 other artists (music, narration, and animation) have been
27 spending the past couple months on a recreation of bill wurtz's legendary
28 "<a href="https://billwurtz.com/history-of-japan.html">history of japan</a>" to celebrate the 10 year anniversary of his release. i
29 contributed two animation sections. the canonical release is on
30 <a href="https://youtu.be/nqsoti-OuRM">LunaPogi's YouTube page</a>, but i
31 am providing this ad-free video player.
29 me and 16 other artists (music, narration, and animation) have been spending
30 the past couple months on a recreation of bill wurtz's legendary "<a href="https://billwurtz.com/history-of-japan.html">
31 history of japan
32 </a>" to celebrate the 10 year anniversary of his release. i contributed two
33 animation sections. the canonical release is on
34 <a href="https://youtu.be/nqsoti-OuRM">LunaPogi's YouTube page</a>, but i am providing
35 this ad-free video player.
3236 </p>
3337
34<clover-video
35 id="main-player"
36 file="/2026/history of japan reanimated/history of japan reanimated.mp4"
37 poster="/2026/history of japan reanimated/thumbnail.jpeg"
38>
39 <@header>history of japan reanimated</>
40</>
41
42<div id="live-credits">
43
38 <clover-video
39 id="main-player"
40 file="/2026/history of japan reanimated/history of japan reanimated.mp4"
41 poster="/2026/history of japan reanimated/thumbnail.jpeg"
42 >
43 <@header>history of japan reanimated</@header>
44 </clover-video>
4445
45</div>
46 <div id="live-credits"/>
4647
47 <p>
48 the wonderful souls who worked on the project, in order of appearance.
49 </p>
48 <p>the wonderful souls who worked on the project, in order of appearance.</p>
5049 <ul>
51 <li><a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">Kappacap</a> - thumbnail artist</li>
52 <li><a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation <span style="color:#B4A7AD">(0:03-1:02)</></li>
53 <li><a href="https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow">moxley</a> - music and narration <span style="color:#B4A7AD">(0:03-3:10)</>, animation <span style="color:#B4A7AD">(3:51-4:03)</></li>
54 <li><a href="https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw">TheJustinator</a> - animation <span style="color:#B4A7AD">(1:03-1:33, 2:02-2:33)</>, music <span style="color:#B4A7AD">(3:11-6:04)</></li>
55 <li><a href="https://chaosyumi.net">chaosyumi</a> - animation <span style="color:#B4A7AD">(1:34-2:01, 5:34-6:05)</></li>
56 <li><a href="https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ">LunaPogi</a> - animation <span style="color:#B4A7AD">(3:36-3:51, 8:01-9:10)</>, music <span style="color:#B4A7AD">(6:05-9:10)</>, narration <span style="color:#B4A7AD">(8:01-9:10)</></li>
57 <li><a href="https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ">zeccet (bgongo)</a> - animation <span style="color:#B4A7AD">(2:23-3:10)</></li>
58 <li><a href="https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg">TheRealTinyWorld</a> - animation <span style="color:#B4A7AD">(3:11-3:35)</></li>
59 <li><a href="https://www.castingcall.club/fredfrenchiii">Fred French VA</a> - narration <span style="color:#B4A7AD">(3:11-4:35)</></li>
60 <li><a href="https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ">rqfirqfo</a> - animation <span style="color:#B4A7AD">(4:04-4:35)</></li>
61 <li><a href="https://www.youtube.com/@krispykarim">krispykarim</a> - animation <span style="color:#B4A7AD">(4:36 - 5:04)</>, narration <span style="color:#B4A7AD">(4:36-8:01)</></li>
62 <li><a href="https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg">SimplyDoodled Studios</a> - animation <span style="color:#B4A7AD">(5:05-5:33)</></li>
63 <li><span style="color:#ff7de9">paper clover (me)</span> - animation <span style="color:#B4A7AD">(6:05-6:38)</>, credits animation <span style="color:#B4A7AD">(9:11-9:35)</></li>
64 <li><a href="https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg">Coffee</a> - transcription <span style="color:#B4A7AD">(6:05-9:10)</></li>
65 <li><a href="https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw">harjjw</a> - animation <span style="color:#B4A7AD">(6:38-7:03)</></li>
66 <li><a href="https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ">yellowmarkers</a> - animation <span style="color:#B4A7AD">(7:04-7:36)</></li>
67 <li><a href="https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg">TheNothingGuy07</a> - animation <span style="color:#B4A7AD">(7:37-7:59)</></li>
50 <li>
51 <a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">
52 Kappacap
53 </a>
54 - thumbnail artist
55 </li>
56 <li>
57 <a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation
58 <span style="color:#B4A7AD">(0:03-1:02)</span>
59 </li>
60 <li>
61 <a href="https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow">
62 moxley
63 </a>
64 - music and narration <span style="color:#B4A7AD">(0:03-3:10)</span>, animation
65 <span style="color:#B4A7AD">(3:51-4:03)</span>
66 </li>
67 <li>
68 <a href="https://www.youtube.com/channel/UCdy0IJXbdKyTWyKajyEvodw">
69 TheJustinator
70 </a>
71 - animation <span style="color:#B4A7AD">(1:03-1:33, 2:02-2:33)</span>, music
72 <span style="color:#B4A7AD">(3:11-6:04)</span>
73 </li>
74 <li>
75 <a href="https://chaosyumi.net">chaosyumi</a> - animation
76 <span style="color:#B4A7AD">(1:34-2:01, 5:34-6:05)</span>
77 </li>
78 <li>
79 <a href="https://www.youtube.com/channel/UC7NHQWu3awWcC0rm8nB5CfQ">
80 LunaPogi
81 </a>
82 - animation <span style="color:#B4A7AD">(3:36-3:51, 8:01-9:10)</span>, music
83 <span style="color:#B4A7AD">(6:05-9:10)</span>, narration
84 <span style="color:#B4A7AD">(8:01-9:10)</span>
85 </li>
86 <li>
87 <a href="https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ">
88 zeccet (bgongo)
89 </a>
90 - animation <span style="color:#B4A7AD">(2:23-3:10)</span>
91 </li>
92 <li>
93 <a href="https://www.youtube.com/channel/UCLfv-6_J0WLsA2jAsJxiLOg">
94 TheRealTinyWorld
95 </a>
96 - animation <span style="color:#B4A7AD">(3:11-3:35)</span>
97 </li>
98 <li>
99 <a href="https://www.castingcall.club/fredfrenchiii">Fred French VA</a> -
100 narration <span style="color:#B4A7AD">(3:11-4:35)</span>
101 </li>
102 <li>
103 <a href="https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ">
104 rqfirqfo
105 </a>
106 - animation <span style="color:#B4A7AD">(4:04-4:35)</span>
107 </li>
108 <li>
109 <a href="https://www.youtube.com/@krispykarim">krispykarim</a> - animation
110 <span style="color:#B4A7AD">(4:36 - 5:04)</span>, narration <span style="color:#B4A7AD">(4:36-8:01)</span>
111 </li>
112 <li>
113 <a href="https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg">
114 SimplyDoodled Studios
115 </a>
116 - animation <span style="color:#B4A7AD">(5:05-5:33)</span>
117 </li>
118 <li>
119 <span style="color:#ff7de9">paper clover (me)</span> - animation
120 <span style="color:#B4A7AD">(6:05-6:38)</span>, credits animation <span style="color:#B4A7AD">(9:11-9:35)</span>
121 </li>
122 <li>
123 <a href="https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg">
124 Coffee
125 </a>
126 - transcription <span style="color:#B4A7AD">(6:05-9:10)</span>
127 </li>
128 <li>
129 <a href="https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw">
130 harjjw
131 </a>
132 - animation <span style="color:#B4A7AD">(6:38-7:03)</span>
133 </li>
134 <li>
135 <a href="https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ">
136 yellowmarkers
137 </a>
138 - animation <span style="color:#B4A7AD">(7:04-7:36)</span>
139 </li>
140 <li>
141 <a href="https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg">
142 TheNothingGuy07
143 </a>
144 - animation <span style="color:#B4A7AD">(7:37-7:59)</span>
145 </li>
68146 </ul>
69147
70148 <p>
71 the music during the credits is an instrumental by bill wurtz: <a href="https://billwurtz.com/hey-mom-i-beat-the-scale.mp3">hey mom i beat the scale!</a>
149 the music during the credits is an instrumental by bill wurtz:
150 <a href="https://billwurtz.com/hey-mom-i-beat-the-scale.mp3">
151 hey mom i beat the scale!
152 </a>
72153 </p>
73154
74155 <h2>what's next</h2>
75156 <p>
76157 from the success of this project, a much more ambitous project is being
77 planned: the reanimation for 'history of the entire world, i guess'. i am joining the project as one of the co-hosts. we are currently doing initial planning,
78 all artists (music, visual, narration) interested should fill out the <a href="https://paperclover.net/hotewig-reanimated">interest form</a>. by submitting the form, i'll be able to reach out when it's actually time to start cooking. you can also join the project's <a href="https://discord.gg/37axTSuGXd">public discord</a>.
158 planned: the reanimation for 'history of the entire world, i guess'. i am
159 joining the project as one of the co-hosts. we are currently doing initial
160 planning, all artists (music, visual, narration) interested should fill out
161 the <a href="https://paperclover.net/hotewig-reanimated">interest form</a>. by
162 submitting the form, i'll be able to reach out when it's actually time to start
163 cooking. you can also join the project's <a href="https://discord.gg/37axTSuGXd">
164 public discord
165 </a>.
79166 </p>
80167
81168 <h2>behind the scenes</h2>
82169 <p>
83 here is a 55 minute dive into my process, as well as nearly every little trick i used on this project. additionally, for one of the first times from me, a free download for <a href="/file/2026/history%20of%20japan%20reanimated/project%20files/history-of-japan-reanimated.zip?dl">all of the project files</a>.
170 here is a 55 minute dive into my process, as well as nearly every little
171 trick i used on this project. additionally, for one of the first times from
172 me, a free download for
173 <a href="/file/2026/history%20of%20japan%20reanimated/project%20files/history-of-japan-reanimated.zip?dl">
174 all of the project files
175 </a>.
84176 </p>
85 <clover-video
86 file="/2026/history of japan reanimated/behind-the-scenes.mp4"
87 >
88 <@header>behind the scenes session with clover</>
89 </>
90
177 <clover-video file="/2026/history of japan reanimated/behind-the-scenes.mp4">
178 <@header>behind the scenes session with clover</@header>
179 </clover-video>
91180
92181 <h2>more links</h2>
93182 <p>
94 <a href="/q+a">paper clover q&a page</a><br/>
183 <a href="/q+a">paper clover q&a page</a><br>
95184 <a href="/subscribe">paper clover's mailing list</a>
96185 </p>
97186
98187 <h2>mentions on the q&a</h2>
99 <questions-embed="history-of-japan-reanimated" />
188 <questions-embed="history-of-japan-reanimated"/>
100189</main>
src/pages/in-the-summer.marko+44-32
......@@ -1,43 +1,55 @@
1export const meta = { title: 'in the summer' };
1export const meta = { title: "in the summer" };
22export const theme = {
3 bg: '#7DCC8C',
4 fg: '#000',
5 primary: '#9b3706',
6}
3 bg: "#7DCC8C",
4 fg: "#000",
5 primary: "#9b3706",
6};
77
88import "./in-the-summer.css";
99
10<main style={ 'max-width': '110ch' }>
11<nav><a href="/">back to home</a></nav>
12<div.contain>
13 <header>
14 <h1>in the summer</h1>
15 <time style="margin-left:.5rem;opacity:.5">(2025-08-01)</time>
16 </header>
10<main style={ "max-width": "110ch" }>
11 <nav><a href="/">back to home</a></nav>
12 <div.contain>
13 <header>
14 <h1>in the summer</h1>
15 <time style="margin-left:.5rem;opacity:.5">(2025-08-01)</time>
16 </header>
1717
18 <p>
18 <p>
1919 this is a song about the summer, and related feelings. written in 2022,
2020 produced in 2025. i had a lot of fun playing with a much larger instrument
2121 selection, and new 3d workflows. enjoy!
22 </p>
23</div>
22 </p>
23 </div>
2424
25<clover-video
26 file="/2025/in the summer/in the summer.mp4"
27 poster="/2025/in the summer/thumbnail.jpeg"
28>
29 <@header><strong>music video</>: in the summer</>
30</>
25 <clover-video
26 file="/2025/in the summer/in the summer.mp4"
27 poster="/2025/in the summer/thumbnail.jpeg"
28 >
29 <@header><strong>music video</strong>: in the summer</@header>
30 </clover-video>
3131
32<div.contain>
33 <h2>downloads</h2>
34 <ul>
35 <li><a href="/file/2025/in%20the%20summer/in%20the%20summer.mp4?view=dl">in the summer.mp4</a> (AV1, requires modern player)</li>
36 <li><a href="/file/2025/in%20the%20summer/in%20the%20summer.mp3?view=dl">in the summer.mp3</a> (Music)</li>
37 <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li>
38 </ul>
39 <h2>mentions on the q&a</h2>
40 <questions-embed="in-the-summer" />
41</div>
32 <div.contain>
33 <h2>downloads</h2>
34 <ul>
35 <li>
36 <a href="/file/2025/in%20the%20summer/in%20the%20summer.mp4?view=dl">
37 in the summer.mp4
38 </a>
39 (AV1, requires modern player)
40 </li>
41 <li>
42 <a href="/file/2025/in%20the%20summer/in%20the%20summer.mp3?view=dl">
43 in the summer.mp3
44 </a>
45 (Music)
46 </li>
47 <li>
48 <a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind
49 the scenes)
50 </li>
51 </ul>
52 <h2>mentions on the q&a</h2>
53 <questions-embed="in-the-summer"/>
54 </div>
4255</main>
43
src/pages/index.marko+19-8
......@@ -4,8 +4,11 @@ import "./index.css";
44export const theme = { bg: "#fff", fg: "#666" };
55export const meta = {
66 title: "paper clover",
7 description: "and then we knew, just like paper airplanes: that we could fly...",
8 embed: { /* Default embed */ },
7 description:
8 "and then we knew, just like paper airplanes: that we could fly...",
9 embed: {
10 /* Default embed */
11 },
912 alternates: {
1013 "application/rss+xml": "rss.xml",
1114 },
......@@ -15,15 +18,23 @@ export const meta = {
1518 <h2>about</h2>
1619 <p>
1720 i am a girl that is adorable and
18 <a class='custom cite' href="https://en.wikipedia.org/wiki/Life">alive</a>.
19 </p>
20 <p>
21 this site contains my art.
21 <a class="custom cite" href="https://en.wikipedia.org/wiki/Life">alive</a>.
2222 </p>
23 <p>this site contains my art.</p>
2324 <h2>posts</h2>
2425 <ul class="section-list">
25 <li>video collab: <a href="/history-of-japan-reanimated">history of japan reanimated</a> (2026-02-02)</li>
26 <li>blog: <a href="/blog/webdev/one-year-next-app-router">One Year with Next.js App Router — why we're moving on</a> (2025-10-21)</li>
26 <li>
27 video collab:
28 <a href="/history-of-japan-reanimated">history of japan reanimated</a>
29 (2026-02-02)
30 </li>
31 <li>
32 blog:
33 <a href="/blog/webdev/one-year-next-app-router">
34 One Year with Next.js App Router — why we're moving on
35 </a>
36 (2025-10-21)
37 </li>
2738 <li>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</li>
2839 <li>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</li>
2940 </ul>
src/pages/resume.marko+39-40
......@@ -3,48 +3,47 @@ import "./resume.css";
33export const meta = { title: "clover's resume" };
44
55<main>
6<h1>clover's resume</h1>
7<div>last updated: 2025</>
6 <h1>clover's resume</h1>
7 <div>last updated: 2025</div>
88
9<article.job>
10 <header>
11 <h2>web/backend engineer</h2>
12 <em>2025-now</em>
13 </>
14 <ul>
15 <i>(more details added as time goes on...)</i>
16 </ul>
17</>
9 <article.job>
10 <header>
11 <h2>web/backend engineer</h2>
12 <em>2025-now</em>
13 </header>
14 <ul><i>(more details added as time goes on...)</i></ul>
15 </article>
1816
19<article.job>
20 <header>
21 <h2>runtime/systems engineer</h2>
22 <em>2023-2025</em>
23 <p>developer tools company</p>
24 </>
25 <ul>
26 <li>hardcore engineering, elegant solutions</>
27 <li>platform compatibility & stability</>
28 <li>debugging and profiling across platforms</>
29 </ul>
30</>
17 <article.job>
18 <header>
19 <h2>runtime/systems engineer</h2>
20 <em>2023-2025</em>
21 <p>developer tools company</p>
22 </header>
23 <ul>
24 <li>hardcore engineering, elegant solutions</li>
25 <li>platform compatibility & stability</li>
26 <li>debugging and profiling across platforms</li>
27 </ul>
28 </article>
3129
32<article.job>
33 <header>
34 <h2>technician</h2>
35 <em>2023; part time</em>
36 <p>automotive maintainance company</p>
37 </>
38 <ul>
39 <li>pressed buttons on a computer</>
40 </ul>
41</>
30 <article.job>
31 <header>
32 <h2>technician</h2>
33 <em>2023; part time</em>
34 <p>automotive maintainance company</p>
35 </header>
36 <ul>
37 <li>pressed buttons on a computer</li>
38 </ul>
39 </article>
4240
43<footer>
44 <h2>eduation</h2> <em>2004-now</em>
45 <p>
46 my life on earth has taught me more than i expected. i <br/>
47 continue to learn new things daily, as if it was magic.
48 </p>
49</footer>
41 <footer>
42 <h2>eduation</h2>
43 ${" "}<em>2004-now</em>
44 <p>
45 my life on earth has taught me more than i expected. i <br> continue to
46 learn new things daily, as if it was magic.
47 </p>
48 </footer>
5049</main>
src/pages/subscribe.marko+22-24
......@@ -1,31 +1,29 @@
1import './subscribe.css';
1import "./subscribe.css";
22export const meta = {
33 title: "paper clover mailing list",
44 description: "subscribe to the mailing list",
55};
66
77<main>
8<h1>mailing list</h1>
9<canvas style="display: none;"></canvas>
10<p>
11 the mailing list is used for big project updates. this is about once every
12 couple of months. to get added, please email anything with the word
13 "subscribe" in the subject or body to the following address:
14</p>
15<code id="subscribe" class="flex">
16 <a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">subscribe@paperclover.net</a>
17</code>
18<p>
19 i read every message, so you can say whatever else you want and i will see
20 it. there won't be a confirmation email for subscribing. for those who are
21 close friends with me, add your name and i'll add you to the 'friends' list,
22 where i send life updates in addition to new content.
23</p>
24<p>to unsubscribe, send an email asking to unsubscribe.</p>
25<br>
26<br>
27<br>
28<p>
29 <a href="/">back to home</a>
30</p>
8 <h1>mailing list</h1>
9 <canvas style="display: none;"/>
10 <p>
11 the mailing list is used for big project updates. this is about once every
12 couple of months. to get added, please email anything with the word
13 "subscribe" in the subject or body to the following address:
14 </p>
15 <code id="subscribe" class="flex">
16 <a href="mailto:subscribe@paperclover.net?subject=paper%20clover%20mailing%20list&body=I%20would%20like%20to%20be%20subscribed%20to%20the%20following%20mailing%20lists%3A%0A%0A-%20Technical%20Blog%20Posts%20-%20YES%0A-%20Art%20(Original%20Music%2FVideo)%20-%20YES%0A%0A(feel%20free%20to%20write%20whatever%20else%20you%20want)">
17 subscribe@paperclover.net
18 </a>
19 </code>
20 <p>
21 i read every message, so you can say whatever else you want and i will see
22 it. there won't be a confirmation email for subscribing. for those who are
23 close friends with me, add your name and i'll add you to the 'friends' list,
24 where i send life updates in addition to new content.
25 </p>
26 <p>to unsubscribe, send an email asking to unsubscribe.</p>
27 <br><br><br>
28 <p><a href="/">back to home</a></p>
3129</main>
src/pages/waterfalls.marko+10-18
......@@ -7,7 +7,7 @@ export const theme = {
77};
88
99<main class="waterfalls">
10 <div class="waterfalls_bg1" />
10 <div class="waterfalls_bg1"/>
1111 <main class="waterfalls">
1212 <div class="contain">
1313 <nav><a href="/">back to home</a></nav>
......@@ -22,30 +22,22 @@ export const theme = {
2222 file="/2025/waterfalls/waterfalls.mp4"
2323 poster="/2025/waterfalls/thumbnail.jpeg"
2424 >
25 <@header><strong>music video</strong>: waterfalls</>
26 </>
25 <@header><strong>music video</strong>: waterfalls</@header>
26 </clover-video>
2727 <div class="contain">
28 <p>
29 it's nice to be <em>free</em>.
30 </p>
28 <p>it's nice to be <em>free</em>.</p>
3129 <p>like paper airplanes in the wind.</p>
3230 <p>the stars shine in the sky. it's beautiful.</p>
31 <p>love <br> –clover caruso</p>
32 <br>
3333 <p>
34 love
35 <br />
36 –clover caruso
37 </p>
38 <br />
39
40 <p>
41 <strong>more</strong>: <br />
42 <a href="/file/2025/waterfalls/fragments">fragments</a>
43 <br />
44 <a href="/q+a">q&a page</a>
34 <strong>more</strong>: <br><a href="/file/2025/waterfalls/fragments">
35 fragments
36 </a><br><a href="/q+a">q&a page</a>
4537 </p>
4638
4739 <h2>mentions on the q&a</h2>
48 <questions-embed="waterfalls" />
40 <questions-embed="waterfalls"/>
4941 </div>
5042 </main>
5143</main>
src/q+a/pages/admin.q+a.marko+5-6
......@@ -7,20 +7,19 @@ export const meta: Metadata = {
77 description: "ask clover a question",
88};
99export const regenerate = {
10 tags: ["q+a", "q+a inbox"]
10 tags: ["q+a", "q+a inbox"],
1111};
1212
13<const/inboxSize = PendingQuestion.getAll().length />
13<const/inboxSize=PendingQuestion.getAll().length>
1414<if=(inboxSize > 0)>
1515 <p>
1616 <strong style={ color: "#ff4b4b" }>
17 notice: ${inboxSize} items in
18 <a href="/admin/q+a/inbox">inbox</a>!
17 notice: ${inboxSize} items in <a href="/admin/q+a/inbox">inbox</a>!
1918 </strong>
2019 </p>
21</>
20</if>
2221
23<MainPage admin />
22<MainPage admin/>
2423
2524import MainPage from "./q+a.marko";
2625import { PendingQuestion } from "#src/q+a/models/PendingQuestion.ts";
src/q+a/pages/q+a.404.marko+3-5
......@@ -4,9 +4,7 @@ export const meta: Metadata = {
44};
55
66<question-layout>
7<h2>sound the alarms</h2>
8<p>this page doesn't exist</p>
9<p>
10 <a href="/q+a">return to the questions list</a>
11</p>
7 <h2>sound the alarms</h2>
8 <p>this page doesn't exist</p>
9 <p><a href="/q+a">return to the questions list</a></p>
1210</question-layout>
src/q+a/pages/q+a.marko+9-11
......@@ -9,19 +9,17 @@ export const meta: Metadata = {
99 description: "ask clover a question",
1010};
1111
12<const/{ admin = false } = input />
13<const/questions = [...Question.getAll()] />
12<const/{ admin = false }=input>
13<const/questions=[...Question.getAll()]>
1414
1515<question-layout>
16<if=!admin>
17 <question-form />
18</>
19<for|question| of=questions>
20 <question ...{ question, admin } />
21</>
22<footer>
23 fun fact: clover has answered ${questions.length} questions
24</footer>
16 <if=!admin>
17 <question-form/>
18 </if>
19 <for|question| of=questions>
20 <question ...{ question, admin }/>
21 </for>
22 <footer>fun fact: clover has answered ${questions.length} questions</footer>
2523</question-layout>
2624
2725import { Question } from "#src/q+a/models/Question.ts";
src/q+a/tags/question-form.marko-1
......@@ -10,4 +10,3 @@
1010 <button type="submit">send</button>
1111 <div class="disabled-button">send</div>
1212</form>
13
src/q+a/tags/question-layout.marko+2-2
......@@ -8,8 +8,8 @@ client import "./header.client.ts";
88 <span class="n" style="--s:#999;margin:0 5px">&</span>
99 <span class="q" style="color:var(--b);--s:#994253">questions</span>
1010 </h1>
11 </>
12 <${input.content} />
11 </if>
12 <${input.content}/>
1313</main>
1414
1515export const theme = {
src/q+a/tags/question.marko+15-18
......@@ -10,34 +10,31 @@ export interface Input {
1010// 2024-12-31 05:00:00 EST
1111export const transitionDate = 1735639200000;
1212
13<const/{ question, admin = false, editor = false } = input />
14<const/{ id, date } = question/>
13<const/{ question, admin = false, editor = false }=input>
14<const/{ id, date }=question>
1515
1616<${"e-"}
1717 f=(date > transitionDate ? true : null)
1818 id=admin ? `q${id}` : undefined
1919>
2020 <if=admin>
21 <a
22 style="margin-right: 0.5rem"
23 href=`/admin/q+a/${id}`
24 >[EDIT]</a>
25 </>
21 <a style="margin-right: 0.5rem" href=`/admin/q+a/${id}`>[EDIT]</a>
22 </if>
2623 <a>
27 <time
28 datetime=formatQuestionISOTimestamp(date)
29 >${formatQuestionTimestamp(date)}</time><br />
30 </a>
31
32 $!{markdown.renderMarkdown(question.text)}
24 <time datetime=formatQuestionISOTimestamp(date)>
25 ${formatQuestionTimestamp(date)}
26 </time>
27 <br>
28 </a>$!{markdown.renderMarkdown(question.text)}
3329</>
3430
3531// this singleton script will make all the '<time>' tags clickable.
36<if=!editor>
37 client import "./clickable-links.client.ts";
38</>
32<if=!editor>client import "./clickable-links.client.ts";</if>
3933
4034import type { Question } from "#src/q+a/models/Question.ts";
41import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "#src/q+a/format.ts";
35import {
36 formatQuestionTimestamp,
37 formatQuestionISOTimestamp,
38} from "#src/q+a/format.ts";
4239import * as markdown from "#src/q+a/markdown.ts";
43import { addScript as Script } from "#sitegen"
40import { addScript as Script } from "#sitegen";
src/q+a/views/backend-inbox.marko+25-29
......@@ -4,39 +4,35 @@ import {
44 formatQuestionISOTimestamp,
55 formatQuestionTimestamp,
66} from "#src/q+a/format.ts";
7export const meta = { title: 'question answer inbox' };
7export const meta = { title: "question answer inbox" };
88
9<const/questions = PendingQuestion.getAll() />
9<const/questions=PendingQuestion.getAll()>
1010
1111<question-layout minimal>
12<h1>inbox</h1>
13<for|q| of=questions>
14 <div
15 data-q=q.id
16 style="border-bottom: 2px solid #fff7; margin-bottom: 1rem"
17 >
18 <time datetime=formatQuestionISOTimestamp(q.date)>
19 ${formatQuestionTimestamp(q.date)} ${q.id}
20 </time>
21 <div style="color: dodgerblue; margin-bottom: 0.25rem">
22 ${q.sourceName}
23 ${q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : ''}
24 ${q.sourceVPN ? `(VPN: ${q.sourceVPN})` : ''}
12 <h1>inbox</h1>
13 <for|q| of=questions>
14 <div
15 data-q=q.id
16 style="border-bottom: 2px solid #fff7; margin-bottom: 1rem"
17 >
18 <time datetime=formatQuestionISOTimestamp(q.date)>
19 ${formatQuestionTimestamp(q.date)} ${q.id}
20 </time>
21 <div style="color: dodgerblue; margin-bottom: 0.25rem">
22 ${q.sourceName}${
23 q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : ""
24 }${q.sourceVPN ? `(VPN: ${q.sourceVPN})` : ""}
25 </div>
26 <p style="white-space: pre-wrap">${q.prompt}</p>
27 <p>
28 <button onclick=`onReply("${q.id}")` style="color: lime">reply</button>
29 <button onclick=`onDelete("${q.id}")` style="color: red">reject</button>
30 <button onclick=`onDeleteFull("${q.id}")` style="color: red">
31 DELETE
32 </button>
33 </p>
2534 </div>
26 <p style="white-space: pre-wrap">${q.prompt}</p>
27 <p>
28 <button onclick=`onReply("${q.id}")` style="color: lime">
29 reply
30 </button>
31 <button onclick=`onDelete("${q.id}")` style="color: red">
32 reject
33 </button>
34 <button onclick=`onDeleteFull("${q.id}")` style="color: red">
35 DELETE
36 </button>
37 </p>
38 </div>
39</>
35 </for>
4036</question-layout>
4137
4238client import "./backend-inbox.client.ts";
src/q+a/views/editor.marko+17-20
......@@ -16,22 +16,19 @@ export interface ScriptPayload {
1616 date: string;
1717}
1818
19<const/{ question } = input />
19<const/{ question }=input>
2020
21<const/payload = {
21<const/payload={
2222 id: question.id,
2323 qmid: question.qmid,
2424 text: question.text,
2525 type: question.type,
2626 date: question.date.toISOString(),
27} satisfies ScriptPayload/>
27} satisfies ScriptPayload>
2828
2929<div#edit-grid.qa>
3030 <div#topleft>
31 <button
32 onclick="location.href='/admin/q+a'"
33 style={ color: "magenta" }
34 >
31 <button onclick="location.href='/admin/q+a'" style={ color: "magenta" }>
3532 all
3633 </button>
3734 <button
......@@ -42,25 +39,25 @@ export interface ScriptPayload {
4239 </button>
4340 </div>
4441 <div#topright>
45 <button onclick="onCommitQuestion()" style="color:lime">
46 update
47 </button>
48 <button onclick="onDelete()" style={ color: "red" }>
49 reject
50 </button>
42 <button onclick="onCommitQuestion()" style="color:lime">update</button>
43 <button onclick="onDelete()" style={ color: "red" }>reject</button>
5144 <select id="type" onchange="onTypeChange()" value=question.type>
52 <option value=QuestionType.normal>normal</>
53 <option value=QuestionType.annotation>annotation</>
54 <option value=QuestionType.reject>reject</>
45 <option value=QuestionType.normal>normal</option>
46 <option value=QuestionType.annotation>annotation</option>
47 <option value=QuestionType.reject>reject</option>
5548 </select>
5649 </div>
57 <textarea#editor />
50 <textarea#editor/>
5851 <main#preview>
59 <question editor ...{ question } />
52 <question editor ...{ question }/>
6053 </main>
6154</div>
62<html-script>self.payload=${JSON.stringify(payload).replaceAll('</script>', '<\\/script>')}</html-script>
63<html-script src="/js/q+a/scripts/editor.js" type="module" />
55<html-script>
56 self.payload = ${
57 JSON.stringify(payload).replaceAll("</script>", "<\\/script>")
58 };
59</html-script>
60<html-script src="/js/q+a/scripts/editor.js" type="module"/>
6461
6562import { type PendingQuestion } from "#src/q+a/models/PendingQuestion.ts";
6663import { Question, QuestionType } from "#src/q+a/models/Question.ts";
src/q+a/views/fail.marko+11-19
......@@ -3,26 +3,18 @@ export const theme = {
33 ...layout.theme,
44 primary: "#58ffee",
55};
6export const meta = { title: 'oh no' };
6export const meta = { title: "oh no" };
77
8<const/{ error, content }=input/>
8<const/{ error, content }=input>
99
1010<question-layout>
11<h2 style="color: red">:(</h2>
12<p style="color: red">
13 ${error}
14</p>
15<if=content>
16 <br />
17 <br />
18 <p>
19 here is a copy of what you wrote, if you want to try again:
20 </p>
21 <pre style="white-space: pre-wrap"><code>${content}</code></pre>
22</>
23<br />
24<br />
25<p>
26 <a href="/q+a">return to the questions list</a>
27</p>
11 <h2 style="color: red">:(</h2>
12 <p style="color: red">${error}</p>
13 <if=content>
14 <br><br>
15 <p>here is a copy of what you wrote, if you want to try again:</p>
16 <pre style="white-space: pre-wrap"><code>${content}</code></pre>
17 </if>
18 <br><br>
19 <p><a href="/q+a">return to the questions list</a></p>
2820</question-layout>
src/q+a/views/image-embed.marko+14-6
......@@ -4,13 +4,21 @@ export interface Input {
44}
55
66<html-style>
7 main { padding-top: 11px; }
8 e- { margin: 0!important }
9 e- > :first-child { margin-top: 0!important }
10 e- > :last-child { margin-bottom: 0!important }
7 main {
8 padding-top: 11px;
9 }
10 e- {
11 margin: 0 !important;
12 }
13 e- > :first-child {
14 margin-top: 0 !important;
15 }
16 e- > :last-child {
17 margin-bottom: 0 !important;
18 }
1119</html-style>
1220<main.qa>
13 <question question=input.question />
21 <question question=input.question/>
1422</main>
1523
16import { Question } from '#src/q+a/models/Question.ts';
24import { Question } from "#src/q+a/models/Question.ts";
src/q+a/views/permalink.marko+37-30
......@@ -4,54 +4,61 @@ export interface Input {
44}
55
66server export function meta({ context, question }) {
7 const isDiscord = context.req.header("user-agent")
7 const isDiscord = context.req
8 .header("user-agent")
89 ?.toLowerCase()
910 .includes("discord");
1011 if (question.type === QuestionType.normal) {
1112 return {
1213 title: "question permalink",
1314 openGraph: {
14 title: isDiscord ? undefined : `question at ${formatQuestionTimestamp(question.date)}`,
15 image: `https://paperclover.net/q+a/${question.id}.png`
15 title: isDiscord
16 ? undefined
17 : `question at ${formatQuestionTimestamp(question.date)}`,
18 image: `https://paperclover.net/q+a/${question.id}.png`,
1619 },
1720 twitter: { card: "summary_large_image" },
1821 themeColor: isDiscord
19 ? question.date.getTime() > transitionDate ? "#8c78ff" : "#58ff71"
22 ? question.date.getTime() > transitionDate
23 ? "#8c78ff"
24 : "#58ff71"
2025 : undefined,
2126 };
2227 }
23 return { title: 'question permalink' };
28 return { title: "question permalink" };
2429}
2530
26<const/{ question }=input/>
27<const/{ type }=question/>
31<const/{ question }=input>
32<const/{ type }=question>
2833
2934<question-layout>
30<if=type==QuestionType.normal>
31 <p>this page is a permalink to the following question:</p>
32 <question ...{question} />
33</><else if=type==QuestionType.pending>
34 <p>
35 this page is a permalink to a question that
36 has not yet been answered.
37 </p>
38 <p><a href="/q+a">read questions with existing responses</a>.</p>
39</><else if=type==QuestionType.reject>
40 <p>
41 this page is a permalink to a question, but the question
42 was deleted instead of answered. maybe it was sent multiple
43 times, or maybe the question was not a question. who knows.
44 </p>
45 <p>sorry, sister</p>
46 <p><a href="/q+a">all questions</a></p>
47</><else>
48 <p>oh dear, this question is in an invalid state</p>
49 <pre>${JSON.stringify(question, null, 2)}</pre>
50</>
35 <if=type == QuestionType.normal>
36 <p>this page is a permalink to the following question:</p>
37 <question ...{ question }/>
38 </if>
39 <else if=type == QuestionType.pending>
40 <p>
41 this page is a permalink to a question that has not yet been answered.
42 </p>
43 <p><a href="/q+a">read questions with existing responses</a>.</p>
44 </else>
45 <else if=type == QuestionType.reject>
46 <p>
47 this page is a permalink to a question, but the question was deleted
48 instead of answered. maybe it was sent multiple times, or maybe the
49 question was not a question. who knows.
50 </p>
51 <p>sorry, sister</p>
52 <p><a href="/q+a">all questions</a></p>
53 </else>
54 <else>
55 <p>oh dear, this question is in an invalid state</p>
56 <pre>${JSON.stringify(question, null, 2)}</pre>
57 </else>
5158
52<a href="/q+a">view more questions</a>
59 <a href="/q+a">view more questions</a>
5360</question-layout>
5461
55import { Question, QuestionType } from '#src/q+a/models/Question.ts';
62import { Question, QuestionType } from "#src/q+a/models/Question.ts";
5663import { transitionDate } from "#src/q+a/tags/question.marko";
5764import { formatQuestionTimestamp } from "#src/q+a/format.ts";
src/q+a/views/success.marko+13-20
......@@ -1,29 +1,22 @@
11export interface Input {
22 permalink: string;
33}
4export const meta = { title: 'question submitted!!!' };
4export const meta = { title: "question submitted!!!" };
55
6<const/{ permalink }=input/>
6<const/{ permalink }=input>
77
88<question-layout>
9<h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
10<p style="color: #8c78ff">
11 your question has been submitted.
12</p>
13<p>
14 please keep in mind it may take multiple days for clover to answer your
15 question, even if other questions are answered before yours. sometimes more
16 in-depth questions need her to dig through archives.
17</p>
18<br />
19<p>
20 <strong>permalink</strong>: <a href=permalink>${permalink}</a>
21</p>
22<br />
23<br />
24<p>
25 <a href="/q+a">return to the questions list</a>
26</p>
9 <h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
10 <p style="color: #8c78ff">your question has been submitted.</p>
11 <p>
12 please keep in mind it may take multiple days for clover to answer your
13 question, even if other questions are answered before yours. sometimes more
14 in-depth questions need her to dig through archives.
15 </p>
16 <br>
17 <p><strong>permalink</strong>: <a href=permalink>${permalink}</a></p>
18 <br><br>
19 <p><a href="/q+a">return to the questions list</a></p>
2720</question-layout>
2821
2922import * as layout from "../tags/question-layout.marko";
src/q+a/views/things-random.marko+2-3
......@@ -1,10 +1,9 @@
1export const meta = { title: 'random number' };
1export const meta = { title: "random number" };
22client import "./things-random.client.ts";
33
4<const/number = Math.floor(Math.random() * 999999) + 1 />
4<const/number=Math.floor(Math.random() * 999999) + 1>
55
66<main>
77 <div>${number}</div>
88 <button>another</button>
99</main>
10
src/tags/clover-media.marko+26-19
......@@ -5,41 +5,48 @@ export interface Input {
55 align?: string;
66}
77
8<const/{ src, alt = "", displayWidthEstimate, align, ...rest } = input />
9<const/file = typeof src === 'object' ? src : MediaFile.getByPath(src) />
8<const/{ src, alt = "", displayWidthEstimate, align, ...rest }=input>
9<const/file=typeof src === "object" ? src : MediaFile.getByPath(src)>
1010
1111<if=!file>
12 <div style={ color: 'red' }>ERROR: No file in database: ${src}</div>
13</><else if=extsVideo.has(file.extension.toLowerCase())>
14 <clover-video noFigure ...{ file } />
15</><else if=file.extension === '.gif'>
16 <img src=`/file${escapeUri(file.path)}` ...rest />
17</><else>
18 <const/base = `/file${escapeUri(file.path)}` />
19 <const/{ width } = UNWRAP(file.parseDimensions()) />
20 <const/targetSizes = transcodeRules.imageSizes.filter((w) => w < width) />
21 <const/sizes =
12 <div style={ color: "red" }>ERROR: No file in database: ${src}</div>
13</if>
14<else if=extsVideo.has(file.extension.toLowerCase())>
15 <clover-video noFigure ...{ file }/>
16</else>
17<else if=file.extension === ".gif">
18 <img src=`/file${escapeUri(file.path)}` ...rest>
19</else>
20<else>
21 <const/base=`/file${escapeUri(file.path)}`>
22 <const/{ width }=UNWRAP(file.parseDimensions())>
23 <const/targetSizes=transcodeRules.imageSizes.filter((w) => w < width)>
24 <const/sizes=(
2225 displayWidthEstimate
2326 ? typeof displayWidthEstimate === "number"
2427 ? `${displayWidthEstimate}px`
2528 : displayWidthEstimate
2629 : `100vw`
27 />
30 )>
2831 <picture>
29 <for|ext| of=[ "jxl", "avif", "webp" ]>
32 <for|ext| of=["jxl", "avif", "webp"]>
3033 <source
3134 type=`image/${ext}`
3235 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)
3336 ...{ sizes }
34 />
35 </>
37 >
38 </for>
3639 <img
3740 src=base
38 style=align ? { 'object-position': align, width: '100%', height: '100%' } : undefined
41 style=(
42 align
43 ? { "object-position": align, width: "100%", height: "100%" }
44 : undefined
45 )
3946 ...rest
40 />
47 >
4148 </picture>
42</>
49</else>
4350
4451import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
4552import { extsVideo } from "#src/file-viewer/rules.ts";
src/tags/clover-video.marko+43-30
......@@ -6,25 +6,35 @@ export interface Input {
66 poster?: MediaFile | string;
77 header?: Marko.AttrTag<{ content: Marko.Body }>;
88 minimal?: boolean;
9 noFigure?: boolean
9 noFigure?: boolean;
1010}
1111
12<const/{ id, header, minimal, noFigure } = input />
13<const/file = typeof UNWRAP(input.file) === 'object'
14 ? input.file
15 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />
16<const/poster = input.poster
17 ? typeof input.poster === 'object'
18 ? input.poster
19 : UNWRAP(MediaFile.getByPath(input.poster), `No file ${JSON.stringify(input.poster)}`)
20 : null />
12<const/{ id, header, minimal, noFigure }=input>
13<const/file=(
14 typeof UNWRAP(input.file) === "object"
15 ? input.file
16 : UNWRAP(
17 MediaFile.getByPath(input.file),
18 `No file ${JSON.stringify(input.file)}`,
19 )
20)>
21<const/poster=(
22 input.poster
23 ? typeof input.poster === "object"
24 ? input.poster
25 : UNWRAP(
26 MediaFile.getByPath(input.poster),
27 `No file ${JSON.stringify(input.poster)}`,
28 )
29 : null
30)>
2131
22<const/{ width, height } = file.parseDimensions() />
32<const/{ width, height }=file.parseDimensions()>
2333
2434<define/Inner>
2535 <video
2636 ...{ id }
27 class={ 'clover-video': noFigure }
37 class={ "clover-video": noFigure }
2838 data-src=file.path
2939 poster=poster ? `/file${poster.path}` : null
3040 width=width
......@@ -36,36 +46,39 @@ export interface Input {
3646 <figure.clover-video class={ minimal }>
3747 <if=!minimal>
3848 <figcaption>
39 <if=header><${header.content}/></><else>
40 ${file.basename}
41 </else>
49 <if=header>
50 <${header.content}/>
51 </if>
52 <else>${file.basename}</else>
4253 </figcaption>
43 </>
54 </if>
4455 <div.stack style=`aspect-ratio:${width}/${height}`>
45 <Inner />
56 <Inner/>
4657 <noscript.fallback>
4758 <h2>scripting disabled!</h2>
4859 <p>
49 to play this video, JavaScript must be enabled in your
50 browser. the default video playback behavior is not enabled here
51 as it results in a worse experience on slower internet
52 connections, and <a href="http://caniuse.com/mpeg-dash">MPEG-DASH
53 streaming is not natively supported in any browser</a> without JavaScript.
60 to play this video, JavaScript must be enabled in your browser. the
61 default video playback behavior is not enabled here as it results in a
62 worse experience on slower internet connections, and
63 <a href="http://caniuse.com/mpeg-dash">
64 MPEG-DASH streaming is not natively supported in any browser
65 </a>
66 without JavaScript.
5467 </p>
5568 <p>
56 since disabling JavaScript is not the default in any browser,
57 you likely wanted to download the raw video file anyways.
58 those links are available below, but note that you will need
59 an AV1-compatible video player, such as VLC Media Player or
60 QuickTime Player (M3 chip or newer)
69 since disabling JavaScript is not the default in any browser, you
70 likely wanted to download the raw video file anyways. those links are
71 available below, but note that you will need an AV1-compatible video
72 player, such as VLC Media Player or QuickTime Player (M3 chip or
73 newer)
6174 </p>
6275 </noscript>
6376 </div>
6477 </figure>
65</>
78</if>
6679<else>
67 <Inner />
68</>
80 <Inner/>
81</else>
6982
7083client import "./clover-video.client.ts";
7184
src/tags/photo-grid.marko+25-23
......@@ -1,4 +1,4 @@
1import './photo-grid.css';
1import "./photo-grid.css";
22
33static const gap = 11;
44
......@@ -39,12 +39,12 @@ static const alignments = {
3939 "right-end": "100% 50%",
4040};
4141
42<const/{ base, cols, style } = input />
43<const/rows = input.rows ?? [325] />
44<const/colCount = input.cols.length />
45<const/rowSum = rows.reduce((a, b) => a + b, 0) + gap * (rows.length - 1) />
42<const/{ base, cols, style }=input>
43<const/rows=input.rows ?? [325]>
44<const/colCount=input.cols.length>
45<const/rowSum=rows.reduce((a, b) => a + b, 0) + gap * (rows.length - 1)>
4646
47<const/items = (() => {
47<const/items=(() => {
4848 let occupied: boolean[][] = [];
4949 const row = (y: number) => (occupied[y] ??= new Array(colCount).fill(false));
5050 const anyCollide = (x: number, y: number, w: number, h: number) => {
......@@ -55,18 +55,20 @@ static const alignments = {
5555 return false;
5656 };
5757
58 let x = 0, y = 0;
58 let x = 0,
59 y = 0;
5960 let result: Positioned[] = [];
6061 for (const img of input.img) {
6162 const w = img.w ?? 1;
6263 const h = img.h ?? 1;
6364 const s = img.s ?? 0;
64 if (w > colCount) throw new Error(`image ${img.src} is wider than the grid`);
65 if (w > colCount)
66 throw new Error(`image ${img.src} is wider than the grid`);
6567
6668 x += s;
6769 while (x > colCount - w || anyCollide(x, y, w, h)) {
6870 x += 1;
69 if (x > colCount - w) x = 0, y += 1;
71 if (x > colCount - w) ((x = 0), (y += 1));
7072 }
7173
7274 result.push({
......@@ -74,7 +76,7 @@ static const alignments = {
7476 y,
7577 w,
7678 h,
77 src: base ? base + '/' + img.src : img.src,
79 src: base ? base + "/" + img.src : img.src,
7880 align: alignments[img.align] ?? img.align,
7981 });
8082
......@@ -84,24 +86,24 @@ static const alignments = {
8486 }
8587
8688 x += w;
87 if (x >= colCount) x = 0, y += 1;
89 if (x >= colCount) ((x = 0), (y += 1));
8890 }
8991 return result;
90})() />
92})()>
9193
92<${'photo-grid'}
93 style={
94 'grid-template-columns': input.cols.map(x => x + 'fr').join(' '),
95 'grid-template-rows': rows.map(x => (100 * x / rowSum).toFixed(1) + '%').join(' '),
96 'aspect-ratio': `768/${rowSum}`,
97 ...style,
98 }
99>
94<${"photo-grid"} style={
95 "grid-template-columns": input.cols.map((x) => x + "fr").join(" "),
96 "grid-template-rows": rows
97 .map((x) => ((100 * x) / rowSum).toFixed(1) + "%")
98 .join(" "),
99 "aspect-ratio": `768/${rowSum}`,
100 ...style,
101}>
100102 <for|{ x, y, w, h, src, align }| of=items>
101 <${'photo-grid-item'} style={
102 'grid-area': `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
103 <${"photo-grid-item"} style={
104 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
103105 }>
104 <clover-media ...{ src, align } />
106 <clover-media ...{ src, align }/>
105107 </>
106108 </for>
107109</>
src/tags/questions-embed.marko+3-4
......@@ -5,11 +5,10 @@ export interface Input {
55}
66
77<div.questions-embed.qa>
8 <a href="/q+a">ask your own question / view all questions</a>
9 <br />
8 <a href="/q+a">ask your own question / view all questions</a><br>
109 <for|question| of=Question.search(input.value)>
11 <QuestionRender ...{ question } />
12 </>
10 <QuestionRender ...{ question }/>
11 </for>
1312</div>
1413
1514import QuestionRender from "#src/q+a/tags/question.marko";