From 1f59594065c736b985f9dcd6e3cbce5979a125a1 Mon Sep 17 00:00:00 2001 From: Clement Denis Date: Wed, 26 Nov 2025 14:52:02 +0100 Subject: [PATCH 1/2] DT-86: integrate 01edu lib --- .env.test | 15 +- .github/workflows/ga-build-image.yml | 8 +- .github/workflows/ga-compliance.yml | 25 +- .github/workflows/ga-linter-fmt.yml | 26 +- .github/workflows/ga-redeploy.yml | 8 +- api/auth.ts | 4 +- api/clickhouse-client.ts | 15 +- api/lib/context.ts | 46 -- api/lib/env.ts | 56 +- api/lib/json_store.test.ts | 11 +- api/lib/json_store.ts | 6 +- api/lib/log.ts | 159 +----- api/lib/response.ts | 164 ------ api/lib/router.test.js | 265 --------- api/lib/router.ts | 121 ---- api/lib/test.ts | 48 -- api/lib/time.ts | 26 - api/lib/validator.test.js | 72 --- api/lib/validator.ts | 315 ---------- api/picture.ts | 14 +- api/project.ts | 0 api/routes.ts | 90 ++- api/schema.ts | 13 +- api/server.ts | 124 +--- api/sql.ts | 6 +- api/user.ts | 7 +- deno.json | 73 ++- deno.lock | 824 ++++++++++++++++----------- tasks/env.ts | 2 +- tasks/vite.js | 77 --- tasks/vite.ts | 43 ++ web/components/BackgroundPattern.tsx | 36 +- web/components/BrandIcons.tsx | 20 + web/components/Dialog.tsx | 6 +- web/components/Filtre.tsx | 6 +- web/components/Layout.tsx | 4 +- web/components/QueryHistory.tsx | 2 +- web/components/SideBar.tsx | 2 +- web/components/forms.tsx | 2 +- web/index.tsx | 10 +- web/layout.tsx | 9 +- web/lib/api.ts | 161 +----- web/lib/router.tsx | 214 ------- web/lib/shared.tsx | 2 +- web/pages/DeploymentPage.tsx | 4 +- web/pages/LoginPage.tsx | 22 +- web/pages/ProjectPage.tsx | 2 +- web/pages/ProjectsPage.tsx | 4 +- web/pages/project/SettingsPage.tsx | 20 +- 49 files changed, 804 insertions(+), 2385 deletions(-) delete mode 100644 api/lib/context.ts delete mode 100644 api/lib/response.ts delete mode 100644 api/lib/router.test.js delete mode 100644 api/lib/router.ts delete mode 100644 api/lib/test.ts delete mode 100644 api/lib/time.ts delete mode 100644 api/lib/validator.test.js delete mode 100644 api/lib/validator.ts delete mode 100644 api/project.ts delete mode 100644 tasks/vite.js create mode 100644 tasks/vite.ts create mode 100644 web/components/BrandIcons.tsx delete mode 100644 web/lib/router.tsx diff --git a/.env.test b/.env.test index c0f4268..de4cc52 100644 --- a/.env.test +++ b/.env.test @@ -1,10 +1,11 @@ APP_ENV=test -PORT=3021 -GOOGLE_CLIENT_ID=...test.apps.googleusercontent.com -CLIENT_SECRET=GOC...test -REDIRECT_URI=http://localhost:7737/api/auth/google - +CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 CLICKHOUSE_HOST=http://localhost:8443 -CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=token_pass -CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \ No newline at end of file +CLICKHOUSE_USER=default +CLIENT_SECRET=GOC...test +GOOGLE_CLIENT_ID=...test.apps.googleusercontent.com +PORT=3021 +REDIRECT_URI=http://localhost:7737/api/auth/google +# Get values from `deno task dev:env` +# then copy missing variables and replace APP_ENV to the matching values diff --git a/.github/workflows/ga-build-image.yml b/.github/workflows/ga-build-image.yml index 5ae0bd8..263cc5c 100644 --- a/.github/workflows/ga-build-image.yml +++ b/.github/workflows/ga-build-image.yml @@ -1,11 +1,7 @@ # Workflow to build and push a Docker image to GitHub Container Registry -name: 'đŸŗ Build Docker Image' - -on: - push: - branches: ['master'] - +name: "đŸŗ Build Docker Image" +on: { push: { branches: ["master"] } } jobs: build-image: name: đŸ—ī¸ Build Image diff --git a/.github/workflows/ga-compliance.yml b/.github/workflows/ga-compliance.yml index 3397ef9..d6f007f 100644 --- a/.github/workflows/ga-compliance.yml +++ b/.github/workflows/ga-compliance.yml @@ -1,20 +1,16 @@ # Workflow for DevTools Compliance - name: DevTools Compliance - on: pull_request: types: - [ - opened, - edited, - synchronize, - reopened, - labeled, - unlabeled, - assigned, - unassigned, - ] + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + - assigned + - unassigned jobs: check-compliance: @@ -23,9 +19,8 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v5 + with: { fetch-depth: 0 } - name: Show PR Labels and Assignees run: | diff --git a/.github/workflows/ga-linter-fmt.yml b/.github/workflows/ga-linter-fmt.yml index adb8703..f92f9c5 100644 --- a/.github/workflows/ga-linter-fmt.yml +++ b/.github/workflows/ga-linter-fmt.yml @@ -1,38 +1,22 @@ # Workflow for DevTools CI/CD # This workflow runs linting and formatting checks on pull requests. # It ensures that the code adheres to style guidelines and is free of linting errors. - name: DevTools CI/CD - on: - pull_request: - types: [opened, reopened, synchronize] + pull_request: { types: [opened, reopened, synchronize] } jobs: lint-and-format: name: Lint and Format Check runs-on: ubuntu-latest - steps: - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v5 + with: { fetch-depth: 0 } - name: Setup Deno uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - - name: Cache Deno dependencies - uses: actions/cache@v4 - with: - path: | - ~/.deno - ~/node_modules - ~/.cache/deno - - key: ${{ runner.os }}-deno-v2-${{ hashFiles('deno.json') }} + with: { deno-version: v2.x, cache: true } - name: 🔍 Get all changed files id: changed-file-list @@ -45,7 +29,7 @@ jobs: - name: Run formatter if: steps.changed-file-list.outputs.changed_files != '' - run: deno task fmt --permit-no-files --check ${{ steps.changed-file-list.outputs.changed_files }} + run: deno task fmt --permit-no-files ${{ steps.changed-file-list.outputs.changed_files }} - name: Run check if: steps.changed-file-list.outputs.changed_files != '' diff --git a/.github/workflows/ga-redeploy.yml b/.github/workflows/ga-redeploy.yml index d6cf819..27d0ae9 100644 --- a/.github/workflows/ga-redeploy.yml +++ b/.github/workflows/ga-redeploy.yml @@ -1,7 +1,7 @@ name: 🔑 Redeploy DevTools - Production on: workflow_run: - workflows: ['đŸŗ Build Docker Image'] + workflows: ["đŸŗ Build Docker Image"] types: [completed] branches: [master] @@ -11,11 +11,7 @@ jobs: name: đŸ’Ŋ Redeploy DevTools runs-on: devtools.01edu.ai environment: production - - permissions: - packages: read - contents: read - + permissions: { packages: read, contents: read } steps: - name: 🔐 Login to GitHub Container Registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin diff --git a/api/auth.ts b/api/auth.ts index 7fef7ce..8b35c41 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -7,10 +7,10 @@ import { verifyGoogleToken, verifyState, } from '/api/lib/google-oauth.ts' -import { respond } from '/api/lib/response.ts' +import { respond } from '@01edu/api/response' import { authenticateOauthUser } from '/api/user.ts' import { savePicture } from '/api/picture.ts' -import type { RequestContext } from '/api/lib/context.ts' +import type { RequestContext } from '@01edu/api/context' interface GoogleTokens { access_token: string diff --git a/api/clickhouse-client.ts b/api/clickhouse-client.ts index bf07026..0952b02 100644 --- a/api/clickhouse-client.ts +++ b/api/clickhouse-client.ts @@ -1,13 +1,20 @@ -import { createClient } from 'npm:@clickhouse/client' +import { createClient } from '@clickhouse/client' import { CLICKHOUSE_HOST, CLICKHOUSE_PASSWORD, CLICKHOUSE_USER, } from './lib/env.ts' -import { respond } from './lib/response.ts' +import { respond } from '@01edu/api/response' import { log } from './lib/log.ts' -import { ARR, NUM, OBJ, optional, STR, UNION } from './lib/validator.ts' -import { Asserted } from './lib/router.ts' +import { + ARR, + type Asserted, + NUM, + OBJ, + optional, + STR, + UNION, +} from '@01edu/api/validator' const LogSchema = OBJ({ timestamp: NUM('The timestamp of the log event'), diff --git a/api/lib/context.ts b/api/lib/context.ts deleted file mode 100644 index 40fb18b..0000000 --- a/api/lib/context.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { startTime } from '/api/lib/time.ts' -import { User } from '../schema.ts' - -type Readonly = { - readonly [P in keyof T]: - // deno-lint-ignore ban-types - T[P] extends Function ? T[P] - : T[P] extends object ? Readonly - : T[P] -} - -// Define the route structure with supported methods -// export type Session = { id: number; createdAt: number; userId: number } -export type RequestContext = { - readonly req: Readonly - readonly url: Readonly - readonly cookies: Readonly> - readonly user: User | undefined - readonly trace: number - readonly span: number | undefined - resource: string | undefined -} - -// we set default values so we don't have to check everytime if they exists -export const makeContext = ( - urlInit: string | URL, - extra?: Partial, -): RequestContext => { - const url = new URL(urlInit, 'http://locahost') - const req = new Request(url) - return { - trace: startTime, - cookies: {}, - user: undefined, - span: undefined, - resource: undefined, - url, - req, - ...extra, - } -} - -const defaultContext = makeContext('/') -export const requestContext = new AsyncLocalStorage() -export const getContext = () => requestContext.getStore() || defaultContext diff --git a/api/lib/env.ts b/api/lib/env.ts index 1f5169e..8b228c4 100644 --- a/api/lib/env.ts +++ b/api/lib/env.ts @@ -1,46 +1,22 @@ -const env = Deno.env.toObject() +import { ENV } from '@01edu/api/env' -type AppEnvironments = 'dev' | 'test' | 'prod' - -export const APP_ENV = env.APP_ENV || 'dev' as AppEnvironments -if (APP_ENV !== 'dev' && APP_ENV !== 'test' && APP_ENV !== 'prod') { - throw Error(`APP_ENV: "${env.APP_ENV}" must be "dev", "test" or "prod"`) -} - -export const PORT = Number(env.PORT) || 2119 -export const Picture_Dir = env.PICTURE_DIR || './.picture' - -export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID -if (!GOOGLE_CLIENT_ID) { - throw Error('GOOGLE_CLIENT_ID: field required in the env') -} -export const CLIENT_SECRET = env.CLIENT_SECRET -if (!CLIENT_SECRET) { - throw Error('CLIENT_SECRET: field required in the env') -} -export const REDIRECT_URI = env.REDIRECT_URI -if (!REDIRECT_URI) { - throw Error('REDIRECT_URI: field required in the env') -} +export const PORT = Number(ENV('PORT', '2119')) +export const PICTURE_DIR = ENV('PICTURE_DIR', './.picture') +export const GOOGLE_CLIENT_ID = ENV('GOOGLE_CLIENT_ID') +export const CLIENT_SECRET = ENV('CLIENT_SECRET') +export const REDIRECT_URI = ENV('REDIRECT_URI') export const ORIGIN = new URL(REDIRECT_URI).origin +export const SECRET = ENV( + 'SECRET', + 'iUokBru8WPSMAuMspijlt7F-Cnpqyg84F36b1G681h0', +) -export const SECRET = env.SECRET || - 'iUokBru8WPSMAuMspijlt7F-Cnpqyg84F36b1G681h0' - -export const CLICKHOUSE_HOST = env.CLICKHOUSE_HOST -if (!CLICKHOUSE_HOST) { - throw Error('CLICKHOUSE_HOST: field required in the env') -} -export const CLICKHOUSE_USER = env.CLICKHOUSE_USER -if (!CLICKHOUSE_USER) { - throw Error('CLICKHOUSE_USER: field required in the env') -} -export const CLICKHOUSE_PASSWORD = env.CLICKHOUSE_PASSWORD -if (!CLICKHOUSE_PASSWORD) { - throw Error('CLICKHOUSE_PASSWORD: field required in the env') -} +export const CLICKHOUSE_HOST = ENV('CLICKHOUSE_HOST') +export const CLICKHOUSE_USER = ENV('CLICKHOUSE_USER') +export const CLICKHOUSE_PASSWORD = ENV('CLICKHOUSE_PASSWORD') // Optional interval (ms) for refreshing external SQL database schemas // Defaults to 24 hours -export const DB_SCHEMA_REFRESH_MS = Number(env.DB_SCHEMA_REFRESH_MS) || - 24 * 60 * 60 * 1000 +export const DB_SCHEMA_REFRESH_MS = Number( + ENV('DB_SCHEMA_REFRESH_MS', `${24 * 60 * 60 * 1000}`), +) diff --git a/api/lib/json_store.test.ts b/api/lib/json_store.test.ts index ebd457a..26ec259 100644 --- a/api/lib/json_store.test.ts +++ b/api/lib/json_store.test.ts @@ -1,13 +1,8 @@ // db_test.ts -import { afterEach, beforeEach, describe, it } from 'jsr:@std/testing/bdd' -import { - assert, - assertEquals, - assertExists, - assertRejects, -} from 'jsr:@std/assert' +import { afterEach, beforeEach, describe, it } from '@std/testing/bdd' +import { assert, assertEquals, assertExists, assertRejects } from '@std/assert' import { createCollection } from './json_store.ts' -import { ensureDir } from 'jsr:@std/fs' +import { ensureDir } from '@std/fs' type User = { id: number diff --git a/api/lib/json_store.ts b/api/lib/json_store.ts index 497219d..14700c3 100644 --- a/api/lib/json_store.ts +++ b/api/lib/json_store.ts @@ -1,7 +1,7 @@ // zero-db.ts -import { join } from 'jsr:@std/path' -import { APP_ENV } from './env.ts' -import { ensureDir } from 'jsr:@std/fs' +import { join } from '@std/path' +import { APP_ENV } from '@01edu/api/env' +import { ensureDir } from '@std/fs' const DB_DIR = APP_ENV === 'test' ? './db_test' : './db' diff --git a/api/lib/log.ts b/api/lib/log.ts index 99ab5c9..d7183b3 100644 --- a/api/lib/log.ts +++ b/api/lib/log.ts @@ -1,157 +1,2 @@ -import { APP_ENV } from '/api/lib/env.ts' -import { now, startTime } from '/api/lib/time.ts' -import { getContext } from '/api/lib/context.ts' -import { - blue, - brightBlue, - brightCyan, - brightGreen, - brightMagenta, - brightRed, - brightYellow, - cyan, - gray, - green, - magenta, - red, - yellow, -} from 'jsr:@std/fmt/colors' - -type LogLevel = - | 'info' // default level - | 'error' - | 'warn' - | 'debug' - -type LogFunction = ( - level: LogLevel, - event: string, - props?: Record, -) => void - -type BoundLogFunction = (event: string, props?: Record) => void - -interface Log extends LogFunction { - error: BoundLogFunction - debug: BoundLogFunction - warn: BoundLogFunction - info: BoundLogFunction -} - -const levels = { - debug: { ico: '🐛', color: green }, - info: { ico: 'â„šī¸', color: cyan }, - warn: { ico: 'âš ī¸', color: yellow }, - error: { ico: 'đŸ’Ĩ', color: red }, -} as const - -// set to recursive to not fail if already exists -const logDir = `${Deno.cwd()}/.logs` -await Deno.mkdir(logDir, { recursive: true }) - -// format: https://jsonlines.org/ -// TODO: see if we can aquire a write lock for the file -export const logFilePath = `${logDir}/api_${APP_ENV}${ - APP_ENV === 'prod' ? `_${startTime.toString(36).split('.')[0]}` : '' -}.jsonl` -// when not in prod, we clean logs between boots -const logFile = await Deno.open(logFilePath, { - append: true, - ...(APP_ENV === 'prod' ? { createNew: true } : { create: true }), -}) -APP_ENV === 'prod' || await logFile.truncate(0).catch(() => { - // console.log('unable to truncate', { logFilePath }) -}) -const encoder = new TextEncoder() - -function replacer(_key: string, value: unknown) { - if (value instanceof Error) return value.stack || value.message - return value -} - -const colors = [ - ...[green, yellow, blue, magenta, cyan, brightRed], - ...[brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan], -] -const colored: Record = { 'Object.fetch': cyan('serve') } -const makePrettyTimestamp = (level: LogLevel, event: string) => { - const at = new Date() - const hh = String(at.getHours()).padStart(2, '0') - const mm = String(at.getMinutes()).padStart(2, '0') - const ss = String(at.getSeconds()).padStart(2, '0') - const ms = String(at.getMilliseconds()).padStart(2, '0').slice(0, 2) - const lvl = levels[level] - return `${gray(`${hh}h${mm}:${ss}.${ms}`)} ${lvl.ico} ${lvl.color(event)}` -} - -const bannedTestEvents = new Set( - ['step-end', 'step-start', 'test-end', 'test-start'], -) - -const rootDir = - import.meta.dirname?.slice(0, -'/lib'.length).replaceAll('\\', '/') || '' -const loggers: Record = { - test: (level, event, props) => { - const { trace, span, url } = getContext() - const data = { - level, - trace, - span, - event, - props, - at: now(), - file: url.pathname, - } - logFile.writeSync(encoder.encode(`${JSON.stringify(data, replacer)}\n`)) - if (bannedTestEvents.has(event)) return - const ev = makePrettyTimestamp(level, event) - props ? console[level](ev, props) : console[level](ev) - }, - dev: (level, event, props) => { - let callChain = '' - for (const s of Error('').stack!.split('\n').slice(2).reverse()) { - if (!s.includes(rootDir)) continue - const fnName = s.split(' ').at(-2) - if (!fnName || fnName === 'async' || fnName === 'at') continue - const coloredName = colored[fnName] || - (colored[fnName] = colors[Object.keys(colored).length % colors.length]( - fnName, - )) - callChain = callChain ? `${callChain}/${coloredName}` : coloredName - } - const { trace, span } = getContext() - const data = { level, trace, span, event, props, at: now() } - const logStr = JSON.stringify(data, replacer) - logFile.writeSync(encoder.encode(`${logStr}\n`)) - const bytes = encoder.encode(`data: ${logStr}\r\n\r\n`) - for (const controller of logListeners) { - try { - controller.enqueue(bytes) - } catch (err) { - console.error('unable to send log', err) - } - } - const ev = `${makePrettyTimestamp(level, event)} ${callChain}`.trim() - props ? console[level](ev, props) : console[level](ev) - }, - prod: (level, event, props) => { - const { trace, span } = getContext() - console.log(JSON.stringify({ level, trace, span, event, props, at: now() })) - }, -} - -export const log = loggers[APP_ENV] as Log - -// Bind is used over wrapping for reducing error stack -log.error = log.bind(null, 'error') -log.debug = log.bind(null, 'debug') -log.warn = log.bind(null, 'warn') -log.info = log.bind(null, 'info') - -export const error = log.error -export const debug = log.debug -export const warn = log.warn -export const info = log.info -export const logListeners = new Set>() - -// TODO: target betterstack, use open telemetry fields +import { logger } from '@01edu/api/log' +export const log = await logger({}) diff --git a/api/lib/response.ts b/api/lib/response.ts deleted file mode 100644 index d33b40b..0000000 --- a/api/lib/response.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { STATUS_CODE, STATUS_TEXT } from 'jsr:@std/http/status' - -const defaultHeaderEntries: [string, string][] = [ - ['content-type', 'application/json'], -] - -const defaultHeaders = new Headers(defaultHeaderEntries) - -const json = (data?: unknown, init?: ResponseInit) => { - if (data == null) return new Response(null, init) - if (!init) { - init = { headers: defaultHeaders } - } else if (!init.headers) { - init.headers = defaultHeaders - } else { - if (!(init.headers instanceof Headers)) { - init.headers = new Headers(init.headers) - } - const h = init.headers as Headers - for (const entry of defaultHeaderEntries) { - h.set(entry[0], entry[1]) - } - } - - return new Response(JSON.stringify(data), init) -} - -class ResponseError extends Error { - public response: Response - - constructor(message: string, response: Response) { - super(message) - this.name = 'ResponseError' - this.response = response - } -} - -type StatusCodeWithoutBody = - | 'Continue' - | 'SwitchingProtocols' - | 'Processing' - | 'EarlyHints' - | 'NoContent' - | 'ResetContent' - | 'NotModified' - -const withoutBody = new Set([ - 100, // Continue - 101, // SwitchingProtocols - 102, // Processing - 103, // EarlyHints - 204, // NoContent - 205, // ResetContent - 304, // NotModified -]) - -type StatusNotErrors = - | 'OK' - | 'Created' - | 'Accepted' - | 'NonAuthoritativeInfo' - | 'NoContent' - | 'ResetContent' - | 'PartialContent' - | 'MultiStatus' - | 'AlreadyReported' - | 'IMUsed' - | 'MultipleChoices' - | 'MovedPermanently' - | 'Found' - | 'SeeOther' - | 'NotModified' - | 'UseProxy' - | 'TemporaryRedirect' - | 'PermanentRedirect' - -const notErrors = new Set([ - 200, // OK - 201, // Created - 202, // Accepted - 203, // NonAuthoritativeInfo - 204, // NoContent - 205, // ResetContent - 206, // PartialContent - 207, // MultiStatus - 208, // AlreadyReported - 226, // IMUsed - 300, // MultipleChoices - 301, // MovedPermanently - 302, // Found - 303, // SeeOther - 304, // NotModified - 305, // UseProxy - 307, // TemporaryRedirect - 308, // PermanentRedirect -]) - -type ErrorStatus = Exclude< - Exclude, - StatusNotErrors -> - -export const respond = Object.fromEntries([ - ...Object.entries(STATUS_CODE).map(([key, status]) => { - const statusText = STATUS_TEXT[status] - const defaultData = new TextEncoder().encode( - JSON.stringify({ message: statusText }) + '\n', - ) - - const makeResponse = withoutBody.has(status) - ? (headers?: HeadersInit) => - headers === undefined - ? json(null, { headers: defaultHeaders, status, statusText }) - : json(null, { headers, status, statusText }) - : (data?: unknown, headers?: HeadersInit) => - data === undefined - ? new Response(defaultData, { - headers: defaultHeaders, - status, - statusText, - }) - : json(data, { headers, status, statusText }) - - return [key, makeResponse] - }), - - ...Object.entries(STATUS_CODE) - .filter(([_, status]) => !withoutBody.has(status) && !notErrors.has(status)) - .map(([key, status]) => { - const statusText = STATUS_TEXT[status] - const name = `${key}Error` - return [ - name, - class extends ResponseError { - constructor(data?: unknown, headers?: HeadersInit) { - super(statusText, respond[key as ErrorStatus](data, headers)) - this.name = name - } - }, - ] - }), - ['ResponseError', ResponseError], -]) as ( - & { - [k in Exclude]: ( - data?: unknown, - headers?: HeadersInit, - ) => Response - } - & { - [k in Extract]: ( - headers?: HeadersInit, - ) => Response - } - & { - [ - k in `${Exclude< - Exclude, - StatusNotErrors - >}Error` - ]: new (data?: unknown, headers?: HeadersInit) => ResponseError - } - & { ResponseError: typeof ResponseError } -) diff --git a/api/lib/router.test.js b/api/lib/router.test.js deleted file mode 100644 index 90fc55c..0000000 --- a/api/lib/router.test.js +++ /dev/null @@ -1,265 +0,0 @@ -import { eq, test } from '/api/lib/test.ts' -import { makeContext } from '/api/lib/context.ts' -import { makeRouter } from '/api/lib/router.ts' -import { ARR, BOOL, NUM, OBJ, optional, STR } from '/api/lib/validator.ts' -import { startTime } from '/api/lib/time.ts' - -const router = (route) => makeRouter(route).handle -const makeContextReq = (path, init) => { - const url = new URL(path, 'http://localhost') - return makeContext(url, { req: new Request(url, init) }) -} - -test('Router - Basic HTTP Methods', async (step) => { - await step('GET request without params', async () => { - const r = router({ - 'GET/health': { - fn: () => ({ status: 'ok' }), - output: OBJ({ status: STR() }), - }, - }) - const response = await r(makeContext('/health')) - eq(response.status, 200) - - const data = await response.json() - eq(data, { status: 'ok' }) - }) - - await step('GET request with query params', async () => { - const r = router({ - 'GET/users': { - fn: (_ctx, input) => ({ id: input.id, name: 'John' }), - input: OBJ({ id: STR() }), - output: OBJ({ id: STR(), name: STR() }), - }, - }) - const response = await r(makeContext('/users?id=123')) - eq(response.status, 200) - - const data = await response.json() - eq(data, { id: '123', name: 'John' }) - }) - - await step('POST request with body', async () => { - const r = router({ - 'POST/users': { - fn: (_ctx, input) => ({ ...input, id: 1 }), - input: OBJ({ name: STR(), age: NUM() }), - output: OBJ({ id: NUM(), name: STR(), age: NUM() }), - }, - }) - const response = await r( - makeContextReq('/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'John', age: 30 }), - }), - ) - const data = await response.json() - - eq(response.status, 200) - eq(data, { id: 1, name: 'John', age: 30 }) - }) - - await step('DELETE request with params', async () => { - const r = router({ - 'DELETE/users': { - fn: (_ctx, input) => ({ deleted: input.id }), - input: OBJ({ id: NUM() }), - output: OBJ({ deleted: NUM() }), - }, - }) - const response = await r( - makeContextReq('/users', { - method: 'DELETE', - body: JSON.stringify({ id: 123 }), - }), - ) - eq(response.status, 200) - - const data = await response.json() - eq(data, { deleted: 123 }) - }) -}) - -test('Router - Complex Data Structures', async (step) => { - await step('Nested objects and arrays', async () => { - const r = router({ - 'POST/articles': { - fn: (_ctx, input) => ({ id: 1, ...input, created: true }), - input: OBJ({ - title: STR(), - content: STR(), - tags: ARR(STR()), - author: OBJ({ name: STR(), email: STR() }), - }), - output: OBJ({ - id: NUM(), - title: STR(), - content: STR(), - tags: ARR(STR()), - author: OBJ({ name: STR(), email: STR() }), - created: BOOL(), - }), - }, - }) - - const response = await r( - makeContextReq('/articles', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Test Article', - content: 'Content here', - tags: ['test', 'article'], - author: { - name: 'John', - email: 'john@test.com', - }, - }), - }), - ) - const data = await response.json() - - eq(response.status, 200) - eq(data, { - created: true, - id: 1, - title: 'Test Article', - content: 'Content here', - tags: ['test', 'article'], - author: { - name: 'John', - email: 'john@test.com', - }, - }) - }) - - await step('Optional fields handling', async () => { - const r = router({ - 'POST/posts': { - fn: (_ctx, input) => ({ id: 1, ...input }), - input: OBJ({ - title: STR(), - content: STR(), - tags: optional(ARR(STR())), - draft: optional(BOOL()), - }), - output: OBJ({ - id: NUM(), - title: STR(), - content: STR(), - tags: optional(ARR(STR())), - draft: optional(BOOL()), - }), - }, - }) - - const response = await r( - makeContextReq('/posts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'Test Post', content: 'Content here' }), - }), - ) - - eq(response.status, 200) - - const data = await response.json() - eq(data.title, 'Test Post') - eq(data.tags, undefined) - }) -}) - -test('Router - Error Handling', async (step) => { - await step('404 - Route not found', async () => { - const r = router({ - 'GET/test': { - fn: () => ({ ok: true }), - input: OBJ({}), - output: OBJ({ ok: BOOL() }), - }, - }) - const response = await r(makeContext('/nonexistent')) - eq(response.status, 404) - }) - - await step('400 - Invalid input type', async () => { - const r = router({ - 'POST/users': { - fn: (_ctx, input) => input, - input: OBJ({ age: NUM() }), - output: OBJ({ age: NUM() }), - }, - }) - const response = await r( - makeContextReq('/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ age: 'not a number' }), - }), - ) - eq(response.status, 400) - }) - - await step('400 - Missing required field', async () => { - const r = router({ - 'POST/users': { - fn: (_ctx, input) => input, - input: OBJ({ name: STR(), age: NUM() }), - output: OBJ({ name: STR(), age: NUM() }), - }, - }) - const response = await r( - makeContextReq('/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'John' }), // missing age - }), - ) - eq(response.status, 400) - }) - - await step('400 - Invalid JSON body', async () => { - const r = router({ - 'POST/users': { - fn: (_ctx, input) => input, - input: OBJ({ name: STR() }), - output: OBJ({ name: STR() }), - }, - }) - const response = await r( - makeContextReq('/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'invalid json', - }), - ) - eq(response.status, 400) - }) -}) - -test('Router - Context should be passed to handler', async (step) => { - await step('should handle custom headers', async () => { - const r = router({ - 'GET/auth': { - fn: (ctx) => ({ trace: ctx.trace }), - input: OBJ({}), - output: OBJ({ trace: NUM() }), - }, - }) - const response = await r(makeContext('/auth')) - eq(response.status, 200) - - const data = await response.json() - eq(data, { trace: startTime }) - }) -}) - -// TODO: -// add tests for no ouput -// add tests for no input and no output -// add test that failing handler error should be passed untouched -// add check that GET can only have OBJ({ [k]: STR() }) as input -// add stringify / parse optional methods to have payload formatting and validation -// ex: handle Date type, numbers in GET requests etc... diff --git a/api/lib/router.ts b/api/lib/router.ts deleted file mode 100644 index 67aab57..0000000 --- a/api/lib/router.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { Def } from '/api/lib/validator.ts' -import { respond } from '/api/lib/response.ts' -import type { RequestContext as Ctx } from '/api/lib/context.ts' - -// Supported HTTP methods -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' -type Path = `/${string}` -export type RoutePattern = `${HttpMethod}${Path}` - -type RequestHandler = (ctx: Ctx) => Awaitable -export type Awaitable = Promise | T -export type Asserted = ReturnType -type Nullish = null | undefined | void -type Respond = Awaitable -type HandlerFn = [TInput] extends [Def] - ? [TOutput] extends [Def] - ? (ctx: Ctx, input: Asserted) => Respond> - : (ctx: Ctx, input: Asserted) => Respond - : [TOutput] extends [Def] ? (ctx: Ctx) => Respond> - : (ctx: Ctx) => Respond - -type AuthorizeHandler = [TInput] extends [Def] - ? (ctx: Ctx, input: Asserted) => Awaitable - : (ctx: Ctx) => Awaitable - -export type Handler = { - authorize?: AuthorizeHandler - fn: HandlerFn - description?: string - input?: TInput - output?: TOutput -} - -export const route = ( - h: TInput extends Def ? TOutput extends Def ? Handler - : Handler - : TOutput extends Def ? Handler - : Handler, -) => h - -export type Router = { - [P in T]: Handler -} - -const getPayloadParams = (ctx: Ctx) => Object.fromEntries(ctx.url.searchParams) -const getPayloadBody = async (ctx: Ctx) => { - try { - return await ctx.req.json() - } catch { - return {} - } -} - -type Route = Record -type SimpleHandler = (ctx: Ctx, payload: unknown) => Respond - -export const makeRouter = (defs: Router) => { - const routeMaps: Record = Object.create(null) - - for (const key in defs) { - const slashIndex = key.indexOf('/') - const method = key.slice(0, slashIndex) as HttpMethod - const url = key.slice(slashIndex) - if (!routeMaps[url]) { - routeMaps[url] = Object.create(null) as Route - routeMaps[`${url}/`] = routeMaps[url] - } - const { fn, input, authorize } = defs[key] as Handler - const simpleHandler = async ( - ctx: Ctx, - payload?: Asserted, - ) => { - try { - await authorize?.(ctx, payload as Asserted) - } catch (err) { - const message = err instanceof Error ? err.message : 'Unauthorized' - return respond.Unauthorized({ message }) - } - const result = await (fn as SimpleHandler)(ctx, payload) - if (result == null) return respond.NoContent() - return result instanceof Response ? result : respond.OK(result) - } - if (input) { - const getPayload = method === 'GET' ? getPayloadParams : getPayloadBody - const assert = input.assert - const report = input.report || (() => [`Expect a ${input?.type}`]) - routeMaps[url][method] = async (ctx: Ctx) => { - const payload = await getPayload(ctx) - let asserted - try { - asserted = assert(payload) - } catch { - const message = 'Input validation failed' - const failures = report(payload) - return respond.BadRequest({ message, failures }) - } - try { - await authorize?.(ctx, payload) - } catch (err) { - const message = err instanceof Error ? err.message : 'Unauthorized' - return respond.Unauthorized({ message }) - } - return simpleHandler(ctx, asserted) - } - } else { - routeMaps[url][method] = simpleHandler - } - } - - const handle = (ctx: Ctx) => { - const route = routeMaps[ctx.url.pathname] - if (!route) return respond.NotFound() - - const handler = route[ctx.req.method as HttpMethod] - if (!handler) return respond.MethodNotAllowed() - - return handler(ctx) - } - - return { handle } -} diff --git a/api/lib/test.ts b/api/lib/test.ts deleted file mode 100644 index f544b21..0000000 --- a/api/lib/test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - assertEquals as eq, - assertRejects as rejects, - assertThrows as throws, -} from 'jsr:@std/assert' - -import { now } from '/api/lib/time.ts' -import { debug } from '/api/lib/log.ts' -import { APP_ENV } from '/api/lib/env.ts' -import { type RequestContext, requestContext } from '/api/lib/context.ts' - -// short aliases for most used asserts -export { eq, rejects, throws } - -// test wrapper that sets a context -type TestStep = Deno.TestContext['step'] -export const test = ( - name: string, - handler: TestStep, - opts?: Omit, -) => { - if (APP_ENV !== 'test') return - const ignore = APP_ENV !== 'test' || opts?.ignore - const url = new URL( - Error('').stack!.split('\n')[1].match(/\([^)]+\)/)![0].slice(1, -1), - ) - const ctx = { - req: new Request(url), - url, - cookies: {}, - trace: now(), - } as RequestContext - const fn = async (t: Deno.TestContext) => { - debug('test-start', { name, origin: t.origin }) - await handler(async (...args) => { - await requestContext.run({ ...ctx, span: now() }, async () => { - const stepName = args[0]?.name || args[0] - debug('step-start', { name: stepName, origin: t.origin }) - const result = await t.step( - ...args as unknown as Parameters, - ) - debug('step-end', { name: stepName, origin: t.origin }) - return result - }) - }) - } - return requestContext.run(ctx, () => Deno.test({ ...opts, name, fn, ignore })) -} diff --git a/api/lib/time.ts b/api/lib/time.ts deleted file mode 100644 index e61b4d7..0000000 --- a/api/lib/time.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Shared functions to manage time - -export const startTime = performance.timeOrigin / 1000 -/** - * Return the current timestamp in seconds since the epoch. - * Uses the performance API for high precision timing. - * Ensured to be unique per process. - * - * @example - * const timestamp = now(); - * console.log(timestamp); // Output: 1731521396.557123 (example timestamp) - */ -let lastTime = startTime -export const now = (): number => { - const time = startTime + performance.now() / 1000 - if (time === lastTime) return now() - lastTime = time - return time -} - -export const SEC = 1 -export const MIN = 60 -export const HOUR = 60 * MIN -export const DAY = 24 * HOUR -export const WEEK = 7 * DAY -export const YEAR = 365.2422 * DAY diff --git a/api/lib/validator.test.js b/api/lib/validator.test.js deleted file mode 100644 index ba8d99c..0000000 --- a/api/lib/validator.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import { eq, test, throws } from '/api/lib/test.ts' -import { ARR, NUM, OBJ, optional, STR } from '/api/lib/validator.ts' - -test('Validator - Success cases', async (step) => { - await step('should validate simple object', () => { - const schema = OBJ({ - name: STR('User name'), - age: NUM('User age'), - }) - - const data = { name: 'John', age: 30 } - const result = schema.assert(data) - eq(result, data) - }) - - await step('should validate nested object with arrays', () => { - const schema = OBJ({ - user: OBJ({ - name: STR(), - tags: ARR(STR()), - }), - }) - - const data = { - user: { - name: 'John', - tags: ['admin', 'user'], - }, - } - const result = schema.assert(data) - eq(result, data) - }) - - await step('should handle optional fields', () => { - const schema = OBJ({ - name: STR(), - age: optional(NUM()), - }) - - const data = { name: 'John' } - const result = schema.assert(data) - eq(result, data) - }) -}) - -test('Validator - Error cases', async (step) => { - await step('should throw on missing required field', () => { - const schema = OBJ({ - name: STR(), - age: NUM(), - }) - - throws( - () => schema.assert({ name: 'John' }), - Error, - 'type assertion failed', - ) - }) - - await step('should throw on invalid type', () => { - const schema = OBJ({ - name: STR(), - age: NUM(), - }) - - throws( - () => schema.assert({ name: 'John', age: '30kds' }), - Error, - 'type assertion failed', - ) - }) -}) diff --git a/api/lib/validator.ts b/api/lib/validator.ts deleted file mode 100644 index 993c126..0000000 --- a/api/lib/validator.ts +++ /dev/null @@ -1,315 +0,0 @@ -// deno-lint-ignore-file no-explicit-any - -type ValidatorFailure = { - type: T['type'] - path: (string | number)[] - value: unknown -} - -type Validator = ( - value: unknown, - path?: (string | number)[], -) => ValidatorFailure[] - -type DefArray = { - type: 'array' - of: Def - report: Validator - optional?: boolean - description?: string - assert: (value: unknown) => ReturnType[] -} - -type DefList = { - type: 'list' - of: T - report: Validator> - optional?: boolean - description?: string - assert: (value: unknown) => T[number] -} - -type DefUnion = { - type: 'union' - of: T - report: Validator> - optional?: boolean - description?: string - assert: (value: unknown) => ReturnType -} - -type DefObject> = { - type: 'object' - properties: { [K in keyof T]: T[K] } - report: Validator - optional?: boolean - description?: string - assert: (value: unknown) => { [K in keyof T]: ReturnType } -} - -type DefString = { - type: 'string' - assert: AssertType - report: Validator - optional?: boolean - description?: string -} - -type DefNumber = { - type: 'number' - assert: AssertType - report: Validator - optional?: boolean - description?: string -} - -type DefBoolean = { - type: 'boolean' - assert: AssertType - report: Validator - optional?: boolean - description?: string -} - -export type DefBase = - | DefString - | DefNumber - | DefBoolean - | DefArray - | DefObject> - | DefList - | DefUnion - -type OptionalAssert = ( - value: unknown, -) => ReturnType | undefined - -type Optional = T & { - assert: OptionalAssert -} - -type AssertType = (value: unknown) => T - -export type Def = T extends DefBase ? DefArray - : T extends Record ? DefObject - : DefBase - -const reportObject = >(properties: T) => { - const body = [ - 'if (!o || typeof o !== "object") return [{ path: p, type: "object", value: o }]', - 'const failures = []', - ...Object.entries(properties).map(([key, def], i) => { - const k = JSON.stringify(key) - const path = `[...p, ${k}]` - if (def.type === 'object' || def.type === 'array') { - const check = ` - const _${i} = v[${k}].report(o[${k}], ${path}); - _${i}.length && failures.push(..._${i}) - ` - return def.optional ? `if (o[${k}] !== undefined) {${check}}` : check - } - const opt = def.optional ? `o[${k}] === undefined || ` : '' - return (`${opt}typeof o[${k}] === "${def.type}" || failures.push({ ${ - [`path: ${path}`, `type: "${def.type}"`, `value: o[${k}]`].join(', ') - } })`) - }), - 'return failures', - ].join('\n') - - return new Function('v, o, p = []', body).bind( - globalThis, - properties, - ) as DefObject['report'] -} - -const assertObject = >(properties: T) => { - const body = [ - 'if (!o || typeof o !== "object") throw Error("type assertion failed")', - ...Object.entries(properties).map(([key, def]) => { - const k = JSON.stringify(key) - return `${ - def.optional ? `v[${k}] === undefined ||` : '' - }v[${k}].assert(o[${k}])` - }), - 'return o', - ].join('\n') - - return new Function('v, o', body).bind(globalThis, properties) as DefObject< - T - >['assert'] -} - -const reportArray = (def: Def) => { - const body = [ - 'if (!Array.isArray(a)) return [{ path: p, type: "array", value: a }]', - 'const failures = []', - 'let i = -1; const max = a.length', - 'while (++i < max) {', - ' const e = a[i]', - def.type === 'object' || def.type === 'array' - ? `const _ = v.report(e, [...p, i]); (_.length && failures.push(..._))` - : `${ - def.optional ? 'e === undefined ||' : '' - }typeof e === "${def.type}" || failures.push({ ${ - [ - `path: [...p, i]`, - `type: "${def.type}"`, - `value: e`, - ].join(', ') - } })`, - ' if (failures.length > 9) return failures', - '}', - 'return failures', - ].join('\n') - - return new Function('v, a, p = []', body) -} - -const assertArray = (assert: T) => (a: unknown) => { - if (!Array.isArray(a)) throw Error('type assertion failed') - a.forEach(assert) - return a as ReturnType[] -} - -const assertNumber = (value: unknown) => { - if (typeof value === 'number' && !isNaN(value)) return value - throw Error(`type assertion failed`) -} - -const assertString = (value: unknown) => { - if (typeof value === 'string') return value - throw Error(`type assertion failed`) -} - -const assertBoolean = (value: unknown) => { - if (typeof value === 'boolean') return value - throw Error(`type assertion failed`) -} - -export const NUM = (description?: string) => - ({ - type: 'number', - assert: assertNumber, - description, - report: (value: unknown) => [{ type: 'number', value, path: [] }], - }) satisfies DefNumber - -export const STR = (description?: string) => - ({ - type: 'string', - assert: assertString, - description, - report: (value: unknown) => [{ type: 'string', value, path: [] }], - }) satisfies DefString - -export const BOOL = (description?: string) => - ({ - type: 'boolean', - assert: assertBoolean, - description, - report: (value: unknown) => [{ type: 'boolean', value, path: [] }], - }) satisfies DefBoolean - -export const optional = (def: T): Optional => { - const { assert, description, ...rest } = def - const optionalAssert: OptionalAssert = (value: unknown) => - value === undefined ? undefined : assert(value) - return { - ...rest, - description, - optional: true, - assert: optionalAssert, - } as Optional -} - -export const OBJ = >( - properties: T, - description?: string, -): DefObject => { - const report = reportObject(properties) - const assert = assertObject(properties) - return { type: 'object', properties, report, assert, description } -} - -export const ARR = ( - def: T, - description?: string, -): DefArray => ({ - type: 'array', - of: def, - report: reportArray(def).bind(globalThis, def), - assert: assertArray(def.assert) as DefArray['assert'], - description, -}) - -export const LIST = ( - possibleValues: T, - description?: string, -): DefList => ({ - type: 'list', - of: possibleValues, - report: (value: unknown, path: (string | number)[] = []) => { - if (possibleValues.includes(value as T[number])) return [] - return [{ - path, - type: 'list', - value, - expected: possibleValues, - }] - }, - assert: (value: unknown): T[number] => { - if (possibleValues.includes(value as T[number])) { - return value as T[number] - } - throw new Error( - `Invalid value. Expected one of: ${possibleValues.join(', ')}`, - ) - }, - description, -}) - -export const UNION = (...types: T): DefUnion => ({ - type: 'union', - of: types, - report: (value: unknown, path: (string | number)[] = []) => { - const failures: ValidatorFailure>[] = [] - for (const type of types) { - const result = type.report(value, path) - if (result.length === 0) return [] - failures.push(...result) - } - return failures - }, - assert: (value: unknown): ReturnType => { - for (const type of types) { - try { - return type.assert(value) - } catch { - // Ignore - } - } - throw new Error( - `Invalid value. Expected one of: ${types.map((t) => t.type).join(', ')}`, - ) - }, -}) - -// const Article = OBJ({ -// id: NUM("Unique identifier for the article"), -// title: STR("Title of the article"), -// isDraft: optional(BOOL("Whether the article is in draft state")), -// tags: ARR(STR("A tag name"), "List of tags associated with the article"), -// author: optional(OBJ({ -// id: NUM("Author's unique identifier"), -// }, "Author information")), -// }, "Article object containing all article information"); - -// type ArticleType = ReturnType; - -// const aaa = Article.report({ -// id: 5, -// title: "hello", -// isDraft: true, -// tags: [], -// // author: { id: 1 }, -// }); diff --git a/api/picture.ts b/api/picture.ts index 82047e7..8c43268 100644 --- a/api/picture.ts +++ b/api/picture.ts @@ -1,16 +1,16 @@ -import { crypto } from 'jsr:@std/crypto/crypto' -import { encodeBase64Url } from 'jsr:@std/encoding/base64url' -import { ensureDirSync, exists } from 'jsr:@std/fs' -import { Picture_Dir } from './lib/env.ts' +import { crypto } from '@std/crypto/crypto' +import { encodeBase64Url } from '@std/encoding/base64url' +import { ensureDirSync, exists } from '@std/fs' +import { PICTURE_DIR } from '/api/lib/env.ts' -ensureDirSync(Picture_Dir) +ensureDirSync(PICTURE_DIR) const encoder = new TextEncoder() export const savePicture = async (url?: string) => { if (!url) return const bytes = await crypto.subtle.digest('BLAKE3', encoder.encode(url)) const hash = encodeBase64Url(bytes) - const file = `${Picture_Dir}/${hash}` + const file = `${PICTURE_DIR}/${hash}` if (await exists(file)) return hash const req = await fetch(url) const data = await req.arrayBuffer() @@ -19,7 +19,7 @@ export const savePicture = async (url?: string) => { } export const getPicture = async (hash: string) => { - const picture = await Deno.open(`${Picture_Dir}/${hash}`) + const picture = await Deno.open(`${PICTURE_DIR}/${hash}`) return new Response(picture.readable, { headers: { 'content-type': 'image/png', diff --git a/api/project.ts b/api/project.ts deleted file mode 100644 index e69de29..0000000 diff --git a/api/routes.ts b/api/routes.ts index 390f5f3..51e3485 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -1,6 +1,7 @@ -import { makeRouter, route } from '/api/lib/router.ts' -import type { RequestContext } from '/api/lib/context.ts' -import { handleGoogleCallback, initiateGoogleAuth } from './auth.ts' +import { makeRouter, route } from '@01edu/api/router' +import type { RequestContext } from '@01edu/api/context' +import { handleGoogleCallback, initiateGoogleAuth } from '/api/auth.ts' +import { log } from '/api/lib/log.ts' import { DatabaseSchemasCollection, DeploymentDef, @@ -8,53 +9,47 @@ import { ProjectsCollection, TeamDef, TeamsCollection, - User, UserDef, UsersCollection, } from './schema.ts' -import { ARR, BOOL, LIST, NUM, OBJ, optional, STR } from './lib/validator.ts' -import { respond } from './lib/response.ts' -import { deleteCookie } from 'jsr:@std/http/cookie' +import { ARR, BOOL, LIST, NUM, OBJ, optional, STR } from '@01edu/api/validator' +import { respond } from '@01edu/api/response' +import { deleteCookie } from '@std/http/cookie' import { getPicture } from '/api/picture.ts' import { getLogs, insertLogs, LogSchemaOutput, LogsInputSchema, -} from './clickhouse-client.ts' -import { decryptMessage, encryptMessage } from './user.ts' -import { log } from './lib/log.ts' +} from '/api/clickhouse-client.ts' +import { decodeSession, decryptMessage, encryptMessage } from '/api/user.ts' import { type ColumnInfo, fetchTablesData, runSQL, SQLQueryError, -} from './sql.ts' +} from '/api/sql.ts' -const withUserSession = ({ user }: RequestContext) => { - if (!user) throw Error('Missing user session') +const withUserSession = async ({ cookies }: RequestContext) => { + const session = await decodeSession(cookies.session) + if (!session) throw Error('Missing user session') + return session } -const withAdminSession = ({ user }: RequestContext) => { - if (!user || !user.isAdmin) throw Error('Admin access required') +const withAdminSession = async (ctx: RequestContext) => { + const session = await withUserSession(ctx) + if (!session || !session.isAdmin) throw Error('Admin access required') } const withDeploymentSession = async (ctx: RequestContext) => { const token = ctx.req.headers.get('Authorization')?.replace(/^Bearer /i, '') - if (!token) throw respond.Unauthorized({ message: 'Missing token' }) - try { - const message = await decryptMessage(token) - if (!message) throw respond.Unauthorized({ message: 'Invalid token' }) - const data = JSON.parse(message) - const dep = DeploymentsCollection.get(data?.url) - if (!dep || dep.tokenSalt !== data?.tokenSalt) { - throw respond.Unauthorized({ message: 'Invalid token' }) - } - ctx.resource = dep?.url - } catch (error) { - log.error('Error validating deployment token:', { error }) - throw respond.Unauthorized({ message: 'Invalid token' }) - } + if (!token) throw Error('Missing token') + const message = await decryptMessage(token) + if (!message) throw Error('Invalid token') + const data = JSON.parse(message) + const dep = DeploymentsCollection.get(data?.url) + if (!dep || dep.tokenSalt !== data?.tokenSalt) throw Error('Invalid token') + return dep } const userInTeam = (teamId: string, userEmail?: string) => { @@ -99,7 +94,7 @@ const defs = { }), 'GET/api/user/me': route({ authorize: withUserSession, - fn: ({ user }) => user as User, + fn: ({ session }) => session, output: UserDef, description: 'Handle Google OAuth callback', }), @@ -163,7 +158,11 @@ const defs = { }), 'PUT/api/team': route({ authorize: withAdminSession, - fn: (_ctx, input) => TeamsCollection.update(input.teamId, input), + fn: (_ctx, input) => + TeamsCollection.update(input.teamId, { + teamName: input.teamName, + teamMembers: input.teamMembers || undefined, + }), input: OBJ({ teamId: STR('The ID of the team'), teamName: STR('The name of the team'), @@ -334,10 +333,7 @@ const defs = { const token = await encryptMessage( JSON.stringify({ url: deployment.url, tokenSalt }), ) - return { - ...deployment, - token, - } + return { ...deployment, token } }, input: OBJ({ url: STR('The URL of the deployment') }), output: deploymentOutput, @@ -388,8 +384,8 @@ const defs = { 'POST/api/logs': route({ authorize: withDeploymentSession, fn: (ctx, logs) => { - if (!ctx.resource) throw respond.InternalServerError() - return insertLogs(ctx.resource, logs) + if (!ctx.session.url) throw respond.InternalServerError() + return insertLogs(ctx.session.url, logs) }, input: LogsInputSchema, description: 'Insert logs into ClickHouse NB: a Bearer token is required', @@ -408,8 +404,8 @@ const defs = { } const project = ProjectsCollection.get(deployment.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) - if (!project.isPublic && !ctx.user?.isAdmin) { - if (!userInTeam(project.teamId, ctx.user?.userEmail)) { + if (!project.isPublic && !ctx.session.isAdmin) { + if (!userInTeam(project.teamId, ctx.session.userEmail)) { throw respond.Forbidden({ message: 'Access to project logs denied' }) } } @@ -451,7 +447,7 @@ const defs = { throw respond.NotFound({ message: 'Deployment not found' }) } - if (!dep?.databaseEnabled) { + if (!dep.databaseEnabled) { throw respond.BadRequest({ message: 'Database not enabled for deployment', }) @@ -459,8 +455,8 @@ const defs = { const project = ProjectsCollection.get(dep.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) - if (!project.isPublic && !ctx.user?.isAdmin) { - if (!userInTeam(project.teamId, ctx.user?.userEmail)) { + if (!project.isPublic && !ctx.session.isAdmin) { + if (!userInTeam(project.teamId, ctx.session.userEmail)) { throw respond.Forbidden({ message: 'Access to project tables denied', }) @@ -480,7 +476,7 @@ const defs = { tableDef.columnsMap as unknown as Map, ) } catch (err) { - console.error('fetchTablesData error', err) + log.error('fetchTablesData-error', { stack: (err as Error)?.stack }) throw err } }, @@ -523,7 +519,7 @@ const defs = { throw new respond.NotFoundError({ message: 'Deployment not found' }) } - if (!dep?.databaseEnabled) { + if (!dep.databaseEnabled) { throw new respond.BadRequestError({ message: 'Database not enabled for deployment', }) @@ -531,8 +527,8 @@ const defs = { const project = ProjectsCollection.get(dep.projectId) if (!project) throw respond.NotFound({ message: 'Project not found' }) - if (!project.isPublic && !ctx.user?.isAdmin) { - if (!userInTeam(project.teamId, ctx.user?.userEmail)) { + if (!project.isPublic && !ctx.session.isAdmin) { + if (!userInTeam(project.teamId, ctx.session.userEmail)) { throw new respond.ForbiddenError({ message: 'Access to project queries denied', }) @@ -582,4 +578,4 @@ const defs = { } as const export type RouteDefinitions = typeof defs -export const routeHandler = makeRouter(defs).handle +export const routeHandler = makeRouter(log, defs) diff --git a/api/schema.ts b/api/schema.ts index 106ed6f..7b10848 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -1,6 +1,13 @@ -import { ARR, BOOL, NUM, OBJ, optional, STR } from './lib/validator.ts' -import { Asserted } from './lib/router.ts' -import { createCollection } from './lib/json_store.ts' +import { + ARR, + type Asserted, + BOOL, + NUM, + OBJ, + optional, + STR, +} from '@01edu/api/validator' +import { createCollection } from '/api/lib/json_store.ts' export const UserDef = OBJ({ userEmail: STR('The user email address'), diff --git a/api/server.ts b/api/server.ts index a5ec8c1..1bb597a 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,110 +1,24 @@ -// api/server.ts -import { decodeSession } from '/api/user.ts' -import { respond } from '/api/lib/response.ts' -import { now } from '/api/lib/time.ts' +import { serveDir } from '@std/http/file-server' +import { APP_ENV } from '@01edu/api/env' +import { server } from '@01edu/api/server' import { log } from '/api/lib/log.ts' import { routeHandler } from '/api/routes.ts' -import { getCookies, setCookie } from 'jsr:@std/http/cookie' -import { type RequestContext, requestContext } from '/api/lib/context.ts' -import { join } from 'jsr:@std/path/join' -import { serveDir } from 'jsr:@std/http/file-server' -import { PORT } from './lib/env.ts' -import { startSchemaRefreshLoop } from './sql.ts' - -const isProd = Deno.args.includes('--env=prod') -const staticDir = isProd - ? join((import.meta.dirname || Deno.cwd()).split('/api')[0], 'dist/web') - : join(Deno.cwd(), 'dist/web') -const indexHtml = isProd - ? await Deno.readFile(join(staticDir, 'index.html')) - : '' -const htmlContent = { headers: { 'Content-Type': 'text/html' } } -const serveDirOpts = { fsRoot: staticDir } - -const { ResponseError } = respond - -const handleRequest = async (ctx: RequestContext) => { - const logProps: Record = {} - logProps.path = `${ctx.req.method}:${ctx.url.pathname.slice('/api/'.length)}` - log.info('in', logProps) - try { - const res = await routeHandler(ctx) - logProps.status = res.status - logProps.duration = now() - ctx.span! - log.info('out', logProps) - return res - } catch (err) { - let response: Response - if (err instanceof ResponseError) { - response = err.response - logProps.status = response.status - } else { - logProps.status = 500 - logProps.stack = err - response = respond.InternalServerError() - } - - logProps.duration = now() - ctx.span! - log.error('out', logProps) - return response - } -} - -export const fetch = async (req: Request) => { - const url = new URL(req.url) - const method = req.method - if (method === 'OPTIONS') return respond.NoContent() - - if (url.pathname.startsWith('/api')) { - // Build the request context - const cookies = getCookies(req.headers) - const ctx = { - req, - url, - cookies, - trace: cookies.trace ? Number(cookies.trace) : now(), - user: await decodeSession(cookies.session), - span: now(), - resource: undefined, - } - - const res = await requestContext.run(ctx, handleRequest, ctx) - if (!cookies.trace) { - // if the cookies do not yet have a trace, we set it for the future - setCookie(res.headers, { - name: 'trace', - value: String(ctx.trace), - path: '/', - secure: true, - httpOnly: true, - sameSite: 'Lax', - }) - } - return res - } - - // Serve static files in production - if (isProd) { - if (url.pathname.includes('.')) { - return serveDir(req, serveDirOpts) - } +const fetch = server({ log, routeHandler }) +export default { fetch } + +if (APP_ENV === 'prod') { + const indexHtml = await Deno.readFile( + import.meta.dirname + '/web/dist/index.html', + ) + const htmlContent = { headers: { 'Content-Type': 'text/html' } } + const serveDirOpts = { fsRoot: import.meta.dirname + '/web/dist' } + Deno.serve((req) => { + const url = new URL(req.url) + if (url.pathname.startsWith('/api/')) return fetch(req, url) + if (url.pathname.includes('.')) return serveDir(req, serveDirOpts) return new Response(indexHtml, htmlContent) - } - - // In development, redirect to Vite dev server - return new Response('Use Vite dev server for frontend', { status: 404 }) + }) +} else { + log.info('server-start') } - -log.info('server-start') - -// Start periodic DB schema refresh (non-blocking) -try { - startSchemaRefreshLoop() -} catch (err) { - log.error('schema-loop-start-failed', { err }) -} - -Deno.serve({ - port: PORT, -}, fetch) diff --git a/api/sql.ts b/api/sql.ts index c790ec0..42e10b5 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -2,9 +2,9 @@ import { DatabaseSchemasCollection, Deployment, DeploymentsCollection, -} from './schema.ts' -import { DB_SCHEMA_REFRESH_MS } from './lib/env.ts' -import { log } from './lib/log.ts' +} from '/api/schema.ts' +import { DB_SCHEMA_REFRESH_MS } from '/api/lib/env.ts' +import { log } from '/api/lib/log.ts' export class SQLQueryError extends Error { constructor(message: string, body: string) { diff --git a/api/user.ts b/api/user.ts index ca3c001..8c6ee0a 100644 --- a/api/user.ts +++ b/api/user.ts @@ -1,7 +1,6 @@ -import { SECRET } from './lib/env.ts' -import { UsersCollection } from './schema.ts' - -import { decodeBase64Url, encodeBase64Url } from 'jsr:@std/encoding/base64url' +import { decodeBase64Url, encodeBase64Url } from '@std/encoding/base64url' +import { SECRET } from '/api/lib/env.ts' +import { UsersCollection } from '/api/schema.ts' const encoder = new TextEncoder() const decoder = new TextDecoder() diff --git a/deno.json b/deno.json index 38f7405..6e430f1 100644 --- a/deno.json +++ b/deno.json @@ -1,45 +1,58 @@ { "tasks": { - "api:dev": "deno run -A --env-file=.env.dev api/server.ts --env=dev --port=3021", - "vite:dev": "deno run -A --env-file=.env.dev tasks/vite.js --env=dev", - "clickhouse:dev": "deno run -A --env-file=.env.dev tasks/clickhouse.ts --env=dev", - "dev": { "dependencies": ["clickhouse:dev", "api:dev", "vite:dev"] }, - "seed": "deno run -A --env-file=.env.dev tasks/seed.ts", - "dev:with-seed": "deno task seed && deno task dev", - "api:prod": "deno compile -A --no-check --output dist/api --target x86_64-unknown-linux-gnu --include dist/web api/server.ts --env=prod", - "vite:prod": "deno run -A tasks/vite.js --build --env=prod", - "clickhouse:prod": "deno run -A --env-file=.env.prod tasks/clickhouse.ts --env=prod", - "prod": "deno task vite:prod && deno task api:prod", - "start:prod": "deno task clickhouse:prod && dist/api --env=prod", - "fmt": "deno fmt", - "lint": "deno lint", - "check": "deno check", - "review": "deno run -A https://gistcdn.githack.com/kigiri/7658b4af30bb5eaca3e4cad1fcac7b0c/raw/review.js", - "test": "deno test --env-file=.env.test -A --unstable-worker-options --no-check", "all": "deno task check && deno task lint && deno task test --parallel", + "check": "deno check", + "dev": { "dependencies": ["dev:clickhouse", "dev:api", "dev:vite"] }, + "dev:api": "deno run -A --env-file=.env.dev api/server.ts", + "dev:clickhouse": "deno run -A --env-file=.env.dev tasks/clickhouse.ts", + "dev:env": "deno run -A tasks/env.ts", + "dev:vite": "deno run -A --env-file=.env.dev tasks/vite.ts", + "dev:with-seed": "deno task seed && deno task dev", "docker:build": "docker build -t devtools .", + "docker:clean": "docker rm -f devtools-app && docker rmi devtools", + "docker:exec": "docker exec -it devtools-app /bin/sh", + "docker:logs": "docker logs -f devtools-app", "docker:prod": "docker run --name devtools-app -v \"$(pwd)/db:/app/db\" -p 8877:3021 --env-file .env.prod devtools", - "docker:stop": "docker stop devtools-app", - "docker:start": "docker start devtools-app", "docker:restart": "docker restart devtools-app", "docker:rm": "docker rm -f devtools-app", - "docker:logs": "docker logs -f devtools-app", - "docker:exec": "docker exec -it devtools-app /bin/sh", - "docker:clean": "docker rm -f devtools-app && docker rmi devtools", - "env:dev": "deno run -A tasks/env.ts" + "docker:start": "docker start devtools-app", + "docker:stop": "docker stop devtools-app", + "fmt": "deno fmt", + "lint": "deno lint", + "prod": "deno task vite:prod && deno task api:prod", + "prod:api": "deno compile -A --no-check --output dist/api --target x86_64-unknown-linux-gnu --include dist/web api/server.ts --env=prod", + "prod:clickhouse": "APP_ENV=prod deno run -A --env-file tasks/clickhouse.ts", + "prod:start": "deno task clickhouse:prod && dist/api", + "prod:vite": "deno run --env-file -A tasks/vite.ts", + "review": "deno run -A https://gistcdn.githack.com/kigiri/7658b4af30bb5eaca3e4cad1fcac7b0c/raw/review.js", + "seed": "deno run -A --env-file=.env.dev tasks/seed.ts", + "test": "deno test --env-file=.env.test -A --unstable-worker-options --no-check" }, "imports": { "./": "./", "/": "./", - "@std/assert": "jsr:@std/assert@1", - "vite": "npm:vite@^7.0.4", - "preact": "npm:preact@^10.26.9", + "@01edu/api": "jsr:@01edu/api@^0.1.3", + "@01edu/api-client": "jsr:@01edu/api-client@^0.1.3", + "@01edu/api-proxy": "jsr:@01edu/api-proxy@^0.1.2", + "@01edu/signal-router": "jsr:@01edu/signal-router@^0.1.6", + "@01edu/time": "jsr:@01edu/time@^0.1.0", + "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.5", + "@std/assert": "jsr:@std/assert@^1.0.16", + "@std/crypto": "jsr:@std/crypto@^1.0.5", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/fmt": "jsr:@std/fmt@^1.0.8", + "@std/fs": "jsr:@std/fs@^1.0.20", + "@std/http": "jsr:@std/http@^1.0.22", + "@std/path": "jsr:@std/path@^1.1.3", + "@std/testing": "jsr:@std/testing@^1.0.16", + "vite": "npm:vite@8.0.0-beta.1", + "preact": "npm:preact@^10.28.0", "@preact/preset-vite": "npm:@preact/preset-vite@^2.10.2", - "@preact/signals": "npm:@preact/signals", - "@clickhouse/client": "npm:@clickhouse/client", - "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.11", - "tailwindcss": "npm:tailwindcss@^4.1.11", - "daisyui": "npm:daisyui@^5.0.46", + "@preact/signals": "npm:@preact/signals@2.5.1", + "@clickhouse/client": "npm:@clickhouse/client@^1.14.0", + "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.17", + "tailwindcss": "npm:tailwindcss@^4.1.17", + "daisyui": "npm:daisyui@^5.5.8", "lucide-preact": "npm:lucide-preact@^0.525.0" }, "fmt": { diff --git a/deno.lock b/deno.lock index 8a950eb..9b1818b 100644 --- a/deno.lock +++ b/deno.lock @@ -1,120 +1,157 @@ { "version": "5", "specifiers": { - "jsr:@std/assert@*": "1.0.13", - "jsr:@std/assert@1": "1.0.11", - "jsr:@std/assert@^1.0.13": "1.0.13", - "jsr:@std/cli@*": "1.0.11", - "jsr:@std/cli@^1.0.8": "1.0.11", + "jsr:@01edu/api-client@~0.1.3": "0.1.3", + "jsr:@01edu/api-proxy@~0.1.2": "0.1.2", + "jsr:@01edu/api@~0.1.3": "0.1.3", + "jsr:@01edu/signal-router@~0.1.6": "0.1.6", + "jsr:@01edu/time@0.1": "0.1.0", + "jsr:@01edu/types@~0.1.2": "0.1.2", + "jsr:@std/assert@^1.0.15": "1.0.16", + "jsr:@std/assert@^1.0.16": "1.0.16", + "jsr:@std/cli@^1.0.24": "1.0.24", "jsr:@std/crypto@*": "1.0.5", - "jsr:@std/encoding@*": "1.0.6", - "jsr:@std/encoding@^1.0.5": "1.0.6", - "jsr:@std/fmt@*": "1.0.4", - "jsr:@std/fmt@^1.0.3": "1.0.4", - "jsr:@std/fs@*": "1.0.19", - "jsr:@std/html@^1.0.3": "1.0.3", - "jsr:@std/http@*": "1.0.12", - "jsr:@std/internal@^1.0.10": "1.0.10", - "jsr:@std/internal@^1.0.5": "1.0.10", - "jsr:@std/internal@^1.0.6": "1.0.10", - "jsr:@std/internal@^1.0.9": "1.0.10", + "jsr:@std/crypto@^1.0.5": "1.0.5", + "jsr:@std/data-structures@^1.0.9": "1.0.9", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/fs@^1.0.19": "1.0.20", + "jsr:@std/fs@^1.0.20": "1.0.20", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@^1.0.22": "1.0.22", + "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/media-types@^1.1.0": "1.1.0", - "jsr:@std/net@^1.0.4": "1.0.4", - "jsr:@std/path@*": "1.1.1", - "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/path@^1.1.1": "1.1.1", - "jsr:@std/streams@^1.0.8": "1.0.8", - "jsr:@std/testing@*": "1.0.15", - "npm:@clickhouse/client@*": "1.12.1", - "npm:@preact/preset-vite@^2.10.2": "2.10.2_@babel+core@7.28.0_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_preact@10.26.9_@types+node@22.15.15", - "npm:@preact/signals@*": "2.2.1_preact@10.26.9", - "npm:@tailwindcss/vite@^4.1.11": "4.1.11_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_@types+node@22.15.15", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@^1.1.2": "1.1.3", + "jsr:@std/path@^1.1.3": "1.1.3", + "jsr:@std/streams@^1.0.14": "1.0.14", + "jsr:@std/testing@^1.0.16": "1.0.16", + "npm:@clickhouse/client@^1.14.0": "1.14.0", + "npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15", + "npm:@preact/preset-vite@^2.10.2": "2.10.2_@babel+core@7.28.0_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_preact@10.28.0_@types+node@22.15.15", + "npm:@preact/signals@2.5.1": "2.5.1_preact@10.28.0", + "npm:@preact/signals@^2.5.1": "2.5.1_preact@10.28.0", + "npm:@tailwindcss/vite@^4.1.17": "4.1.17_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15", "npm:@types/node@*": "22.15.15", "npm:create-vite-extra@latest": "4.0.0", - "npm:daisyui@^5.0.46": "5.0.46", - "npm:lucide-preact@0.525": "0.525.0_preact@10.26.9", - "npm:preact@^10.26.9": "10.26.9", - "npm:tailwindcss@^4.1.11": "4.1.11", - "npm:vite@*": "7.0.4_picomatch@4.0.2_@types+node@22.15.15", - "npm:vite@^7.0.4": "7.0.4_picomatch@4.0.2_@types+node@22.15.15" + "npm:daisyui@^5.5.8": "5.5.8", + "npm:lucide-preact@0.525": "0.525.0_preact@10.28.0", + "npm:preact@^10.27.2": "10.28.0", + "npm:preact@^10.28.0": "10.28.0", + "npm:tailwindcss@^4.1.17": "4.1.17", + "npm:vite@8.0.0-beta.1": "8.0.0-beta.1_@types+node@22.15.15_picomatch@4.0.3", + "npm:vite@^7.2.4": "7.2.7_@types+node@22.15.15_picomatch@4.0.3" }, "jsr": { - "@std/assert@1.0.11": { - "integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1", + "@01edu/api@0.1.3": { + "integrity": "fcb88766e2772c6f6e5c2b9d7ede5460f532f9398c4c20f466c6d1f667d2a0d1", "dependencies": [ - "jsr:@std/internal@^1.0.5" + "jsr:@01edu/time", + "jsr:@01edu/types", + "jsr:@std/fmt", + "jsr:@std/http" ] }, - "@std/assert@1.0.13": { - "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "@01edu/api-client@0.1.3": { + "integrity": "292c41437b7b77aa0b0c128a152e1b09434f45d84e8f252b5f8ce088c07f01b4", "dependencies": [ - "jsr:@std/internal@^1.0.6" + "jsr:@01edu/types", + "npm:@preact/signals@^2.5.1" ] }, - "@std/cli@1.0.11": { - "integrity": "ec219619fdcd31bcf0d8e53bee1e2706ec9a02f70255365a094f69755dadd340" + "@01edu/api-proxy@0.1.2": { + "integrity": "1fa3fbd790dfd435893d3b3554f509749411ef342f3b62515a78cdd83a09b80e", + "dependencies": [ + "npm:vite@^7.2.4" + ] + }, + "@01edu/signal-router@0.1.6": { + "integrity": "816485c671b21472da571f692731901a15ed77725ac9dfffdcee1dba0d736c22", + "dependencies": [ + "npm:@preact/signals@^2.5.1", + "npm:preact@^10.27.2" + ] + }, + "@01edu/time@0.1.0": { + "integrity": "638ea7d2d00bfbf487e5262b4d6207de7f2101793d0d27a63d492e113a07dcb2" + }, + "@01edu/types@0.1.2": { + "integrity": "58c6925af51586a33bb8a42a2c191c760044aec7f85fb00e8824781176d7b6e2" + }, + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/cli@1.0.24": { + "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" }, "@std/crypto@1.0.5": { "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" }, - "@std/encoding@1.0.6": { - "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" + "@std/data-structures@1.0.9": { + "integrity": "033d6e17e64bf1f84a614e647c1b015fa2576ae3312305821e1a4cb20674bb4d" }, - "@std/fmt@1.0.4": { - "integrity": "e14fe5bedee26f80877e6705a97a79c7eed599e81bb1669127ef9e8bc1e29a74" + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, - "@std/fs@1.0.19": { - "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.20": { + "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", "dependencies": [ - "jsr:@std/internal@^1.0.9", - "jsr:@std/path@^1.1.1" + "jsr:@std/internal", + "jsr:@std/path@^1.1.3" ] }, - "@std/html@1.0.3": { - "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" }, - "@std/http@1.0.12": { - "integrity": "85246d8bfe9c8e2538518725b158bdc31f616e0869255f4a8d9e3de919cab2aa", + "@std/http@1.0.22": { + "integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a", "dependencies": [ - "jsr:@std/cli@^1.0.8", - "jsr:@std/encoding@^1.0.5", - "jsr:@std/fmt@^1.0.3", + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs@^1.0.20", "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path@^1.0.8", + "jsr:@std/path@^1.1.3", "jsr:@std/streams" ] }, "@std/internal@1.0.5": { "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" }, - "@std/internal@1.0.10": { - "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, - "@std/net@1.0.4": { - "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" - }, - "@std/path@1.0.8": { - "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, - "@std/path@1.1.1": { - "integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76", + "@std/path@1.1.3": { + "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/internal@^1.0.9" + "jsr:@std/internal" ] }, - "@std/streams@1.0.8": { - "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3" + "@std/streams@1.0.14": { + "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411" }, - "@std/testing@1.0.15": { - "integrity": "a490169f5ccb0f3ae9c94fbc69d2cd43603f2cffb41713a85f99bbb0e3087cbc", + "@std/testing@1.0.16": { + "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", "dependencies": [ - "jsr:@std/assert@^1.0.13", - "jsr:@std/internal@^1.0.10" + "jsr:@std/assert@^1.0.15", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.19", + "jsr:@std/internal", + "jsr:@std/path@^1.1.2" ] } }, @@ -280,170 +317,170 @@ "@babel/helper-validator-identifier" ] }, - "@clickhouse/client-common@1.12.1": { - "integrity": "sha512-ccw1N6hB4+MyaAHIaWBwGZ6O2GgMlO99FlMj0B0UEGfjxM9v5dYVYql6FpP19rMwrVAroYs/IgX2vyZEBvzQLg==" + "@clickhouse/client-common@1.14.0": { + "integrity": "sha512-CyUcv2iCkZ1A++vmOSufYRpHR3aAWVfbrWed7ATzf0yyx/BW/2SEqlL07vBpSRa3BIkQe/DSOHVv8JkWZpUOwQ==" }, - "@clickhouse/client@1.12.1": { - "integrity": "sha512-7ORY85rphRazqHzImNXMrh4vsaPrpetFoTWpZYueCO2bbO6PXYDXp/GQ4DgxnGIqbWB/Di1Ai+Xuwq2o7DJ36A==", + "@clickhouse/client@1.14.0": { + "integrity": "sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw==", "dependencies": [ "@clickhouse/client-common" ] }, - "@emnapi/core@1.4.4": { - "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", + "@deno/vite-plugin@1.0.5_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15": { + "integrity": "sha512-tLja5n4dyMhcze1NzvSs2iiriBymfBlDCZIrjMTxb9O2ru0gvmV6mn5oBD2teNw5Sd92cj3YJzKwsAs8tMJXlg==", + "dependencies": [ + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3" + ] + }, + "@emnapi/core@1.7.1": { + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "dependencies": [ "@emnapi/wasi-threads", "tslib" ] }, - "@emnapi/runtime@1.4.4": { - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "@emnapi/runtime@1.7.1": { + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "dependencies": [ "tslib" ] }, - "@emnapi/wasi-threads@1.0.3": { - "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", + "@emnapi/wasi-threads@1.1.0": { + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dependencies": [ "tslib" ] }, - "@esbuild/aix-ppc64@0.25.6": { - "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "@esbuild/aix-ppc64@0.25.12": { + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "os": ["aix"], "cpu": ["ppc64"] }, - "@esbuild/android-arm64@0.25.6": { - "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "@esbuild/android-arm64@0.25.12": { + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "os": ["android"], "cpu": ["arm64"] }, - "@esbuild/android-arm@0.25.6": { - "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "@esbuild/android-arm@0.25.12": { + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "os": ["android"], "cpu": ["arm"] }, - "@esbuild/android-x64@0.25.6": { - "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "@esbuild/android-x64@0.25.12": { + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "os": ["android"], "cpu": ["x64"] }, - "@esbuild/darwin-arm64@0.25.6": { - "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "@esbuild/darwin-arm64@0.25.12": { + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "os": ["darwin"], "cpu": ["arm64"] }, - "@esbuild/darwin-x64@0.25.6": { - "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "@esbuild/darwin-x64@0.25.12": { + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "os": ["darwin"], "cpu": ["x64"] }, - "@esbuild/freebsd-arm64@0.25.6": { - "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "@esbuild/freebsd-arm64@0.25.12": { + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@esbuild/freebsd-x64@0.25.6": { - "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "@esbuild/freebsd-x64@0.25.12": { + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "os": ["freebsd"], "cpu": ["x64"] }, - "@esbuild/linux-arm64@0.25.6": { - "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "@esbuild/linux-arm64@0.25.12": { + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@esbuild/linux-arm@0.25.6": { - "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "@esbuild/linux-arm@0.25.12": { + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "os": ["linux"], "cpu": ["arm"] }, - "@esbuild/linux-ia32@0.25.6": { - "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "@esbuild/linux-ia32@0.25.12": { + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "os": ["linux"], "cpu": ["ia32"] }, - "@esbuild/linux-loong64@0.25.6": { - "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "@esbuild/linux-loong64@0.25.12": { + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "os": ["linux"], "cpu": ["loong64"] }, - "@esbuild/linux-mips64el@0.25.6": { - "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "@esbuild/linux-mips64el@0.25.12": { + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "os": ["linux"], "cpu": ["mips64el"] }, - "@esbuild/linux-ppc64@0.25.6": { - "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "@esbuild/linux-ppc64@0.25.12": { + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "os": ["linux"], "cpu": ["ppc64"] }, - "@esbuild/linux-riscv64@0.25.6": { - "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "@esbuild/linux-riscv64@0.25.12": { + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "os": ["linux"], "cpu": ["riscv64"] }, - "@esbuild/linux-s390x@0.25.6": { - "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "@esbuild/linux-s390x@0.25.12": { + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "os": ["linux"], "cpu": ["s390x"] }, - "@esbuild/linux-x64@0.25.6": { - "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "@esbuild/linux-x64@0.25.12": { + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "os": ["linux"], "cpu": ["x64"] }, - "@esbuild/netbsd-arm64@0.25.6": { - "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "@esbuild/netbsd-arm64@0.25.12": { + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "os": ["netbsd"], "cpu": ["arm64"] }, - "@esbuild/netbsd-x64@0.25.6": { - "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "@esbuild/netbsd-x64@0.25.12": { + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "os": ["netbsd"], "cpu": ["x64"] }, - "@esbuild/openbsd-arm64@0.25.6": { - "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "@esbuild/openbsd-arm64@0.25.12": { + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "os": ["openbsd"], "cpu": ["arm64"] }, - "@esbuild/openbsd-x64@0.25.6": { - "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "@esbuild/openbsd-x64@0.25.12": { + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "os": ["openbsd"], "cpu": ["x64"] }, - "@esbuild/openharmony-arm64@0.25.6": { - "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "@esbuild/openharmony-arm64@0.25.12": { + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@esbuild/sunos-x64@0.25.6": { - "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "@esbuild/sunos-x64@0.25.12": { + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "os": ["sunos"], "cpu": ["x64"] }, - "@esbuild/win32-arm64@0.25.6": { - "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "@esbuild/win32-arm64@0.25.12": { + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "os": ["win32"], "cpu": ["arm64"] }, - "@esbuild/win32-ia32@0.25.6": { - "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "@esbuild/win32-ia32@0.25.12": { + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "os": ["win32"], "cpu": ["ia32"] }, - "@esbuild/win32-x64@0.25.6": { - "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "@esbuild/win32-x64@0.25.12": { + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "os": ["win32"], "cpu": ["x64"] }, - "@isaacs/fs-minipass@4.0.1": { - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dependencies": [ - "minipass" - ] - }, "@jridgewell/gen-mapping@0.3.12": { "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dependencies": [ @@ -451,11 +488,18 @@ "@jridgewell/trace-mapping" ] }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, "@jridgewell/resolve-uri@3.1.2": { "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" }, - "@jridgewell/sourcemap-codec@1.5.4": { - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "@jridgewell/trace-mapping@0.3.29": { "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", @@ -464,15 +508,21 @@ "@jridgewell/sourcemap-codec" ] }, - "@napi-rs/wasm-runtime@0.2.11": { - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "@napi-rs/wasm-runtime@1.1.0": { + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", "dependencies": [ "@emnapi/core", "@emnapi/runtime", "@tybys/wasm-util" ] }, - "@preact/preset-vite@2.10.2_@babel+core@7.28.0_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_preact@10.26.9_@types+node@22.15.15": { + "@oxc-project/runtime@0.101.0": { + "integrity": "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==" + }, + "@oxc-project/types@0.101.0": { + "integrity": "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==" + }, + "@preact/preset-vite@2.10.2_@babel+core@7.28.0_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_preact@10.28.0_@types+node@22.15.15": { "integrity": "sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==", "dependencies": [ "@babel/core", @@ -483,15 +533,15 @@ "babel-plugin-transform-hook-names", "debug", "picocolors", - "vite@7.0.4_picomatch@4.0.2_@types+node@22.15.15", + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3", "vite-prerender-plugin" ] }, - "@preact/signals-core@1.11.0": { - "integrity": "sha512-jglbibeWHuFRzEWVFY/TT7wB1PppJxmcSfUHcK+2J9vBRtiooMfw6tAPttojNYrrpdGViqAYCbPpmWYlMm+eMQ==" + "@preact/signals-core@1.12.1": { + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==" }, - "@preact/signals@2.2.1_preact@10.26.9": { - "integrity": "sha512-cX3mijdjHbbz3dBoJ6z687CGYEOp9ifj3uFnm4UKW+DxXKPMvE2y/VSdm0PXhXmHnr6F0iSnDJ+dLwmV7CYT5A==", + "@preact/signals@2.5.1_preact@10.28.0": { + "integrity": "sha512-VPjk5YFt7i11Fi4UK0tzaEe5xLwfhUxXL3l89ocxQ5aPz7bRo8M5+N73LjBMPklyXKYKz6YsNo4Smp8n6nplng==", "dependencies": [ "@preact/signals-core", "preact" @@ -500,7 +550,7 @@ "@prefresh/babel-plugin@0.5.2": { "integrity": "sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==" }, - "@prefresh/core@1.5.5_preact@10.26.9": { + "@prefresh/core@1.5.5_preact@10.28.0": { "integrity": "sha512-H6GTXUl4V4fe3ijz7yhSa/mZ+pGSOh7XaJb6uP/sQsagBx9yl0D1HKDaeoMQA8Ad2Xm27LqvbitMGSdY9UFSKQ==", "dependencies": [ "preact" @@ -509,7 +559,7 @@ "@prefresh/utils@1.2.1": { "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==" }, - "@prefresh/vite@2.4.8_preact@10.26.9_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_@types+node@22.15.15": { + "@prefresh/vite@2.4.8_preact@10.28.0_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15": { "integrity": "sha512-H7vlo9UbJInuRbZhRQrdgVqLP7qKjDoX7TgYWWwIVhEHeHO0hZ4zyicvwBrV1wX5A3EPOmArgRkUaN7cPI2VXQ==", "dependencies": [ "@babel/core", @@ -518,9 +568,79 @@ "@prefresh/utils", "@rollup/pluginutils", "preact", - "vite@7.0.4_picomatch@4.0.2_@types+node@22.15.15" + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3" ] }, + "@rolldown/binding-android-arm64@1.0.0-beta.53": { + "integrity": "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-arm64@1.0.0-beta.53": { + "integrity": "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-x64@1.0.0-beta.53": { + "integrity": "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rolldown/binding-freebsd-x64@1.0.0-beta.53": { + "integrity": "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53": { + "integrity": "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53": { + "integrity": "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-arm64-musl@1.0.0-beta.53": { + "integrity": "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-x64-gnu@1.0.0-beta.53": { + "integrity": "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-x64-musl@1.0.0-beta.53": { + "integrity": "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-openharmony-arm64@1.0.0-beta.53": { + "integrity": "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rolldown/binding-wasm32-wasi@1.0.0-beta.53": { + "integrity": "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==", + "dependencies": [ + "@napi-rs/wasm-runtime" + ], + "cpu": ["wasm32"] + }, + "@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53": { + "integrity": "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rolldown/binding-win32-x64-msvc@1.0.0-beta.53": { + "integrity": "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rolldown/pluginutils@1.0.0-beta.53": { + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==" + }, "@rollup/pluginutils@4.2.1": { "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", "dependencies": [ @@ -528,110 +648,120 @@ "picomatch@2.3.1" ] }, - "@rollup/rollup-android-arm-eabi@4.44.2": { - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "@rollup/rollup-android-arm-eabi@4.53.3": { + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.44.2": { - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "@rollup/rollup-android-arm64@4.53.3": { + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.44.2": { - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "@rollup/rollup-darwin-arm64@4.53.3": { + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.44.2": { - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "@rollup/rollup-darwin-x64@4.53.3": { + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.44.2": { - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "@rollup/rollup-freebsd-arm64@4.53.3": { + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.44.2": { - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "@rollup/rollup-freebsd-x64@4.53.3": { + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.44.2": { - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "@rollup/rollup-linux-arm-gnueabihf@4.53.3": { + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.44.2": { - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "@rollup/rollup-linux-arm-musleabihf@4.53.3": { + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.44.2": { - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "@rollup/rollup-linux-arm64-gnu@4.53.3": { + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.44.2": { - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "@rollup/rollup-linux-arm64-musl@4.53.3": { + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loongarch64-gnu@4.44.2": { - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "@rollup/rollup-linux-loong64-gnu@4.53.3": { + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-powerpc64le-gnu@4.44.2": { - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "@rollup/rollup-linux-ppc64-gnu@4.53.3": { + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.44.2": { - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "@rollup/rollup-linux-riscv64-gnu@4.53.3": { + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.44.2": { - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "@rollup/rollup-linux-riscv64-musl@4.53.3": { + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.44.2": { - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "@rollup/rollup-linux-s390x-gnu@4.53.3": { + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.44.2": { - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "@rollup/rollup-linux-x64-gnu@4.53.3": { + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.44.2": { - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "@rollup/rollup-linux-x64-musl@4.53.3": { + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-win32-arm64-msvc@4.44.2": { - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "@rollup/rollup-openharmony-arm64@4.53.3": { + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.53.3": { + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.44.2": { - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "@rollup/rollup-win32-ia32-msvc@4.53.3": { + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-msvc@4.44.2": { - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "@rollup/rollup-win32-x64-gnu@4.53.3": { + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "os": ["win32"], "cpu": ["x64"] }, - "@tailwindcss/node@4.1.11": { - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "@rollup/rollup-win32-x64-msvc@4.53.3": { + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tailwindcss/node@4.1.17": { + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", "dependencies": [ - "@ampproject/remapping", + "@jridgewell/remapping", "enhanced-resolve", "jiti", "lightningcss", @@ -640,79 +770,67 @@ "tailwindcss" ] }, - "@tailwindcss/oxide-android-arm64@4.1.11": { - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "@tailwindcss/oxide-android-arm64@4.1.17": { + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", "os": ["android"], "cpu": ["arm64"] }, - "@tailwindcss/oxide-darwin-arm64@4.1.11": { - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "@tailwindcss/oxide-darwin-arm64@4.1.17": { + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", "os": ["darwin"], "cpu": ["arm64"] }, - "@tailwindcss/oxide-darwin-x64@4.1.11": { - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "@tailwindcss/oxide-darwin-x64@4.1.17": { + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", "os": ["darwin"], "cpu": ["x64"] }, - "@tailwindcss/oxide-freebsd-x64@4.1.11": { - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "@tailwindcss/oxide-freebsd-x64@4.1.17": { + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", "os": ["freebsd"], "cpu": ["x64"] }, - "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11": { - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17": { + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", "os": ["linux"], "cpu": ["arm"] }, - "@tailwindcss/oxide-linux-arm64-gnu@4.1.11": { - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "@tailwindcss/oxide-linux-arm64-gnu@4.1.17": { + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", "os": ["linux"], "cpu": ["arm64"] }, - "@tailwindcss/oxide-linux-arm64-musl@4.1.11": { - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "@tailwindcss/oxide-linux-arm64-musl@4.1.17": { + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", "os": ["linux"], "cpu": ["arm64"] }, - "@tailwindcss/oxide-linux-x64-gnu@4.1.11": { - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "@tailwindcss/oxide-linux-x64-gnu@4.1.17": { + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", "os": ["linux"], "cpu": ["x64"] }, - "@tailwindcss/oxide-linux-x64-musl@4.1.11": { - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "@tailwindcss/oxide-linux-x64-musl@4.1.17": { + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", "os": ["linux"], "cpu": ["x64"] }, - "@tailwindcss/oxide-wasm32-wasi@4.1.11": { - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", - "dependencies": [ - "@emnapi/core", - "@emnapi/runtime", - "@emnapi/wasi-threads", - "@napi-rs/wasm-runtime", - "@tybys/wasm-util", - "tslib" - ], + "@tailwindcss/oxide-wasm32-wasi@4.1.17": { + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", "cpu": ["wasm32"] }, - "@tailwindcss/oxide-win32-arm64-msvc@4.1.11": { - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "@tailwindcss/oxide-win32-arm64-msvc@4.1.17": { + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", "os": ["win32"], "cpu": ["arm64"] }, - "@tailwindcss/oxide-win32-x64-msvc@4.1.11": { - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "@tailwindcss/oxide-win32-x64-msvc@4.1.17": { + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", "os": ["win32"], "cpu": ["x64"] }, - "@tailwindcss/oxide@4.1.11": { - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", - "dependencies": [ - "detect-libc", - "tar" - ], + "@tailwindcss/oxide@4.1.17": { + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", "optionalDependencies": [ "@tailwindcss/oxide-android-arm64", "@tailwindcss/oxide-darwin-arm64", @@ -726,20 +844,19 @@ "@tailwindcss/oxide-wasm32-wasi", "@tailwindcss/oxide-win32-arm64-msvc", "@tailwindcss/oxide-win32-x64-msvc" - ], - "scripts": true + ] }, - "@tailwindcss/vite@4.1.11_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_@types+node@22.15.15": { - "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "@tailwindcss/vite@4.1.17_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15": { + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", "dependencies": [ "@tailwindcss/node", "@tailwindcss/oxide", "tailwindcss", - "vite@7.0.4_picomatch@4.0.2_@types+node@22.15.15" + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3" ] }, - "@tybys/wasm-util@0.9.0": { - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "@tybys/wasm-util@0.10.1": { + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dependencies": [ "tslib" ] @@ -775,9 +892,6 @@ "caniuse-lite@1.0.30001727": { "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==" }, - "chownr@3.0.0": { - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" - }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, @@ -803,8 +917,8 @@ "css-what@6.2.2": { "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" }, - "daisyui@5.0.46": { - "integrity": "sha512-vMDZK1tI/bOb2Mc3Mk5WpquBG3ZqBz1YKZ0xDlvpOvey60dOS4/5Qhdowq1HndbQl7PgDLDYysxAjjUjwR7/eQ==" + "daisyui@5.5.8": { + "integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==" }, "debug@4.4.1": { "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", @@ -812,8 +926,8 @@ "ms" ] }, - "detect-libc@2.0.4": { - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==" + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, "dom-serializer@2.0.0": { "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", @@ -843,8 +957,8 @@ "electron-to-chromium@1.5.181": { "integrity": "sha512-+ISMj8OIQ+0qEeDj14Rt8WwcTOiqHyAB+5bnK1K7xNNLjBJ4hRCQfUkw8RWtcLbfBzDwc15ZnKH0c7SNOfwiyA==" }, - "enhanced-resolve@5.18.2": { - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "enhanced-resolve@5.18.3": { + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dependencies": [ "graceful-fs", "tapable" @@ -853,8 +967,8 @@ "entities@4.5.0": { "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, - "esbuild@0.25.6": { - "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "esbuild@0.25.12": { + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "optionalDependencies": [ "@esbuild/aix-ppc64", "@esbuild/android-arm", @@ -892,13 +1006,13 @@ "estree-walker@2.0.2": { "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, - "fdir@6.4.6_picomatch@4.0.2": { - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ - "picomatch@4.0.2" + "picomatch@4.0.3" ], "optionalPeers": [ - "picomatch@4.0.2" + "picomatch@4.0.3" ] }, "fsevents@2.3.3": { @@ -916,8 +1030,8 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "bin": true }, - "jiti@2.4.2": { - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "jiti@2.6.1": { + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "bin": true }, "js-tokens@4.0.0": { @@ -937,62 +1051,68 @@ "kolorist@1.8.0": { "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==" }, - "lightningcss-darwin-arm64@1.30.1": { - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "lightningcss-android-arm64@1.30.2": { + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "os": ["android"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-arm64@1.30.2": { + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "os": ["darwin"], "cpu": ["arm64"] }, - "lightningcss-darwin-x64@1.30.1": { - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "lightningcss-darwin-x64@1.30.2": { + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "os": ["darwin"], "cpu": ["x64"] }, - "lightningcss-freebsd-x64@1.30.1": { - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "lightningcss-freebsd-x64@1.30.2": { + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "os": ["freebsd"], "cpu": ["x64"] }, - "lightningcss-linux-arm-gnueabihf@1.30.1": { - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "lightningcss-linux-arm-gnueabihf@1.30.2": { + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "os": ["linux"], "cpu": ["arm"] }, - "lightningcss-linux-arm64-gnu@1.30.1": { - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "lightningcss-linux-arm64-gnu@1.30.2": { + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "os": ["linux"], "cpu": ["arm64"] }, - "lightningcss-linux-arm64-musl@1.30.1": { - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "lightningcss-linux-arm64-musl@1.30.2": { + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "os": ["linux"], "cpu": ["arm64"] }, - "lightningcss-linux-x64-gnu@1.30.1": { - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "lightningcss-linux-x64-gnu@1.30.2": { + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "os": ["linux"], "cpu": ["x64"] }, - "lightningcss-linux-x64-musl@1.30.1": { - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "lightningcss-linux-x64-musl@1.30.2": { + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "os": ["linux"], "cpu": ["x64"] }, - "lightningcss-win32-arm64-msvc@1.30.1": { - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "lightningcss-win32-arm64-msvc@1.30.2": { + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "os": ["win32"], "cpu": ["arm64"] }, - "lightningcss-win32-x64-msvc@1.30.1": { - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "lightningcss-win32-x64-msvc@1.30.2": { + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "os": ["win32"], "cpu": ["x64"] }, - "lightningcss@1.30.1": { - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "lightningcss@1.30.2": { + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dependencies": [ "detect-libc" ], "optionalDependencies": [ + "lightningcss-android-arm64", "lightningcss-darwin-arm64", "lightningcss-darwin-x64", "lightningcss-freebsd-x64", @@ -1008,17 +1128,17 @@ "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ - "yallist@3.1.1" + "yallist" ] }, - "lucide-preact@0.525.0_preact@10.26.9": { + "lucide-preact@0.525.0_preact@10.28.0": { "integrity": "sha512-6nvEe7KdEBa36IZ/jjoOHZxd6YyrjfE3Lf/M2PxJXAVeIY8ywKRVkRwjkcXKNa+F9qiN7Bfv334TeREAk8iAtQ==", "dependencies": [ "preact" ] }, - "magic-string@0.30.17": { - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dependencies": [ "@jridgewell/sourcemap-codec" ] @@ -1026,19 +1146,6 @@ "minimist@1.2.8": { "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, - "minipass@7.1.2": { - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" - }, - "minizlib@3.0.2": { - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dependencies": [ - "minipass" - ] - }, - "mkdirp@3.0.1": { - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "bin": true - }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, @@ -1068,8 +1175,8 @@ "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, - "picomatch@4.0.2": { - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" }, "postcss@8.5.6": { "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", @@ -1079,8 +1186,8 @@ "source-map-js" ] }, - "preact@10.26.9": { - "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==" + "preact@10.28.0": { + "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==" }, "prompts@2.4.2": { "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", @@ -1089,8 +1196,31 @@ "sisteransi" ] }, - "rollup@4.44.2": { - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "rolldown@1.0.0-beta.53": { + "integrity": "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==", + "dependencies": [ + "@oxc-project/types", + "@rolldown/pluginutils" + ], + "optionalDependencies": [ + "@rolldown/binding-android-arm64", + "@rolldown/binding-darwin-arm64", + "@rolldown/binding-darwin-x64", + "@rolldown/binding-freebsd-x64", + "@rolldown/binding-linux-arm-gnueabihf", + "@rolldown/binding-linux-arm64-gnu", + "@rolldown/binding-linux-arm64-musl", + "@rolldown/binding-linux-x64-gnu", + "@rolldown/binding-linux-x64-musl", + "@rolldown/binding-openharmony-arm64", + "@rolldown/binding-wasm32-wasi", + "@rolldown/binding-win32-arm64-msvc", + "@rolldown/binding-win32-x64-msvc" + ], + "bin": true + }, + "rollup@4.53.3": { + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dependencies": [ "@types/estree" ], @@ -1105,15 +1235,17 @@ "@rollup/rollup-linux-arm-musleabihf", "@rollup/rollup-linux-arm64-gnu", "@rollup/rollup-linux-arm64-musl", - "@rollup/rollup-linux-loongarch64-gnu", - "@rollup/rollup-linux-powerpc64le-gnu", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-ppc64-gnu", "@rollup/rollup-linux-riscv64-gnu", "@rollup/rollup-linux-riscv64-musl", "@rollup/rollup-linux-s390x-gnu", "@rollup/rollup-linux-x64-gnu", "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openharmony-arm64", "@rollup/rollup-win32-arm64-msvc", "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", "@rollup/rollup-win32-x64-msvc", "fsevents" ], @@ -1141,28 +1273,17 @@ "stack-trace@1.0.0-pre2": { "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==" }, - "tailwindcss@4.1.11": { - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==" + "tailwindcss@4.1.17": { + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==" }, - "tapable@2.2.2": { - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==" + "tapable@2.3.0": { + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==" }, - "tar@7.4.3": { - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dependencies": [ - "@isaacs/fs-minipass", - "chownr", - "minipass", - "minizlib", - "mkdirp", - "yallist@5.0.0" - ] - }, - "tinyglobby@0.2.14_picomatch@4.0.2": { - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "tinyglobby@0.2.15_picomatch@4.0.3": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dependencies": [ "fdir", - "picomatch@4.0.2" + "picomatch@4.0.3" ] }, "tslib@2.8.1": { @@ -1180,7 +1301,7 @@ ], "bin": true }, - "vite-prerender-plugin@0.5.11_vite@7.0.4__picomatch@4.0.2__@types+node@22.15.15_@types+node@22.15.15": { + "vite-prerender-plugin@0.5.11_vite@7.2.7__@types+node@22.15.15__picomatch@4.0.3_@types+node@22.15.15": { "integrity": "sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==", "dependencies": [ "kolorist", @@ -1189,15 +1310,16 @@ "simple-code-frame", "source-map", "stack-trace", - "vite@7.0.4_picomatch@4.0.2_@types+node@22.15.15" + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3" ] }, - "vite@7.0.4_picomatch@4.0.2": { - "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==", + "vite@7.2.7_@types+node@22.15.15_picomatch@4.0.3": { + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dependencies": [ + "@types/node", "esbuild", "fdir", - "picomatch@4.0.2", + "picomatch@4.0.3", "postcss", "rollup", "tinyglobby" @@ -1205,17 +1327,21 @@ "optionalDependencies": [ "fsevents" ], + "optionalPeers": [ + "@types/node" + ], "bin": true }, - "vite@7.0.4_picomatch@4.0.2_@types+node@22.15.15": { - "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==", + "vite@8.0.0-beta.1_@types+node@22.15.15_picomatch@4.0.3": { + "integrity": "sha512-wWdn95sweG8/AEklHTAwZ9rwxZn77jQ72Flj2004oWpFBao9sTRXAntWEKKyp1qZXXLxNFRhr5Mp5tQIeR0SXA==", "dependencies": [ + "@oxc-project/runtime", "@types/node", - "esbuild", "fdir", - "picomatch@4.0.2", + "lightningcss", + "picomatch@4.0.3", "postcss", - "rollup", + "rolldown", "tinyglobby" ], "optionalDependencies": [ @@ -1228,9 +1354,6 @@ }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - }, - "yallist@5.0.0": { - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" } }, "remote": { @@ -1238,16 +1361,29 @@ }, "workspace": { "dependencies": [ - "jsr:@std/assert@1", - "npm:@clickhouse/client@*", + "jsr:@01edu/api-client@~0.1.3", + "jsr:@01edu/api-proxy@~0.1.2", + "jsr:@01edu/api@~0.1.3", + "jsr:@01edu/signal-router@~0.1.6", + "jsr:@01edu/time@0.1", + "jsr:@std/assert@^1.0.16", + "jsr:@std/crypto@^1.0.5", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fmt@^1.0.8", + "jsr:@std/fs@^1.0.20", + "jsr:@std/http@^1.0.22", + "jsr:@std/path@^1.1.3", + "jsr:@std/testing@^1.0.16", + "npm:@clickhouse/client@^1.14.0", + "npm:@deno/vite-plugin@^1.0.5", "npm:@preact/preset-vite@^2.10.2", - "npm:@preact/signals@*", - "npm:@tailwindcss/vite@^4.1.11", - "npm:daisyui@^5.0.46", + "npm:@preact/signals@2.5.1", + "npm:@tailwindcss/vite@^4.1.17", + "npm:daisyui@^5.5.8", "npm:lucide-preact@0.525", - "npm:preact@^10.26.9", - "npm:tailwindcss@^4.1.11", - "npm:vite@^7.0.4" + "npm:preact@^10.28.0", + "npm:tailwindcss@^4.1.17", + "npm:vite@8.0.0-beta.1" ] } } diff --git a/tasks/env.ts b/tasks/env.ts index fb09dce..b6b7f1b 100644 --- a/tasks/env.ts +++ b/tasks/env.ts @@ -1,5 +1,5 @@ import { decrypt } from 'https://gistcdn.githack.com/kigiri/21df06d173fcdced5281b86ba6ac1382/raw/crypto.js' - +localStorage.password = 'Teranga' const env = await decrypt( '45641083e50bf3bc5b65dd16c16f7455367074d2c8a3ea16845704a7be6f457bde06b40740ae3456874486092d447eeae44341f5f2f53f9ed974d8182709c53a315a7942eb9699d993159aa2710de5e3eb1eaa780c832ad61c7e95e832bbfdf2ea704904c815e45ed901464ef680456f8ca7cdf561d7c4a100dad7d427383fa8ebb125f58ef4ad9c23029bcfd7a86a712dcc19ceec98e0c513cd297d43c547561f012c823790712391a5c186d9f2e52e971e2a71f4920331a00ea5532b3a6b28280c0b955fc90647dd48591ed9f782dac9fcead5709dbc0c27de142de663998040cb862f', localStorage.password || (localStorage.password = prompt('password')), diff --git a/tasks/vite.js b/tasks/vite.js deleted file mode 100644 index baf1d26..0000000 --- a/tasks/vite.js +++ /dev/null @@ -1,77 +0,0 @@ -// tasks/vite.js -import { join } from 'node:path' -import { Readable } from 'node:stream' -import { pipeline } from 'node:stream/promises' -import { build, createServer } from 'vite' -import preact from '@preact/preset-vite' -import tailwindcss from '@tailwindcss/vite' -const isBuild = Deno.args.includes('--build') -const PORT = Number(Deno.env.get('PORT')) || 2119 - -const denoProxy = () => ({ - name: 'deno-proxy', - configureServer(server) { - server.middlewares.use((req, res, next) => { - if (!req.url.startsWith('/api/')) return next() - const hasBody = !(req.method === 'GET' || req.method === 'HEAD') - const controller = new AbortController() - res.on('close', () => controller.abort()) - fetch(`http://localhost:${PORT}${req.url}`, { - method: req.method, - signal: controller.signal, - headers: { ...req.headers }, - body: hasBody ? Readable.toWeb(req) : undefined, - redirect: 'manual', - }) - .then((apiRes) => { - const headers = Object.fromEntries(apiRes.headers) - const cookies = apiRes.headers.getSetCookie() - if (cookies.length > 0) headers['set-cookie'] = cookies - res.writeHead(apiRes.status, headers) - return apiRes.body - ? pipeline(Readable.fromWeb(apiRes.body), res) - : res.end() - }) - .catch((err) => { - if (controller.signal.aborted) return - console.error('Error while attempting to proxy', req.method, req.url) - console.error(err) - next() - }) - }) - }, -}) - -if (isBuild) { - // Production build - await build({ - configFile: false, - root: join(import.meta.dirname, '../web'), - plugins: [ - preact({ jsxImportSource: 'preact' }), - tailwindcss(), - ], - build: { - outDir: '../dist/web', - emptyOutDir: true, - }, - }) -} else { - // Development server - const server = await createServer({ - configFile: false, - root: join(import.meta.dirname, '../web'), - plugins: [ - preact({ jsxImportSource: 'preact' }), - tailwindcss(), - denoProxy(), - ], - server: { - port: 7737, - host: true, - }, - }) - await server.listen() - server.printUrls() - server.bindCLIShortcuts({ print: true }) -} diff --git a/tasks/vite.ts b/tasks/vite.ts new file mode 100644 index 0000000..6cef221 --- /dev/null +++ b/tasks/vite.ts @@ -0,0 +1,43 @@ +// tasks/vite.js +import { join } from 'node:path' +import { build, createServer } from 'vite' +import { apiProxy } from '@01edu/api-proxy' +import deno from '@deno/vite-plugin' +import preact from '@preact/preset-vite' +import tailwindcss from '@tailwindcss/vite' +import { APP_ENV } from '@01edu/api/env' + +const plugins = [ + preact({ jsxImportSource: 'preact' }), + tailwindcss(), + deno(), +] + +// Production build +if (APP_ENV === 'prod') { + await build({ + configFile: false, + root: join(import.meta.dirname!, '../web'), + plugins, + build: { + outDir: '../dist/web', + emptyOutDir: true, + }, + }) + Deno.exit(0) +} + +// Development server +const PORT = Number(Deno.env.get('PORT')) || 2119 +const server = await createServer({ + configFile: false, + root: join(import.meta.dirname!, '../web'), + plugins: [...plugins, apiProxy({ port: PORT, prefix: '/api/' })], + server: { + port: 7737, + host: true, + }, +}) +await server.listen() +server.printUrls() +server.bindCLIShortcuts({ print: true }) diff --git a/web/components/BackgroundPattern.tsx b/web/components/BackgroundPattern.tsx index 19f65cb..f2e4c78 100644 --- a/web/components/BackgroundPattern.tsx +++ b/web/components/BackgroundPattern.tsx @@ -246,18 +246,18 @@ export const BackgroundPattern = () => { } return ( -
+
@@ -267,7 +267,7 @@ export const BackgroundPattern = () => { return (
{ }} > {el.type === 'icon' && el.component - ? + ? : (
{el.content} @@ -292,7 +292,7 @@ export const BackgroundPattern = () => { {borderElements.map((el, i) => (
{ transform: `rotate(${el.rotation}deg)`, }} > - +
))} {floatingElements.map((el, i) => (
{ fontSize: `${el.size * 0.2}rem`, }} > - +
))} {bands.map((band, i) => (
{ {band.items.map((item, j) => (
{item.text} @@ -359,7 +359,7 @@ export const BackgroundPattern = () => { {['top', 'right', 'bottom', 'left'].map((position) => (
+const Icon = (title: string, path: string) => (props: SVGProps) => ( + + {title} + + +) + +export const GitHub = Icon( + 'GibHub', + 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', +) diff --git a/web/components/Dialog.tsx b/web/components/Dialog.tsx index f048f86..e21f195 100644 --- a/web/components/Dialog.tsx +++ b/web/components/Dialog.tsx @@ -1,12 +1,12 @@ -import type { JSX } from 'preact' +import type { HTMLAttributes } from 'preact' import { useState } from 'preact/hooks' -import { navigate, url } from '../lib/router.tsx' +import { navigate, url } from '@01edu/signal-router' type DialogProps = { id: string children: preact.ComponentChildren -} & JSX.HTMLAttributes +} & HTMLAttributes export const Dialog = ({ id, diff --git a/web/components/Filtre.tsx b/web/components/Filtre.tsx index 329ff05..962c110 100644 --- a/web/components/Filtre.tsx +++ b/web/components/Filtre.tsx @@ -1,5 +1,5 @@ import { ArrowUpDown, Filter, Plus } from 'lucide-preact' -import { navigate, url } from '../lib/router.tsx' +import { navigate, url } from '@01edu/signal-router' type FilterRow = { idx: number; key: string; op: string; value: string } @@ -15,7 +15,7 @@ const filterOperators = [ ] as const export function parseFilters(prefix: string): FilterRow[] { - const data = url.getAll(`f${prefix}`) + const data = url.value.searchParams.getAll(`f${prefix}`) const rows: FilterRow[] = [] for (let i = 0; i < data.length; i++) { const str = data[i] @@ -74,7 +74,7 @@ function removeFilter(prefix: string, idx: number) { type SortRow = { idx: number; key: string; dir: 'asc' | 'desc' } export function parseSort(prefix: string): SortRow[] { - const data = url.getAll(`s${prefix}`) + const data = url.value.searchParams.getAll(`s${prefix}`) const rows: SortRow[] = [] for (let i = 0; i < data.length; i++) { const str = data[i] diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index 4437486..32f2307 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -11,8 +11,8 @@ export const PageLayout = ( ) export const PageHeader = ( - { children, className }: { - className?: string + { children, class: className }: { + class?: string children: JSX.Element | JSX.Element[] }, ) => ( diff --git a/web/components/QueryHistory.tsx b/web/components/QueryHistory.tsx index 080361a..32049ba 100644 --- a/web/components/QueryHistory.tsx +++ b/web/components/QueryHistory.tsx @@ -1,5 +1,5 @@ import { ChevronRight, Clock, Play, Search, Trash2 } from 'lucide-preact' -import { A, navigate, url } from '../lib/router.tsx' +import { A, navigate, url } from '@01edu/signal-router' import { queriesHistory, runQuery } from '../lib/shared.tsx' const deleteQuery = (hash: string) => { diff --git a/web/components/SideBar.tsx b/web/components/SideBar.tsx index 854949b..b1b5f79 100644 --- a/web/components/SideBar.tsx +++ b/web/components/SideBar.tsx @@ -6,7 +6,7 @@ import { Settings, } from 'lucide-preact' import { user } from '../lib/session.ts' -import { A, url } from '../lib/router.tsx' +import { A, url } from '@01edu/signal-router' export type SidebarItem = { label: string diff --git a/web/components/forms.tsx b/web/components/forms.tsx index 8d6fd90..6e0fc47 100644 --- a/web/components/forms.tsx +++ b/web/components/forms.tsx @@ -1,6 +1,6 @@ import { JSX } from 'preact' import { useId } from 'preact/hooks' -import { A, LinkProps } from '../lib/router.tsx' +import { A, LinkProps } from '@01edu/signal-router' // Card component export const Card = ( diff --git a/web/index.tsx b/web/index.tsx index 7634b3c..c070327 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -4,7 +4,7 @@ import { ProjectsPage } from './pages/ProjectsPage.tsx' import { BackgroundPattern } from './components/BackgroundPattern.tsx' import { Header } from './layout.tsx' import { user } from './lib/session.ts' -import { url } from './lib/router.tsx' +import { url } from '@01edu/signal-router' import { ProjectPage } from './pages/ProjectPage.tsx' const renderPage = () => { @@ -19,14 +19,14 @@ const renderPage = () => { } const App = () => { return ( -
-
+
+
-
+
-
+
{renderPage()}
diff --git a/web/layout.tsx b/web/layout.tsx index 0e99f50..e549da4 100644 --- a/web/layout.tsx +++ b/web/layout.tsx @@ -1,7 +1,8 @@ import { effect, signal } from '@preact/signals' -import { Code, Github, LogOut, Moon, Sun } from 'lucide-preact' +import { A, url } from '@01edu/signal-router' +import { Code, LogOut, Moon, Sun } from 'lucide-preact' +import { GitHub } from './components/BrandIcons.tsx' import { user } from './lib/session.ts' -import { A, url } from './lib/router.tsx' const $theme = signal(localStorage.theme || 'dark') @@ -61,7 +62,7 @@ export const Header = () => ( href='/' class='btn btn-ghost text-xl gap-2 cursor-pointer' > - + DevTools
@@ -74,7 +75,7 @@ export const Header = () => ( class='btn btn-ghost btn-square' aria-label='GitHub repository' > - + {'/login' !== url.path && } diff --git a/web/lib/api.ts b/web/lib/api.ts index 823d499..01b8e1c 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -1,163 +1,6 @@ -import { Signal } from '@preact/signals' -import { useMemo } from 'preact/hooks' +import { makeClient } from '@01edu/api-client' import type { RouteDefinitions } from '../../api/routes.ts' -import type { Asserted, Handler, HttpMethod } from '../../api/lib/router.ts' -import type { Def } from '../../api/lib/validator.ts' - -export class ErrorWithData extends Error { - public data: Record - constructor(message: string, data: Record) { - super(message) - this.name = 'ErrorWithData' - this.data = data - } -} - -export class ErrorWithBody extends ErrorWithData { - public body: string - constructor(body: string, data: Record) { - super('Failed to parse body', data) - this.name = 'ErrorWithBody' - this.body = body - } -} - -type ExtractAndAssert = T extends Def ? Asserted - : undefined - -// I made the other field always explicitly undefined and optional -// this way we do not have to check all the time that they exists -type RequestState = - | { data: T; pending?: undefined; controller?: undefined; error?: undefined } - | { - data?: T | undefined - pending: number - controller?: AbortController - error?: undefined - } - | { - data?: T | undefined - pending?: undefined - controller?: undefined - error: ErrorWithBody | ErrorWithData | Error - } - -type Options = { - headers?: HeadersInit - signal?: AbortSignal -} - -const withoutBody = new Set([ - 204, // NoContent - 205, // ResetContent - 304, // NotModified -]) - -function createApiClient(baseUrl = '') { - type HandlerIO = T[K] extends - Handler - ? [ExtractAndAssert, ExtractAndAssert] - : never - - function makeClientCall(urlKey: K) { - type IO = HandlerIO - type Input = IO[0] - type Output = IO[1] - const key = urlKey as string - const slashIndex = key.indexOf('/') - const method = key.slice(0, slashIndex) as HttpMethod - const path = key.slice(slashIndex) - const defaultHeaders = { 'Content-Type': 'application/json' } - - async function fetcher(input?: Input, options?: Options | undefined) { - let url = `${baseUrl}${path}` - let headers = options?.headers - if (!headers) { - headers = defaultHeaders - } else { - headers instanceof Headers || (headers = new Headers(headers)) - for (const [key, value] of Object.entries(defaultHeaders)) { - headers.set(key, value) - } - } - - let bodyInput: string | undefined = undefined - if (input) { - method === 'GET' - ? (url += `?${new URLSearchParams(input as Record)}`) - : (bodyInput = JSON.stringify(input)) - } - - const response = await fetch( - url, - { ...options, method, headers, body: bodyInput }, - ) - if (withoutBody.has(response.status)) return null as unknown as Output - const body = await response.text() - let payload - try { - payload = JSON.parse(body) - if (response.ok) return payload as Output - } catch { - throw new ErrorWithBody(body, { response }) - } - const { message, ...data } = payload - throw new ErrorWithData(message, data) - } - - const signal = () => { - const $ = new Signal>({ pending: 0 }) - return { - $, - reset: () => { - $.peek().controller?.abort() - $.value = { pending: 0 } - }, - fetch: async (input, headers: HeadersInit) => { - const prev = $.peek() - try { - const controller = new AbortController() - prev.controller?.abort() - $.value = { pending: Date.now(), controller, data: prev.data } - const { signal } = controller - $.value = { data: await fetcher(input, { signal, headers }) } - } catch (err) { - $.value = (err instanceof DOMException && err.name === 'AbortError') - ? { pending: 0, data: prev.data } - : { - error: err as (ErrorWithBody | ErrorWithData | Error), - data: prev.data, - } - } - }, - get data() { - return $.value.data - }, - get error() { - return $.value.error - }, - get pending() { - return $.value.pending - }, - } as RequestState & { - $: Signal> - reset: () => void - fetch: (input?: Input, options?: Options | undefined) => Promise - } - } - - const use = () => useMemo(signal, []) - return { fetch: fetcher, use, signal } - } - - const client = {} as { [K in keyof T]: ReturnType> } - const lazy = (k: keyof T) => client[k] || (client[k] = makeClientCall(k)) - return new Proxy(client, { - get: (_, key: string) => lazy(key as keyof T), - }) -} - -export const api = createApiClient() +export const api = makeClient() export type ApiOutput = { [K in keyof typeof api]: Awaited> diff --git a/web/lib/router.tsx b/web/lib/router.tsx deleted file mode 100644 index fbe9f0a..0000000 --- a/web/lib/router.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import type { JSX } from 'preact' -import { computed, Signal } from '@preact/signals' - -const isCurrentURL = (alt: URL) => { - const url = urlSignal.value - if (url.href === alt.href) return true - if (url.origin !== alt.origin) return false - if (url.pathname !== alt.pathname) return false - // handle special case, same params but different order. - // must still be equal - if (alt.searchParams.size !== url.searchParams.size) return false - if (alt.searchParams.size === 0) return false // no params -> not the same - // both urls have the same numbers of params, now let's confirm they are - // all the same values - for (const [k, v] of alt.searchParams) { - if (url.searchParams.get(k) !== v) return false - } - return true -} - -// ensure we never have trailing / -const initialUrl = new URL(location.href) -if (location.pathname.at(-1) === '/') { - initialUrl.pathname = initialUrl.pathname.slice(0, -1) - history.replaceState({}, '', initialUrl.href) -} - -const urlSignal = new Signal(initialUrl) -const { origin } = initialUrl - -const dispatchNavigation = () => { - // If the path did change, we update the local state and trigger the change - const url = new URL(location.href) - url.pathname.at(-1) === '/' && (url.pathname = url.pathname.slice(0, -1)) - if (isCurrentURL(url)) return - urlSignal.value = url -} - -addEventListener('popstate', dispatchNavigation) -addEventListener('hashchange', dispatchNavigation) - -const navigateUrl = (to: string, replace = false) => { - history[replace ? 'replaceState' : 'pushState']({}, '', to) - dispatchNavigation() -} - -type ParamPrimitive = string | number | boolean -type ParamValue = ParamPrimitive | null | undefined | ParamPrimitive[] -type GetUrlProps = { - href?: string - hash?: string - // params supports arrays to allow multiple identical keys: { tag: ['a','b'] } -> ?tag=a&tag=b - params?: URLSearchParams | Record -} - -const getUrl = ({ href, hash, params }: GetUrlProps) => { - const currentUrl = urlSignal.value - const url = new URL(href || currentUrl, origin) - hash != null && (url.hash = hash) - url.pathname.at(-1) === '/' && (url.pathname = url.pathname.slice(0, -1)) - if (!params) { - if (url.pathname !== currentUrl.pathname) return url - url.search = `?${currentUrl.searchParams}` - return url - } - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) { - // Remove existing then append each to preserve ordering - url.searchParams.delete(key) - for (const v of value) { - if (v === false || v == null) continue // skip deletions inside arrays - if (v === true) { - url.searchParams.append(key, '') - } else { - url.searchParams.append(key, v) - } - } - continue - } - if (value === true) { - url.searchParams.set(key, '') - } else if (value === false || value == null) { - url.searchParams.delete(key) - } else { - url.searchParams.set(key, value) - } - } - return url -} - -export const navigate = (props: GetUrlProps & { replace?: boolean }) => - navigateUrl(getUrl(props).href, props.replace) - -export type LinkProps = - & { replace?: boolean } - & JSX.HTMLAttributes - & GetUrlProps - -export const A = ({ - href, - hash, - params, - replace, - onClick, - onMouseDown, - ...props -}: LinkProps) => { - const url = getUrl({ href, hash, params }) - const noRouting = url.origin !== origin || - url.pathname.startsWith('/api/') - - if (noRouting) { - return ( - - ) - } - - const mouseDownHandler = ( - event: JSX.TargetedMouseEvent, - ) => { - // Experimenting with using `onMouseDown` to trigger - // it's faster but not cancellable, let's see how it's percieved - if (typeof onMouseDown === 'function') { - onMouseDown(event) - } - // We don't want to skip if it's a special click - // that would break the default browser behaviour - const shouldSkip = event.defaultPrevented || - event.button || - event.metaKey || - event.altKey || - event.ctrlKey || - event.shiftKey - - if (shouldSkip) return - - // In the normal case we handle the routing internally - event.preventDefault() - navigateUrl(url.href, replace) - } - - return ( - { - if (typeof onClick === 'function') { - onClick(event) - } - - const notMouse = event.clientX === 0 && event.clientY === 0 - if (notMouse) { - mouseDownHandler(event) - } else { - event.preventDefault() - } - }} - {...props} - /> - ) -} - -const toDeletedParam = (k: string) => [k, null] -export const replaceParams = ( - newParams: Record, -) => ({ - ...Object.fromEntries( - urlSignal.value.searchParams.keys().map(toDeletedParam), - ), - ...newParams, -}) - -// wrap params behind a proxy to allow accessing -const params = new Proxy({} as Record>, { - // this allow enumeration to work, so Object.keys(), {...params} will work - ownKeys: () => [...urlSignal.value.searchParams.keys()], - getOwnPropertyDescriptor: (_, key) => ({ - enumerable: true, - configurable: true, - value: urlSignal.value.searchParams.get(key as string), - }), - - // this is when we get a single key - get: (cache, key) => - (typeof key !== 'string' || !key) ? null : (cache[key] || ( - cache[key] = computed(() => urlSignal.value.searchParams.get(key)) - )).value, -}) as unknown as Record - -// http://localhost:8000/user/settings?id=454&options=open#display -// url.path: 'user/settings' -// url.hash: 'display' -// url.params: { id: 454, option: 'open' } -const hashSignal = computed(() => urlSignal.value.hash) -const pathSignal = computed(() => urlSignal.value.pathname) -export const url = { - get path() { - return pathSignal.value - }, - get hash() { - return hashSignal.value - }, - params, - // Retrieve all values (including duplicates) for a given key - getAll: (key: string) => urlSignal.value.searchParams.getAll(key), - // All param entries preserving duplicates & order - paramEntries: () => [...urlSignal.value.searchParams.entries()], - equals: (url: URL) => isCurrentURL(url), -} diff --git a/web/lib/shared.tsx b/web/lib/shared.tsx index 0e10ad3..acf506f 100644 --- a/web/lib/shared.tsx +++ b/web/lib/shared.tsx @@ -2,7 +2,7 @@ import { HardDrive, ListTodo } from 'lucide-preact' import { api } from './api.ts' import { DeploymentPage } from '../pages/DeploymentPage.tsx' import { SidebarItem } from '../components/SideBar.tsx' -import { url } from './router.tsx' +import { url } from '@01edu/signal-router' import { Signal } from '@preact/signals' // export diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index cbf2965..18a6e73 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -1,4 +1,4 @@ -import { A, navigate, url } from '../lib/router.tsx' +import { A, navigate, url } from '@01edu/signal-router' import { AlertCircle, AlertTriangle, @@ -1064,7 +1064,7 @@ const Drawer = () => ( />