From 0c5db556f1502f42c2eed5c107936627e1de4e9c Mon Sep 17 00:00:00 2001 From: chloe caruso Date: Sun, 8 Jun 2025 15:12:04 -0700 Subject: [PATCH] primative backend support --- framework/backend/entry-node.ts | 32 ++++++++++ framework/bundle.ts | 21 +++++-- framework/fs.ts | 2 +- framework/{sitegen.tsx => generate.tsx} | 20 ++---- framework/hot.ts | 3 +- framework/incremental.ts | 23 +++---- framework/{ => lib}/assets.ts | 24 ++++++-- framework/{sitegen-lib.ts => lib/sitegen.ts} | 9 ++- framework/queue.ts | 4 ++ framework/sqlite.ts | 7 +-- package.json | 5 +- readme.md | 1 + repl.js | 7 +-- run.js | 2 +- src/admin.ts | 64 ++++++++++++++++++++ src/backend.ts | 38 ++++++++++++ src/pages/404.mdx | 6 ++ src/pages/index.marko | 1 - src/q+a/backend.ts | 3 + src/q+a/components/Question.marko | 2 +- src/{sections.ts => site.ts} | 0 21 files changed, 217 insertions(+), 57 deletions(-) create mode 100644 framework/backend/entry-node.ts rename framework/{sitegen.tsx => generate.tsx} (94%) rename framework/{ => lib}/assets.ts (76%) rename framework/{sitegen-lib.ts => lib/sitegen.ts} (82%) create mode 100644 readme.md create mode 100644 src/admin.ts create mode 100644 src/backend.ts create mode 100644 src/pages/404.mdx create mode 100644 src/q+a/backend.ts rename src/{sections.ts => site.ts} (100%) diff --git a/framework/backend/entry-node.ts b/framework/backend/entry-node.ts new file mode 100644 index 0000000..035a433 --- /dev/null +++ b/framework/backend/entry-node.ts @@ -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"; diff --git a/framework/bundle.ts b/framework/bundle.ts index 865150a..300d3d2 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -49,7 +49,7 @@ export async function bundleClientJavaScript( const publicScriptRoutes = extraPublicScripts.map((file) => path.basename(file).replace(/\.client\.[tj]sx?/, "") ); - const promises: Promise[] = []; + const promises: Promise[] = []; // TODO: add a shared build hash to entrypoints, derived from all the chunk hashes. for (const file of bundle.outputFiles) { let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); @@ -73,10 +73,23 @@ export async function bundleClientJavaScript( })); } } + await Promise.all(promises); +} - if (promises.length > 0) { - 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"; diff --git a/framework/fs.ts b/framework/fs.ts index 30554c4..4761f37 100644 --- a/framework/fs.ts +++ b/framework/fs.ts @@ -34,7 +34,7 @@ export function writeMkdirSync(file: string, contents: Buffer | string) { export function readDirRecOptionalSync(dir: string) { try { - return readdirSync(dir, { withFileTypes: true }); + return readdirSync(dir, { recursive: true, withFileTypes: true }); } catch (err: any) { if (err.code === "ENOENT") return []; throw err; diff --git a/framework/sitegen.tsx b/framework/generate.tsx similarity index 94% rename from framework/sitegen.tsx rename to framework/generate.tsx index 0a03f1b..cbe24f7 100644 --- a/framework/sitegen.tsx +++ b/framework/generate.tsx @@ -1,5 +1,3 @@ -// Sitegen! Clover's static site generator, built with love. - export function main() { return withSpinner({ text: "Recovering State", @@ -26,8 +24,8 @@ async function sitegen(status: Spinner) { const incr = new Incremental(); // Sitegen reviews every defined section for resources to process - const sections: Section[] = - require(path.join(root, "sections.ts")).siteSections; + const sections: sg.Section[] = + require(path.join(root, "site.ts")).siteSections; // Static files are compressed and served as-is. // - "{section}/static/*.png" @@ -43,8 +41,6 @@ async function sitegen(status: Spinner) { // Note that '.client.ts' can be placed anywhere in the file structure. // - "{section}/scripts/*.client.ts" let scripts: FileItem[] = []; - // 'backend.ts' - const backendFiles = []; // -- Scan for files -- status.text = "Scanning Project"; @@ -88,11 +84,6 @@ async function sitegen(status: Spinner) { 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?/)); const globalCssPath = join("global.css"); @@ -215,7 +206,7 @@ async function sitegen(status: Spinner) { // -- copy/compress static files -- async function doStaticFile(item: FileItem) { const body = await fs.readFile(item.file); - incr.putAsset({ + await incr.putAsset({ srcId: "static:" + item.file, key: item.id, body, @@ -250,7 +241,7 @@ async function sitegen(status: Spinner) { ), ).map((x) => `{${x}}`).join("\n"), }); - incr.putAsset({ + await incr.putAsset({ srcId: "page:" + page.file, key: page.id, body: doc, @@ -295,7 +286,6 @@ function wrapDocument({ }${body}${scripts ? `` : ""}`; } -import type { Section } from "./sitegen-lib.ts"; import { OnceMap, Queue } from "./queue.ts"; import { Incremental } from "./incremental.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 * as meta from "./meta.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 path from "node:path"; diff --git a/framework/hot.ts b/framework/hot.ts index 5078e5c..8e60aac 100644 --- a/framework/hot.ts +++ b/framework/hot.ts @@ -146,6 +146,7 @@ function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) { target: "esnext", jsx: "automatic", jsxImportSource: "#ssr", + sourcefile: filepath, }).code; return module._compile(src, filepath, "commonjs"); } @@ -162,8 +163,6 @@ function loadMarko(module: NodeJS.Module, filepath: string) { ) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n'; } - console.log(src); - console.log("---"); src = marko.compileSync(src, filepath).code; src = src.replace("marko/debug/html", "#ssr/marko"); return loadEsbuildCode(module, filepath, src); diff --git a/framework/incremental.ts b/framework/incremental.ts index 12bbc3e..793cf07 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -7,7 +7,6 @@ // all its imports) to map to the build of a page, which produces an HTML file // plus a list of scripts. -import { Buffer } from "node:buffer"; interface ArtifactMap { asset: Asset; script: string; @@ -126,18 +125,17 @@ export class Incremental { }, hash, }; + const a = this.put({ ...info, type: "asset", value }); if (!this.compress.has(hash)) { const label = info.key; this.compress.set(hash, { zstd: undefined, gzip: undefined, }); - await Promise.all([ - this.compressQueue.add({ label, buffer, algo: "zstd", hash }), - this.compressQueue.add({ label, buffer, algo: "gzip", hash }), - ]); + this.compressQueue.add({ label, buffer, algo: "zstd", hash }); + this.compressQueue.add({ label, buffer, algo: "gzip", hash }); } - return this.put({ ...info, type: "asset", value }); + return a; } async compressImpl({ algo, buffer, hash }: CompressJob) { @@ -167,16 +165,18 @@ export class Incremental { } async wait() { - await this.compressQueue.done({ method: "stop" }); + await this.compressQueue.done({ method: "success" }); } + async flush() { + ASSERT(!this.compressQueue.active); const writer = new BufferWriter(); const asset = Object.fromEntries( Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => { const raw = writer.write(buffer, hash); const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {}; - const gzip = gzipBuf ? writer.write(gzipBuf, hash) : null; - const zstd = zstdBuf ? writer.write(zstdBuf, hash) : null; + const gzip = gzipBuf ? writer.write(gzipBuf, hash + ".gz") : null; + const zstd = zstdBuf ? writer.write(zstdBuf, hash + ".zstd") : null; return [key, { raw, gzip, @@ -252,10 +252,6 @@ export interface SerializedMeta { script: [key: string, value: string][]; } -function never(): never { - throw new Error("Impossible"); -} - import * as path from "node:path"; import * as fs from "./fs.ts"; import * as zlib from "node:zlib"; @@ -263,3 +259,4 @@ import * as util from "node:util"; import { Queue } from "./queue.ts"; import * as hot from "./hot.ts"; import * as mime from "./mime.ts"; +import { Buffer } from "node:buffer"; diff --git a/framework/assets.ts b/framework/lib/assets.ts similarity index 76% rename from framework/assets.ts rename to framework/lib/assets.ts index 109e727..9dc295b 100644 --- a/framework/assets.ts +++ b/framework/lib/assets.ts @@ -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(); const asset = assets!.map[c.req.path]; if (asset) { @@ -35,6 +35,19 @@ export async function assetMiddleware(c: Context, next: 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( c: Context, id: StaticPageId, @@ -62,14 +75,13 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { if (ifnonematch) { const etag = asset.headers.ETag; if (etagMatches(etag, ifnonematch)) { - c.res = new Response(null, { + return c.res = new Response(null, { status: 304, statusText: "Not Modified", headers: { ETag: etag, }, }); - return; } } const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; @@ -90,11 +102,11 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { } else { 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 { 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"; diff --git a/framework/sitegen-lib.ts b/framework/lib/sitegen.ts similarity index 82% rename from framework/sitegen-lib.ts rename to framework/lib/sitegen.ts index e9ea2d5..5404b8c 100644 --- a/framework/sitegen-lib.ts +++ b/framework/lib/sitegen.ts @@ -1,6 +1,8 @@ // Import this file with 'import * as sg from "#sitegen";' export type ScriptId = string; +const frameworkDir = path.dirname(import.meta.dirname); + export interface SitegenRender { scripts: Set; } @@ -22,7 +24,7 @@ export function getRender() { /** Add a client-side script to the page. */ export function addScript(id: ScriptId) { const srcFile: string = util.getCallSites() - .find((site) => !site.scriptName.startsWith(import.meta.dirname))! + .find((site) => !site.scriptName.startsWith(frameworkDir))! .scriptName; const filePath = hot.resolveFrom(srcFile, id); if ( @@ -44,6 +46,7 @@ export interface Section { root: string; } -import * as ssr from "./engine/ssr.ts"; +import * as ssr from "../engine/ssr.ts"; import * as util from "node:util"; -import * as hot from "./hot.ts"; +import * as hot from "../hot.ts"; +import * as path from "node:path"; diff --git a/framework/queue.ts b/framework/queue.ts index 532a249..08bfcfb 100644 --- a/framework/queue.ts +++ b/framework/queue.ts @@ -165,6 +165,10 @@ export class Queue { if (bar) bar[method](); } + + get active(): boolean { + return this.#active.length !== 0; + } } const cwd = process.cwd(); diff --git a/framework/sqlite.ts b/framework/sqlite.ts index 0343a0d..1646932 100644 --- a/framework/sqlite.ts +++ b/framework/sqlite.ts @@ -41,11 +41,8 @@ export class WrappedDatabase { (key, version) values (?, ?); `), )); - const { changes, lastInsertRowid } = s.run(name, 1); - console.log(changes, lastInsertRowid); - if (changes === 1) { - this.node.exec(schema); - } + const { changes } = s.run(name, 1); + if (changes === 1) this.node.exec(schema); } prepare( diff --git a/package.json b/package.json index a4002aa..ef8de70 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "typescript": "^5.8.3" }, "imports": { - "#sitegen": "./framework/sitegen-lib.ts", + "#backend": "./src/backend.ts", + "#sitegen": "./framework/lib/sitegen.ts", + "#sitegen/*": "./framework/lib/*.ts", "#sqlite": "./framework/sqlite.ts", "#ssr": "./framework/engine/ssr.ts", "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", @@ -26,6 +28,7 @@ "production": "marko/production", "node": "marko/debug/html" }, + "#hono": "hono", "#hono/platform": { "bun": "hono/bun", "deno": "hono/deno", diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..efc8523 --- /dev/null +++ b/readme.md @@ -0,0 +1 @@ +# clover sitegen framework diff --git a/repl.js b/repl.js index 10b4f1e..1fb7330 100644 --- a/repl.js +++ b/repl.js @@ -27,8 +27,7 @@ hot.load("node:repl").start({ }); setTimeout(() => { - hot.reloadRecursive("./framework/engine/ssr.ts"); - hot.reloadRecursive("./framework/bundle.ts"); + hot.reloadRecursive("./framework/generate.tsx"); }, 100); async function evaluate(code) { @@ -41,11 +40,11 @@ async function evaluate(code) { if (code[0] === "=") { try { const result = await eval(code[1]); - console.log(inspect(result)); + console.info(inspect(result)); } catch (err) { if (err instanceof SyntaxError) { const result = await eval("(async() => { return " + code + " })()"); - console.log(inspect(result)); + console.info(inspect(result)); } else { throw err; } diff --git a/run.js b/run.js index 80abcec..2a0cba8 100644 --- a/run.js +++ b/run.js @@ -18,7 +18,7 @@ import process from "node:process"; const hot = await import("./framework/hot.ts"); const console = hot.load("@paperclover/console"); -globalThis.console.log = console.info; +globalThis.console["log"] = console.info; globalThis.console.info = console.info; globalThis.console.warn = console.warn; globalThis.console.error = console.error; diff --git a/src/admin.ts b/src/admin.ts new file mode 100644 index 0000000..96691e6 --- /dev/null +++ b/src/admin.ts @@ -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"; diff --git a/src/backend.ts b/src/backend.ts new file mode 100644 index 0000000..5cbda38 --- /dev/null +++ b/src/backend.ts @@ -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"; diff --git a/src/pages/404.mdx b/src/pages/404.mdx new file mode 100644 index 0000000..d5b52e1 --- /dev/null +++ b/src/pages/404.mdx @@ -0,0 +1,6 @@ +export const meta = { title: 'oh no,,,' }; + +# oh dear + +sound the alarms + diff --git a/src/pages/index.marko b/src/pages/index.marko index 7caf6b5..50d72b1 100644 --- a/src/pages/index.marko +++ b/src/pages/index.marko @@ -16,7 +16,6 @@ export const meta: Meta = { type: "website", url: "https://paperclover.net", }, - generator: "clover", alternates: { canonical: "https://paperclover.net", types: { diff --git a/src/q+a/backend.ts b/src/q+a/backend.ts new file mode 100644 index 0000000..95dec0c --- /dev/null +++ b/src/q+a/backend.ts @@ -0,0 +1,3 @@ +export const app = new Hono(); + +import { Hono } from "#hono"; diff --git a/src/q+a/components/Question.marko b/src/q+a/components/Question.marko index a5debf1..063708f 100644 --- a/src/q+a/components/Question.marko +++ b/src/q+a/components/Question.marko @@ -30,7 +30,7 @@ static const transitionDate = 1735639200000; // this singleton script will make all the '