| author | |
| committer | |
| log | ad50869c177bec4aa2eb956e55acbee3ba04ad27 |
| tree | 205b5fb1ccac84685e481d51a4bc3d7ceebb303b |
| parent | 8421676693001c607e4963009031d205fa94bbe6 |
| signature | Commit is signed but in an unrecognized format. |
24 files changed, 372 insertions(+), 331 deletions(-)
framework/backend/entry-passthru.ts created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | import "@paperclover/console/inject"; | ||
| 2 | export default app; | ||
| 3 | |||
| 4 | import app from "#backend"; | ||
framework/css.ts+2-3| ... | @@ -64,7 +64,7 @@ export async function bundleCssFiles( | ... | @@ -64,7 +64,7 @@ export async function bundleCssFiles( |
| 64 | }), | 64 | }), |
| 65 | ); | 65 | ); |
| 66 | }, | 66 | }, |
| 67 | } satisfies Plugin; | 67 | } satisfies esbuild.Plugin; |
| 68 | const build = await esbuild.build({ | 68 | const build = await esbuild.build({ |
| 69 | bundle: true, | 69 | bundle: true, |
| 70 | entryPoints: ["$input$"], | 70 | entryPoints: ["$input$"], |
| ... | @@ -85,6 +85,5 @@ export async function bundleCssFiles( | ... | @@ -85,6 +85,5 @@ export async function bundleCssFiles( |
| 85 | return outputFiles[0].text; | 85 | return outputFiles[0].text; |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | import type { Plugin } from "esbuild"; | ||
| 89 | import * as esbuild from "esbuild"; | 88 | import * as esbuild from "esbuild"; |
| 90 | import * as fs from "./fs.ts"; | 89 | import * as fs from "#sitegen/fs"; |
framework/fs.ts deleted-61| ... | @@ -1,61 +0,0 @@ | ||
| 1 | // File System APIs. Some custom APIs, but mostly a re-export a mix of built-in | ||
| 2 | // Node.js sync+promise fs methods. For convenince. | ||
| 3 | export { | ||
| 4 | existsSync, | ||
| 5 | readdir, | ||
| 6 | readdirSync, | ||
| 7 | readFile, | ||
| 8 | readFileSync, | ||
| 9 | rm, | ||
| 10 | rmSync, | ||
| 11 | stat, | ||
| 12 | statSync, | ||
| 13 | writeFile, | ||
| 14 | writeFileSync, | ||
| 15 | }; | ||
| 16 | |||
| 17 | export function mkdir(dir: string) { | ||
| 18 | return nodeMkdir(dir, { recursive: true }); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function mkdirSync(dir: string) { | ||
| 22 | return nodeMkdirSync(dir, { recursive: true }); | ||
| 23 | } | ||
| 24 | |||
| 25 | export async function writeMkdir(file: string, contents: Buffer | string) { | ||
| 26 | await mkdir(path.dirname(file)); | ||
| 27 | return writeFile(file, contents); | ||
| 28 | } | ||
| 29 | |||
| 30 | export function writeMkdirSync(file: string, contents: Buffer | string) { | ||
| 31 | mkdirSync(path.dirname(file)); | ||
| 32 | return writeFileSync(file, contents); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function readDirRecOptionalSync(dir: string) { | ||
| 36 | try { | ||
| 37 | return readdirSync(dir, { recursive: true, withFileTypes: true }); | ||
| 38 | } catch (err: any) { | ||
| 39 | if (err.code === "ENOENT") return []; | ||
| 40 | throw err; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | import * as path from "node:path"; | ||
| 45 | import { | ||
| 46 | existsSync, | ||
| 47 | mkdirSync as nodeMkdirSync, | ||
| 48 | readdirSync, | ||
| 49 | readFileSync, | ||
| 50 | rmSync, | ||
| 51 | statSync, | ||
| 52 | writeFileSync, | ||
| 53 | } from "node:fs"; | ||
| 54 | import { | ||
| 55 | mkdir as nodeMkdir, | ||
| 56 | readdir, | ||
| 57 | readFile, | ||
| 58 | rm, | ||
| 59 | stat, | ||
| 60 | writeFile, | ||
| 61 | } from "node:fs/promises"; | ||
framework/generate.tsx+4-4| ... | @@ -290,10 +290,10 @@ import { OnceMap, Queue } from "./queue.ts"; | ... | @@ -290,10 +290,10 @@ import { OnceMap, Queue } from "./queue.ts"; |
| 290 | import { Incremental } from "./incremental.ts"; | 290 | import { Incremental } from "./incremental.ts"; |
| 291 | import * as bundle from "./bundle.ts"; | 291 | import * as bundle from "./bundle.ts"; |
| 292 | import * as css from "./css.ts"; | 292 | import * as css from "./css.ts"; |
| 293 | import * as fs from "./fs.ts"; | ||
| 294 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; | ||
| 295 | import * as meta from "./meta.ts"; | ||
| 296 | import * as ssr from "./engine/ssr.ts"; | 293 | import * as ssr from "./engine/ssr.ts"; |
| 297 | import * as sg from "#sitegen"; | ||
| 298 | import * as hot from "./hot.ts"; | 294 | import * as hot from "./hot.ts"; |
| 295 | import * as fs from "#sitegen/fs"; | ||
| 296 | import * as sg from "#sitegen"; | ||
| 299 | import * as path from "node:path"; | 297 | import * as path from "node:path"; |
| 298 | import * as meta from "#sitegen/meta"; | ||
| 299 | import { Spinner, withSpinner } from "@paperclover/console/Spinner"; |
framework/hot.ts+1-1| ... | @@ -236,7 +236,7 @@ declare module "node:module" { | ... | @@ -236,7 +236,7 @@ declare module "node:module" { |
| 236 | ): unknown; | 236 | ): unknown; |
| 237 | } | 237 | } |
| 238 | 238 | ||
| 239 | import * as fs from "./fs.ts"; | 239 | import * as fs from "./lib/fs.ts"; |
| 240 | import * as path from "node:path"; | 240 | import * as path from "node:path"; |
| 241 | import { pathToFileURL } from "node:url"; | 241 | import { pathToFileURL } from "node:url"; |
| 242 | import * as esbuild from "esbuild"; | 242 | import * as esbuild from "esbuild"; |
framework/incremental.ts+2-3| ... | @@ -252,11 +252,10 @@ export interface SerializedMeta { | ... | @@ -252,11 +252,10 @@ export interface SerializedMeta { |
| 252 | script: [key: string, value: string][]; | 252 | script: [key: string, value: string][]; |
| 253 | } | 253 | } |
| 254 | 254 | ||
| 255 | import * as path from "node:path"; | 255 | import * as fs from "#sitegen/fs"; |
| 256 | import * as fs from "./fs.ts"; | ||
| 257 | import * as zlib from "node:zlib"; | 256 | import * as zlib from "node:zlib"; |
| 258 | import * as util from "node:util"; | 257 | import * as util from "node:util"; |
| 259 | import { Queue } from "./queue.ts"; | 258 | import { Queue } from "./queue.ts"; |
| 260 | import * as hot from "./hot.ts"; | 259 | import * as hot from "./hot.ts"; |
| 261 | import * as mime from "./mime.ts"; | 260 | import * as mime from "#sitegen/mime"; |
| 262 | import { Buffer } from "node:buffer"; | 261 | import { Buffer } from "node:buffer"; |
framework/lib/assets.ts+1-1| ... | @@ -105,7 +105,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { | ... | @@ -105,7 +105,7 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) { |
| 105 | return c.res = new Response(body, { headers, status }); | 105 | return c.res = new Response(body, { headers, status }); |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | import * as fs from "../fs.ts"; | 108 | import * as fs from "#sitegen/fs"; |
| 109 | import type { Context, Next } from "hono"; | 109 | import type { Context, Next } from "hono"; |
| 110 | import type { StatusCode } from "hono/utils/http-status"; | 110 | import type { StatusCode } from "hono/utils/http-status"; |
| 111 | import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts"; | 111 | import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts"; |
framework/lib/fs.ts created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | // File System APIs. Some custom APIs, but mostly a re-export a mix of built-in | ||
| 2 | // Node.js sync+promise fs methods. For convenince. | ||
| 3 | export { | ||
| 4 | existsSync, | ||
| 5 | readdir, | ||
| 6 | readdirSync, | ||
| 7 | readFile, | ||
| 8 | readFileSync, | ||
| 9 | rm, | ||
| 10 | rmSync, | ||
| 11 | stat, | ||
| 12 | statSync, | ||
| 13 | writeFile, | ||
| 14 | writeFileSync, | ||
| 15 | }; | ||
| 16 | |||
| 17 | export function mkdir(dir: string) { | ||
| 18 | return nodeMkdir(dir, { recursive: true }); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function mkdirSync(dir: string) { | ||
| 22 | return nodeMkdirSync(dir, { recursive: true }); | ||
| 23 | } | ||
| 24 | |||
| 25 | export async function writeMkdir(file: string, contents: Buffer | string) { | ||
| 26 | await mkdir(path.dirname(file)); | ||
| 27 | return writeFile(file, contents); | ||
| 28 | } | ||
| 29 | |||
| 30 | export function writeMkdirSync(file: string, contents: Buffer | string) { | ||
| 31 | mkdirSync(path.dirname(file)); | ||
| 32 | return writeFileSync(file, contents); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function readDirRecOptionalSync(dir: string) { | ||
| 36 | try { | ||
| 37 | return readdirSync(dir, { recursive: true, withFileTypes: true }); | ||
| 38 | } catch (err: any) { | ||
| 39 | if (err.code === "ENOENT") return []; | ||
| 40 | throw err; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | import * as path from "node:path"; | ||
| 45 | import { | ||
| 46 | existsSync, | ||
| 47 | mkdirSync as nodeMkdirSync, | ||
| 48 | readdirSync, | ||
| 49 | readFileSync, | ||
| 50 | rmSync, | ||
| 51 | statSync, | ||
| 52 | writeFileSync, | ||
| 53 | } from "node:fs"; | ||
| 54 | import { | ||
| 55 | mkdir as nodeMkdir, | ||
| 56 | readdir, | ||
| 57 | readFile, | ||
| 58 | rm, | ||
| 59 | stat, | ||
| 60 | writeFile, | ||
| 61 | } from "node:fs/promises"; | ||
framework/lib/meta.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | export interface Meta { | ||
| 2 | title: string; | ||
| 3 | description?: string | undefined; | ||
| 4 | openGraph?: OpenGraph; | ||
| 5 | alternates?: Alternates; | ||
| 6 | } | ||
| 7 | export interface OpenGraph { | ||
| 8 | title?: string; | ||
| 9 | description?: string | undefined; | ||
| 10 | type: string; | ||
| 11 | url: string; | ||
| 12 | } | ||
| 13 | export interface Alternates { | ||
| 14 | canonical: string; | ||
| 15 | types: { [mime: string]: AlternateType }; | ||
| 16 | } | ||
| 17 | export interface AlternateType { | ||
| 18 | url: string; | ||
| 19 | title: string; | ||
| 20 | } | ||
| 21 | export function renderMeta({ title }: Meta): string { | ||
| 22 | return `<title>${esc(title)}</title>`; | ||
| 23 | } | ||
| 24 | import { escapeHtml as esc } from "../engine/ssr.ts"; | ||
framework/lib/mime.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | const entries = fs.readFileSync( | ||
| 2 | path.join(import.meta.dirname, "mime.txt"), | ||
| 3 | "utf8", | ||
| 4 | ) | ||
| 5 | .split("\n") | ||
| 6 | .map((line) => line.trim()) | ||
| 7 | .filter((line) => line && !line.startsWith("#")) | ||
| 8 | .map((line) => line.split(/\s+/, 2) as [string, string]); | ||
| 9 | const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); | ||
| 10 | const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); | ||
| 11 | |||
| 12 | /** | ||
| 13 | * Accepts: | ||
| 14 | * - Full file path or basename | ||
| 15 | * - Extension (with or without dot) | ||
| 16 | */ | ||
| 17 | export function contentTypeFor(file: string) { | ||
| 18 | const slash = file.indexOf("/"); | ||
| 19 | if (slash !== -1) file = file.slice(slash + 1); | ||
| 20 | const dot = file.indexOf("."); | ||
| 21 | if (dot === -1) file = "." + file; | ||
| 22 | else if (dot > 0) { | ||
| 23 | let entry = fullNames.get(file); | ||
| 24 | if (entry) return entry; | ||
| 25 | file = file.slice(dot); | ||
| 26 | } | ||
| 27 | return extensions.get(file) ?? "application/octet-stream"; | ||
| 28 | } | ||
| 29 | |||
| 30 | import * as fs from "#sitegen/fs"; | ||
| 31 | import * as path from "node:path"; | ||
framework/lib/mime.txt created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | # media types | ||
| 2 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types | ||
| 3 | .aac audio/x-aac | ||
| 4 | .aif audio/x-aiff | ||
| 5 | .aifc audio/x-aiff | ||
| 6 | .aiff audio/x-aiff | ||
| 7 | .asm text/x-asm | ||
| 8 | .avi video/x-msvideo | ||
| 9 | .bat application/x-msdownload | ||
| 10 | .c text/x-c | ||
| 11 | .chat text/x-clover-chatlog | ||
| 12 | .class application/java-vm | ||
| 13 | .cmd application/x-msdownload | ||
| 14 | .com application/x-msdownload | ||
| 15 | .conf text/plain | ||
| 16 | .cpp text/x-c | ||
| 17 | .css text/css | ||
| 18 | .csv text/csv | ||
| 19 | .cxx text/x-c | ||
| 20 | .def text/plain | ||
| 21 | .diff text/plain | ||
| 22 | .dll application/x-msdownload | ||
| 23 | .dmg application/octet-stream | ||
| 24 | .doc application/msword | ||
| 25 | .docx application/vnd.openxmlformats-officedocument.wordprocessingml.document | ||
| 26 | .epub application/epub+zip | ||
| 27 | .exe application/x-msdownload | ||
| 28 | .flv video/x-flv | ||
| 29 | .fbx application/fbx | ||
| 30 | .gz application/x-gzip | ||
| 31 | .h text/x-c | ||
| 32 | .h264 video/h264 | ||
| 33 | .hh text/x-c | ||
| 34 | .htm text/html;charset=utf-8 | ||
| 35 | .html text/html;charset=utf-8 | ||
| 36 | .ico image/x-icon | ||
| 37 | .ics text/calendar | ||
| 38 | .in text/plain | ||
| 39 | .jar application/java-archive | ||
| 40 | .java text/x-java-source | ||
| 41 | .jpeg image/jpeg | ||
| 42 | .jpg image/jpeg | ||
| 43 | .jpgv video/jpeg | ||
| 44 | .jxl image/jxl | ||
| 45 | .js application/javascript | ||
| 46 | .json application/json | ||
| 47 | .latex application/x-latex | ||
| 48 | .list text/plain | ||
| 49 | .log text/plain | ||
| 50 | .m4a audio/mp4 | ||
| 51 | .man text/troff | ||
| 52 | .mid audio/midi | ||
| 53 | .midi audio/midi | ||
| 54 | .mov video/quicktime | ||
| 55 | .mp3 audio/mpeg | ||
| 56 | .mp4 video/mp4 | ||
| 57 | .msh model/mesh | ||
| 58 | .msi application/x-msdownload | ||
| 59 | .obj application/octet-stream | ||
| 60 | .ogg audio/ogg | ||
| 61 | .otf application/x-font-otf | ||
| 62 | .pdf application/pdf | ||
| 63 | .png image/png | ||
| 64 | .ppt application/vnd.ms-powerpoint | ||
| 65 | .pptx application/vnd.openxmlformats-officedocument.presentationml.presentation | ||
| 66 | .psd image/vnd.adobe.photoshop | ||
| 67 | .py text/x-python | ||
| 68 | .rar application/x-rar-compressed | ||
| 69 | .rss application/rss+xml | ||
| 70 | .rtf application/rtf | ||
| 71 | .rtx text/richtext | ||
| 72 | .s text/x-asm | ||
| 73 | .pem application/x-pem-file" | ||
| 74 | .ser application/java-serialized-object | ||
| 75 | .sh application/x-sh | ||
| 76 | .sig application/pgp-signature | ||
| 77 | .silo model/mesh | ||
| 78 | .svg image/svg+xml | ||
| 79 | .t text/troff | ||
| 80 | .tar application/x-tar | ||
| 81 | .text text/plain | ||
| 82 | .tgz application/x-gzip | ||
| 83 | .tif image/tiff | ||
| 84 | .tiff image/tiff | ||
| 85 | .torrent application/x-bittorrent | ||
| 86 | .ttc application/x-font-ttf | ||
| 87 | .ttf application/x-font-ttf | ||
| 88 | .txt text/plain | ||
| 89 | .urls text/uri-list | ||
| 90 | .v text/x-v | ||
| 91 | .wav audio/x-wav | ||
| 92 | .wmv video/x-ms-wmv | ||
| 93 | .xls application/vnd.ms-excel | ||
| 94 | .xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | ||
| 95 | .xml application/xml | ||
| 96 | .xps application/vnd.ms-xpsdocument | ||
| 97 | |||
| 98 | # special cased based on file name | ||
| 99 | rss.xml application/rss+xml | ||
framework/lib/sqlite.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | // Guard against reloads and bundler duplication. | ||
| 2 | // @ts-ignore | ||
| 3 | const map = globalThis[Symbol.for("clover.db")] ??= new Map< | ||
| 4 | string, | ||
| 5 | WrappedDatabase | ||
| 6 | >(); | ||
| 7 | |||
| 8 | export function getDb(file: string) { | ||
| 9 | let db: WrappedDatabase | null = map.get(file); | ||
| 10 | if (db) return db; | ||
| 11 | const fileWithExt = file.includes(".") ? file : file + ".sqlite"; | ||
| 12 | db = new WrappedDatabase( | ||
| 13 | new DatabaseSync(path.join(".clover/", fileWithExt)), | ||
| 14 | ); | ||
| 15 | map.set(file, db); | ||
| 16 | return db; | ||
| 17 | } | ||
| 18 | |||
| 19 | export class WrappedDatabase { | ||
| 20 | node: DatabaseSync; | ||
| 21 | stmtTableMigrate: WeakRef<StatementSync> | null = null; | ||
| 22 | |||
| 23 | constructor(node: DatabaseSync) { | ||
| 24 | this.node = node; | ||
| 25 | this.node.exec(` | ||
| 26 | create table if not exists clover_migrations ( | ||
| 27 | key text not null primary key, | ||
| 28 | version integer not null | ||
| 29 | ); | ||
| 30 | `); | ||
| 31 | } | ||
| 32 | |||
| 33 | // TODO: add migration support | ||
| 34 | // the idea is you keep `schema` as the new schema but can add | ||
| 35 | // migrations to the mix really easily. | ||
| 36 | table(name: string, schema: string) { | ||
| 37 | let s = this.stmtTableMigrate?.deref(); | ||
| 38 | s ?? (this.stmtTableMigrate = new WeakRef( | ||
| 39 | s = this.node.prepare(` | ||
| 40 | insert or ignore into clover_migrations | ||
| 41 | (key, version) values (?, ?); | ||
| 42 | `), | ||
| 43 | )); | ||
| 44 | const { changes } = s.run(name, 1); | ||
| 45 | if (changes === 1) this.node.exec(schema); | ||
| 46 | } | ||
| 47 | |||
| 48 | prepare<Args extends unknown[] = [], Result = unknown>( | ||
| 49 | query: string, | ||
| 50 | ): Stmt<Args, Result> { | ||
| 51 | return new Stmt(this.node.prepare(query)); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | export class Stmt<Args extends unknown[] = unknown[], Row = unknown> { | ||
| 56 | #node: StatementSync; | ||
| 57 | #class: any | null = null; | ||
| 58 | constructor(node: StatementSync) { | ||
| 59 | this.#node = node; | ||
| 60 | } | ||
| 61 | |||
| 62 | /** Get one row */ | ||
| 63 | get(...args: Args): Row | null { | ||
| 64 | const item = this.#node.get(...args as any) as Row; | ||
| 65 | if (!item) return null; | ||
| 66 | const C = this.#class; | ||
| 67 | if (C) Object.setPrototypeOf(item, C.prototype); | ||
| 68 | return item; | ||
| 69 | } | ||
| 70 | getNonNull(...args: Args) { | ||
| 71 | const item = this.get(...args); | ||
| 72 | if (!item) throw new Error("Query returned no result"); | ||
| 73 | return item; | ||
| 74 | } | ||
| 75 | iter(...args: Args): Iterator<Row> { | ||
| 76 | return this.array(...args)[Symbol.iterator](); | ||
| 77 | } | ||
| 78 | /** Get all rows */ | ||
| 79 | array(...args: Args): Row[] { | ||
| 80 | const array = this.#node.all(...args as any) as Row[]; | ||
| 81 | const C = this.#class; | ||
| 82 | if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype)); | ||
| 83 | return array; | ||
| 84 | } | ||
| 85 | /** Return the number of changes / row ID */ | ||
| 86 | run(...args: Args) { | ||
| 87 | return this.#node.run(...args as any); | ||
| 88 | } | ||
| 89 | |||
| 90 | as<R>(Class: { new (): R }): Stmt<Args, R> { | ||
| 91 | this.#class = Class; | ||
| 92 | return this as any; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | import { DatabaseSync, StatementSync } from "node:sqlite"; | ||
| 97 | import * as path from "node:path"; | ||
framework/meta.ts deleted-24| ... | @@ -1,24 +0,0 @@ | ||
| 1 | export interface Meta { | ||
| 2 | title: string; | ||
| 3 | description?: string | undefined; | ||
| 4 | openGraph?: OpenGraph; | ||
| 5 | alternates?: Alternates; | ||
| 6 | } | ||
| 7 | export interface OpenGraph { | ||
| 8 | title?: string; | ||
| 9 | description?: string | undefined; | ||
| 10 | type: string; | ||
| 11 | url: string; | ||
| 12 | } | ||
| 13 | export interface Alternates { | ||
| 14 | canonical: string; | ||
| 15 | types: { [mime: string]: AlternateType }; | ||
| 16 | } | ||
| 17 | export interface AlternateType { | ||
| 18 | url: string; | ||
| 19 | title: string; | ||
| 20 | } | ||
| 21 | export function renderMeta({ title }: Meta): string { | ||
| 22 | return `<title>${esc(title)}</title>`; | ||
| 23 | } | ||
| 24 | import { escapeHtml as esc } from "./engine/ssr.ts"; | ||
framework/mime.ts deleted-31| ... | @@ -1,31 +0,0 @@ | ||
| 1 | const entries = fs.readFileSync( | ||
| 2 | path.join(import.meta.dirname, "mime.txt"), | ||
| 3 | "utf8", | ||
| 4 | ) | ||
| 5 | .split("\n") | ||
| 6 | .map((line) => line.trim()) | ||
| 7 | .filter((line) => line && !line.startsWith("#")) | ||
| 8 | .map((line) => line.split(/\s+/, 2) as [string, string]); | ||
| 9 | const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); | ||
| 10 | const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); | ||
| 11 | |||
| 12 | /** | ||
| 13 | * Accepts: | ||
| 14 | * - Full file path or basename | ||
| 15 | * - Extension (with or without dot) | ||
| 16 | */ | ||
| 17 | export function contentTypeFor(file: string) { | ||
| 18 | const slash = file.indexOf("/"); | ||
| 19 | if (slash !== -1) file = file.slice(slash + 1); | ||
| 20 | const dot = file.indexOf("."); | ||
| 21 | if (dot === -1) file = "." + file; | ||
| 22 | else if (dot > 0) { | ||
| 23 | let entry = fullNames.get(file); | ||
| 24 | if (entry) return entry; | ||
| 25 | file = file.slice(dot); | ||
| 26 | } | ||
| 27 | return extensions.get(file) ?? "application/octet-stream"; | ||
| 28 | } | ||
| 29 | |||
| 30 | import * as fs from "./fs.ts"; | ||
| 31 | import * as path from "node:path"; | ||
framework/mime.txt deleted-99| ... | @@ -1,99 +0,0 @@ | ||
| 1 | # media types | ||
| 2 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types | ||
| 3 | .aac audio/x-aac | ||
| 4 | .aif audio/x-aiff | ||
| 5 | .aifc audio/x-aiff | ||
| 6 | .aiff audio/x-aiff | ||
| 7 | .asm text/x-asm | ||
| 8 | .avi video/x-msvideo | ||
| 9 | .bat application/x-msdownload | ||
| 10 | .c text/x-c | ||
| 11 | .chat text/x-clover-chatlog | ||
| 12 | .class application/java-vm | ||
| 13 | .cmd application/x-msdownload | ||
| 14 | .com application/x-msdownload | ||
| 15 | .conf text/plain | ||
| 16 | .cpp text/x-c | ||
| 17 | .css text/css | ||
| 18 | .csv text/csv | ||
| 19 | .cxx text/x-c | ||
| 20 | .def text/plain | ||
| 21 | .diff text/plain | ||
| 22 | .dll application/x-msdownload | ||
| 23 | .dmg application/octet-stream | ||
| 24 | .doc application/msword | ||
| 25 | .docx application/vnd.openxmlformats-officedocument.wordprocessingml.document | ||
| 26 | .epub application/epub+zip | ||
| 27 | .exe application/x-msdownload | ||
| 28 | .flv video/x-flv | ||
| 29 | .fbx application/fbx | ||
| 30 | .gz application/x-gzip | ||
| 31 | .h text/x-c | ||
| 32 | .h264 video/h264 | ||
| 33 | .hh text/x-c | ||
| 34 | .htm text/html;charset=utf-8 | ||
| 35 | .html text/html;charset=utf-8 | ||
| 36 | .ico image/x-icon | ||
| 37 | .ics text/calendar | ||
| 38 | .in text/plain | ||
| 39 | .jar application/java-archive | ||
| 40 | .java text/x-java-source | ||
| 41 | .jpeg image/jpeg | ||
| 42 | .jpg image/jpeg | ||
| 43 | .jpgv video/jpeg | ||
| 44 | .jxl image/jxl | ||
| 45 | .js application/javascript | ||
| 46 | .json application/json | ||
| 47 | .latex application/x-latex | ||
| 48 | .list text/plain | ||
| 49 | .log text/plain | ||
| 50 | .m4a audio/mp4 | ||
| 51 | .man text/troff | ||
| 52 | .mid audio/midi | ||
| 53 | .midi audio/midi | ||
| 54 | .mov video/quicktime | ||
| 55 | .mp3 audio/mpeg | ||
| 56 | .mp4 video/mp4 | ||
| 57 | .msh model/mesh | ||
| 58 | .msi application/x-msdownload | ||
| 59 | .obj application/octet-stream | ||
| 60 | .ogg audio/ogg | ||
| 61 | .otf application/x-font-otf | ||
| 62 | .pdf application/pdf | ||
| 63 | .png image/png | ||
| 64 | .ppt application/vnd.ms-powerpoint | ||
| 65 | .pptx application/vnd.openxmlformats-officedocument.presentationml.presentation | ||
| 66 | .psd image/vnd.adobe.photoshop | ||
| 67 | .py text/x-python | ||
| 68 | .rar application/x-rar-compressed | ||
| 69 | .rss application/rss+xml | ||
| 70 | .rtf application/rtf | ||
| 71 | .rtx text/richtext | ||
| 72 | .s text/x-asm | ||
| 73 | .pem application/x-pem-file" | ||
| 74 | .ser application/java-serialized-object | ||
| 75 | .sh application/x-sh | ||
| 76 | .sig application/pgp-signature | ||
| 77 | .silo model/mesh | ||
| 78 | .svg image/svg+xml | ||
| 79 | .t text/troff | ||
| 80 | .tar application/x-tar | ||
| 81 | .text text/plain | ||
| 82 | .tgz application/x-gzip | ||
| 83 | .tif image/tiff | ||
| 84 | .tiff image/tiff | ||
| 85 | .torrent application/x-bittorrent | ||
| 86 | .ttc application/x-font-ttf | ||
| 87 | .ttf application/x-font-ttf | ||
| 88 | .txt text/plain | ||
| 89 | .urls text/uri-list | ||
| 90 | .v text/x-v | ||
| 91 | .wav audio/x-wav | ||
| 92 | .wmv video/x-ms-wmv | ||
| 93 | .xls application/vnd.ms-excel | ||
| 94 | .xlsx application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | ||
| 95 | .xml application/xml | ||
| 96 | .xps application/vnd.ms-xpsdocument | ||
| 97 | |||
| 98 | # special cased based on file name | ||
| 99 | rss.xml application/rss+xml | ||
framework/sqlite.ts deleted-98| ... | @@ -1,98 +0,0 @@ | ||
| 1 | // Guard against reloads and bundler duplication. | ||
| 2 | // @ts-ignore | ||
| 3 | const map = globalThis[Symbol.for("clover.db")] ??= new Map< | ||
| 4 | string, | ||
| 5 | WrappedDatabase | ||
| 6 | >(); | ||
| 7 | |||
| 8 | export function getDb(file: string) { | ||
| 9 | let db: WrappedDatabase | null = map.get(file); | ||
| 10 | if (db) return db; | ||
| 11 | const fileWithExt = file.includes(".") ? file : file + ".sqlite"; | ||
| 12 | db = new WrappedDatabase( | ||
| 13 | new DatabaseSync(path.join(".clover/", fileWithExt)), | ||
| 14 | ); | ||
| 15 | map.set(file, db); | ||
| 16 | return db; | ||
| 17 | } | ||
| 18 | |||
| 19 | export class WrappedDatabase { | ||
| 20 | node: DatabaseSync; | ||
| 21 | stmtTableMigrate: WeakRef<StatementSync> | null = null; | ||
| 22 | |||
| 23 | constructor(node: DatabaseSync) { | ||
| 24 | this.node = node; | ||
| 25 | this.node.exec(` | ||
| 26 | create table if not exists clover_migrations ( | ||
| 27 | key text not null primary key, | ||
| 28 | version integer not null | ||
| 29 | ); | ||
| 30 | `); | ||
| 31 | } | ||
| 32 | |||
| 33 | // TODO: add migration support | ||
| 34 | // the idea is you keep `schema` as the new schema but can add | ||
| 35 | // migrations to the mix really easily. | ||
| 36 | table(name: string, schema: string) { | ||
| 37 | let s = this.stmtTableMigrate?.deref(); | ||
| 38 | s ?? (this.stmtTableMigrate = new WeakRef( | ||
| 39 | s = this.node.prepare(` | ||
| 40 | insert or ignore into clover_migrations | ||
| 41 | (key, version) values (?, ?); | ||
| 42 | `), | ||
| 43 | )); | ||
| 44 | const { changes } = s.run(name, 1); | ||
| 45 | if (changes === 1) this.node.exec(schema); | ||
| 46 | } | ||
| 47 | |||
| 48 | prepare<Args extends unknown[] = [], Result = unknown>( | ||
| 49 | query: string, | ||
| 50 | ): Stmt<Args, Result> { | ||
| 51 | return new Stmt(this.node.prepare(query)); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | export class Stmt<Args extends unknown[] = unknown[], Row = unknown> { | ||
| 56 | #node: StatementSync; | ||
| 57 | #class: any | null = null; | ||
| 58 | constructor(node: StatementSync) { | ||
| 59 | this.#node = node; | ||
| 60 | } | ||
| 61 | |||
| 62 | /** Get one row */ | ||
| 63 | get(...args: Args): Row | null { | ||
| 64 | const item = this.#node.get(...args as any) as Row; | ||
| 65 | if (!item) return null; | ||
| 66 | const C = this.#class; | ||
| 67 | if (C) Object.setPrototypeOf(item, C.prototype); | ||
| 68 | return item; | ||
| 69 | } | ||
| 70 | getNonNull(...args: Args) { | ||
| 71 | const item = this.get(...args); | ||
| 72 | if (!item) throw new Error("Query returned no result"); | ||
| 73 | return item; | ||
| 74 | } | ||
| 75 | iter(...args: Args): Iterator<Row> { | ||
| 76 | return this.array(...args)[Symbol.iterator](); | ||
| 77 | } | ||
| 78 | /** Get all rows */ | ||
| 79 | array(...args: Args): Row[] { | ||
| 80 | const array = this.#node.all(...args as any) as Row[]; | ||
| 81 | const C = this.#class; | ||
| 82 | if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype)); | ||
| 83 | return array; | ||
| 84 | } | ||
| 85 | /** Return the number of changes / row ID */ | ||
| 86 | run(...args: Args) { | ||
| 87 | return this.#node.run(...args as any); | ||
| 88 | } | ||
| 89 | |||
| 90 | as<R>(Class: { new (): R }): Stmt<Args, R> { | ||
| 91 | this.#class = Class; | ||
| 92 | return this as any; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | import { DatabaseSync, StatementSync } from "node:sqlite"; | ||
| 97 | import * as fs from "./fs.ts"; | ||
| 98 | import * as path from "node:path"; | ||
framework/typecheck.ts deletedpackage-lock.json+1-1| ... | @@ -943,7 +943,7 @@ | ... | @@ -943,7 +943,7 @@ |
| 943 | } | 943 | } |
| 944 | }, | 944 | }, |
| 945 | "node_modules/@paperclover/console": { | 945 | "node_modules/@paperclover/console": { |
| 946 | "resolved": "git+https://git.paperclover.net/clo/console.git#29ae0fe6bb948723655b2d875faef75433b78ac0", | 946 | "resolved": "git+https://git.paperclover.net/clo/console.git#acdad1b233b5e27dd8ff817d3b8ee7f1a88519d3", |
| 947 | "dependencies": { | 947 | "dependencies": { |
| 948 | "ansi-escapes": "^7.0.0", | 948 | "ansi-escapes": "^7.0.0", |
| 949 | "chalk": "^5.4.1", | 949 | "chalk": "^5.4.1", |
package.json-1| ... | @@ -18,7 +18,6 @@ | ... | @@ -18,7 +18,6 @@ |
| 18 | "#backend": "./src/backend.ts", | 18 | "#backend": "./src/backend.ts", |
| 19 | "#sitegen": "./framework/lib/sitegen.ts", | 19 | "#sitegen": "./framework/lib/sitegen.ts", |
| 20 | "#sitegen/*": "./framework/lib/*.ts", | 20 | "#sitegen/*": "./framework/lib/*.ts", |
| 21 | "#sqlite": "./framework/sqlite.ts", | ||
| 22 | "#ssr": "./framework/engine/ssr.ts", | 21 | "#ssr": "./framework/engine/ssr.ts", |
| 23 | "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", | 22 | "#ssr/jsx-dev-runtime": "./framework/engine/jsx-runtime.ts", |
| 24 | "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts", | 23 | "#ssr/jsx-runtime": "./framework/engine/jsx-runtime.ts", |
readme.md+41| ... | @@ -13,7 +13,48 @@ that assist building websites. these tools power https://paperclover.net. | ... | @@ -13,7 +13,48 @@ that assist building websites. these tools power https://paperclover.net. |
| 13 | - The only difference in development and production mode is hidden | 13 | - The only difference in development and production mode is hidden |
| 14 | source-maps and stripped assertions and `console.debug` calls. The site | 14 | source-maps and stripped assertions and `console.debug` calls. The site |
| 15 | you see locally is the site you see deployed. | 15 | you see locally is the site you see deployed. |
| 16 | - Tools for building complex, content heavy web sites. | ||
| 17 | - Static asset serving with ETag and build-time compression. | ||
| 18 | - Dynamic server side rendering from within backend code. | ||
| 19 | - Databases with a typed SQLite wrapper. | ||
| 16 | - Built on the battle-tested Node.js runtime. Partial support for Deno and Bun. | 20 | - Built on the battle-tested Node.js runtime. Partial support for Deno and Bun. |
| 17 | 21 | ||
| 18 | [1]: https://next.markojs.com | 22 | [1]: https://next.markojs.com |
| 19 | 23 | ||
| 24 | Included is `src`, which contains `paperclover.net`. Website highlights: | ||
| 25 | |||
| 26 | - [Question/Answer board, custom markdown parser and components][q+a]. | ||
| 27 | - [File viewer with prefetching + client-side navigation][file]. | ||
| 28 | - [Personal, friends-only blog with password protection][friends]. | ||
| 29 | |||
| 30 | [q+a]: https://paperclover.net/q+a | ||
| 31 | [file]: https://paperclover.net/file | ||
| 32 | [friends]: https://paperclover.net/friends | ||
| 33 | |||
| 34 | ## Development | ||
| 35 | |||
| 36 | ``` | ||
| 37 | npm install | ||
| 38 | |||
| 39 | # production generation | ||
| 40 | node run generate | ||
| 41 | |||
| 42 | # live development environment | ||
| 43 | node repl | ||
| 44 | ``` | ||
| 45 | |||
| 46 | `repl.js` will open a read-eval-print-loop where plugin state is cached (on my | ||
| 47 | 2014 dev laptop, startup time is 600-1000ms). every file in `framework` and | ||
| 48 | `src` besides `hot.ts` can be edited and quickly re-run. for example, to run | ||
| 49 | `framework/generate.ts`, you can type "generate" into the shell. since | ||
| 50 | top-level await is not supported (plugins are built on `require` as Node has | ||
| 51 | poor module support), CLIs can include a `main` function, which is executed | ||
| 52 | when the REPL runs it. | ||
| 53 | |||
| 54 | TODO: in the future, the repl will have a "dev" command which will do the incremental | ||
| 55 | site build + host, possibly immediatly on opening the repl. | ||
| 56 | |||
| 57 | ## Contributions | ||
| 58 | |||
| 59 | No contributions to `src` accepted, only `framework`. | ||
| 60 |
src/admin.ts+1-1| ... | @@ -59,6 +59,6 @@ export function hasAdminToken(c: Context) { | ... | @@ -59,6 +59,6 @@ export function hasAdminToken(c: Context) { |
| 59 | return token && compareToken(token); | 59 | return token && compareToken(token); |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | import * as fs from "fs"; | 62 | import * as fs from "node:fs"; |
| 63 | import type { Context, Next } from "hono"; | 63 | import type { Context, Next } from "hono"; |
| 64 | import { serveAsset } from "#sitegen/assets"; | 64 | import { serveAsset } from "#sitegen/assets"; |
src/backend.ts+1-1| ... | @@ -30,7 +30,7 @@ async function removeDuplicateSlashes(c: Context, next: Next) { | ... | @@ -30,7 +30,7 @@ async function removeDuplicateSlashes(c: Context, next: Next) { |
| 30 | await next(); | 30 | await next(); |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | import { Hono } from "#hono"; | 33 | import { type Context, Hono, type Next } from "#hono"; |
| 34 | import { logger } from "hono/logger"; | 34 | import { logger } from "hono/logger"; |
| 35 | import { trimTrailingSlash } from "hono/trailing-slash"; | 35 | import { trimTrailingSlash } from "hono/trailing-slash"; |
| 36 | import * as assets from "#sitegen/assets"; | 36 | import * as assets from "#sitegen/assets"; |
src/q+a/models/PendingQuestion.ts+1-1| ... | @@ -66,7 +66,7 @@ const getByDateQuery = db.prepare<[qmid: number]>(` | ... | @@ -66,7 +66,7 @@ const getByDateQuery = db.prepare<[qmid: number]>(` |
| 66 | SELECT * FROM questions WHERE qmid = ? AND type = ${QuestionType.pending} LIMIT 1 | 66 | SELECT * FROM questions WHERE qmid = ? AND type = ${QuestionType.pending} LIMIT 1 |
| 67 | `).as(PendingQuestion); | 67 | `).as(PendingQuestion); |
| 68 | 68 | ||
| 69 | import { getDb } from "#sqlite"; | 69 | import { getDb } from "#sitegen/sqlite"; |
| 70 | import assert from "node:assert"; | 70 | import assert from "node:assert"; |
| 71 | import { QuestionType } from "./Question.ts"; | 71 | import { QuestionType } from "./Question.ts"; |
| 72 | import { formatQuestionId } from "../format.ts"; | 72 | import { formatQuestionId } from "../format.ts"; |
src/q+a/models/Question.ts+1-1| ... | @@ -115,4 +115,4 @@ const updateByQmidQuery = db.prepare< | ... | @@ -115,4 +115,4 @@ const updateByQmidQuery = db.prepare< |
| 115 | `); | 115 | `); |
| 116 | 116 | ||
| 117 | import { formatQuestionId } from "../format.ts"; | 117 | import { formatQuestionId } from "../format.ts"; |
| 118 | import { getDb } from "#sqlite"; | 118 | import { getDb } from "#sitegen/sqlite"; |