primative backend support

This commit is contained in:
chloe caruso 2025-06-08 15:12:04 -07:00
parent 46a67453a1
commit 0c5db556f1
21 changed files with 217 additions and 57 deletions

View file

@ -0,0 +1,32 @@
import "@paperclover/console/inject";
const protocol = "http";
const server = serve(app, ({ address, port }) => {
if (address === "::") address = "::1";
console.info(url.format({
protocol,
hostname: address,
port,
}));
});
process.on("SIGINT", () => {
server.close();
process.exit(0);
});
process.on("SIGTERM", () => {
server.close((err) => {
if (err) {
console.error(err);
process.exit(1);
}
process.exit(0);
});
});
import app from "#backend";
import url from "node:url";
import { serve } from "@hono/node-server";
import process from "node:process";

View file

@ -49,7 +49,7 @@ export async function bundleClientJavaScript(
const publicScriptRoutes = extraPublicScripts.map((file) => const publicScriptRoutes = extraPublicScripts.map((file) =>
path.basename(file).replace(/\.client\.[tj]sx?/, "") path.basename(file).replace(/\.client\.[tj]sx?/, "")
); );
const promises: Promise<unknown>[] = []; const promises: Promise<void>[] = [];
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes. // TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
for (const file of bundle.outputFiles) { for (const file of bundle.outputFiles) {
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
@ -73,10 +73,23 @@ export async function bundleClientJavaScript(
})); }));
} }
} }
if (promises.length > 0) {
await Promise.all(promises); await Promise.all(promises);
} }
export async function bundleServerJavaScript(entryPoint: string) {
const bundle = await esbuild.build({
bundle: true,
chunkNames: "/js/c.[hash]",
entryNames: "/js/[name]",
assetNames: "/asset/[hash]",
entryPoints: [entryPoint],
format: "esm",
minify: true,
outdir: "/out!",
plugins,
splitting: true,
write: false,
});
} }
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";

View file

@ -34,7 +34,7 @@ export function writeMkdirSync(file: string, contents: Buffer | string) {
export function readDirRecOptionalSync(dir: string) { export function readDirRecOptionalSync(dir: string) {
try { try {
return readdirSync(dir, { withFileTypes: true }); return readdirSync(dir, { recursive: true, withFileTypes: true });
} catch (err: any) { } catch (err: any) {
if (err.code === "ENOENT") return []; if (err.code === "ENOENT") return [];
throw err; throw err;

View file

@ -1,5 +1,3 @@
// Sitegen! Clover's static site generator, built with love.
export function main() { export function main() {
return withSpinner({ return withSpinner({
text: "Recovering State", text: "Recovering State",
@ -26,8 +24,8 @@ async function sitegen(status: Spinner) {
const incr = new Incremental(); const incr = new Incremental();
// Sitegen reviews every defined section for resources to process // Sitegen reviews every defined section for resources to process
const sections: Section[] = const sections: sg.Section[] =
require(path.join(root, "sections.ts")).siteSections; require(path.join(root, "site.ts")).siteSections;
// Static files are compressed and served as-is. // Static files are compressed and served as-is.
// - "{section}/static/*.png" // - "{section}/static/*.png"
@ -43,8 +41,6 @@ async function sitegen(status: Spinner) {
// Note that '.client.ts' can be placed anywhere in the file structure. // Note that '.client.ts' can be placed anywhere in the file structure.
// - "{section}/scripts/*.client.ts" // - "{section}/scripts/*.client.ts"
let scripts: FileItem[] = []; let scripts: FileItem[] = [];
// 'backend.ts'
const backendFiles = [];
// -- Scan for files -- // -- Scan for files --
status.text = "Scanning Project"; status.text = "Scanning Project";
@ -88,11 +84,6 @@ async function sitegen(status: Spinner) {
list.push({ id, file: path.join(item.parentPath, item.name) }); list.push({ id, file: path.join(item.parentPath, item.name) });
} }
} }
let backendFile = [
sectionPath("backend.ts"),
sectionPath("backend.tsx"),
].find((file) => fs.existsSync(file));
if (backendFile) backendFiles.push(backendFile);
} }
scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/)); scripts = scripts.filter(({ file }) => !file.match(/\.client\.[tj]sx?/));
const globalCssPath = join("global.css"); const globalCssPath = join("global.css");
@ -215,7 +206,7 @@ async function sitegen(status: Spinner) {
// -- copy/compress static files -- // -- copy/compress static files --
async function doStaticFile(item: FileItem) { async function doStaticFile(item: FileItem) {
const body = await fs.readFile(item.file); const body = await fs.readFile(item.file);
incr.putAsset({ await incr.putAsset({
srcId: "static:" + item.file, srcId: "static:" + item.file,
key: item.id, key: item.id,
body, body,
@ -250,7 +241,7 @@ async function sitegen(status: Spinner) {
), ),
).map((x) => `{${x}}`).join("\n"), ).map((x) => `{${x}}`).join("\n"),
}); });
incr.putAsset({ await incr.putAsset({
srcId: "page:" + page.file, srcId: "page:" + page.file,
key: page.id, key: page.id,
body: doc, body: doc,
@ -295,7 +286,6 @@ function wrapDocument({
}</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body>`; }</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body>`;
} }
import type { Section } from "./sitegen-lib.ts";
import { OnceMap, Queue } from "./queue.ts"; import { OnceMap, Queue } from "./queue.ts";
import { Incremental } from "./incremental.ts"; import { Incremental } from "./incremental.ts";
import * as bundle from "./bundle.ts"; import * as bundle from "./bundle.ts";
@ -304,6 +294,6 @@ import * as fs from "./fs.ts";
import { Spinner, withSpinner } from "@paperclover/console/Spinner"; import { Spinner, withSpinner } from "@paperclover/console/Spinner";
import * as meta from "./meta.ts"; import * as meta from "./meta.ts";
import * as ssr from "./engine/ssr.ts"; import * as ssr from "./engine/ssr.ts";
import * as sg from "./sitegen-lib.ts"; import * as sg from "#sitegen";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as path from "node:path"; import * as path from "node:path";

View file

@ -146,6 +146,7 @@ function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) {
target: "esnext", target: "esnext",
jsx: "automatic", jsx: "automatic",
jsxImportSource: "#ssr", jsxImportSource: "#ssr",
sourcefile: filepath,
}).code; }).code;
return module._compile(src, filepath, "commonjs"); return module._compile(src, filepath, "commonjs");
} }
@ -162,8 +163,6 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n'; ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n';
} }
console.log(src);
console.log("---");
src = marko.compileSync(src, filepath).code; src = marko.compileSync(src, filepath).code;
src = src.replace("marko/debug/html", "#ssr/marko"); src = src.replace("marko/debug/html", "#ssr/marko");
return loadEsbuildCode(module, filepath, src); return loadEsbuildCode(module, filepath, src);

View file

@ -7,7 +7,6 @@
// all its imports) to map to the build of a page, which produces an HTML file // all its imports) to map to the build of a page, which produces an HTML file
// plus a list of scripts. // plus a list of scripts.
import { Buffer } from "node:buffer";
interface ArtifactMap { interface ArtifactMap {
asset: Asset; asset: Asset;
script: string; script: string;
@ -126,18 +125,17 @@ export class Incremental {
}, },
hash, hash,
}; };
const a = this.put({ ...info, type: "asset", value });
if (!this.compress.has(hash)) { if (!this.compress.has(hash)) {
const label = info.key; const label = info.key;
this.compress.set(hash, { this.compress.set(hash, {
zstd: undefined, zstd: undefined,
gzip: undefined, gzip: undefined,
}); });
await Promise.all([ this.compressQueue.add({ label, buffer, algo: "zstd", hash });
this.compressQueue.add({ label, buffer, algo: "zstd", hash }), this.compressQueue.add({ label, buffer, algo: "gzip", hash });
this.compressQueue.add({ label, buffer, algo: "gzip", hash }),
]);
} }
return this.put({ ...info, type: "asset", value }); return a;
} }
async compressImpl({ algo, buffer, hash }: CompressJob) { async compressImpl({ algo, buffer, hash }: CompressJob) {
@ -167,16 +165,18 @@ export class Incremental {
} }
async wait() { async wait() {
await this.compressQueue.done({ method: "stop" }); await this.compressQueue.done({ method: "success" });
} }
async flush() { async flush() {
ASSERT(!this.compressQueue.active);
const writer = new BufferWriter(); const writer = new BufferWriter();
const asset = Object.fromEntries( const asset = Object.fromEntries(
Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => { Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => {
const raw = writer.write(buffer, hash); const raw = writer.write(buffer, hash);
const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {}; const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {};
const gzip = gzipBuf ? writer.write(gzipBuf, hash) : null; const gzip = gzipBuf ? writer.write(gzipBuf, hash + ".gz") : null;
const zstd = zstdBuf ? writer.write(zstdBuf, hash) : null; const zstd = zstdBuf ? writer.write(zstdBuf, hash + ".zstd") : null;
return [key, { return [key, {
raw, raw,
gzip, gzip,
@ -252,10 +252,6 @@ export interface SerializedMeta {
script: [key: string, value: string][]; script: [key: string, value: string][];
} }
function never(): never {
throw new Error("Impossible");
}
import * as path from "node:path"; import * as path from "node:path";
import * as fs from "./fs.ts"; import * as fs from "./fs.ts";
import * as zlib from "node:zlib"; import * as zlib from "node:zlib";
@ -263,3 +259,4 @@ import * as util from "node:util";
import { Queue } from "./queue.ts"; import { Queue } from "./queue.ts";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as mime from "./mime.ts"; import * as mime from "./mime.ts";
import { Buffer } from "node:buffer";

View file

@ -26,7 +26,7 @@ export async function reloadSync() {
}; };
} }
export async function assetMiddleware(c: Context, next: Next) { export async function middleware(c: Context, next: Next) {
if (!assets) await reload(); if (!assets) await reload();
const asset = assets!.map[c.req.path]; const asset = assets!.map[c.req.path];
if (asset) { if (asset) {
@ -35,6 +35,19 @@ export async function assetMiddleware(c: Context, next: Next) {
return next(); return next();
} }
export async function notFound(c: Context) {
if (!assets) await reload();
let pathname = c.req.path;
do {
const asset = assets!.map[pathname + "/404"];
if (asset) return assetInner(c, asset, 404);
pathname = pathname.slice(0, pathname.lastIndexOf("/"));
} while (pathname);
const asset = assets!.map["/404"];
if (asset) return assetInner(c, asset, 404);
return c.text("the 'Not Found' page was not found", 404);
}
export async function serveAsset( export async function serveAsset(
c: Context, c: Context,
id: StaticPageId, id: StaticPageId,
@ -62,14 +75,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
if (ifnonematch) { if (ifnonematch) {
const etag = asset.headers.ETag; const etag = asset.headers.ETag;
if (etagMatches(etag, ifnonematch)) { if (etagMatches(etag, ifnonematch)) {
c.res = new Response(null, { return c.res = new Response(null, {
status: 304, status: 304,
statusText: "Not Modified", statusText: "Not Modified",
headers: { headers: {
ETag: etag, ETag: etag,
}, },
}); });
return;
} }
} }
const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
@ -90,11 +102,11 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
} else { } else {
body = subarrayAsset(asset.raw); body = subarrayAsset(asset.raw);
} }
c.res = new Response(body, { headers, status }); return c.res = new Response(body, { headers, status });
} }
import * as fs from "./fs.ts"; import * as fs from "../fs.ts";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import type { StatusCode } from "hono/utils/http-status"; import type { StatusCode } from "hono/utils/http-status";
import type { BuiltAsset, BuiltAssetMap, View } from "./incremental.ts"; import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";

View file

@ -1,6 +1,8 @@
// Import this file with 'import * as sg from "#sitegen";' // Import this file with 'import * as sg from "#sitegen";'
export type ScriptId = string; export type ScriptId = string;
const frameworkDir = path.dirname(import.meta.dirname);
export interface SitegenRender { export interface SitegenRender {
scripts: Set<ScriptId>; scripts: Set<ScriptId>;
} }
@ -22,7 +24,7 @@ export function getRender() {
/** Add a client-side script to the page. */ /** Add a client-side script to the page. */
export function addScript(id: ScriptId) { export function addScript(id: ScriptId) {
const srcFile: string = util.getCallSites() const srcFile: string = util.getCallSites()
.find((site) => !site.scriptName.startsWith(import.meta.dirname))! .find((site) => !site.scriptName.startsWith(frameworkDir))!
.scriptName; .scriptName;
const filePath = hot.resolveFrom(srcFile, id); const filePath = hot.resolveFrom(srcFile, id);
if ( if (
@ -44,6 +46,7 @@ export interface Section {
root: string; root: string;
} }
import * as ssr from "./engine/ssr.ts"; import * as ssr from "../engine/ssr.ts";
import * as util from "node:util"; import * as util from "node:util";
import * as hot from "./hot.ts"; import * as hot from "../hot.ts";
import * as path from "node:path";

View file

@ -165,6 +165,10 @@ export class Queue<T, R> {
if (bar) bar[method](); if (bar) bar[method]();
} }
get active(): boolean {
return this.#active.length !== 0;
}
} }
const cwd = process.cwd(); const cwd = process.cwd();

View file

@ -41,11 +41,8 @@ export class WrappedDatabase {
(key, version) values (?, ?); (key, version) values (?, ?);
`), `),
)); ));
const { changes, lastInsertRowid } = s.run(name, 1); const { changes } = s.run(name, 1);
console.log(changes, lastInsertRowid); if (changes === 1) this.node.exec(schema);
if (changes === 1) {
this.node.exec(schema);
}
} }
prepare<Args extends unknown[] = [], Result = unknown>( prepare<Args extends unknown[] = [], Result = unknown>(

View file

@ -15,7 +15,9 @@
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"imports": { "imports": {
"#sitegen": "./framework/sitegen-lib.ts", "#backend": "./src/backend.ts",
"#sitegen": "./framework/lib/sitegen.ts",
"#sitegen/*": "./framework/lib/*.ts",
"#sqlite": "./framework/sqlite.ts", "#sqlite": "./framework/sqlite.ts",
"#ssr": "./framework/engine/ssr.ts", "#ssr": "./framework/engine/ssr.ts",
"#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts",
@ -26,6 +28,7 @@
"production": "marko/production", "production": "marko/production",
"node": "marko/debug/html" "node": "marko/debug/html"
}, },
"#hono": "hono",
"#hono/platform": { "#hono/platform": {
"bun": "hono/bun", "bun": "hono/bun",
"deno": "hono/deno", "deno": "hono/deno",

1
readme.md Normal file
View file

@ -0,0 +1 @@
# clover sitegen framework

View file

@ -27,8 +27,7 @@ hot.load("node:repl").start({
}); });
setTimeout(() => { setTimeout(() => {
hot.reloadRecursive("./framework/engine/ssr.ts"); hot.reloadRecursive("./framework/generate.tsx");
hot.reloadRecursive("./framework/bundle.ts");
}, 100); }, 100);
async function evaluate(code) { async function evaluate(code) {
@ -41,11 +40,11 @@ async function evaluate(code) {
if (code[0] === "=") { if (code[0] === "=") {
try { try {
const result = await eval(code[1]); const result = await eval(code[1]);
console.log(inspect(result)); console.info(inspect(result));
} catch (err) { } catch (err) {
if (err instanceof SyntaxError) { if (err instanceof SyntaxError) {
const result = await eval("(async() => { return " + code + " })()"); const result = await eval("(async() => { return " + code + " })()");
console.log(inspect(result)); console.info(inspect(result));
} else { } else {
throw err; throw err;
} }

2
run.js
View file

@ -18,7 +18,7 @@ import process from "node:process";
const hot = await import("./framework/hot.ts"); const hot = await import("./framework/hot.ts");
const console = hot.load("@paperclover/console"); const console = hot.load("@paperclover/console");
globalThis.console.log = console.info; globalThis.console["log"] = console.info;
globalThis.console.info = console.info; globalThis.console.info = console.info;
globalThis.console.warn = console.warn; globalThis.console.warn = console.warn;
globalThis.console.error = console.error; globalThis.console.error = console.error;

64
src/admin.ts Normal file
View file

@ -0,0 +1,64 @@
const cookieAge = 60 * 60 * 24 * 365; // 1 year
let lastKnownToken: string | null = null;
function compareToken(token: string) {
if (token === lastKnownToken) return true;
lastKnownToken = fs.readFileSync(".clover/admin-token.txt", "utf8").trim();
return token === lastKnownToken;
}
export async function middleware(c: Context, next: Next) {
if (c.req.path.startsWith("/admin")) {
return adminInner(c, next);
}
return next();
}
export function adminInner(c: Context, next: Next) {
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
if (c.req.path === "/admin/login") {
const key = c.req.query("key");
if (key) {
if (compareToken(key)) {
return c.body(null, 303, {
"Location": "/admin",
"Set-Cookie":
`admin-token=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
});
}
return serveAsset(c, "/admin/login/fail", 403);
}
if (token && compareToken(token)) {
return c.redirect("/admin", 303);
}
if (c.req.method === "POST") {
return serveAsset(c, "/admin/login/fail", 403);
} else {
return serveAsset(c, "/admin/login", 200);
}
}
if (c.req.path === "/admin/logout") {
return c.body(null, 303, {
"Location": "/admin/login",
"Set-Cookie":
`admin-token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
});
}
if (token && compareToken(token)) {
return next();
}
return c.redirect("/admin/login", 303);
}
export function hasAdminToken(c: Context) {
const token = c.req.header("Cookie")?.match(/admin-token=([^;]+)/)?.[1];
return token && compareToken(token);
}
import * as fs from "fs";
import type { Context, Next } from "hono";
import { serveAsset } from "#sitegen/assets";

38
src/backend.ts Normal file
View file

@ -0,0 +1,38 @@
const logHttp = scoped("http", { color: "magenta" });
const app = new Hono();
app.notFound(assets.notFound);
app.use(trimTrailingSlash());
app.use(removeDuplicateSlashes);
app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
app.use(admin.middleware);
app.route("", require("./q+a/backend.ts").app);
// app.route("", require("./file-viewer/backend.tsx").app);
// app.route("", require("./friends/backend.tsx").app);
app.use(assets.middleware);
export default app;
async function removeDuplicateSlashes(c: Context, next: Next) {
const path = c.req.path;
if (/\/\/+/.test(path)) {
const normalizedPath = path.replace(/\/\/+/g, "/");
const query = c.req.query();
const queryString = Object.keys(query).length > 0
? "?" + new URLSearchParams(query).toString()
: "";
return c.redirect(normalizedPath + queryString, 301);
}
await next();
}
import { Hono } from "#hono";
import { logger } from "hono/logger";
import { trimTrailingSlash } from "hono/trailing-slash";
import * as assets from "#sitegen/assets";
import * as admin from "./admin.ts";
import { scoped } from "@paperclover/console";

6
src/pages/404.mdx Normal file
View file

@ -0,0 +1,6 @@
export const meta = { title: 'oh no,,,' };
# oh dear
sound the alarms

View file

@ -16,7 +16,6 @@ export const meta: Meta = {
type: "website", type: "website",
url: "https://paperclover.net", url: "https://paperclover.net",
}, },
generator: "clover",
alternates: { alternates: {
canonical: "https://paperclover.net", canonical: "https://paperclover.net",
types: { types: {

3
src/q+a/backend.ts Normal file
View file

@ -0,0 +1,3 @@
export const app = new Hono();
import { Hono } from "#hono";

View file

@ -30,7 +30,7 @@ static const transitionDate = 1735639200000;
</> </>
// this singleton script will make all the '<time>' tags clickable. // this singleton script will make all the '<time>' tags clickable.
client import "./clickable-links.ts"; client import "./clickable-links.client.ts";
import type { Question } from "@/q+a/models/Question.ts"; import type { Question } from "@/q+a/models/Question.ts";
import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts"; import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";