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 @@...@@ -1,14 +1,13 @@
1{1{
2 "typescript": {
3 },
4 "json": {
5 },
6 "markdown": {2 "markdown": {
7 "textWrap": "always",3 "textWrap": "always",
8 },4 },
9 "malva": {5 "exec": {
10 },6 "cwd": "${configDir}",
11 "markup": {7 "commands": [{
8 "command": "node_modules/.bin/prettier --plugin prettier-plugin-marko {{file_path}}",
9 "exts": ["marko"],
10 }],
12 },11 },
13 "excludes": [12 "excludes": [
14 "**/node_modules",13 "**/node_modules",
...@@ -20,5 +19,6 @@...@@ -20,5 +19,6 @@
20 "https://plugins.dprint.dev/markdown-0.21.1.wasm",19 "https://plugins.dprint.dev/markdown-0.21.1.wasm",
21 "https://plugins.dprint.dev/g-plane/malva-v0.15.2.wasm",20 "https://plugins.dprint.dev/g-plane/malva-v0.15.2.wasm",
22 "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm",21 "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm",
22 "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463",
23 ],23 ],
24}24}
framework/lib/add-script.marko+1-1
...@@ -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,2 +1,2 @@
1<const/_=$global.cloverScripts=input.cloverScripts />1<const/_=$global.cloverScripts = input.cloverScripts>
2<${input.page} ...input.attrs />2<${input.page} ...input.attrs/>
package-lock.json+26
...@@ -19,6 +19,8 @@...@@ -19,6 +19,8 @@
19 "marko": "5.38.33",19 "marko": "5.38.33",
20 "msgpackr": "1.11.5",20 "msgpackr": "1.11.5",
21 "playwright": "1.58.2",21 "playwright": "1.58.2",
22 "prettier": "^3.8.1",
23 "prettier-plugin-marko": "^4.0.9",
22 "source-map-support": "0.5.21",24 "source-map-support": "0.5.21",
23 "typescript": "6.0.2",25 "typescript": "6.0.2",
24 "unique-names-generator": "4.7.1",26 "unique-names-generator": "4.7.1",
...@@ -2178,6 +2180,30 @@...@@ -2178,6 +2180,30 @@
2178 "node": "^10 || ^12 || >=14"2180 "node": "^10 || ^12 || >=14"
2179 }2181 }
2180 },2182 },
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 },
2181 "node_modules/raptor-async": {2207 "node_modules/raptor-async": {
2182 "version": "1.1.3",2208 "version": "1.1.3",
2183 "resolved": "https://registry.npmjs.org/raptor-async/-/raptor-async-1.1.3.tgz",2209 "resolved": "https://registry.npmjs.org/raptor-async/-/raptor-async-1.1.3.tgz",
package.json+2
...@@ -15,6 +15,8 @@...@@ -15,6 +15,8 @@
15 "marko": "5.38.33",15 "marko": "5.38.33",
16 "msgpackr": "1.11.5",16 "msgpackr": "1.11.5",
17 "playwright": "1.58.2",17 "playwright": "1.58.2",
18 "prettier": "^3.8.1",
19 "prettier-plugin-marko": "^4.0.9",
18 "source-map-support": "0.5.21",20 "source-map-support": "0.5.21",
19 "typescript": "6.0.2",21 "typescript": "6.0.2",
20 "unique-names-generator": "4.7.1",22 "unique-names-generator": "4.7.1",
src/blog/tags/blog-layout.marko+52-53
...@@ -6,7 +6,7 @@ export const theme = {...@@ -6,7 +6,7 @@ export const theme = {
6};6};
7export const components = {7export const components = {
8 heading: Heading,8 heading: Heading,
9 codeBlock: CodeBlock9 codeBlock: CodeBlock,
10};10};
1111
12export interface Module {12export interface Module {
...@@ -35,68 +35,67 @@ export interface Context {...@@ -35,68 +35,67 @@ export interface Context {
3535
36export const markoOnly = true;36export const markoOnly = true;
3737
38<const/module: Module = input.module />38<const/module: Module=input.module>
39<const/{ date, translation } = module />39<const/{ date, translation }=module>
40<const/{ title, description, keywords, canonical } = module.meta />40<const/{ title, description, keywords, canonical }=module.meta>
41<const/slug = canonical.replace(/^\/blog\/|\.[a-zA-Z_-]+$/g, '') />41<const/slug=canonical.replace(/^\/blog\/|\.[a-zA-Z_-]+$/g, "")>
4242
43<const/translations=43<const/translations=(
44 Path.resolve(import.meta.dirname, "../pages/" + slug).readDirSync()44 Path.resolve(import.meta.dirname, "../pages/" + slug)
45 .readDirSync()
45 .filter((x) => x.ext === ".mdo" && x.baseWithoutExt !== "en")46 .filter((x) => x.ext === ".mdo" && x.baseWithoutExt !== "en")
46 .map((x) => x.baseWithoutExt) />47 .map((x) => x.baseWithoutExt)
47<const/t = UNWRAP(localization.languages[translation?.lang ?? "en"]) />48)>
49<const/t=UNWRAP(localization.languages[translation?.lang ?? "en"])>
4850
49<const/_=(() => {51<const/_=(() => {
50 $global.cloverOutline = input.outline;52 $global.cloverOutline = input.outline;
51})() />53})()>
5254
53<main>55<main>
54<header>56 <header>
55<a href="/">${t.return}</a>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}</>61 <div.meta>
58<p class="description"><em>${description}</em></p>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>95 <hr>
61 <a class="meta-author" href="/">${t.writtenByClover}</a>96 </header>
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} />
9997
98 <${input.content}/>
100</main>99</main>
101100
102import * as localization from "../localization.ts";101import * as localization from "../localization.ts";
src/blog/tags/code-block.marko+14-9
...@@ -1,15 +1,20 @@...@@ -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)3<const/highlightedPromise=(
4 ? highlight.highlightCode(content.trim(), language)4 ["tsx", "jsx", "json", "html", "ts", "js"].includes(language)
5 : content.trim()5 ? highlight.highlightCode(content.trim(), language)
6/>6 : content.trim()
7)>
78
8<figure class=["code", tint]>9<figure class=["code", tint]>
9<if=filename><figcaption>${filename}</figcaption></>10 <if=filename>
10<await|highlighted|=highlightedPromise>11 <figcaption>${filename}</figcaption>
11 <pre style=whitespace ? { "white-space": whitespace } : {}><code>$!{highlighted}</code></pre>12 </if>
12</>13 <await|highlighted|=highlightedPromise>
14 <pre style=whitespace ? { "white-space": whitespace } : {}><code>$!{
15 highlighted
16 }</code></pre>
17 </await>
13</figure>18</figure>
1419
15import * as highlight from "#src/file-viewer/highlight.ts";20import * as highlight from "#src/file-viewer/highlight.ts";
src/blog/tags/heading.marko+3-3
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1<const/{ level, content, ...attrs } = input />1<const/{ level, content, ...attrs }=input>
2<${'h' + level} ...attrs>2<${"h" + level} ...attrs>
3 <a.return href="#toc">§</a>3 <a.return href="#toc">§</a>
4 <${content} />4 <${content}/>
5</>5</>
src/blog/tags/table-of-contents.marko+18-10
...@@ -5,25 +5,33 @@ export interface Node {...@@ -5,25 +5,33 @@ export interface Node {
5 children: Node[];5 children: Node[];
6}6}
77
8<const/outline = $global.cloverOutline />8<const/outline=$global.cloverOutline>
9<const/groups = markodown.outlineToTree(outline) />9<const/groups=markodown.outlineToTree(outline)>
1010
11<define/Recurse|input: markodown.HeadingTree|>11<define/Recurse|input: markodown.HeadingTree|>
12 <a href=`#${input.id}`><${input.content} /></a>12 <a href=`#${input.id}`>
13 <${input.content}/>
14 </a>
13 <if=input.children.length>15 <if=input.children.length>
14 <ul>16 <ul>
15 <for|item| of=input.children>17 <for|item| of=input.children>
16 <li><Recurse ...item /></li>18 <li>
17 </>19 <Recurse ...item/>
20 </li>
21 </for>
18 </ul>22 </ul>
19 </>23 </if>
20</>24</define>
2125
22<div#toc>26<div#toc>
23 <h2>Contents:</h2>27 <h2>Contents:</h2>
24 <ul><for|item| of=groups>28 <ul>
25 <li><Recurse ...item /></li>29 <for|item| of=groups>
26 </></ul>30 <li>
31 <Recurse ...item/>
32 </li>
33 </for>
34 </ul>
27</div>35</div>
2836
29import * as markodown from "@clo/markodown";37import * 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;...@@ -15,8 +15,7 @@ const sotToken = process.env.CLOVER_SOT_KEY;
15export async function main() {15export async function main() {
16 const start = performance.now();16 const start = performance.now();
17 using _ = log.startWidget({17 using _ = log.startWidget({
18 format: ({ now }) =>18 format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
19 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
20 });19 });
2120
22 const promises = new async.PromiseAggregator();21 const promises = new async.PromiseAggregator();
...@@ -63,10 +62,10 @@ export async function main() {...@@ -63,10 +62,10 @@ export async function main() {
63 }62 }
6463
65 if (64 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.
67 // Rerun all processors if it changed66 // Rerun all processors if it changed
68 stat.size !== mediaFile.size ||67 || stat.size !== mediaFile.size
69 stat.mtime.getTime() !== mediaFile.date.getTime()68 || stat.mtime.getTime() !== mediaFile.date.getTime()
70 ) {69 ) {
71 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));70 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));
72 } else {71 } else {
...@@ -101,9 +100,9 @@ export async function main() {...@@ -101,9 +100,9 @@ export async function main() {
101 });100 });
102 let date = stat.mtime;101 let date = stat.mtime;
103 if (102 if (
104 mediaFile &&103 mediaFile
105 mediaFile.date.getTime() < stat.mtime.getTime() &&104 && mediaFile.date.getTime() < stat.mtime.getTime()
106 Date.now() - stat.mtime.getTime() < monthMilliseconds105 && Date.now() - stat.mtime.getTime() < monthMilliseconds
107 ) {106 ) {
108 date = mediaFile.date;107 date = mediaFile.date;
109 console.warn(108 console.warn(
...@@ -137,7 +136,7 @@ export async function main() {...@@ -137,7 +136,7 @@ export async function main() {
137 },136 },
138 () => ({ priority: -1 }),137 () => ({ priority: -1 }),
139 );138 );
140 const queueFileProcessor = queue.wrap(async function ({139 const queueFileProcessor = queue.wrap(async function({
141 path,140 path,
142 stat,141 stat,
143 mediaFile,142 mediaFile,
...@@ -190,9 +189,7 @@ export async function main() {...@@ -190,9 +189,7 @@ export async function main() {
190 node.passive = true;189 node.passive = true;
191190
192 const ext = mediaFile.extensionNonEmpty.toLowerCase();191 const ext = mediaFile.extensionNonEmpty.toLowerCase();
193 let possible = processors.filter((p) =>192 let possible = processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext));
194 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
195 );
196 if (possible.length === 0) return;193 if (possible.length === 0) return;
197 ASSERT(possible.length < 16, "too many bits");194 ASSERT(possible.length < 16, "too many bits");
198195
...@@ -227,9 +224,7 @@ export async function main() {...@@ -227,9 +224,7 @@ export async function main() {
227 .join(";"),224 .join(";"),
228 );225 );
229 } else {226 } else {
230 possible = order.map(({ id }) =>227 possible = order.map(({ id }) => UNWRAP(possible.find((p) => p.id === id)));
231 UNWRAP(possible.find((p) => p.id === id))
232 );
233 }228 }
234229
235 // Queue needed processors.230 // Queue needed processors.
...@@ -294,14 +289,11 @@ export async function main() {...@@ -294,14 +289,11 @@ export async function main() {
294 const children = dir.getChildren();289 const children = dir.getChildren();
295290
296 // readme.txt291 // readme.txt
297 const readmeContent = children.find((x) =>292 const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? "";
298 x.basename === "readme.txt"
299 )?.contents ?? "";
300293
301 // dirsort294 // dirsort
302 let dirsort: string[] | null = null;295 let dirsort: string[] | null = null;
303 const dirSortRaw =296 const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? "";
304 children.find((x) => x.basename === ".dirsort")?.contents ?? "";
305 if (dirSortRaw) {297 if (dirSortRaw) {
306 dirsort = dirSortRaw298 dirsort = dirSortRaw
307 .split("\n")299 .split("\n")
...@@ -417,9 +409,9 @@ export async function main() {...@@ -417,9 +409,9 @@ export async function main() {
417 } else console.warn("Missing SOT token");409 } else console.warn("Missing SOT token");
418410
419 console.info(411 console.info(
420 "Updated file viewer index in \x1b[1m" +412 "Updated file viewer index in \x1b[1m"
421 ((performance.now() - start) / 1000).toFixed(1) +413 + ((performance.now() - start) / 1000).toFixed(1)
422 "s\x1b[0m",414 + "s\x1b[0m",
423 );415 );
424416
425 const { duration, count } = MediaFile.db417 const { duration, count } = MediaFile.db
...@@ -442,12 +434,12 @@ export async function main() {...@@ -442,12 +434,12 @@ export async function main() {
442434
443 console.info();435 console.info();
444 console.info(436 console.info(
445 "Global Stats:\n" +437 "Global Stats:\n"
446 `- File Count: \x1b[1m${count}\x1b[0m\n` +438 + `- File Count: \x1b[1m${count}\x1b[0m\n`
447 `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n` +439 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`
448 `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n` +440 + `- Media Duration: \x1b[1m${formatDurationLong(duration)}\x1b[0m\n`
449 `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n` +441 + `- Canonical Size: \x1b[1m${formatSize(canonicalSize)}\x1b[0m\n`
450 `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,442 + `- Derived Size: \x1b[1m${formatSize(derivedSize)}\x1b[0m\n`,
451 );443 );
452}444}
453445
...@@ -837,8 +829,8 @@ const processors = [...@@ -837,8 +829,8 @@ const processors = [
837 crypto829 crypto
838 .createHash("sha1")830 .createHash("sha1")
839 .update(831 .update(
840 process.run.toString() +832 process.run.toString()
841 (process.version ? String(process.version) : ""),833 + (process.version ? String(process.version) : ""),
842 )834 )
843 .digest().buffer,835 .digest().buffer,
844 ).reduce((a, b) => a ^ b),836 ).reduce((a, b) => a ^ b),
...@@ -888,12 +880,12 @@ function skipBasename(basename: string): boolean {...@@ -888,12 +880,12 @@ function skipBasename(basename: string): boolean {
888 if (basename === ".date") return false;880 if (basename === ".date") return false;
889881
890 return (882 return (
891 basename.startsWith(".") ||883 basename.startsWith(".")
892 // basename.startsWith("._") ||884 // basename.startsWith("._") ||
893 // basename.startsWith(".tmp") ||885 // basename.startsWith(".tmp") ||
894 // basename === ".DS_Store" ||886 // basename === ".DS_Store" ||
895 basename.toLowerCase() === "thumbs.db" ||887 || basename.toLowerCase() === "thumbs.db"
896 basename.toLowerCase() === "desktop.ini"888 || basename.toLowerCase() === "desktop.ini"
897 );889 );
898}890}
899891
...@@ -946,8 +938,8 @@ async function scrubLocationMetadata(...@@ -946,8 +938,8 @@ async function scrubLocationMetadata(
946 "-s",938 "-s",
947 path.toString(),939 path.toString(),
948 ]);940 ]);
949 hasLocation = videoCheck.includes("GPS") ||941 hasLocation = videoCheck.includes("GPS")
950 videoCheck.includes("Location");942 || videoCheck.includes("Location");
951 args = [943 args = [
952 "-gps:all=",944 "-gps:all=",
953 "-xmp:all=",945 "-xmp:all=",
...@@ -963,10 +955,10 @@ async function scrubLocationMetadata(...@@ -963,10 +955,10 @@ async function scrubLocationMetadata(
963 "-s",955 "-s",
964 path.toString(),956 path.toString(),
965 ]);957 ]);
966 hasLocation = m4aCheck.includes("GPS") ||958 hasLocation = m4aCheck.includes("GPS")
967 m4aCheck.includes("Location") ||959 || m4aCheck.includes("Location")
968 m4aCheck.includes("Filename") ||960 || m4aCheck.includes("Filename")
969 m4aCheck.includes("Title");961 || m4aCheck.includes("Title");
970962
971 if (hasLocation) {963 if (hasLocation) {
972 args = [964 args = [
...@@ -1028,13 +1020,13 @@ async function scrubLocationMetadata(...@@ -1028,13 +1020,13 @@ async function scrubLocationMetadata(
10281020
1029const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;1021const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
10301022
1031import * as async from "@clo/lib/async";
1032import * as fs from "#sitegen/fs";1023import * as fs from "#sitegen/fs";
1033import * as subprocess from "@clo/lib/subprocess";
1034import { Path } from "#sitegen/path";1024import { Path } from "#sitegen/path";
1035import * as queue from "@clo/lib/queue";1025import * as async from "@clo/lib/async";
1036import * as log from "@clo/lib/log";1026import * as log from "@clo/lib/log";
1037import * as progress from "@clo/lib/progress";1027import * as progress from "@clo/lib/progress";
1028import * as queue from "@clo/lib/queue";
1029import * as subprocess from "@clo/lib/subprocess";
1038import * as ts from "@clo/lib/ts";1030import * as ts from "@clo/lib/ts";
10391031
1040import * as child_process from "node:child_process";1032import * as child_process from "node:child_process";
...@@ -1044,19 +1036,15 @@ import * as path from "node:path";...@@ -1044,19 +1036,15 @@ import * as path from "node:path";
1044import * as stream from "node:stream";1036import * as stream from "node:stream";
1045import * as zlib from "node:zlib";1037import * as zlib from "node:zlib";
10461038
1047import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";1039import { formatDate, formatDurationLong, formatSize } from "#src/file-viewer/format.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";
1055import * as highlight from "#src/file-viewer/highlight.ts";1040import * 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";
1058import * as derived from "#src/file-viewer/models/derived.ts";1041import * 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";
1059import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";1046import * 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";
1062import { ASSERT, UNWRAP } from "@clo/lib/assert";1049import { 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 @@...@@ -1,8 +1,7 @@
1export async function main() {1export async function main() {
2 const start = performance.now();2 const start = performance.now();
3 using _ = log.startWidget({3 using _ = log.startWidget({
4 format: (now) =>4 format: (now) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
5 `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
6 });5 });
76
8 const orphaned = derived.findOrphanedRoots();7 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", () => {...@@ -226,15 +226,10 @@ test("dense sweep never produces mismatched ladder aspect ratios", () => {
226 }226 }
227});227});
228228
229import { test } from "vitest";
230import assert from "node:assert";
231import { Path } from "#sitegen/path";229import { Path } from "#sitegen/path";
232import {230import assert from "node:assert";
233 getAv1VideoArgs,231import { test } from "vitest";
234 getConstantAspectScaleFilter,232import { getAv1VideoArgs, getConstantAspectScaleFilter, getMpegDashArgs, videoFormats } from "./transcode-rules.ts";
235 getMpegDashArgs,
236 videoFormats,
237} from "./transcode-rules.ts";
238233
239function assertString(value: string | undefined | null): string {234function assertString(value: string | undefined | null): string {
240 assert.equal(typeof value, "string");235 assert.equal(typeof value, "string");
...@@ -275,9 +270,7 @@ const EXACT_ERROR_THRESHOLD = 0.0005;...@@ -275,9 +270,7 @@ const EXACT_ERROR_THRESHOLD = 0.0005;
275function getFeasibleCandidates(source: { width: number; height: number }) {270function getFeasibleCandidates(source: { width: number; height: number }) {
276 const caps = [271 const caps = [
277 source.height,272 source.height,
278 ...videoFormats.flatMap((preset) =>273 ...videoFormats.flatMap((preset) => preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : []),
279 preset.maxHeight != null ? [Math.min(preset.maxHeight, source.height)] : []
280 ),
281 ];274 ];
282 const sourceRatio = source.width / source.height;275 const sourceRatio = source.width / source.height;
283 const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2));276 const maxDenominator = Math.max(1, Math.floor(Math.min(...caps) / 2));
...@@ -300,16 +293,14 @@ function getFeasibleCandidates(source: { width: number; height: number }) {...@@ -300,16 +293,14 @@ function getFeasibleCandidates(source: { width: number; height: number }) {
300 if (seen.has(key)) continue;293 if (seen.has(key)) continue;
301 seen.add(key);294 seen.add(key);
302295
303 const dims = videoFormats.map((preset) =>296 const dims = videoFormats.map((preset) => getExpectedDimensionsForPreset(source, candidate, preset.maxHeight));
304 getExpectedDimensionsForPreset(source, candidate, preset.maxHeight)
305 );
306 if (dims.some((value) => value.width <= 0 || value.height <= 0)) continue;297 if (dims.some((value) => value.width <= 0 || value.height <= 0)) continue;
307298
308 const retainedArea = dims299 const retainedArea = dims
309 .map((value) => value.width * value.height)300 .map((value) => value.width * value.height)
310 .reduce((sum, value) => sum + value, 0);301 .reduce((sum, value) => sum + value, 0);
311 const error = Math.abs((candidate.width / candidate.height) - sourceRatio) /302 const error = Math.abs((candidate.width / candidate.height) - sourceRatio)
312 sourceRatio;303 / sourceRatio;
313 candidates.push({ ...candidate, error, retainedArea });304 candidates.push({ ...candidate, error, retainedArea });
314 }305 }
315306
...@@ -337,9 +328,7 @@ function getCandidateScore(...@@ -337,9 +328,7 @@ function getCandidateScore(
337 source: { width: number; height: number },328 source: { width: number; height: number },
338 ratio: { width: number; height: number },329 ratio: { width: number; height: number },
339) {330) {
340 const dims = videoFormats.map((preset) =>331 const dims = videoFormats.map((preset) => getExpectedDimensionsForPreset(source, ratio, preset.maxHeight));
341 getExpectedDimensionsForPreset(source, ratio, preset.maxHeight)
342 );
343 if (dims.some((value) => value.width <= 0 || value.height <= 0)) return null;332 if (dims.some((value) => value.width <= 0 || value.height <= 0)) return null;
344 const sourceRatio = source.width / source.height;333 const sourceRatio = source.width / source.height;
345 return {334 return {
src/file-viewer/transcode-rules.ts+42-31
...@@ -202,9 +202,7 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {...@@ -202,9 +202,7 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
202 if (seen.has(key)) continue;202 if (seen.has(key)) continue;
203 seen.add(key);203 seen.add(key);
204204
205 const dimensions = caps.map((cap) =>205 const dimensions = caps.map((cap) => getExactSquarePixelDimensions(source, aspect, cap));
206 getExactSquarePixelDimensions(source, aspect, cap)
207 );
208 if (dimensions.some((value) => value == null)) continue;206 if (dimensions.some((value) => value == null)) continue;
209207
210 const retainedArea = dimensions208 const retainedArea = dimensions
...@@ -215,11 +213,11 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {...@@ -215,11 +213,11 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
215213
216 if (error <= exactErrorThreshold) {214 if (error <= exactErrorThreshold) {
217 if (215 if (
218 !bestWithinThreshold ||216 !bestWithinThreshold
219 retainedArea > bestWithinThreshold.retainedArea ||217 || retainedArea > bestWithinThreshold.retainedArea
220 (218 || (
221 retainedArea === bestWithinThreshold.retainedArea &&219 retainedArea === bestWithinThreshold.retainedArea
222 error < bestWithinThreshold.error220 && error < bestWithinThreshold.error
223 )221 )
224 ) {222 ) {
225 bestWithinThreshold = candidate;223 bestWithinThreshold = candidate;
...@@ -228,9 +226,9 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {...@@ -228,9 +226,9 @@ function getCanonicalSquarePixelAspectRatio(source: Dimensions): Dimensions {
228 }226 }
229227
230 if (228 if (
231 !bestFallback ||229 !bestFallback
232 error < bestFallback.error ||230 || error < bestFallback.error
233 (error === bestFallback.error && retainedArea > bestFallback.retainedArea)231 || (error === bestFallback.error && retainedArea > bestFallback.retainedArea)
234 ) {232 ) {
235 bestFallback = candidate;233 bestFallback = candidate;
236 }234 }
...@@ -247,8 +245,8 @@ export function getConstantAspectScaleFilter(...@@ -247,8 +245,8 @@ export function getConstantAspectScaleFilter(
247 const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight);245 const dimensions = getExactSquarePixelDimensions(source, aspect, maxHeight);
248 if (!dimensions) return null;246 if (!dimensions) return null;
249 if (247 if (
250 dimensions.width === source.width &&248 dimensions.width === source.width
251 dimensions.height === source.height249 && dimensions.height === source.height
252 ) return null;250 ) return null;
253 return `scale=${dimensions.width}:${dimensions.height}`;251 return `scale=${dimensions.width}:${dimensions.height}`;
254}252}
...@@ -336,9 +334,7 @@ export function getMpegDashArgs(...@@ -336,9 +334,7 @@ export function getMpegDashArgs(
336 if (audios.length > 0) {334 if (audios.length > 0) {
337 const audioId = videos.length > 0 ? 1 : 0;335 const audioId = videos.length > 0 ? 1 : 0;
338 const audioIndices = audios.map((_, i) => videos.length + i).join(",");336 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
339 adaptationSets += `${337 adaptationSets += `${adaptationSets ? " " : ""}id=${audioId},streams=${audioIndices}`;
340 adaptationSets ? " " : ""
341 }id=${audioId},streams=${audioIndices}`;
342 }338 }
343339
344 cmd.push("-adaptation_sets", adaptationSets);340 cmd.push("-adaptation_sets", adaptationSets);
...@@ -378,11 +374,16 @@ export function getH264HlsArgs(...@@ -378,11 +374,16 @@ export function getH264HlsArgs(
378 const aIdx = i * 2 + 1;374 const aIdx = i * 2 + 1;
379 // deno-fmt-ignore375 // deno-fmt-ignore
380 cmd.push(376 cmd.push(
381 `-c:${vIdx}`, "libx264",377 `-c:${vIdx}`,
382 `-b:v:${vIdx}`, preset.videoBitrate,378 "libx264",
383 `-filter:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,379 `-b:v:${vIdx}`,
384 `-c:${aIdx}`, "aac",380 preset.videoBitrate,
385 `-b:a:${aIdx}`, preset.audioBitrate,381 `-filter:${vIdx}`,
382 `scale=-2:min(${preset.maxHeight}\\,ih)`,
383 `-c:${aIdx}`,
384 "aac",
385 `-b:a:${aIdx}`,
386 preset.audioBitrate,
386 );387 );
387 });388 });
388389
...@@ -399,9 +400,12 @@ export function getH264HlsArgs(...@@ -399,9 +400,12 @@ export function getH264HlsArgs(
399 hlsPresets.forEach((preset, i) => {400 hlsPresets.forEach((preset, i) => {
400 // deno-fmt-ignore401 // deno-fmt-ignore
401 cmd.push(402 cmd.push(
402 `-c:v:${i}`, "libx264",403 `-c:v:${i}`,
403 `-b:v:${i}`, preset.videoBitrate,404 "libx264",
404 `-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`,405 `-b:v:${i}`,
406 preset.videoBitrate,
407 `-filter:v:${i}`,
408 `scale=-2:min(${preset.maxHeight}\\,ih)`,
405 );409 );
406 });410 });
407411
...@@ -413,13 +417,20 @@ export function getH264HlsArgs(...@@ -413,13 +417,20 @@ export function getH264HlsArgs(
413417
414 // deno-fmt-ignore418 // deno-fmt-ignore
415 cmd.push(419 cmd.push(
416 "-f", "hls",420 "-f",
417 "-hls_time", "2",421 "hls",
418 "-hls_list_size", "0",422 "-hls_time",
419 "-hls_segment_type", "fmp4",423 "2",
420 "-hls_allow_cache", "1",424 "-hls_list_size",
421 "-master_pl_name", "master.m3u8",425 "0",
422 "-hls_segment_filename", "hls.%v.%03d.ts",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",
423 "-y",434 "-y",
424 outDir.join("hls.%v.m3u8").toString(),435 outDir.join("hls.%v.m3u8").toString(),
425 );436 );
src/file-viewer/views/canvas.marko+6-5
...@@ -3,15 +3,16 @@ export interface Input {...@@ -3,15 +3,16 @@ export interface Input {
3}3}
4export const meta = { title: "canvas" };4export const meta = { title: "canvas" };
55
6<const/{ script } = input />6<const/{ script }=input>
77
8<canvas id="canvas"8<canvas
9 id="canvas"
9 data-canvas=script10 data-canvas=script
10 data-standalone="true"11 data-standalone="true"
11 style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"12 style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
12></canvas>13/>
13<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts" />14<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts"/>
1415
15client import "./canvas.client.ts";16client 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 {...@@ -5,66 +5,87 @@ export interface Input {
5}5}
6export { meta, theme } from "./clofi.tsx";6export { meta, theme } from "./clofi.tsx";
77
8<const/{ file: dir, hasCotyledonCookie } = input />8<const/{ file: dir, hasCotyledonCookie }=input>
9<const/{ path: fullPath, dirname, basename, kind } = dir />9<const/{ path: fullPath, dirname, basename, kind }=dir>
10<const/isRoot = fullPath == '/'>10<const/isRoot=fullPath == "/">
1111
12<div#lofi>12<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 }|>33 <h1>
15 <const/dir=file.kind === MediaFileKind.directory/>34 <if=isRoot>clo's files</if>
16 <const/meta=(35 <else>${fullPath}</else>
17 dir36 </h1>
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>
3037
31<h1>38 <if=isRoot>
32 <if=isRoot>clo's files</>39 <const/{ readme, sections }=sort.splitRootDirFiles(dir, hasCotyledonCookie)>
33 <else>${fullPath}</>40 <if=readme>
34</h1>41 <ul>
3542 <ListItem=readme/>
36<if=isRoot>43 </ul>
37 <const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />44 </if>
38 <if=readme><ul><ListItem=readme/></ul></>45 <for|{ key, files, titleColor }| of=sections>
39 <for|{key, files, titleColor }| of=sections>46 <h2 style={ color: titleColor }>${key}</h2>
40 <h2 style={color: titleColor }>${key}</>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>
41 <ul>68 <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>
43 </ul>79 </ul>
44 </>80 </else>
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
65</div>81</div>
6682
67import * as path from "node:path";83import * 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";
69import { MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";90import { MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
70import * as sort from "#src/file-viewer/sort.ts";91import * as sort from "#src/file-viewer/sort.ts";
src/file-viewer/views/lofideon.marko+19-15
...@@ -1,22 +1,26 @@...@@ -1,22 +1,26 @@
1export interface Input {1export interface Input {
2 stage: number2 stage: number;
3}3}
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" };
5export const theme = {5export const theme = {
6 bg: '#ff00ff',6 bg: "#ff00ff",
7 fg: '#000000',7 fg: "#000000",
8};8};
99
10<h1>co<br>ty<br>le<br>don</h1>10<h1>co<br>ty<br>le<br>don</h1>
1111
12<if=input.stage==0>12<if=input.stage == 0>
1313 <p>
14<p>14 this place is sacred, but dangerous. i have to keep visitors to an absolute
15this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.15 minimum; you'll get dust on all the artifacts.
16</p><p>16 </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 sensor17 <p>
18</p><p>18 by entering our museum, you agree not to use your camera. flash off isn't
19<sub>(in english: please do not store downloads after you're done viewing them)</sub>19 enough; the bits and bytes are alergic even to a camera's sensor
20</p>20 </p>
2121 <p>
22</>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 @@...@@ -1,4 +1,4 @@
1export const meta = { title: 'paper clover admin' };1export const meta = { title: "paper clover admin" };
22
3<main>3<main>
4 <h1>login</h1>4 <h1>login</h1>
src/pages/admin/login.marko+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1export const meta = { title: 'paper clover admin' };1export const meta = { title: "paper clover admin" };
22
3<main>3<main>
4 <h1>login</h1>4 <h1>login</h1>
src/pages/friends/auth.fail.marko+1-1
...@@ -5,7 +5,7 @@ export const meta = { title: "password required" };...@@ -5,7 +5,7 @@ export const meta = { title: "password required" };
5 <p>please contact clover</p>5 <p>please contact clover</p>
6 <form method="post">6 <form method="post">
7 <h1>try again</h1>7 <h1>try again</h1>
8 <input type="password" name="password" placeholder="password" />8 <input type="password" name="password" placeholder="password">
9 <button type="submit">enter</button>9 <button type="submit">enter</button>
10 </form>10 </form>
11</main>11</main>
src/pages/friends/auth.marko+1-1
...@@ -3,7 +3,7 @@ export const meta = { title: "password required" };...@@ -3,7 +3,7 @@ export const meta = { title: "password required" };
3<main>3<main>
4 <form method="post">4 <form method="post">
5 <h1>what's the password</h1>5 <h1>what's the password</h1>
6 <input type="password" name="password" placeholder="password"/>6 <input type="password" name="password" placeholder="password">
7 <button type="submit">enter</button>7 <button type="submit">enter</button>
8 <p>(clover will give the password to you if you're a friend)</p>8 <p>(clover will give the password to you if you're a friend)</p>
9 </form>9 </form>
src/pages/friends/misconfigure.marko+1-1
...@@ -3,7 +3,7 @@ export const meta = { title: "password required" };...@@ -3,7 +3,7 @@ export const meta = { title: "password required" };
3<main>3<main>
4 <form method="post" action="/friends">4 <form method="post" action="/friends">
5 <h1>what's the password</h1>5 <h1>what's the password</h1>
6 <input type="password" name="password" placeholder="password" disabled />6 <input type="password" name="password" placeholder="password" disabled>
7 <button type="submit" disabled>enter</button>7 <button type="submit" disabled>enter</button>
8 <p style="color:red">this page's password is not configured</p>8 <p style="color:red">this page's password is not configured</p>
9 </form>9 </form>
src/pages/history-of-japan-reanimated.marko+142-53
...@@ -4,97 +4,186 @@ client import "./history-of-japan-reanimated.client.ts";...@@ -4,97 +4,186 @@ client import "./history-of-japan-reanimated.client.ts";
44
5export const meta = {5export const meta = {
6 title: "history of japan reanimated",6 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.',
8 embed: {9 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",
10 },12 },
11 canonical: "/history-of-japan-reanimated",13 canonical: "/history-of-japan-reanimated",
12 twitter: {14 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",
14 },17 },
15};18};
16export const theme = {19export const theme = {
17 fg: "#fff",20 fg: "#fff",
18 bg: "#442233",21 bg: "#442233",
19 primary: "#ff8b47"22 primary: "#ff8b47",
20};23};
2124
22<main>25<main>
23 <nav><a href="/">back to home</a></nav>26 <nav><a href="/">back to home</a></nav>
24 <h1>history of japan reanimated</h1>27 <h1>history of japan reanimated</h1>
25 <p>28 <p>
26 me and 16 other artists (music, narration, and animation) have been29 me and 16 other artists (music, narration, and animation) have been spending
27 spending the past couple months on a recreation of bill wurtz's legendary30 the past couple months on a recreation of bill wurtz's legendary "<a href="https://billwurtz.com/history-of-japan.html">
28 "<a href="https://billwurtz.com/history-of-japan.html">history of japan</a>" to celebrate the 10 year anniversary of his release. i31 history of japan
29 contributed two animation sections. the canonical release is on32 </a>" to celebrate the 10 year anniversary of his release. i contributed two
30 <a href="https://youtu.be/nqsoti-OuRM">LunaPogi's YouTube page</a>, but i33 animation sections. the canonical release is on
31 am providing this ad-free video player.34 <a href="https://youtu.be/nqsoti-OuRM">LunaPogi's YouTube page</a>, but i am providing
35 this ad-free video player.
32 </p>36 </p>
3337
34<clover-video38 <clover-video
35 id="main-player"39 id="main-player"
36 file="/2026/history of japan reanimated/history of japan reanimated.mp4"40 file="/2026/history of japan reanimated/history of japan reanimated.mp4"
37 poster="/2026/history of japan reanimated/thumbnail.jpeg"41 poster="/2026/history of japan reanimated/thumbnail.jpeg"
38>42 >
39 <@header>history of japan reanimated</>43 <@header>history of japan reanimated</@header>
40</>44 </clover-video>
41
42<div id="live-credits">
43
4445
45</div>46 <div id="live-credits"/>
4647
47 <p>48 <p>the wonderful souls who worked on the project, in order of appearance.</p>
48 the wonderful souls who worked on the project, in order of appearance.
49 </p>
50 <ul>49 <ul>
51 <li><a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">Kappacap</a> - thumbnail artist</li>50 <li>
52 <li><a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation <span style="color:#B4A7AD">(0:03-1:02)</></li>51 <a href="https://www.youtube.com/channel/UClywJ-scBa8hcCywQ_7gWbg">
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>52 Kappacap
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>53 </a>
55 <li><a href="https://chaosyumi.net">chaosyumi</a> - animation <span style="color:#B4A7AD">(1:34-2:01, 5:34-6:05)</></li>54 - thumbnail artist
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>55 </li>
57 <li><a href="https://www.youtube.com/channel/UChec240BfC1UKX9DvBBIyLQ">zeccet (bgongo)</a> - animation <span style="color:#B4A7AD">(2:23-3:10)</></li>56 <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>57 <a href="https://www.tumblr.com/atamai-m8">atamai</a> - animation
59 <li><a href="https://www.castingcall.club/fredfrenchiii">Fred French VA</a> - narration <span style="color:#B4A7AD">(3:11-4:35)</></li>58 <span style="color:#B4A7AD">(0:03-1:02)</span>
60 <li><a href="https://www.youtube.com/channel/UCqeAGMquQNtrSfd-quZX3KQ">rqfirqfo</a> - animation <span style="color:#B4A7AD">(4:04-4:35)</></li>59 </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>60 <li>
62 <li><a href="https://www.youtube.com/channel/UCkhbgaqiIkliOKyEORnkhDg">SimplyDoodled Studios</a> - animation <span style="color:#B4A7AD">(5:05-5:33)</></li>61 <a href="https://www.youtube.com/channel/UCNJ6EbYQcnqpB6cQLGYXQow">
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>62 moxley
64 <li><a href="https://www.youtube.com/channel/UCftnPCmG6dfQ50YOMRMtkjg">Coffee</a> - transcription <span style="color:#B4A7AD">(6:05-9:10)</></li>63 </a>
65 <li><a href="https://www.youtube.com/channel/UCPQv3ueB-E771gnwcgALzxw">harjjw</a> - animation <span style="color:#B4A7AD">(6:38-7:03)</></li>64 - music and narration <span style="color:#B4A7AD">(0:03-3:10)</span>, animation
66 <li><a href="https://www.youtube.com/channel/UCcR5G761gkEKvWskjEqWFkQ">yellowmarkers</a> - animation <span style="color:#B4A7AD">(7:04-7:36)</></li>65 <span style="color:#B4A7AD">(3:51-4:03)</span>
67 <li><a href="https://www.youtube.com/channel/UC8SZGQ32RQxiQeTnQ5hgYAg">TheNothingGuy07</a> - animation <span style="color:#B4A7AD">(7:37-7:59)</></li>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>
68 </ul>146 </ul>
69147
70 <p>148 <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>
72 </p>153 </p>
73154
74 <h2>what's next</h2>155 <h2>what's next</h2>
75 <p>156 <p>
76 from the success of this project, a much more ambitous project is being157 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,158 planned: the reanimation for 'history of the entire world, i guess'. i am
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>.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>.
79 </p>166 </p>
80167
81 <h2>behind the scenes</h2>168 <h2>behind the scenes</h2>
82 <p>169 <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>.
84 </p>176 </p>
85 <clover-video177 <clover-video file="/2026/history of japan reanimated/behind-the-scenes.mp4">
86 file="/2026/history of japan reanimated/behind-the-scenes.mp4"178 <@header>behind the scenes session with clover</@header>
87 >179 </clover-video>
88 <@header>behind the scenes session with clover</>
89 </>
90
91180
92 <h2>more links</h2>181 <h2>more links</h2>
93 <p>182 <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>
95 <a href="/subscribe">paper clover's mailing list</a>184 <a href="/subscribe">paper clover's mailing list</a>
96 </p>185 </p>
97186
98 <h2>mentions on the q&a</h2>187 <h2>mentions on the q&a</h2>
99 <questions-embed="history-of-japan-reanimated" />188 <questions-embed="history-of-japan-reanimated"/>
100</main>189</main>
src/pages/in-the-summer.marko+44-32
...@@ -1,43 +1,55 @@...@@ -1,43 +1,55 @@
1export const meta = { title: 'in the summer' };1export const meta = { title: "in the summer" };
2export const theme = {2export const theme = {
3 bg: '#7DCC8C',3 bg: "#7DCC8C",
4 fg: '#000',4 fg: "#000",
5 primary: '#9b3706',5 primary: "#9b3706",
6}6};
77
8import "./in-the-summer.css";8import "./in-the-summer.css";
99
10<main style={ 'max-width': '110ch' }>10<main style={ "max-width": "110ch" }>
11<nav><a href="/">back to home</a></nav>11 <nav><a href="/">back to home</a></nav>
12<div.contain>12 <div.contain>
13 <header>13 <header>
14 <h1>in the summer</h1>14 <h1>in the summer</h1>
15 <time style="margin-left:.5rem;opacity:.5">(2025-08-01)</time>15 <time style="margin-left:.5rem;opacity:.5">(2025-08-01)</time>
16 </header>16 </header>
1717
18 <p>18 <p>
19 this is a song about the summer, and related feelings. written in 2022,19 this is a song about the summer, and related feelings. written in 2022,
20 produced in 2025. i had a lot of fun playing with a much larger instrument20 produced in 2025. i had a lot of fun playing with a much larger instrument
21 selection, and new 3d workflows. enjoy!21 selection, and new 3d workflows. enjoy!
22 </p>22 </p>
23</div>23 </div>
2424
25<clover-video25 <clover-video
26 file="/2025/in the summer/in the summer.mp4"26 file="/2025/in the summer/in the summer.mp4"
27 poster="/2025/in the summer/thumbnail.jpeg"27 poster="/2025/in the summer/thumbnail.jpeg"
28>28 >
29 <@header><strong>music video</>: in the summer</>29 <@header><strong>music video</strong>: in the summer</@header>
30</>30 </clover-video>
3131
32<div.contain>32 <div.contain>
33 <h2>downloads</h2>33 <h2>downloads</h2>
34 <ul>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>35 <li>
36 <li><a href="/file/2025/in%20the%20summer/in%20the%20summer.mp3?view=dl">in the summer.mp3</a> (Music)</li>36 <a href="/file/2025/in%20the%20summer/in%20the%20summer.mp4?view=dl">
37 <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li>37 in the summer.mp4
38 </ul>38 </a>
39 <h2>mentions on the q&a</h2>39 (AV1, requires modern player)
40 <questions-embed="in-the-summer" />40 </li>
41</div>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>
42</main>55</main>
43
src/pages/index.marko+19-8
...@@ -4,8 +4,11 @@ import "./index.css";...@@ -4,8 +4,11 @@ import "./index.css";
4export const theme = { bg: "#fff", fg: "#666" };4export const theme = { bg: "#fff", fg: "#666" };
5export const meta = {5export const meta = {
6 title: "paper clover",6 title: "paper clover",
7 description: "and then we knew, just like paper airplanes: that we could fly...",7 description:
8 embed: { /* Default embed */ },8 "and then we knew, just like paper airplanes: that we could fly...",
9 embed: {
10 /* Default embed */
11 },
9 alternates: {12 alternates: {
10 "application/rss+xml": "rss.xml",13 "application/rss+xml": "rss.xml",
11 },14 },
...@@ -15,15 +18,23 @@ export const meta = {...@@ -15,15 +18,23 @@ export const meta = {
15 <h2>about</h2>18 <h2>about</h2>
16 <p>19 <p>
17 i am a girl that is adorable and20 i am a girl that is adorable and
18 <a class='custom cite' href="https://en.wikipedia.org/wiki/Life">alive</a>.21 <a class="custom cite" href="https://en.wikipedia.org/wiki/Life">alive</a>.
19 </p>
20 <p>
21 this site contains my art.
22 </p>22 </p>
23 <p>this site contains my art.</p>
23 <h2>posts</h2>24 <h2>posts</h2>
24 <ul class="section-list">25 <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>
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>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>
27 <li>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</li>38 <li>song: <a href="/in-the-summer">in the summer</a> (2025-08-08)</li>
28 <li>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</li>39 <li>song: <a href="/waterfalls">waterfalls</a> (2025-01-01)</li>
29 </ul>40 </ul>
src/pages/resume.marko+39-40
...@@ -3,48 +3,47 @@ import "./resume.css";...@@ -3,48 +3,47 @@ import "./resume.css";
3export const meta = { title: "clover's resume" };3export const meta = { title: "clover's resume" };
44
5<main>5<main>
6<h1>clover's resume</h1>6 <h1>clover's resume</h1>
7<div>last updated: 2025</>7 <div>last updated: 2025</div>
88
9<article.job>9 <article.job>
10 <header>10 <header>
11 <h2>web/backend engineer</h2>11 <h2>web/backend engineer</h2>
12 <em>2025-now</em>12 <em>2025-now</em>
13 </>13 </header>
14 <ul>14 <ul><i>(more details added as time goes on...)</i></ul>
15 <i>(more details added as time goes on...)</i>15 </article>
16 </ul>
17</>
1816
19<article.job>17 <article.job>
20 <header>18 <header>
21 <h2>runtime/systems engineer</h2>19 <h2>runtime/systems engineer</h2>
22 <em>2023-2025</em>20 <em>2023-2025</em>
23 <p>developer tools company</p>21 <p>developer tools company</p>
24 </>22 </header>
25 <ul>23 <ul>
26 <li>hardcore engineering, elegant solutions</>24 <li>hardcore engineering, elegant solutions</li>
27 <li>platform compatibility & stability</>25 <li>platform compatibility & stability</li>
28 <li>debugging and profiling across platforms</>26 <li>debugging and profiling across platforms</li>
29 </ul>27 </ul>
30</>28 </article>
3129
32<article.job>30 <article.job>
33 <header>31 <header>
34 <h2>technician</h2>32 <h2>technician</h2>
35 <em>2023; part time</em>33 <em>2023; part time</em>
36 <p>automotive maintainance company</p>34 <p>automotive maintainance company</p>
37 </>35 </header>
38 <ul>36 <ul>
39 <li>pressed buttons on a computer</>37 <li>pressed buttons on a computer</li>
40 </ul>38 </ul>
41</>39 </article>
4240
43<footer>41 <footer>
44 <h2>eduation</h2> <em>2004-now</em>42 <h2>eduation</h2>
45 <p>43 ${" "}<em>2004-now</em>
46 my life on earth has taught me more than i expected. i <br/>44 <p>
47 continue to learn new things daily, as if it was magic.45 my life on earth has taught me more than i expected. i <br> continue to
48 </p>46 learn new things daily, as if it was magic.
49</footer>47 </p>
48 </footer>
50</main>49</main>
src/pages/subscribe.marko+22-24
...@@ -1,31 +1,29 @@...@@ -1,31 +1,29 @@
1import './subscribe.css';1import "./subscribe.css";
2export const meta = {2export const meta = {
3 title: "paper clover mailing list",3 title: "paper clover mailing list",
4 description: "subscribe to the mailing list",4 description: "subscribe to the mailing list",
5};5};
66
7<main>7<main>
8<h1>mailing list</h1>8 <h1>mailing list</h1>
9<canvas style="display: none;"></canvas>9 <canvas style="display: none;"/>
10<p>10 <p>
11 the mailing list is used for big project updates. this is about once every11 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 word12 couple of months. to get added, please email anything with the word
13 "subscribe" in the subject or body to the following address:13 "subscribe" in the subject or body to the following address:
14</p>14 </p>
15<code id="subscribe" class="flex">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>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</code>17 subscribe@paperclover.net
18<p>18 </a>
19 i read every message, so you can say whatever else you want and i will see19 </code>
20 it. there won't be a confirmation email for subscribing. for those who are20 <p>
21 close friends with me, add your name and i'll add you to the 'friends' list,21 i read every message, so you can say whatever else you want and i will see
22 where i send life updates in addition to new content.22 it. there won't be a confirmation email for subscribing. for those who are
23</p>23 close friends with me, add your name and i'll add you to the 'friends' list,
24<p>to unsubscribe, send an email asking to unsubscribe.</p>24 where i send life updates in addition to new content.
25<br>25 </p>
26<br>26 <p>to unsubscribe, send an email asking to unsubscribe.</p>
27<br>27 <br><br><br>
28<p>28 <p><a href="/">back to home</a></p>
29 <a href="/">back to home</a>
30</p>
31</main>29</main>
src/pages/waterfalls.marko+10-18
...@@ -7,7 +7,7 @@ export const theme = {...@@ -7,7 +7,7 @@ export const theme = {
7};7};
88
9<main class="waterfalls">9<main class="waterfalls">
10 <div class="waterfalls_bg1" />10 <div class="waterfalls_bg1"/>
11 <main class="waterfalls">11 <main class="waterfalls">
12 <div class="contain">12 <div class="contain">
13 <nav><a href="/">back to home</a></nav>13 <nav><a href="/">back to home</a></nav>
...@@ -22,30 +22,22 @@ export const theme = {...@@ -22,30 +22,22 @@ export const theme = {
22 file="/2025/waterfalls/waterfalls.mp4"22 file="/2025/waterfalls/waterfalls.mp4"
23 poster="/2025/waterfalls/thumbnail.jpeg"23 poster="/2025/waterfalls/thumbnail.jpeg"
24 >24 >
25 <@header><strong>music video</strong>: waterfalls</>25 <@header><strong>music video</strong>: waterfalls</@header>
26 </>26 </clover-video>
27 <div class="contain">27 <div class="contain">
28 <p>28 <p>it's nice to be <em>free</em>.</p>
29 it's nice to be <em>free</em>.
30 </p>
31 <p>like paper airplanes in the wind.</p>29 <p>like paper airplanes in the wind.</p>
32 <p>the stars shine in the sky. it's beautiful.</p>30 <p>the stars shine in the sky. it's beautiful.</p>
31 <p>love <br> –clover caruso</p>
32 <br>
33 <p>33 <p>
34 love34 <strong>more</strong>: <br><a href="/file/2025/waterfalls/fragments">
35 <br />35 fragments
36 –clover caruso36 </a><br><a href="/q+a">q&a page</a>
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>
45 </p>37 </p>
4638
47 <h2>mentions on the q&a</h2>39 <h2>mentions on the q&a</h2>
48 <questions-embed="waterfalls" />40 <questions-embed="waterfalls"/>
49 </div>41 </div>
50 </main>42 </main>
51</main>43</main>
src/q+a/pages/admin.q+a.marko+5-6
...@@ -7,20 +7,19 @@ export const meta: Metadata = {...@@ -7,20 +7,19 @@ export const meta: Metadata = {
7 description: "ask clover a question",7 description: "ask clover a question",
8};8};
9export const regenerate = {9export const regenerate = {
10 tags: ["q+a", "q+a inbox"]10 tags: ["q+a", "q+a inbox"],
11};11};
1212
13<const/inboxSize = PendingQuestion.getAll().length />13<const/inboxSize=PendingQuestion.getAll().length>
14<if=(inboxSize > 0)>14<if=(inboxSize > 0)>
15 <p>15 <p>
16 <strong style={ color: "#ff4b4b" }>16 <strong style={ color: "#ff4b4b" }>
17 notice: ${inboxSize} items in17 notice: ${inboxSize} items in <a href="/admin/q+a/inbox">inbox</a>!
18 <a href="/admin/q+a/inbox">inbox</a>!
19 </strong>18 </strong>
20 </p>19 </p>
21</>20</if>
2221
23<MainPage admin />22<MainPage admin/>
2423
25import MainPage from "./q+a.marko";24import MainPage from "./q+a.marko";
26import { PendingQuestion } from "#src/q+a/models/PendingQuestion.ts";25import { 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 = {...@@ -4,9 +4,7 @@ export const meta: Metadata = {
4};4};
55
6<question-layout>6<question-layout>
7<h2>sound the alarms</h2>7 <h2>sound the alarms</h2>
8<p>this page doesn't exist</p>8 <p>this page doesn't exist</p>
9<p>9 <p><a href="/q+a">return to the questions list</a></p>
10 <a href="/q+a">return to the questions list</a>
11</p>
12</question-layout>10</question-layout>
src/q+a/pages/q+a.marko+9-11
...@@ -9,19 +9,17 @@ export const meta: Metadata = {...@@ -9,19 +9,17 @@ export const meta: Metadata = {
9 description: "ask clover a question",9 description: "ask clover a question",
10};10};
1111
12<const/{ admin = false } = input />12<const/{ admin = false }=input>
13<const/questions = [...Question.getAll()] />13<const/questions=[...Question.getAll()]>
1414
15<question-layout>15<question-layout>
16<if=!admin>16 <if=!admin>
17 <question-form />17 <question-form/>
18</>18 </if>
19<for|question| of=questions>19 <for|question| of=questions>
20 <question ...{ question, admin } />20 <question ...{ question, admin }/>
21</>21 </for>
22<footer>22 <footer>fun fact: clover has answered ${questions.length} questions</footer>
23 fun fact: clover has answered ${questions.length} questions
24</footer>
25</question-layout>23</question-layout>
2624
27import { Question } from "#src/q+a/models/Question.ts";25import { Question } from "#src/q+a/models/Question.ts";
src/q+a/tags/question-form.marko-1
...@@ -10,4 +10,3 @@...@@ -10,4 +10,3 @@
10 <button type="submit">send</button>10 <button type="submit">send</button>
11 <div class="disabled-button">send</div>11 <div class="disabled-button">send</div>
12</form>12</form>
13
src/q+a/tags/question-layout.marko+2-2
...@@ -8,8 +8,8 @@ client import "./header.client.ts";...@@ -8,8 +8,8 @@ client import "./header.client.ts";
8 <span class="n" style="--s:#999;margin:0 5px">&</span>8 <span class="n" style="--s:#999;margin:0 5px">&</span>
9 <span class="q" style="color:var(--b);--s:#994253">questions</span>9 <span class="q" style="color:var(--b);--s:#994253">questions</span>
10 </h1>10 </h1>
11 </>11 </if>
12 <${input.content} />12 <${input.content}/>
13</main>13</main>
1414
15export const theme = {15export const theme = {
src/q+a/tags/question.marko+15-18
...@@ -10,34 +10,31 @@ export interface Input {...@@ -10,34 +10,31 @@ export interface Input {
10// 2024-12-31 05:00:00 EST10// 2024-12-31 05:00:00 EST
11export const transitionDate = 1735639200000;11export const transitionDate = 1735639200000;
1212
13<const/{ question, admin = false, editor = false } = input />13<const/{ question, admin = false, editor = false }=input>
14<const/{ id, date } = question/>14<const/{ id, date }=question>
1515
16<${"e-"}16<${"e-"}
17 f=(date > transitionDate ? true : null)17 f=(date > transitionDate ? true : null)
18 id=admin ? `q${id}` : undefined18 id=admin ? `q${id}` : undefined
19>19>
20 <if=admin>20 <if=admin>
21 <a21 <a style="margin-right: 0.5rem" href=`/admin/q+a/${id}`>[EDIT]</a>
22 style="margin-right: 0.5rem"22 </if>
23 href=`/admin/q+a/${id}`
24 >[EDIT]</a>
25 </>
26 <a>23 <a>
27 <time24 <time datetime=formatQuestionISOTimestamp(date)>
28 datetime=formatQuestionISOTimestamp(date)25 ${formatQuestionTimestamp(date)}
29 >${formatQuestionTimestamp(date)}</time><br />26 </time>
30 </a>27 <br>
3128 </a>$!{markdown.renderMarkdown(question.text)}
32 $!{markdown.renderMarkdown(question.text)}
33</>29</>
3430
35// this singleton script will make all the '<time>' tags clickable.31// this singleton script will make all the '<time>' tags clickable.
36<if=!editor>32<if=!editor>client import "./clickable-links.client.ts";</if>
37 client import "./clickable-links.client.ts";
38</>
3933
40import type { Question } from "#src/q+a/models/Question.ts";34import 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";
42import * as markdown from "#src/q+a/markdown.ts";39import * 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 {...@@ -4,39 +4,35 @@ import {
4 formatQuestionISOTimestamp,4 formatQuestionISOTimestamp,
5 formatQuestionTimestamp,5 formatQuestionTimestamp,
6} from "#src/q+a/format.ts";6} 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
11<question-layout minimal>11<question-layout minimal>
12<h1>inbox</h1>12 <h1>inbox</h1>
13<for|q| of=questions>13 <for|q| of=questions>
14 <div14 <div
15 data-q=q.id15 data-q=q.id
16 style="border-bottom: 2px solid #fff7; margin-bottom: 1rem"16 style="border-bottom: 2px solid #fff7; margin-bottom: 1rem"
17 >17 >
18 <time datetime=formatQuestionISOTimestamp(q.date)>18 <time datetime=formatQuestionISOTimestamp(q.date)>
19 ${formatQuestionTimestamp(q.date)} ${q.id}19 ${formatQuestionTimestamp(q.date)} ${q.id}
20 </time>20 </time>
21 <div style="color: dodgerblue; margin-bottom: 0.25rem">21 <div style="color: dodgerblue; margin-bottom: 0.25rem">
22 ${q.sourceName}22 ${q.sourceName}${
23 ${q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : ''}23 q.sourceLocation !== "unknown" ? `from ${q.sourceLocation}` : ""
24 ${q.sourceVPN ? `(VPN: ${q.sourceVPN})` : ''}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>
25 </div>34 </div>
26 <p style="white-space: pre-wrap">${q.prompt}</p>35 </for>
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</>
40</question-layout>36</question-layout>
4137
42client import "./backend-inbox.client.ts";38client import "./backend-inbox.client.ts";
src/q+a/views/editor.marko+17-20
...@@ -16,22 +16,19 @@ export interface ScriptPayload {...@@ -16,22 +16,19 @@ export interface ScriptPayload {
16 date: string;16 date: string;
17}17}
1818
19<const/{ question } = input />19<const/{ question }=input>
2020
21<const/payload = {21<const/payload={
22 id: question.id,22 id: question.id,
23 qmid: question.qmid,23 qmid: question.qmid,
24 text: question.text,24 text: question.text,
25 type: question.type,25 type: question.type,
26 date: question.date.toISOString(),26 date: question.date.toISOString(),
27} satisfies ScriptPayload/>27} satisfies ScriptPayload>
2828
29<div#edit-grid.qa>29<div#edit-grid.qa>
30 <div#topleft>30 <div#topleft>
31 <button31 <button onclick="location.href='/admin/q+a'" style={ color: "magenta" }>
32 onclick="location.href='/admin/q+a'"
33 style={ color: "magenta" }
34 >
35 all32 all
36 </button>33 </button>
37 <button34 <button
...@@ -42,25 +39,25 @@ export interface ScriptPayload {...@@ -42,25 +39,25 @@ export interface ScriptPayload {
42 </button>39 </button>
43 </div>40 </div>
44 <div#topright>41 <div#topright>
45 <button onclick="onCommitQuestion()" style="color:lime">42 <button onclick="onCommitQuestion()" style="color:lime">update</button>
46 update43 <button onclick="onDelete()" style={ color: "red" }>reject</button>
47 </button>
48 <button onclick="onDelete()" style={ color: "red" }>
49 reject
50 </button>
51 <select id="type" onchange="onTypeChange()" value=question.type>44 <select id="type" onchange="onTypeChange()" value=question.type>
52 <option value=QuestionType.normal>normal</>45 <option value=QuestionType.normal>normal</option>
53 <option value=QuestionType.annotation>annotation</>46 <option value=QuestionType.annotation>annotation</option>
54 <option value=QuestionType.reject>reject</>47 <option value=QuestionType.reject>reject</option>
55 </select>48 </select>
56 </div>49 </div>
57 <textarea#editor />50 <textarea#editor/>
58 <main#preview>51 <main#preview>
59 <question editor ...{ question } />52 <question editor ...{ question }/>
60 </main>53 </main>
61</div>54</div>
62<html-script>self.payload=${JSON.stringify(payload).replaceAll('</script>', '<\\/script>')}</html-script>55<html-script>
63<html-script src="/js/q+a/scripts/editor.js" type="module" />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
65import { type PendingQuestion } from "#src/q+a/models/PendingQuestion.ts";62import { type PendingQuestion } from "#src/q+a/models/PendingQuestion.ts";
66import { Question, QuestionType } from "#src/q+a/models/Question.ts";63import { Question, QuestionType } from "#src/q+a/models/Question.ts";
src/q+a/views/fail.marko+11-19
...@@ -3,26 +3,18 @@ export const theme = {...@@ -3,26 +3,18 @@ export const theme = {
3 ...layout.theme,3 ...layout.theme,
4 primary: "#58ffee",4 primary: "#58ffee",
5};5};
6export const meta = { title: 'oh no' };6export const meta = { title: "oh no" };
77
8<const/{ error, content }=input/>8<const/{ error, content }=input>
99
10<question-layout>10<question-layout>
11<h2 style="color: red">:(</h2>11 <h2 style="color: red">:(</h2>
12<p style="color: red">12 <p style="color: red">${error}</p>
13 ${error}13 <if=content>
14</p>14 <br><br>
15<if=content>15 <p>here is a copy of what you wrote, if you want to try again:</p>
16 <br />16 <pre style="white-space: pre-wrap"><code>${content}</code></pre>
17 <br />17 </if>
18 <p>18 <br><br>
19 here is a copy of what you wrote, if you want to try again:19 <p><a href="/q+a">return to the questions list</a></p>
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>
28</question-layout>20</question-layout>
src/q+a/views/image-embed.marko+14-6
...@@ -4,13 +4,21 @@ export interface Input {...@@ -4,13 +4,21 @@ export interface Input {
4}4}
55
6<html-style>6<html-style>
7 main { padding-top: 11px; }7 main {
8 e- { margin: 0!important }8 padding-top: 11px;
9 e- > :first-child { margin-top: 0!important }9 }
10 e- > :last-child { margin-bottom: 0!important }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 }
11</html-style>19</html-style>
12<main.qa>20<main.qa>
13 <question question=input.question />21 <question question=input.question/>
14</main>22</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 {...@@ -4,54 +4,61 @@ export interface Input {
4}4}
55
6server export function meta({ context, question }) {6server export function meta({ context, question }) {
7 const isDiscord = context.req.header("user-agent")7 const isDiscord = context.req
8 .header("user-agent")
8 ?.toLowerCase()9 ?.toLowerCase()
9 .includes("discord");10 .includes("discord");
10 if (question.type === QuestionType.normal) {11 if (question.type === QuestionType.normal) {
11 return {12 return {
12 title: "question permalink",13 title: "question permalink",
13 openGraph: {14 openGraph: {
14 title: isDiscord ? undefined : `question at ${formatQuestionTimestamp(question.date)}`,15 title: isDiscord
15 image: `https://paperclover.net/q+a/${question.id}.png`16 ? undefined
17 : `question at ${formatQuestionTimestamp(question.date)}`,
18 image: `https://paperclover.net/q+a/${question.id}.png`,
16 },19 },
17 twitter: { card: "summary_large_image" },20 twitter: { card: "summary_large_image" },
18 themeColor: isDiscord21 themeColor: isDiscord
19 ? question.date.getTime() > transitionDate ? "#8c78ff" : "#58ff71"22 ? question.date.getTime() > transitionDate
23 ? "#8c78ff"
24 : "#58ff71"
20 : undefined,25 : undefined,
21 };26 };
22 }27 }
23 return { title: 'question permalink' };28 return { title: "question permalink" };
24}29}
2530
26<const/{ question }=input/>31<const/{ question }=input>
27<const/{ type }=question/>32<const/{ type }=question>
2833
29<question-layout>34<question-layout>
30<if=type==QuestionType.normal>35 <if=type == QuestionType.normal>
31 <p>this page is a permalink to the following question:</p>36 <p>this page is a permalink to the following question:</p>
32 <question ...{question} />37 <question ...{ question }/>
33</><else if=type==QuestionType.pending>38 </if>
34 <p>39 <else if=type == QuestionType.pending>
35 this page is a permalink to a question that40 <p>
36 has not yet been answered.41 this page is a permalink to a question that has not yet been answered.
37 </p>42 </p>
38 <p><a href="/q+a">read questions with existing responses</a>.</p>43 <p><a href="/q+a">read questions with existing responses</a>.</p>
39</><else if=type==QuestionType.reject>44 </else>
40 <p>45 <else if=type == QuestionType.reject>
41 this page is a permalink to a question, but the question46 <p>
42 was deleted instead of answered. maybe it was sent multiple47 this page is a permalink to a question, but the question was deleted
43 times, or maybe the question was not a question. who knows.48 instead of answered. maybe it was sent multiple times, or maybe the
44 </p>49 question was not a question. who knows.
45 <p>sorry, sister</p>50 </p>
46 <p><a href="/q+a">all questions</a></p>51 <p>sorry, sister</p>
47</><else>52 <p><a href="/q+a">all questions</a></p>
48 <p>oh dear, this question is in an invalid state</p>53 </else>
49 <pre>${JSON.stringify(question, null, 2)}</pre>54 <else>
50</>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>
53</question-layout>60</question-layout>
5461
55import { Question, QuestionType } from '#src/q+a/models/Question.ts';62import { Question, QuestionType } from "#src/q+a/models/Question.ts";
56import { transitionDate } from "#src/q+a/tags/question.marko";63import { transitionDate } from "#src/q+a/tags/question.marko";
57import { formatQuestionTimestamp } from "#src/q+a/format.ts";64import { formatQuestionTimestamp } from "#src/q+a/format.ts";
src/q+a/views/success.marko+13-20
...@@ -1,29 +1,22 @@...@@ -1,29 +1,22 @@
1export interface Input {1export interface Input {
2 permalink: string;2 permalink: string;
3}3}
4export const meta = { title: 'question submitted!!!' };4export const meta = { title: "question submitted!!!" };
55
6<const/{ permalink }=input/>6<const/{ permalink }=input>
77
8<question-layout>8<question-layout>
9<h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>9 <h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
10<p style="color: #8c78ff">10 <p style="color: #8c78ff">your question has been submitted.</p>
11 your question has been submitted.11 <p>
12</p>12 please keep in mind it may take multiple days for clover to answer your
13<p>13 question, even if other questions are answered before yours. sometimes more
14 please keep in mind it may take multiple days for clover to answer your14 in-depth questions need her to dig through archives.
15 question, even if other questions are answered before yours. sometimes more15 </p>
16 in-depth questions need her to dig through archives.16 <br>
17</p>17 <p><strong>permalink</strong>: <a href=permalink>${permalink}</a></p>
18<br />18 <br><br>
19<p>19 <p><a href="/q+a">return to the questions list</a></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>
27</question-layout>20</question-layout>
2821
29import * as layout from "../tags/question-layout.marko";22import * as layout from "../tags/question-layout.marko";
src/q+a/views/things-random.marko+2-3
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1export const meta = { title: 'random number' };1export const meta = { title: "random number" };
2client import "./things-random.client.ts";2client 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
6<main>6<main>
7 <div>${number}</div>7 <div>${number}</div>
8 <button>another</button>8 <button>another</button>
9</main>9</main>
10
src/tags/clover-media.marko+26-19
...@@ -5,41 +5,48 @@ export interface Input {...@@ -5,41 +5,48 @@ export interface Input {
5 align?: string;5 align?: string;
6}6}
77
8<const/{ src, alt = "", displayWidthEstimate, align, ...rest } = input />8<const/{ src, alt = "", displayWidthEstimate, align, ...rest }=input>
9<const/file = typeof src === 'object' ? src : MediaFile.getByPath(src) />9<const/file=typeof src === "object" ? src : MediaFile.getByPath(src)>
1010
11<if=!file>11<if=!file>
12 <div style={ color: 'red' }>ERROR: No file in database: ${src}</div>12 <div style={ color: "red" }>ERROR: No file in database: ${src}</div>
13</><else if=extsVideo.has(file.extension.toLowerCase())>13</if>
14 <clover-video noFigure ...{ file } />14<else if=extsVideo.has(file.extension.toLowerCase())>
15</><else if=file.extension === '.gif'>15 <clover-video noFigure ...{ file }/>
16 <img src=`/file${escapeUri(file.path)}` ...rest />16</else>
17</><else>17<else if=file.extension === ".gif">
18 <const/base = `/file${escapeUri(file.path)}` />18 <img src=`/file${escapeUri(file.path)}` ...rest>
19 <const/{ width } = UNWRAP(file.parseDimensions()) />19</else>
20 <const/targetSizes = transcodeRules.imageSizes.filter((w) => w < width) />20<else>
21 <const/sizes =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=(
22 displayWidthEstimate25 displayWidthEstimate
23 ? typeof displayWidthEstimate === "number"26 ? typeof displayWidthEstimate === "number"
24 ? `${displayWidthEstimate}px`27 ? `${displayWidthEstimate}px`
25 : displayWidthEstimate28 : displayWidthEstimate
26 : `100vw`29 : `100vw`
27 />30 )>
28 <picture>31 <picture>
29 <for|ext| of=[ "jxl", "avif", "webp" ]>32 <for|ext| of=["jxl", "avif", "webp"]>
30 <source33 <source
31 type=`image/${ext}`34 type=`image/${ext}`
32 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)35 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)
33 ...{ sizes }36 ...{ sizes }
34 />37 >
35 </>38 </for>
36 <img39 <img
37 src=base40 src=base
38 style=align ? { 'object-position': align, width: '100%', height: '100%' } : undefined41 style=(
42 align
43 ? { "object-position": align, width: "100%", height: "100%" }
44 : undefined
45 )
39 ...rest46 ...rest
40 />47 >
41 </picture>48 </picture>
42</>49</else>
4350
44import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";51import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
45import { extsVideo } from "#src/file-viewer/rules.ts";52import { extsVideo } from "#src/file-viewer/rules.ts";
src/tags/clover-video.marko+43-30
...@@ -6,25 +6,35 @@ export interface Input {...@@ -6,25 +6,35 @@ export interface Input {
6 poster?: MediaFile | string;6 poster?: MediaFile | string;
7 header?: Marko.AttrTag<{ content: Marko.Body }>;7 header?: Marko.AttrTag<{ content: Marko.Body }>;
8 minimal?: boolean;8 minimal?: boolean;
9 noFigure?: boolean9 noFigure?: boolean;
10}10}
1111
12<const/{ id, header, minimal, noFigure } = input />12<const/{ id, header, minimal, noFigure }=input>
13<const/file = typeof UNWRAP(input.file) === 'object'13<const/file=(
14 ? input.file14 typeof UNWRAP(input.file) === "object"
15 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />15 ? input.file
16<const/poster = input.poster16 : UNWRAP(
17 ? typeof input.poster === 'object'17 MediaFile.getByPath(input.file),
18 ? input.poster18 `No file ${JSON.stringify(input.file)}`,
19 : UNWRAP(MediaFile.getByPath(input.poster), `No file ${JSON.stringify(input.poster)}`)19 )
20 : null />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
24<define/Inner>34<define/Inner>
25 <video35 <video
26 ...{ id }36 ...{ id }
27 class={ 'clover-video': noFigure }37 class={ "clover-video": noFigure }
28 data-src=file.path38 data-src=file.path
29 poster=poster ? `/file${poster.path}` : null39 poster=poster ? `/file${poster.path}` : null
30 width=width40 width=width
...@@ -36,36 +46,39 @@ export interface Input {...@@ -36,36 +46,39 @@ export interface Input {
36 <figure.clover-video class={ minimal }>46 <figure.clover-video class={ minimal }>
37 <if=!minimal>47 <if=!minimal>
38 <figcaption>48 <figcaption>
39 <if=header><${header.content}/></><else>49 <if=header>
40 ${file.basename}50 <${header.content}/>
41 </else>51 </if>
52 <else>${file.basename}</else>
42 </figcaption>53 </figcaption>
43 </>54 </if>
44 <div.stack style=`aspect-ratio:${width}/${height}`>55 <div.stack style=`aspect-ratio:${width}/${height}`>
45 <Inner />56 <Inner/>
46 <noscript.fallback>57 <noscript.fallback>
47 <h2>scripting disabled!</h2>58 <h2>scripting disabled!</h2>
48 <p>59 <p>
49 to play this video, JavaScript must be enabled in your60 to play this video, JavaScript must be enabled in your browser. the
50 browser. the default video playback behavior is not enabled here61 default video playback behavior is not enabled here as it results in a
51 as it results in a worse experience on slower internet62 worse experience on slower internet connections, and
52 connections, and <a href="http://caniuse.com/mpeg-dash">MPEG-DASH63 <a href="http://caniuse.com/mpeg-dash">
53 streaming is not natively supported in any browser</a> without JavaScript.64 MPEG-DASH streaming is not natively supported in any browser
65 </a>
66 without JavaScript.
54 </p>67 </p>
55 <p>68 <p>
56 since disabling JavaScript is not the default in any browser,69 since disabling JavaScript is not the default in any browser, you
57 you likely wanted to download the raw video file anyways.70 likely wanted to download the raw video file anyways. those links are
58 those links are available below, but note that you will need71 available below, but note that you will need an AV1-compatible video
59 an AV1-compatible video player, such as VLC Media Player or72 player, such as VLC Media Player or QuickTime Player (M3 chip or
60 QuickTime Player (M3 chip or newer)73 newer)
61 </p>74 </p>
62 </noscript>75 </noscript>
63 </div>76 </div>
64 </figure>77 </figure>
65</>78</if>
66<else>79<else>
67 <Inner />80 <Inner/>
68</>81</else>
6982
70client import "./clover-video.client.ts";83client import "./clover-video.client.ts";
7184
src/tags/photo-grid.marko+25-23
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import './photo-grid.css';1import "./photo-grid.css";
22
3static const gap = 11;3static const gap = 11;
44
...@@ -39,12 +39,12 @@ static const alignments = {...@@ -39,12 +39,12 @@ static const alignments = {
39 "right-end": "100% 50%",39 "right-end": "100% 50%",
40};40};
4141
42<const/{ base, cols, style } = input />42<const/{ base, cols, style }=input>
43<const/rows = input.rows ?? [325] />43<const/rows=input.rows ?? [325]>
44<const/colCount = input.cols.length />44<const/colCount=input.cols.length>
45<const/rowSum = rows.reduce((a, b) => a + b, 0) + gap * (rows.length - 1) />45<const/rowSum=rows.reduce((a, b) => a + b, 0) + gap * (rows.length - 1)>
4646
47<const/items = (() => {47<const/items=(() => {
48 let occupied: boolean[][] = [];48 let occupied: boolean[][] = [];
49 const row = (y: number) => (occupied[y] ??= new Array(colCount).fill(false));49 const row = (y: number) => (occupied[y] ??= new Array(colCount).fill(false));
50 const anyCollide = (x: number, y: number, w: number, h: number) => {50 const anyCollide = (x: number, y: number, w: number, h: number) => {
...@@ -55,18 +55,20 @@ static const alignments = {...@@ -55,18 +55,20 @@ static const alignments = {
55 return false;55 return false;
56 };56 };
5757
58 let x = 0, y = 0;58 let x = 0,
59 y = 0;
59 let result: Positioned[] = [];60 let result: Positioned[] = [];
60 for (const img of input.img) {61 for (const img of input.img) {
61 const w = img.w ?? 1;62 const w = img.w ?? 1;
62 const h = img.h ?? 1;63 const h = img.h ?? 1;
63 const s = img.s ?? 0;64 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
66 x += s;68 x += s;
67 while (x > colCount - w || anyCollide(x, y, w, h)) {69 while (x > colCount - w || anyCollide(x, y, w, h)) {
68 x += 1;70 x += 1;
69 if (x > colCount - w) x = 0, y += 1;71 if (x > colCount - w) ((x = 0), (y += 1));
70 }72 }
7173
72 result.push({74 result.push({
...@@ -74,7 +76,7 @@ static const alignments = {...@@ -74,7 +76,7 @@ static const alignments = {
74 y,76 y,
75 w,77 w,
76 h,78 h,
77 src: base ? base + '/' + img.src : img.src,79 src: base ? base + "/" + img.src : img.src,
78 align: alignments[img.align] ?? img.align,80 align: alignments[img.align] ?? img.align,
79 });81 });
8082
...@@ -84,24 +86,24 @@ static const alignments = {...@@ -84,24 +86,24 @@ static const alignments = {
84 }86 }
8587
86 x += w;88 x += w;
87 if (x >= colCount) x = 0, y += 1;89 if (x >= colCount) ((x = 0), (y += 1));
88 }90 }
89 return result;91 return result;
90})() />92})()>
9193
92<${'photo-grid'}94<${"photo-grid"} style={
93 style={95 "grid-template-columns": input.cols.map((x) => x + "fr").join(" "),
94 'grid-template-columns': input.cols.map(x => x + 'fr').join(' '),96 "grid-template-rows": rows
95 'grid-template-rows': rows.map(x => (100 * x / rowSum).toFixed(1) + '%').join(' '),97 .map((x) => ((100 * x) / rowSum).toFixed(1) + "%")
96 'aspect-ratio': `768/${rowSum}`,98 .join(" "),
97 ...style,99 "aspect-ratio": `768/${rowSum}`,
98 }100 ...style,
99>101}>
100 <for|{ x, y, w, h, src, align }| of=items>102 <for|{ x, y, w, h, src, align }| of=items>
101 <${'photo-grid-item'} style={103 <${"photo-grid-item"} style={
102 'grid-area': `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,104 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
103 }>105 }>
104 <clover-media ...{ src, align } />106 <clover-media ...{ src, align }/>
105 </>107 </>
106 </for>108 </for>
107</>109</>
src/tags/questions-embed.marko+3-4
...@@ -5,11 +5,10 @@ export interface Input {...@@ -5,11 +5,10 @@ export interface Input {
5}5}
66
7<div.questions-embed.qa>7<div.questions-embed.qa>
8 <a href="/q+a">ask your own question / view all questions</a>8 <a href="/q+a">ask your own question / view all questions</a><br>
9 <br />
10 <for|question| of=Question.search(input.value)>9 <for|question| of=Question.search(input.value)>
11 <QuestionRender ...{ question } />10 <QuestionRender ...{ question }/>
12 </>11 </for>
13</div>12</div>
1413
15import QuestionRender from "#src/q+a/tags/question.marko";14import QuestionRender from "#src/q+a/tags/question.marko";