From 06460874c161e0fac0698c3e74a78963fe45caa8 Mon Sep 17 00:00:00 2001 From: soup Date: Sun, 2 Feb 2025 12:24:00 -0500 Subject: [PATCH] [shelves] initial TS proto --- .gitignore | 3 + deno.jsonc | 22 ++ deno.lock | 68 ++++++ pritty/src/main.ts | 23 ++ shelves/backend/db-schema.ts | 46 ++++ shelves/backend/main.ts | 15 ++ shelves/backend/migrations/001_init.sql | 64 ++++++ shelves/backend/routes.ts | 99 ++++++++ shelves/backend/routes/home.ts | 40 ++++ shelves/backend/routes/items.ts | 55 +++++ shelves/backend/routes/shelves.ts | 9 + shelves/backend/templates/index.ts | 57 +++++ shelves/deno.jsonc | 33 +++ shelves/deno.lock | 289 ++++++++++++++++++++++++ shelves/frontend/bundle.ts | 3 + ts/array.ts | 3 + ts/promise.ts | 3 + ts/responses.ts | 13 ++ ts/router.ts | 77 +++++++ ts/util.ts | 1 + 20 files changed, 923 insertions(+) create mode 100644 deno.jsonc create mode 100644 deno.lock create mode 100644 shelves/backend/db-schema.ts create mode 100644 shelves/backend/main.ts create mode 100644 shelves/backend/migrations/001_init.sql create mode 100644 shelves/backend/routes.ts create mode 100644 shelves/backend/routes/home.ts create mode 100644 shelves/backend/routes/items.ts create mode 100644 shelves/backend/routes/shelves.ts create mode 100644 shelves/backend/templates/index.ts create mode 100644 shelves/deno.jsonc create mode 100644 shelves/deno.lock create mode 100644 shelves/frontend/bundle.ts create mode 100644 ts/array.ts create mode 100644 ts/promise.ts create mode 100644 ts/responses.ts create mode 100644 ts/router.ts create mode 100644 ts/util.ts diff --git a/.gitignore b/.gitignore index ab62a3b..64982e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ **/build/* **/target/* pritty/outputs/* + +**/*.db +*.db diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..55b6908 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "checkJs": true, + "lib": [ + "esnext", + "dom", + "dom.iterable", + "deno.window", + "deno.unstable" + ] + }, + "fmt": { + "semiColons": true, + "singleQuote": true, + "useTabs": true, + "lineWidth": 90 + }, + "imports": { + "@deno/emit": "jsr:@deno/emit@^0.46.0", + "htmx.org": "npm:htmx.org@2.0.4" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..ebb86da --- /dev/null +++ b/deno.lock @@ -0,0 +1,68 @@ +{ + "version": "4", + "specifiers": { + "jsr:@deno/cache-dir@0.13.2": "0.13.2", + "jsr:@deno/emit@0.46": "0.46.0", + "jsr:@std/assert@0.223": "0.223.0", + "jsr:@std/bytes@0.223": "0.223.0", + "jsr:@std/fmt@0.223": "0.223.0", + "jsr:@std/fs@0.223": "0.223.0", + "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/path@0.223": "0.223.0", + "npm:htmx.org@2.0.4": "2.0.4" + }, + "jsr": { + "@deno/cache-dir@0.13.2": { + "integrity": "c22419dfe27ab85f345bee487aaaadba498b005cce3644e9d2528db035c5454d", + "dependencies": [ + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/io", + "jsr:@std/path" + ] + }, + "@deno/emit@0.46.0": { + "integrity": "e276be2c77bac1b93caf775762e2a49a54cb00da2d48ca2b01ed8d7cba9d082c", + "dependencies": [ + "jsr:@deno/cache-dir", + "jsr:@std/path" + ] + }, + "@std/assert@0.223.0": { + "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" + }, + "@std/bytes@0.223.0": { + "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" + }, + "@std/fmt@0.223.0": { + "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" + }, + "@std/fs@0.223.0": { + "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" + }, + "@std/io@0.223.0": { + "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes" + ] + }, + "@std/path@0.223.0": { + "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", + "dependencies": [ + "jsr:@std/assert" + ] + } + }, + "npm": { + "htmx.org@2.0.4": { + "integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@deno/emit@0.46", + "npm:htmx.org@2.0.4" + ] + } +} diff --git a/pritty/src/main.ts b/pritty/src/main.ts index 692720c..cb5a68e 100644 --- a/pritty/src/main.ts +++ b/pritty/src/main.ts @@ -113,6 +113,29 @@ function bindGet(palette: Palette) { return (entry: TableEntry | undefined) => get(entry, palette); } +async function generateSublime( + dir: string, + name: string, + variantName: string, + tables: Tables, + palette: Palette, +) { + let output = '{'; + const get = bindGet(palette); + + const w = (prop: string, v: string | undefined) => { + if (v !== undefined) { + } + }; + + output += `"globals": {`; + w('background', get(tables['background']?.['primary'])); + w('foreground', get(tables['text']?.['primary'])); + output += `},`; + + output += '}'; +} + async function generateGhostty( dir: string, name: string, diff --git a/shelves/backend/db-schema.ts b/shelves/backend/db-schema.ts new file mode 100644 index 0000000..ef02ac6 --- /dev/null +++ b/shelves/backend/db-schema.ts @@ -0,0 +1,46 @@ +import { Database } from '@db/sqlite'; + +export function migrate(db: Database) { + const migrationDirEntries = Deno.readDirSync(import.meta.dirname + '/migrations'); + const migrations = []; + for (const migration of migrationDirEntries) { + const content = Deno.readTextFileSync( + import.meta.dirname + `/migrations/${migration.name}`, + ); + migrations.push([migration.name, content]); + } + + migrations.sort((a, b) => a[0].localeCompare(b[0])); + + const schemaVersionDesired = migrations.length; + const { user_version: schemaVersionDb } = db.prepare('pragma user_version').get< + { user_version: number } + >()!; + + let applyMigrations = false; + if (schemaVersionDb != schemaVersionDesired) { + console.error( + `The database schema is on ${schemaVersionDb}, but the desired version is ${schemaVersionDesired}`, + ); + applyMigrations = confirm('Apply migrations?'); + } + + if (!applyMigrations) { + return; + } + + const migrationsToApply = migrations.slice(schemaVersionDb); + const txn = db.transaction(() => { + for (const [name, migration] of migrationsToApply) { + try { + db.exec(migration); + } catch (e) { + throw new Error(`While running migration ${name}`, { cause: e }); + } + } + + db.exec(`pragma user_version = ${schemaVersionDesired}`); + }); + + txn.immediate(); +} diff --git a/shelves/backend/main.ts b/shelves/backend/main.ts new file mode 100644 index 0000000..b392413 --- /dev/null +++ b/shelves/backend/main.ts @@ -0,0 +1,15 @@ +import { Database } from '@db/sqlite'; + +import { migrate } from '@/backend/db-schema.ts'; +import { makeRoutes } from '@/backend/routes.ts'; + +async function main() { + const db = new Database('shelves.db'); + await migrate(db); + + const handler = makeRoutes(db); + + await Deno.serve({ port: 8444 }, handler); +} + +await main(); diff --git a/shelves/backend/migrations/001_init.sql b/shelves/backend/migrations/001_init.sql new file mode 100644 index 0000000..9006ee2 --- /dev/null +++ b/shelves/backend/migrations/001_init.sql @@ -0,0 +1,64 @@ +create table blob ( + blob_id integer primary key autoincrement, + data blob not null, + content_type text not null, + + created_timestamp text not null +) strict; + +create table shelf ( + shelf_id integer primary key autoincrement, + shelf_xid text not null unique, + + name text not null, + + created_timestamp text not null +) strict; + +create table item ( + item_id integer primary key autoincrement, + item_xid text not null unique, + + item_type text not null, + name text not null, + image_blob_id integer, + + started_timestamp text, + finished_timestamp text, + + rating real, + review text not null, + + other_metadata_json text, + + foreign key (image_blob_id) references blob(blob_id) +) strict; + +create table shelf_item ( + shelf_item_id integer primary key, + + item_id integer not null, + collection_id integer not null, + + foreign key (item_id) references item(item_id), + foreign key (collection_id) references collection(collection_id), + unique (item_id, collection_id) +) strict; + +create table shelf_arrangement ( + shelf_arrangement_id integer primary key, + shelf_arrangement_xid text not null unique, + + name text not null, + + collection_id integer not null, + shelf_arrangement_json_blob text not null, + + foreign key (collection_id) references collection(collection_id) +) strict; + +create table activity ( + activity_id integer primary key, + + activity_json_blob text not null +) strict; diff --git a/shelves/backend/routes.ts b/shelves/backend/routes.ts new file mode 100644 index 0000000..92d39a6 --- /dev/null +++ b/shelves/backend/routes.ts @@ -0,0 +1,99 @@ +import { Match, Method, Router } from '@atelier/router.ts'; +import { OrPromise } from '@atelier/util.ts'; + +import { Database } from '@db/sqlite'; + +import { viewShelf, viewShelves } from '@/backend/routes/shelves.ts'; +import { postItem, viewCreateItem, viewItem, viewItems } from '@/backend/routes/items.ts'; +import { viewHome } from '@/backend/routes/home.ts'; + +const { GET, POST, PATCH } = Method; + +export type RequestCtx = { + pathParams: Record; + db: Database; +}; + +export type Handler = (req: Request, ctx: RequestCtx) => OrPromise; + +export function makeRoutes(db: Database) { + const router = new Router(); + const r = (m: Method, p: Match, h: Handler) => + router.register(m, p, (req, pathParams) => { + return h(req, { pathParams, db }); + }); + + r(GET, '/', viewHome); + r(GET, '/shelves', viewShelves); + r(GET, '/shelves/:shelfXid', viewShelf); + r(GET, '/items', viewItems); + r(GET, '/items/create', viewCreateItem); + r(GET, '/items/:itemXid', viewItem); + r(POST, '/items/create', postItem); + + r(GET, '/static/js/bundle.js', serveJsBundle); + r(GET, '/static/*', serveStatic); + + return router.handle.bind(router); +} + +const EXT_TO_MIME = { + '.css': 'text/css', +}; +async function serveStatic(req: Request) { + const url = new URL(req.url); + const path = url.pathname.substring('/static'.length); + + const staticPath = import.meta.dirname + '/../static' + path; + const data = await Deno.readFile(staticPath); + let mimetype = 'text/plain'; + for (const [ext, mime] of Object.entries(EXT_TO_MIME)) { + if (path.endsWith(ext)) { + mimetype = mime; + } + } + + return new Response(data, { headers: { 'Content-Type': mimetype } }); +} + +const isProduction = false; +let cachedBundlePath: string | null = null; +let cachedBundleAt = new Date(0); +async function serveJsBundle(req: Request) { + if (isProduction) { + throw new Error('todo'); + } + + if (cachedBundlePath === null) { + cachedBundlePath = await Deno.makeTempFile({ prefix: 'bundle', suffix: '.js' }); + } + + const bundlePath = import.meta.dirname + '/../frontend/bundle.ts'; + const stat = await Deno.stat(bundlePath); + const mtime = stat.mtime ?? new Date(0); + if (cachedBundleAt > mtime) { + const file = await Deno.open(cachedBundlePath); + return new Response(file.readable, { + headers: { 'Content-Type': 'text/javascript' }, + }); + } + + const esbuild = await import('npm:esbuild'); + const { denoPlugins } = await import('jsr:@luca/esbuild-deno-loader'); + + const result = await esbuild.build({ + plugins: [...denoPlugins()], + entryPoints: [import.meta.dirname + '/../frontend/bundle.ts'], + outfile: cachedBundlePath, + bundle: true, + format: 'esm', + sourcemap: 'inline', + }); + esbuild.stop(); + + cachedBundleAt = new Date(); + + return new Response((await Deno.open(cachedBundlePath)).readable, { + headers: { 'Content-Type': 'text/javascript' }, + }); +} diff --git a/shelves/backend/routes/home.ts b/shelves/backend/routes/home.ts new file mode 100644 index 0000000..73ec55e --- /dev/null +++ b/shelves/backend/routes/home.ts @@ -0,0 +1,40 @@ +import { RequestCtx } from '@/backend/routes.ts'; + +import { View } from '@/backend/templates/index.ts'; +import { html } from '@atelier/responses.ts'; +import { arrayIsEmpty } from '@atelier/array.ts'; + +type ActivityType = 'created_item'; +type Activity = { + activityType: ActivityType; + itemXid: string; + createdTimestamp: string; +}; + +function Activity(props: { activity: Activity }) {} +function ActivityList(props: { activities: Activity[] }) { + if (arrayIsEmpty(props.activities)) { + return 'No activities yet!'; + } + + const activities = props.activities.map((activity) => Activity({ activity })); + return ` +
    + +
+ `; +} + +export function viewHome(req: Request, { db }: RequestCtx) { + const recentActivities: Activity[] = db.prepare(` + select activity_json_blob from activity + order by json_extract(activity_json_blob, '$.createdTimestamp') + limit 20 + `).values<[string]>().map(([blob]) => JSON.parse(blob)); + + const body = ` +

Recent Activity

+ ${ActivityList({ activities: recentActivities })} + `; + return html(View({ title: 'Home', body })); +} diff --git a/shelves/backend/routes/items.ts b/shelves/backend/routes/items.ts new file mode 100644 index 0000000..46f87b8 --- /dev/null +++ b/shelves/backend/routes/items.ts @@ -0,0 +1,55 @@ +import { RequestCtx } from '@/backend/routes.ts'; + +import { html } from '@atelier/responses.ts'; +import { Form, View } from '@/backend/templates/index.ts'; + +export function viewItems(req: Request, ctx: RequestCtx) { + return new Response(''); +} + +export function viewItem(req: Request, ctx: RequestCtx) { + return new Response(''); +} + +/* + item_type text not null, + name text not null, + image_blob_id integer, + + started_timestamp text, + finished_timestamp text, + + rating real, + review text, + + other_metadata_json text, + + foreign key (image_blob_id) references blob(blob_id) + +*/ + +export function viewCreateItem(req: Request, ctx: RequestCtx) { + const body = ` + ${ + Form({ + hasFileData: true, + action: '/items/create', + fields: [ + { name: 'name', type: 'text', label: 'Name' }, + { name: 'image', type: 'file', label: 'Image', required: false }, + { name: 'rating', type: 'number', label: 'Rating', required: false }, + { name: 'review', type: 'textarea', label: 'Review', required: false }, + ], + }) + } + `; + + return html(View({ title: 'Create item', body })); +} + +export async function postItem(req: Request, ctx: RequestCtx) { + const form = await req.formData(); + console.debug({ form }); + + return new Response(''); +} diff --git a/shelves/backend/routes/shelves.ts b/shelves/backend/routes/shelves.ts new file mode 100644 index 0000000..0347626 --- /dev/null +++ b/shelves/backend/routes/shelves.ts @@ -0,0 +1,9 @@ +import { RequestCtx } from '@/backend/routes.ts'; + +export function viewShelves(req: Request, ctx: RequestCtx) { + return new Response(''); +} + +export function viewShelf(req: Request, ctx: RequestCtx) { + return new Response(''); +} diff --git a/shelves/backend/templates/index.ts b/shelves/backend/templates/index.ts new file mode 100644 index 0000000..02ce430 --- /dev/null +++ b/shelves/backend/templates/index.ts @@ -0,0 +1,57 @@ +export function View(props: { title: string; body: string }) { + return ` + + + + ${props.title} - Shelves + + + ${props.body} + + + + `; +} + +export type FormFieldType = 'text' | 'textarea' | 'file' | 'number'; +export type FormFieldProps = { + name: string; + type: FormFieldType; + label: string; + error?: string; + required?: boolean; +}; +export function FormField(props: FormFieldProps) { + const input = () => { + const attrs = `name='${props.name}' id="${props.name}" ${ + (props.required ?? true) && "required aria-required='true'" + }`; + if (props.type === 'textarea') { + return ``; + } else { + return ``; + } + }; + + return ` +
+ + ${input()} +
${ + props.error ?? '' + }
+
+ `; +} +export function Form( + props: { action: string; fields: FormFieldProps[]; hasFileData?: boolean }, +) { + return ` +
+ ${props.fields.map(FormField).join('')} + +
+ `; +} diff --git a/shelves/deno.jsonc b/shelves/deno.jsonc new file mode 100644 index 0000000..8bd342c --- /dev/null +++ b/shelves/deno.jsonc @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "checkJs": true, + "lib": [ + "esnext", + "dom", + "dom.iterable", + "deno.window", + "deno.unstable" + ] + }, + "tasks": { + "run": "deno run -A backend/main.ts", + "check": "deno check backend/main.ts", + "watch": "deno run --watch -A backend/main.ts", + "check-watch": "deno run --check --watch -A backend/main.ts" + }, + "imports": { + "@atelier/": "../ts/", + "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", + "@std/http": "jsr:@std/http", + "@db/sqlite": "jsr:@db/sqlite", + "@/": "./", + "esbuild": "npm:esbuild@^0.24.2", + "@htmx": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.js" + }, + "fmt": { + "semiColons": true, + "singleQuote": true, + "useTabs": true, + "lineWidth": 90 + } +} diff --git a/shelves/deno.lock b/shelves/deno.lock new file mode 100644 index 0000000..173ee3f --- /dev/null +++ b/shelves/deno.lock @@ -0,0 +1,289 @@ +{ + "version": "4", + "specifiers": { + "jsr:@db/sqlite@*": "0.12.0", + "jsr:@deno/cache-dir@0.13.2": "0.13.2", + "jsr:@deno/emit@*": "0.46.0", + "jsr:@deno/graph@~0.73.1": "0.73.1", + "jsr:@denosaurs/plug@1": "1.0.6", + "jsr:@luca/esbuild-deno-loader@*": "0.11.1", + "jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1", + "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@0.221": "0.221.0", + "jsr:@std/assert@0.223": "0.223.0", + "jsr:@std/bytes@0.223": "0.223.0", + "jsr:@std/bytes@^1.0.2": "1.0.4", + "jsr:@std/cli@^1.0.6": "1.0.6", + "jsr:@std/encoding@0.221": "0.221.0", + "jsr:@std/encoding@^1.0.5": "1.0.5", + "jsr:@std/fmt@0.221": "0.221.0", + "jsr:@std/fmt@0.223": "0.223.0", + "jsr:@std/fmt@^1.0.2": "1.0.2", + "jsr:@std/fs@0.221": "0.221.0", + "jsr:@std/fs@0.223": "0.223.0", + "jsr:@std/http@*": "1.0.7", + "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/media-types@^1.0.3": "1.0.3", + "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@0.221": "0.221.0", + "jsr:@std/path@0.223": "0.223.0", + "jsr:@std/path@^1.0.6": "1.0.6", + "jsr:@std/streams@^1.0.6": "1.0.6", + "npm:esbuild@*": "0.24.2", + "npm:esbuild@~0.24.2": "0.24.2" + }, + "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@0.217" + ] + }, + "@deno/cache-dir@0.13.2": { + "integrity": "c22419dfe27ab85f345bee487aaaadba498b005cce3644e9d2528db035c5454d", + "dependencies": [ + "jsr:@deno/graph", + "jsr:@std/fmt@0.223", + "jsr:@std/fs@0.223", + "jsr:@std/io", + "jsr:@std/path@0.223" + ] + }, + "@deno/emit@0.46.0": { + "integrity": "e276be2c77bac1b93caf775762e2a49a54cb00da2d48ca2b01ed8d7cba9d082c", + "dependencies": [ + "jsr:@deno/cache-dir", + "jsr:@std/path@0.223" + ] + }, + "@deno/graph@0.73.1": { + "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" + }, + "@denosaurs/plug@1.0.6": { + "integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7", + "dependencies": [ + "jsr:@std/encoding@0.221", + "jsr:@std/fmt@0.221", + "jsr:@std/fs@0.221", + "jsr:@std/path@0.221" + ] + }, + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes@^1.0.2", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/path@^1.0.6" + ] + }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, + "@std/assert@0.221.0": { + "integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a" + }, + "@std/assert@0.223.0": { + "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" + }, + "@std/bytes@0.223.0": { + "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" + }, + "@std/bytes@1.0.4": { + "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" + }, + "@std/cli@1.0.6": { + "integrity": "d22d8b38c66c666d7ad1f2a66c5b122da1704f985d3c47f01129f05abb6c5d3d" + }, + "@std/encoding@0.221.0": { + "integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45" + }, + "@std/encoding@1.0.5": { + "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + }, + "@std/fmt@0.221.0": { + "integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a" + }, + "@std/fmt@0.223.0": { + "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" + }, + "@std/fmt@1.0.2": { + "integrity": "87e9dfcdd3ca7c066e0c3c657c1f987c82888eb8103a3a3baa62684ffeb0f7a7" + }, + "@std/fs@0.221.0": { + "integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286", + "dependencies": [ + "jsr:@std/assert@0.221", + "jsr:@std/path@0.221" + ] + }, + "@std/fs@0.223.0": { + "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" + }, + "@std/http@1.0.7": { + "integrity": "9b904fc256678a5c9759f1a53a24a3fdcc59d83dc62099bb472683b6f819194c", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/fmt@^1.0.2", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@^1.0.6", + "jsr:@std/streams" + ] + }, + "@std/io@0.223.0": { + "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", + "dependencies": [ + "jsr:@std/assert@0.223", + "jsr:@std/bytes@0.223" + ] + }, + "@std/media-types@1.0.3": { + "integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@0.221.0": { + "integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095", + "dependencies": [ + "jsr:@std/assert@0.221" + ] + }, + "@std/path@0.223.0": { + "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", + "dependencies": [ + "jsr:@std/assert@0.223" + ] + }, + "@std/path@1.0.6": { + "integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed" + }, + "@std/streams@1.0.6": { + "integrity": "022ed94e380d06b4d91c49eb70241b7289ab78b8c2b4c4bbb7eb265e4997c25c" + } + }, + "npm": { + "@esbuild/aix-ppc64@0.24.2": { + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==" + }, + "@esbuild/android-arm64@0.24.2": { + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==" + }, + "@esbuild/android-arm@0.24.2": { + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==" + }, + "@esbuild/android-x64@0.24.2": { + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==" + }, + "@esbuild/darwin-arm64@0.24.2": { + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==" + }, + "@esbuild/darwin-x64@0.24.2": { + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==" + }, + "@esbuild/freebsd-arm64@0.24.2": { + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==" + }, + "@esbuild/freebsd-x64@0.24.2": { + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==" + }, + "@esbuild/linux-arm64@0.24.2": { + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==" + }, + "@esbuild/linux-arm@0.24.2": { + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==" + }, + "@esbuild/linux-ia32@0.24.2": { + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==" + }, + "@esbuild/linux-loong64@0.24.2": { + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==" + }, + "@esbuild/linux-mips64el@0.24.2": { + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==" + }, + "@esbuild/linux-ppc64@0.24.2": { + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==" + }, + "@esbuild/linux-riscv64@0.24.2": { + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==" + }, + "@esbuild/linux-s390x@0.24.2": { + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==" + }, + "@esbuild/linux-x64@0.24.2": { + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==" + }, + "@esbuild/netbsd-arm64@0.24.2": { + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==" + }, + "@esbuild/netbsd-x64@0.24.2": { + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==" + }, + "@esbuild/openbsd-arm64@0.24.2": { + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==" + }, + "@esbuild/openbsd-x64@0.24.2": { + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==" + }, + "@esbuild/sunos-x64@0.24.2": { + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==" + }, + "@esbuild/win32-arm64@0.24.2": { + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==" + }, + "@esbuild/win32-ia32@0.24.2": { + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==" + }, + "@esbuild/win32-x64@0.24.2": { + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==" + }, + "esbuild@0.24.2": { + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@db/sqlite@*", + "jsr:@luca/esbuild-deno-loader@~0.11.1", + "jsr:@std/http@*", + "npm:esbuild@~0.24.2" + ] + } +} diff --git a/shelves/frontend/bundle.ts b/shelves/frontend/bundle.ts new file mode 100644 index 0000000..6514bff --- /dev/null +++ b/shelves/frontend/bundle.ts @@ -0,0 +1,3 @@ +import { skibidi } from '@htmx'; + +console.debug({ skibidi }); diff --git a/ts/array.ts b/ts/array.ts new file mode 100644 index 0000000..193da69 --- /dev/null +++ b/ts/array.ts @@ -0,0 +1,3 @@ +export function arrayIsEmpty(a: any[]): boolean { + return a.length === 0; +} diff --git a/ts/promise.ts b/ts/promise.ts new file mode 100644 index 0000000..835a171 --- /dev/null +++ b/ts/promise.ts @@ -0,0 +1,3 @@ +export function sleep(timeoutMs: number): Promise { + return new Promise((res) => setTimeout(res, timeoutMs)); +} diff --git a/ts/responses.ts b/ts/responses.ts new file mode 100644 index 0000000..5ea2d59 --- /dev/null +++ b/ts/responses.ts @@ -0,0 +1,13 @@ +export type Body = ConstructorParameters[0]; +export type ResponseOptions = ConstructorParameters[1]; + +export function badRequest(body?: Body, options?: ResponseOptions): Response { + return new Response(body, { status: 400, ...options }); +} + +export function html(body: Body, options?: ResponseOptions): Response { + return new Response(body, { + ...options, + headers: { 'Content-Type': 'text/html', ...options?.headers }, + }); +} diff --git a/ts/router.ts b/ts/router.ts new file mode 100644 index 0000000..5483c4a --- /dev/null +++ b/ts/router.ts @@ -0,0 +1,77 @@ +import { STATUS_CODE } from '@std/http'; + +import { OrPromise } from './util.ts'; + +export type Method = string; + +export const Method = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + PATCH: 'PATCH', + Any: '___INTERNAL_ANY', +}; + +export type Handler = ( + request: Request, + pathParams: Record, +) => OrPromise; + +export type Match = string | URLPattern; +export class Router { + #pathnameCache: Map; + #routes: Map>; + + constructor() { + this.#routes = new Map(); + this.#pathnameCache = new Map(); + } + + register(method: Method, match: Match, handler: Handler) { + const urlPattern = (match instanceof URLPattern) ? match : this.#cachePathname(match); + if (!this.#routes.has(urlPattern)) { + this.#routes.set(urlPattern, new Map()); + } + + const methodMap = this.#routes.get(urlPattern)!; + method = method.toUpperCase(); + methodMap.set(method, handler); + } + + #cachePathname(pathname: string) { + const cached = this.#pathnameCache.get(pathname); + if (cached) { + return cached; + } + + const pattern = new URLPattern({ pathname }); + this.#pathnameCache.set(pathname, pattern); + + return pattern; + } + + async handle(req: Request): Promise { + const method = req.method.toUpperCase(); + + for (const [pattern, methodMap] of this.#routes.entries()) { + const match = pattern.exec(req.url); + if (!match) { + continue; + } + + let handler = methodMap.get(method); + if (!handler) { + handler = methodMap.get(Method.Any); + } + if (!handler) { + return new Response('Method not allowed', { + status: STATUS_CODE.MethodNotAllowed, + }); + } + + return handler(req, match.pathname.groups as Record); + } + + return new Response('Not found', { status: STATUS_CODE.NotFound }); + } +} diff --git a/ts/util.ts b/ts/util.ts new file mode 100644 index 0000000..fcb903b --- /dev/null +++ b/ts/util.ts @@ -0,0 +1 @@ +export type OrPromise = T | Promise;