feat: font subsetting

This commit is contained in:
clover caruso 2025-08-14 20:35:33 -07:00
parent cb12824da4
commit c9d24a4fdd
25 changed files with 685 additions and 191 deletions

View file

@ -7,8 +7,12 @@
with inputs.nixpkgs.legacyPackages.${system}; { with inputs.nixpkgs.legacyPackages.${system}; {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = [ buildInputs = [
# clover sitegen v3
pkgs.nodejs_24 # runtime pkgs.nodejs_24 # runtime
pkgs.deno # formatter pkgs.deno # formatter
pkgs.python3 # for font subsetting
# paperclover.net
(pkgs.ffmpeg.override { (pkgs.ffmpeg.override {
withOpus = true; withOpus = true;
withSvtav1 = true; withSvtav1 = true;

View file

@ -9,7 +9,7 @@ const server = serve({
fetch: app.default.fetch, fetch: app.default.fetch,
port: Number(process.env.PORT ?? 3000), port: Number(process.env.PORT ?? 3000),
}, ({ address, port }) => { }, ({ address, port }) => {
if (address === "::") address = "::1"; if (address === "::") address = "localhost";
console.info(url.format({ console.info(url.format({
protocol, protocol,
hostname: address, hostname: address,

View file

@ -94,7 +94,6 @@ export async function bundleClientJavaScript(
const chunk = route.startsWith("/js/c."); const chunk = route.startsWith("/js/c.");
if (!chunk) { if (!chunk) {
const key = hot.getScriptId(toAbs(UNWRAP(entryPoint))); const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
console.log(route, key);
route = "/js/" + key.replace(/\.client\.tsx?/, ".js"); route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
scripts[key] = text; scripts[key] = text;
} }

192
framework/font.ts Normal file
View file

@ -0,0 +1,192 @@
export async function buildFonts(fonts: sg.Font[]) {
if (fonts.length === 0) return;
const temp = path.resolve(".clover/font");
const venv = path.join(temp, "venv");
const bin = path.join(venv, "bin");
if (!fs.existsSync(venv)) {
await subprocess.exec("python", ["-m", "venv", venv]);
}
if (!fs.existsSync(path.join(bin, "pyftsubset"))) {
await subprocess.exec(path.join(bin, "pip"), [
"install",
"fonttools==4.38.0",
"brotli==1.0.7",
]);
}
const instances = new async.OnceMap<void>();
const subsets = new async.OnceMap<void>();
async function makeInstance(input: string, vars: sg.FontVars) {
const args = Object.entries(vars).map((x) => {
const lower = x[0].toLowerCase();
const k = lower === "wght" || lower === "slnt" ? lower : x[0];
const v = Array.isArray(x[1]) ? x[1].join(":") : x[1];
return `${k}=${v}`;
}).sort();
const hash = crypto
.createHash("sha256")
.update(`${input}${args.join(" ")}`)
.digest("hex")
.slice(0, 16)
.toLowerCase();
const outfile = path.join(temp, `${hash}${path.extname(input)}`);
await instances.get(outfile, async () => {
await fs.rm(outfile, { force: true });
await subprocess.exec(path.join(bin, "fonttools"), [
"varLib.instancer",
input,
...args,
`--output=${outfile}`,
]);
ASSERT(fs.existsSync(outfile));
});
return outfile;
}
await Promise.all(fonts.map((font) =>
incr.work(async (io, font) => {
const baseFile = await fetchFont(font.name, font.sources);
await Promise.all(font.subsets.map(async (subset) => {
let file = baseFile;
if (subset.vars) {
file = await makeInstance(baseFile, subset.vars);
}
const unicodes = fontRangesToString(
Array.isArray(subset.unicodes) ? subset.unicodes : [subset.unicodes],
);
const hash = crypto
.createHash("sha256")
.update(`${file}${unicodes}`)
.digest("hex")
.slice(0, 16)
.toLowerCase();
const woff = path.join(temp, hash + ".woff2");
await subprocess.exec(path.join(bin, "pyftsubset"), [
file,
"--flavor=woff2",
`--output-file=${woff}`,
...subset.layoutFeatures
? [`--layout-features=${subset.layoutFeatures.join(",")}`]
: [],
`--unicodes=${unicodes}`,
]);
await io.writeAsset({
pathname: subset.asset.replace("[ext]", "woff2"),
buffer: await fs.readFile(woff),
});
}));
}, font)
));
}
export async function fetchFont(name: string, sources: string[]) {
const errs = [];
for (const source of sources) {
const cacheName = path.join("", name + path.extname(source));
if (fs.existsSync(cacheName)) return cacheName;
if (source.startsWith("https://")) {
const response = await fetch(source);
if (response.ok) {
await fs.writeMkdir(
cacheName,
Buffer.from(await response.arrayBuffer()),
);
} else {
errs.push(
new Error(
`Fetching from ${source} failed: ${response.status} ${response.statusText}`,
),
);
continue;
}
}
if (path.isAbsolute(source)) {
if (fs.existsSync(source)) return source;
errs.push(new Error(`Font not available at absolute path ${source}`));
} else if (!source.includes("/") && !source.includes("\\")) {
const home = os.homedir();
const bases = process.platform === "win32"
? [
"\\Windows\\Fonts",
path.win32.join(home, "AppData\\Local\\Microsoft\\Windows\\Fonts"),
]
: process.platform === "darwin"
? [
"/Library/Fonts",
path.posix.join(home, "Library/Fonts"),
]
: [
"/usr/share/fonts",
"/usr/local/share/fonts",
path.posix.join(home, ".local/share/fonts"),
];
for (const base of bases) {
const found = fs.readDirRecOptionalSync(base)
.find((file) => path.basename(file) === source);
if (found) {
return path.join(base, found);
}
}
errs.push(
new Error(
`Font file ${source} not found in the following directories: ${
bases.join(", ")
}`,
),
);
}
}
throw new AggregateError(errs, `Failed to fetch the ${name} font`);
}
function fontRangesToString(ranges: sg.FontRange[]): string {
const segments: Array<{ start: number; end: number }> = [];
ranges.forEach((range) => {
if (typeof range === "string") {
for (let i = 0; i < range.length; i++) {
const cp = range.codePointAt(i) || 0;
segments.push({ start: cp, end: cp });
}
} else if (typeof range === "number") {
segments.push({ start: range, end: range });
} else {
segments.push({ start: range.start, end: range.end });
}
});
segments.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
for (const seg of segments) {
const last = merged[merged.length - 1];
if (last && seg.start <= last.end + 1) {
last.end = Math.max(last.end, seg.end);
} else {
merged.push({ ...seg });
}
}
return merged.map(({ start, end }) =>
start === end
? `U+${start.toString(16).toUpperCase().padStart(4, "0")}`
: `U+${start.toString(16).toUpperCase().padStart(4, "0")}-${
end.toString(16).toUpperCase().padStart(4, "0")
}`
).join(",");
}
import * as fs from "#sitegen/fs";
import * as os from "node:os";
import * as path from "node:path";
import * as sg from "#sitegen";
import * as subprocess from "#sitegen/subprocess";
import * as incr from "./incremental.ts";
import * as crypto from "node:crypto";
import * as async from "#sitegen/async";

View file

@ -22,6 +22,9 @@ export async function generate() {
// TODO: loadMarkoCache // TODO: loadMarkoCache
// -- start font work --
const builtFonts = fonts.buildFonts(siteConfig.fonts);
// -- perform build-time rendering -- // -- perform build-time rendering --
const builtPages = pages.map((item) => incr.work(preparePage, item)); const builtPages = pages.map((item) => incr.work(preparePage, item));
const builtViews = views.map((item) => incr.work(prepareView, item)); const builtViews = views.map((item) => incr.work(prepareView, item));
@ -70,7 +73,12 @@ export async function generate() {
assembleAndWritePage(page, styleMap, scriptMap) assembleAndWritePage(page, styleMap, scriptMap)
); );
await Promise.all([builtBackend, builtStaticFiles, ...pAssemblePages]); await Promise.all([
builtBackend,
builtStaticFiles,
...pAssemblePages,
builtFonts,
]);
} }
export async function readManifest(io: Io) { export async function readManifest(io: Io) {
@ -80,6 +88,7 @@ export async function readManifest(io: Io) {
root: toRel(section.root), root: toRel(section.root),
})), })),
backends: cfg.backends.map(toRel), backends: cfg.backends.map(toRel),
fonts: cfg.fonts,
}; };
} }
@ -324,14 +333,17 @@ export async function assembleAndWritePage(
export type PageOrView = PreparedPage | PreparedView; export type PageOrView = PreparedPage | PreparedView;
import * as path from "node:path";
import * as fs from "#sitegen/fs";
import * as meta from "#sitegen/meta";
import * as render from "#engine/render";
import * as sg from "#sitegen"; import * as sg from "#sitegen";
import * as incr from "./incremental.ts"; import type { FileItem } from "#sitegen";
import { Io } from "./incremental.ts";
import * as bundle from "./bundle.ts"; import * as bundle from "./bundle.ts";
import * as css from "./css.ts"; import * as css from "./css.ts";
import * as render from "#engine/render"; import * as fonts from "./font.ts";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as fs from "#sitegen/fs"; import * as incr from "./incremental.ts";
import type { FileItem } from "#sitegen"; import { Io } from "./incremental.ts";
import * as path from "node:path";
import * as meta from "#sitegen/meta";

View file

@ -2,6 +2,7 @@
// This module implements decoding and serving of the asset blobs, // This module implements decoding and serving of the asset blobs,
// but also implements patching of dynamic assets. The `Manifest` // but also implements patching of dynamic assets. The `Manifest`
// is generated by `incremental.ts` // is generated by `incremental.ts`
const debug = console.scoped("assets");
const root = import.meta.dirname; const root = import.meta.dirname;
let current: Loaded | null = null; let current: Loaded | null = null;
@ -36,6 +37,7 @@ interface Loaded {
export interface DynamicEntry extends AssetBase { export interface DynamicEntry extends AssetBase {
buffer: Buffer; buffer: Buffer;
} }
type CacheMode = "auto" | "long-term" | "temporary" | "no-cache";
export async function reload() { export async function reload() {
const map = await fs.readJson<Manifest>(path.join(root, "asset.json")); const map = await fs.readJson<Manifest>(path.join(root, "asset.json"));
@ -59,7 +61,7 @@ export async function reload() {
export async function middleware(c: Context, next: Next) { export async function middleware(c: Context, next: Next) {
if (!current) current = await reload(); if (!current) current = await reload();
const asset = current.map[c.req.path]; const asset = current.map[c.req.path];
if (asset) return assetInner(c, asset, 200); if (asset) return assetInner(c, c.req.path, asset, 200);
return next(); return next();
} }
@ -68,16 +70,27 @@ export async function notFound(c: Context) {
let pathname = c.req.path; let pathname = c.req.path;
do { do {
const asset = current.map[pathname + "/404"]; const asset = current.map[pathname + "/404"];
if (asset) return assetInner(c, asset, 404); if (asset) return assetInner(c, pathname + "/404", asset, 404);
pathname = pathname.slice(0, pathname.lastIndexOf("/")); pathname = pathname.slice(0, pathname.lastIndexOf("/"));
} while (pathname); } while (pathname);
const asset = current.map["/404"]; const asset = current.map["/404"];
if (asset) return assetInner(c, asset, 404); if (asset) return assetInner(c, "/404", asset, 404);
return c.text("the 'Not Found' page was not found", 404); return c.text("the 'Not Found' page was not found", 404);
} }
export async function serveAsset(c: Context, id: Key, status: StatusCode) { export async function serveAsset(
return assetInner(c, (current ?? (await reload())).map[id], status); c: Context,
id: Key,
status: StatusCode = 200,
cache: CacheMode = 'auto',
) {
return assetInner(
c,
id,
(current ?? (await reload())).map[id],
status,
cache,
);
} }
/** @deprecated */ /** @deprecated */
@ -89,21 +102,43 @@ export function etagMatches(etag: string, ifNoneMatch: string) {
return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1; return ifNoneMatch === etag || ifNoneMatch.split(/,\s*/).indexOf(etag) > -1;
} }
function assetInner(c: Context, asset: Manifest[Key], status: StatusCode) { function assetInner(
c: Context,
key: string,
asset: Manifest[Key],
status: StatusCode,
cache: CacheMode = "auto",
) {
ASSERT(current); ASSERT(current);
if (asset.type === 0) { if (asset.type === 0) {
return respondWithBufferAndViews(c, current.static, asset, status); return respondWithBufferAndViews(
c,
key,
current.static,
asset,
status,
cache,
);
} else { } else {
const entry = UNWRAP(current.dynamic.get(asset.id)); const entry = UNWRAP(current.dynamic.get(asset.id));
return respondWithBufferAndViews(c, entry.buffer, entry, status); return respondWithBufferAndViews(
c,
key,
entry.buffer,
entry,
status,
cache,
);
} }
} }
function respondWithBufferAndViews( function respondWithBufferAndViews(
c: Context, c: Context,
key: string,
buffer: Buffer, buffer: Buffer,
asset: AssetBase, asset: AssetBase,
status: StatusCode, status: StatusCode,
cache: CacheMode,
) { ) {
const ifNoneMatch = c.req.header("If-None-Match"); const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch) { if (ifNoneMatch) {
@ -136,6 +171,29 @@ function respondWithBufferAndViews(
} else { } else {
body = buffer.subarray(...asset.raw); body = buffer.subarray(...asset.raw);
} }
debug(
`${key} encoding=${headers["Content-Encoding"] ?? "raw"} status=${status}`,
);
if (!Object.keys(headers).some((x) => x.toLowerCase() === "cache-control")) {
if (cache === "auto") {
if (status < 200 || status >= 300) {
cache = 'no-cache';
} else if (headers['content-type']?.includes('text/html')) {
cache = 'temporary';
} else {
cache = 'long-term';
}
}
if (cache === "no-cache") {
headers["Cache-Control"] = "no-store";
} else if (cache === "long-term") {
headers["Cache-Control"] =
"public, max-age=7200, stale-while-revalidate=7200, immutable";
} else if (cache === "temporary") {
headers["Cache-Control"] =
"public, max-age=7200, stale-while-revalidate=7200";
}
}
return (c.res = new Response(body, { headers, status })); return (c.res = new Response(body, { headers, status }));
} }
@ -232,3 +290,4 @@ import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import * as zlib from "node:zlib"; import * as zlib from "node:zlib";
import * as util from "node:util"; import * as util from "node:util";
import * as console from "@paperclover/console";

47
framework/lib/error.ts Normal file
View file

@ -0,0 +1,47 @@
declare global {
interface Error {
/* Extra fields on errors are extremely helpful for debugging. */
[metadata: string]: unknown;
}
}
/** Retrieve an error message from any value */
export function message(error: unknown): string {
const message = (error as { message: unknown })?.message ?? error;
try {
return typeof message === "string" ? message : JSON.stringify(message);
} catch {}
try {
return String(message);
} catch {}
return `Could not stringify error message ${typeof message}`;
}
/** Retrieve an error-like object from any value. Useful to check fields. */
export function obj(error: unknown): { message: string } & Partial<Error> {
if (error instanceof Error) return error;
return {
message: message(error),
...(typeof error === "object" && error),
};
}
/* Node.js error codes are strings */
export function code(error: unknown): NodeErrorCode | null {
const code = (error as { code: unknown })?.code;
return typeof code === "string" ? code : null;
}
/** Attach extra fields and throw */
export function rethrowWithMetadata(
err: unknown,
meta: Record<string, unknown>,
): never {
const error = err && typeof err === "object" ? err : new Error(message(err)); // no stack trace :/
Object.assign(error, meta);
throw error;
}
export type NodeErrorCode =
| keyof typeof import("node:os").constants.errno
| (string & {});

View file

@ -4,6 +4,9 @@ export interface Meta {
openGraph?: OpenGraph; openGraph?: OpenGraph;
alternates?: Alternates; alternates?: Alternates;
} }
export interface Template extends Omit<Meta, "title"> {
titleTemplate?: (title: string) => string,
}
export interface OpenGraph { export interface OpenGraph {
title?: string; title?: string;
description?: string | undefined; description?: string | undefined;
@ -19,6 +22,6 @@ export interface AlternateType {
title: string; title: string;
} }
export function renderMeta({ title }: Meta): string { export function renderMeta({ title }: Meta): string {
return `<title>${esc(title)}</title>`; return `<title>${esc(title)}</title><link rel="icon" type="image/x-icon" href="/favicon.ico">`;
} }
import { escapeHtml as esc } from "#engine/render"; import { escapeHtmlContent as esc } from "#engine/render";

View file

@ -90,6 +90,7 @@
.v text/x-v .v text/x-v
.wav audio/x-wav .wav audio/x-wav
.wmv video/x-ms-wmv .wmv video/x-ms-wmv
.woff2 font/woff2
.xls application/vnd.ms-excel .xls application/vnd.ms-excel
.xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet .xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
.xml application/xml .xml application/xml

View file

@ -31,6 +31,24 @@ export interface FileItem {
export interface Section { export interface Section {
root: string; root: string;
} }
export interface Font {
name: string;
/**
* Specify either font name, file path, or URL to fetch it.
* Private fonts do not have a URL and will fail to build if missing.
*/
sources: string[];
subsets: Array<{
vars?: Record<string, FontVariableAxis>;
layoutFeatures?: string[];
unicodes: FontRange | FontRange[];
/** Include [ext] to autofill 'woff2' */
asset: string;
}>;
}
export type FontVars = Record<string, FontVariableAxis>;
export type FontVariableAxis = number | [min: number, max: number];
export type FontRange = string | number | { start: number, end: number };
export const userData = render.userData<SitegenRender>(() => { export const userData = render.userData<SitegenRender>(() => {
throw new Error("This function can only be used in a page (static or view)"); throw new Error("This function can only be used in a page (static or view)");

View file

@ -11,9 +11,9 @@ export function getDb(file: string) {
let db = map.get(file); let db = map.get(file);
if (db) return db; if (db) return db;
const fileWithExt = file.includes(".") ? file : file + ".sqlite"; const fileWithExt = file.includes(".") ? file : file + ".sqlite";
db = new WrappedDatabase( const dir = process.env.CLOVER_DB ?? ".clover";
path.join(process.env.CLOVER_DB ?? ".clover", fileWithExt), fs.mkdirSync(dir);
); db = new WrappedDatabase(path.join(dir, fileWithExt));
map.set(file, db); map.set(file, db);
return db; return db;
} }
@ -21,7 +21,7 @@ export function getDb(file: string) {
export class WrappedDatabase { export class WrappedDatabase {
file: string; file: string;
node: DatabaseSync; node: DatabaseSync;
stmts: Stmt[]; stmts: Stmt[] = [];
stmtTableMigrate: WeakRef<StatementSync> | null = null; stmtTableMigrate: WeakRef<StatementSync> | null = null;
constructor(file: string) { constructor(file: string) {
@ -153,3 +153,4 @@ export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {
import { DatabaseSync, StatementSync } from "node:sqlite"; import { DatabaseSync, StatementSync } from "node:sqlite";
import * as path from "node:path"; import * as path from "node:path";
import * as fs from "#sitegen/fs";

View file

@ -0,0 +1,15 @@
const execFileRaw = util.promisify(child_process.execFile);
export const exec: typeof execFileRaw = ((
...args: Parameters<typeof execFileRaw>
) =>
execFileRaw(...args).catch((e: any) => {
if (e?.message?.startsWith?.("Command failed")) {
if (e.code > 2 ** 31) e.code |= 0;
const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
}
throw e;
})) as any;
import * as util from 'node:util';
import * as child_process from 'node:child_process';

View file

@ -25,6 +25,7 @@ function serve() {
"--development", "--development",
], { ], {
stdio: "inherit", stdio: "inherit",
execArgv: ['--no-warnings'],
}); });
subprocess.on("close", onSubprocessClose); subprocess.on("close", onSubprocessClose);
} }

View file

@ -11,23 +11,21 @@ that assist building websites. these tools power <https://paperclover.net>.
- Different languages can be used at the same time. Supports `async function` - Different languages can be used at the same time. Supports `async function`
components, `<Suspense />`, and custom extensions. components, `<Suspense />`, and custom extensions.
- **Incremental static site generator and build system.** - **Incremental static site generator and build system.**
- Build entire production site at start, incremental updates when pages - Build both development and production sites on startup start. Incremental
change; Build system state survives coding sessions. generator rebuilds changed pages; Build system state survives coding sessions.
- The only difference in development and production mode is hidden source-maps - Multiple backend support.
and stripped `console.debug` calls. The site you see locally is the same
site you see deployed.
- (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs - (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs
checks when the files change. For example, changing a component re-tests checks when the files change. For example, changing a component re-tests
only pages that use that component and re-lints only the changed file. only pages that use that component and re-lints only the changed file.
- **Integrated libraries for building complex, content heavy web sites.** - **Integrated libraries for building complex, content heavy web sites.**
- Static asset serving with ETag and build-time compression. - Static asset serving with ETag and build-time compression.
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`) - Dynamicly rendered pages with static client. (`import "#sitegen/view"`)
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`) - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
- TODO: Meta and Open Graph generation. (`export const meta`) - TODO: Meta and Open Graph generation. (`export const meta`)
- TODO: Font subsetting tools to reduce bytes downloaded by fonts. - Font subsetting to reduce page bandwidth.
- **Built on the battle-tested Node.js runtime.** - **Built on the stable and battle-tested Node.js runtime.**
None of these tools are complex or revolutionary. Rather, this project is the none of these tools are complex or revolutionary. rather, this project is the
sum of many years of experience on managing content heavy websites, and an sum of many years of experience on managing content heavy websites, and an
example on how other over-complicate other frameworks. example on how other over-complicate other frameworks.
@ -35,9 +33,11 @@ example on how other over-complicate other frameworks.
Included is `src`, which contains `paperclover.net`. Website highlights: Included is `src`, which contains `paperclover.net`. Website highlights:
- TODO: flashy homepage.
- [Question/Answer board, custom markdown parser and components][q+a]. - [Question/Answer board, custom markdown parser and components][q+a].
- [File viewer with fast ui/ux + optimized media streaming][file]. - [File viewer with fast ui/ux + optimized media streaming][file].
- [Personal, friends-only blog with password protection][friends]. - [Personal, friends-only blog with password protection][friends].
- TODO: digital garden styled blog.
[q+a]: https://paperclover.net/q+a [q+a]: https://paperclover.net/q+a
[file]: https://paperclover.net/file [file]: https://paperclover.net/file
@ -51,15 +51,21 @@ minimum system requirements:
- random access memory. - random access memory.
- windows 7 or later, macos, or other operating system. - windows 7 or later, macos, or other operating system.
required software:
- node.js v24
- python v3
my development machine, for example, is Dell Inspiron 7348 with Core i7 my development machine, for example, is Dell Inspiron 7348 with Core i7
npm install npm install
# production generation # build site using 'run.js' to enable runtime plugins
node run generate node run generate
node .clover/o/backend # the built site runs in regular node.js
node --enable-source-maps .clover/o/backend
# "development" watch mode # watch-rebuild mode
node run watch node run watch
for unix systems, the provided `flake.nix` can be used with `nix develop` to for unix systems, the provided `flake.nix` can be used with `nix develop` to
@ -67,7 +73,7 @@ open a shell with all needed system dependencies.
## Deployment ## Deployment
There are two primary server components to be deployed: the web server and the there are two primary server components to be deployed: the web server and the
sourth of truth server. The latter is a singleton that runs on Clover's NAS, sourth of truth server. The latter is a singleton that runs on Clover's NAS,
which holds the full contents of the file storage. The web server pulls data which holds the full contents of the file storage. The web server pulls data
from the source of truth and renders web pages, and can be duplicated to from the source of truth and renders web pages, and can be duplicated to
@ -79,7 +85,8 @@ Deployment of the source of truth can be done with Docker Compose:
backend: backend:
container_name: backend container_name: backend
build: build:
# this uses loopback to hit the self-hosted git server # this uses loopback to hit the self-hosted git server,
# docker will cache the image to not re-fetch on reboot.
context: http://127.0.0.1:3000/clo/sitegen.git context: http://127.0.0.1:3000/clo/sitegen.git
dockerfile: src/source-of-truth.dockerfile dockerfile: src/source-of-truth.dockerfile
environment: environment:
@ -96,10 +103,23 @@ Deployment of the source of truth can be done with Docker Compose:
- /mnt/storage1/clover/Documents/Config/paperclover:/data - /mnt/storage1/clover/Documents/Config/paperclover:/data
- /mnt/storage1/clover/Published:/published - /mnt/storage1/clover/Published:/published
Due to caching, one may need to manually purge images via Due to caching, images may need to be purged via `docker image rm {image} -f`
`docker image rm ix-clover-backend -f` when an update is desired when an update is desired. Some docker GUIs support force pulls, some are buggy.
TODO: deployment instructions for a web node The web server performs rendering. A `Dockerfile` for it is present in
`src/web.dockerfile` but it is currently unused. Deployments are done by
building the project locally, and then using `rsync` to copy files.
node run generate
rsync .clover/o "$REMOTE_USER:~/paperclover" --exclude=.clover --exclude=.env \
--delete-after --progress --human-readable
ssh "$REMOTE_USER" /bin/bash << EOF
set -e
cd ~/paperclover
npm ci
pm2 restart site
echo "-> https://paperclover.net"
EOF
## Contributions ## Contributions

21
run.js
View file

@ -3,7 +3,6 @@
import * as util from "node:util"; import * as util from "node:util";
import * as zlib from "node:zlib"; import * as zlib from "node:zlib";
import * as url from "node:url"; import * as url from "node:url";
import * as module from "node:module";
import process from "node:process"; import process from "node:process";
// Disable experimental warnings (Type Stripping, etc) // Disable experimental warnings (Type Stripping, etc)
@ -75,16 +74,26 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
else if (mod.default?.fetch) { else if (mod.default?.fetch) {
const protocol = "http"; const protocol = "http";
const { serve } = hot.load("@hono/node-server"); const { serve } = hot.load("@hono/node-server");
serve({ serve(
{
fetch: mod.default.fetch, fetch: mod.default.fetch,
}, ({ address, port }) => { },
({ address, port }) => {
if (address === "::") address = "::1"; if (address === "::") address = "::1";
console.info(url.format({ console.info(
url.format({
protocol, protocol,
hostname: address, hostname: address,
port, port,
})); }),
}); );
},
);
} else {
console.warn(
hot.load("path").relative('.', found),
'does not export a "main" function',
);
} }
} catch (e) { } catch (e) {
console.error(util.inspect(e)); console.error(util.inspect(e));

View file

@ -3,7 +3,11 @@ const cookieAge = 60 * 60 * 24 * 365; // 1 year
let lastKnownToken: string | null = null; let lastKnownToken: string | null = null;
function compareToken(token: string) { function compareToken(token: string) {
if (token === lastKnownToken) return true; if (token === lastKnownToken) return true;
try {
lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim(); lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
} catch {
return false;
}
return token === lastKnownToken; return token === lastKnownToken;
} }

View file

@ -15,6 +15,20 @@ app.route("", require("./file-viewer/backend.tsx").app);
// Asset middleware has least precedence // Asset middleware has least precedence
app.use(assets.middleware); app.use(assets.middleware);
// Asset Aliases
app.get(
"/apple-touch-icon-precomposed.png",
(c) => assets.serveAsset(c, "/apple-touch-icon.png"),
);
app.get(
"/apple-touch-icon-57x57.png",
(c) => assets.serveAsset(c, "/apple-touch-icon.png"),
);
app.get(
"/apple-touch-icon-57x57-precomposed.png",
(c) => assets.serveAsset(c, "/apple-touch-icon.png"),
);
// Handlers // Handlers
app.notFound(assets.notFound); app.notFound(assets.notFound);

View file

@ -10,7 +10,6 @@
// This is the third iteration of the scanner, hence its name "scan3"; // This is the third iteration of the scanner, hence its name "scan3";
// Remember that any software you want to be maintainable and high // Remember that any software you want to be maintainable and high
// quality cannot be written with AI. // quality cannot be written with AI.
const root = path.resolve("/Volumes/clover/Published");
const workDir = path.resolve(".clover/derived"); const workDir = path.resolve(".clover/derived");
const sotToken = UNWRAP(process.env.CLOVER_SOT_KEY); const sotToken = UNWRAP(process.env.CLOVER_SOT_KEY);
@ -404,18 +403,6 @@ interface Process {
undo?(mediaFile: MediaFile): Promise<void>; undo?(mediaFile: MediaFile): Promise<void>;
} }
const execFileRaw = util.promisify(child_process.execFile);
const execFile: typeof execFileRaw = ((
...args: Parameters<typeof execFileRaw>
) =>
execFileRaw(...args).catch((e: any) => {
if (e?.message?.startsWith?.("Command failed")) {
if (e.code > 2 ** 31) e.code |= 0;
const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
}
throw e;
})) as any;
const ffprobeBin = testProgram("ffprobe", "--help"); const ffprobeBin = testProgram("ffprobe", "--help");
const ffmpegBin = testProgram("ffmpeg", "--help"); const ffmpegBin = testProgram("ffmpeg", "--help");
@ -426,7 +413,7 @@ const procDuration: Process = {
enable: ffprobeBin !== null, enable: ffprobeBin !== null,
include: rules.extsDuration, include: rules.extsDuration,
async run({ absPath, mediaFile }) { async run({ absPath, mediaFile }) {
const { stdout } = await execFile(ffprobeBin!, [ const { stdout } = await subprocess.exec(ffprobeBin!, [
"-v", "-v",
"error", "error",
"-show_entries", "-show_entries",
@ -793,11 +780,11 @@ import { Progress } from "@paperclover/console/Progress";
import { Spinner } from "@paperclover/console/Spinner"; import { Spinner } from "@paperclover/console/Spinner";
import * as async from "#sitegen/async"; import * as async from "#sitegen/async";
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import * as subprocess from "#sitegen/subprocess";
import * as path from "node:path"; import * as path from "node:path";
import * as zlib from "node:zlib"; import * as zlib from "node:zlib";
import * as child_process from "node:child_process"; import * as child_process from "node:child_process";
import * as util from "node:util";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import * as stream from "node:stream"; import * as stream from "node:stream";
@ -814,3 +801,4 @@ import * as highlight from "@/file-viewer/highlight.ts";
import * as ffmpeg from "@/file-viewer/ffmpeg.ts"; import * as ffmpeg from "@/file-viewer/ffmpeg.ts";
import * as rsync from "@/file-viewer/rsync.ts"; import * as rsync from "@/file-viewer/rsync.ts";
import * as transcodeRules from "@/file-viewer/transcode-rules.ts"; import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
import { rawFileRoot as root } from "../paths.ts";

View file

@ -12,9 +12,9 @@ import { escapeUri } from "./format.ts";
declare const Deno: any; declare const Deno: any;
const sourceOfTruth = "https://nas.paperclover.net:43250"; const sourceOfTruth = "https://nas.paperclover.net:43250";
const caCert = fs.readFileSync("src/file-viewer/cert.pem"); // const caCert = fs.readFileSync("src/file-viewer/cert.pem");
const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/"); const diskCacheRoot = path.join(import.meta.dirname, ".filecache/");
const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB
const ramCacheMaxSize = 1 * 1024 * 1024 * 1024; // 1.5GB const ramCacheMaxSize = 1 * 1024 * 1024 * 1024; // 1.5GB
const loadInProgress = new Map< const loadInProgress = new Map<
@ -338,7 +338,7 @@ const agent: any = typeof Bun !== "undefined"
// Bun has two non-standard fetch extensions // Bun has two non-standard fetch extensions
decompress: false, decompress: false,
tls: { tls: {
ca: caCert, // ca: caCert,
}, },
} }
// TODO: https://github.com/denoland/deno/issues/12291 // TODO: https://github.com/denoland/deno/issues/12291
@ -351,7 +351,7 @@ const agent: any = typeof Bun !== "undefined"
// } // }
// Node.js supports node:http // Node.js supports node:http
: new Agent({ : new Agent({
ca: caCert, // ca: caCert,
}); });
function fetchFileNode(pathname: string): Promise<ReadableStream> { function fetchFileNode(pathname: string): Promise<ReadableStream> {

12
src/file-viewer/paths.ts Normal file
View file

@ -0,0 +1,12 @@
export const nasRoot = process.platform === "win32"
? "\\\\zenith\\clover"
: process.platform === "darwin"
? "/Volumes/clover"
: "/media/clover";
export const rawFileRoot = process.env.CLOVER_FILE_RAW ??
path.join(nasRoot, "Published");
export const derivedFileRoot = process.env.CLOVER_FILE_DERIVED ??
path.join(nasRoot, "Documents/Config/paperclover/derived");
import * as path from "node:path";

View file

@ -1,11 +1,11 @@
// This file defines the different "Sections" of the website. The sections act
// as separate codebases, but hosted as one. This allows me to have
// sub-projects like the file viewer in 'file', or the question answer system
// in 'q+a'. Each section can define configuration, pages, backend routes, and
// contain other files.
const join = (...paths: string[]) => path.join(import.meta.dirname, ...paths); const join = (...paths: string[]) => path.join(import.meta.dirname, ...paths);
export const siteSections: Section[] = [ // Different sections of the website are split into their own folders. Acting as
// as separate codebases, they are hosted as one. This allows me to have
// sub-projects like the file viewer in 'file/', and the question answer system
// in 'q+a', but maintain clear boundaries. Each section can define
// configuration, pages, backend routes, and contain other files.
export const siteSections: sg.Section[] = [
{ root: join(".") }, { root: join(".") },
{ root: join("q+a/") }, { root: join("q+a/") },
{ root: join("file-viewer/") }, { root: join("file-viewer/") },
@ -14,10 +14,96 @@ export const siteSections: Section[] = [
// { root: join("fiction/"), pageBase: "/fiction" }, // { root: join("fiction/"), pageBase: "/fiction" },
]; ];
// All backends are bundled. The backend named "backend" is run by "node run watch"
export const backends: string[] = [ export const backends: string[] = [
join("backend.ts"), join("backend.ts"),
join("source-of-truth.ts"), join("source-of-truth.ts"),
]; ];
// Font subsets reduce bandwidth and protect against proprietary font theft.
const fontRoot = path.join(nasRoot, 'Documents/Font');
const ascii = { start: 0x20, end: 0x7E };
const nonAscii: sg.FontRange[] = [
{ start: 0xC0, end: 0xFF },
{ start: 0x2190, end: 0x2193 },
{ start: 0xA0, end: 0xA8 },
{ start: 0xAA, end: 0xBF },
{ start: 0x2194, end: 0x2199 },
{ start: 0x100, end: 0x17F },
0xA9,
0x2018,
0x2019,
0x201C,
0x201D,
0x2022,
];
const recursiveVars: sg.FontVars = {
WGHT: [400, 750],
SLNT: [-15, 0],
CASL: 0.25,
CRSV: 0.5,
MONO: 0,
};
const recursiveVarsMono = { ...recursiveVars, MONO: 1 };
const recursiveVarsQuestion: sg.FontVars = {
...recursiveVars,
MONO: [0, 1],
WGHT: [400, 1000],
};
const layoutFeatures = ["numr", "dnom", "frac"];
export const fonts: sg.Font[] = [
{
name: "Recursive",
sources: [
"Recursive_VF_1.085.ttf",
path.join(fontRoot, "/ArrowType/Recursive/Recursive_VF_1.085.ttf"),
"https://paperclover.net/file/_unlisted/Recursive_VF_1.085.ttf",
],
subsets: [
{
asset: "/recultramin.[ext]",
layoutFeatures,
vars: recursiveVars,
unicodes: ascii,
},
{
asset: "/recmono.[ext]",
vars: recursiveVarsMono,
unicodes: ascii,
},
{
asset: "/recqa.[ext]",
vars: recursiveVarsQuestion,
unicodes: ascii,
},
{
asset: "/recexotic.[ext]",
vars: recursiveVarsQuestion,
unicodes: nonAscii,
},
],
},
{
name: "AT Name Sans Display Hairline",
sources: [
"ATNameSansDisplay-Hairline.woff2",
path.join(fontRoot, "/ArrowType/Recursive/Recursive_VF_1.085.ttf"),
],
subsets: [
{
asset: "/cydn_header.[ext]",
layoutFeatures,
unicodes: "cotlyedon",
},
],
},
];
export async function main() {
await font.buildFonts(fonts);
}
import * as path from "node:path"; import * as path from "node:path";
import type { Section } from "#sitegen"; import * as font from "../framework/font.ts";
import type * as sg from "#sitegen";
import { nasRoot } from "./file-viewer/paths.ts";

View file

@ -22,17 +22,6 @@ export default app;
const token = UNWRAP(process.env.CLOVER_SOT_KEY); const token = UNWRAP(process.env.CLOVER_SOT_KEY);
const nasRoot = process.platform === "win32"
? "\\\\zenith\\clover"
: process.platform === "darwin"
? "/Volumes/clover"
: "/media/clover";
const rawFileRoot = process.env.CLOVER_FILE_RAW ??
path.join(nasRoot, "Published");
const derivedFileRoot = process.env.CLOVER_FILE_DERIVED ??
path.join(nasRoot, "Documents/Config/paperclover/derived");
if (!fs.existsSync(rawFileRoot)) { if (!fs.existsSync(rawFileRoot)) {
throw new Error(`${rawFileRoot} does not exist`); throw new Error(`${rawFileRoot} does not exist`);
} }
@ -125,3 +114,4 @@ import * as fsCallbacks from "node:fs";
import * as util from "node:util"; import * as util from "node:util";
import * as stream from "node:stream"; import * as stream from "node:stream";
import * as mime from "#sitegen/mime"; import * as mime from "#sitegen/mime";
import { derivedFileRoot, rawFileRoot } from "./file-viewer/paths.ts";

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

19
src/web.dockerfile Normal file
View file

@ -0,0 +1,19 @@
from node:24 as builder
run apt install -y git
workdir /tmp/builder
copy package*.json ./
run npm ci
copy . ./
run node run generate
run npm prune --production && cp -r .clover/o /app && cp -r node_modules /app/
from node:24 as runtime
workdir /app
copy --from=builder /app/ ./
env PORT=80
cmd ["node", "--enable-source-maps", "/app/backend.js"]