commit 743eb6a8e87c1f79b831e5ac563e73804370a3bf Author: Hellings Date: Fri Oct 23 08:53:07 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e609278 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# port upstream +ametrine/ + +# shit small-time slopcoding +.opencode/ + +/_muddle \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..720ed16 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# muddle + +> [!CAUTION] +> MUDDLE IS EXPERIMENTAL SOFTWARE. EXPECT LITERALLY ANYTHING TO BREAK AT ANY TIME. +> DURING TESTING THINGS HAVE WORKED. THEY WILL NOT ALWAYS. + +Muddle is a Muddle Dark Wet Dub SSG. \ No newline at end of file diff --git a/adapters/cloudflare-wrangler.ts b/adapters/cloudflare-wrangler.ts new file mode 100644 index 0000000..ea46b7c --- /dev/null +++ b/adapters/cloudflare-wrangler.ts @@ -0,0 +1,53 @@ +import type { App } from "../core/app.ts"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export interface WorkerEnv { + [key: string]: unknown; +} + +export interface WorkerContext { + waitUntil(promise: Promise): void; + passThroughOnException?(): void; +} + +export interface WorkerOptions { + /** Builds the application from the bindings of the worker. */ + create?: (env: WorkerEnv) => App | Promise; +} + +export interface WranglerSetupOptions { + name?: string; + main?: string; + compatibilityDate?: string; +} + +/** Creates a minimal Wrangler config when a project has not supplied one. */ +export async function setupWrangler(root: string, options: WranglerSetupOptions = {}): Promise { + const path = join(resolve(root), "wrangler.jsonc"); + if (existsSync(path)) return path; + const source = JSON.stringify({ name: options.name ?? "muddle-app", main: options.main ?? "src/worker.ts", compatibility_date: options.compatibilityDate ?? "2026-10-22" }, null, 2).concat("\n"); + await writeFile(path, source, "utf8"); + return path; +} + +/** Creates the fetch handler of a worker. */ +export function createWorker(app: App | WorkerOptions["create"]): { + fetch( + request: Request, + env: WorkerEnv, + ctx: WorkerContext, + ): Promise; +} { + let instance: App | undefined = typeof app === "function" ? undefined : app; + + return { + async fetch(request: Request, env: WorkerEnv, _ctx: WorkerContext) { + if (!instance) { + instance = await (app as (env: WorkerEnv) => Promise)(env); + } + return instance.fetch(request); + }, + }; +} diff --git a/adapters/index.ts b/adapters/index.ts new file mode 100644 index 0000000..92ea9f7 --- /dev/null +++ b/adapters/index.ts @@ -0,0 +1,4 @@ +export * from "./cloudflare-wrangler.ts"; +export * from "./server-bun.ts"; +export * from "./static.ts"; +export * from "./vercel-vercel.ts"; diff --git a/adapters/prediction.ts b/adapters/prediction.ts new file mode 100644 index 0000000..637495d --- /dev/null +++ b/adapters/prediction.ts @@ -0,0 +1,442 @@ +export type BreakpointAcc = "XL" | "LG" | "MD" | "SM"; + +export interface LayoutPredictionOptions { + selectors?: string[]; + viewport?: { width: number; height: number }; + strict?: boolean; + mobileViewport?: { width: number; height: number }; + /** Media query receiving the mobile relation values. Defaults to the page's widest max-width query. */ + mobileMediaQuery?: string; + /** Named responsive layers to calculate ahead of time. Multiple values produce cascading relation layers. */ + breakpointAcc?: BreakpointAcc | BreakpointAcc[]; + /** Maximum number of relation propagation passes before a static build fails. */ + maxPasses?: number; + /** Emit discovered dotted-anchor group metadata for reflective selectors. Enabled by default. */ + reflective?: boolean; +} + +export interface LayoutPrediction { + selector: string; + index: number; + width: number; + height: number; + x: number; + y: number; + mobile?: boolean; + anchor?: string | null; + backgroundColor?: string; + color?: string; + transition?: string; + /** Extra computed properties requested through `$refer[](...)`. */ + relate?: Record; + /** Scoped mirrors of the element itself, requested through `$self[]`. */ + self?: Record; +} + +export interface ReflectiveAnchorGroup { + name: string; + anchors: string[]; +} + +/** + * Measures a rendered page inside headless chromium. + * + * The script discovers which `$refer` and `$self` properties the shipped + * stylesheets request, labels every measure target and mirrors the requested + * properties for every anchor element. Geometry properties (`scroll-height`, + * `client-width`, ...) are read as numbers off the element, every other name + * is read from its computed style. + * + * The pass runs twice: once for the desktop viewport and, with `remeasure`, + * again on the mobile viewport where only the previously labelled targets are + * visited. + */ +const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => { + const { remeasure, selectors = [] } = input; + const document = (globalThis as unknown as { document: any }).document; + const getComputedStyle = (globalThis as unknown as { getComputedStyle: (element: any) => any }).getComputedStyle; + type Entry = { + selector: string; + index: number; + width: number; + height: number; + x: number; + y: number; + anchor: string | null; + backgroundColor: string; + color: string; + transition: string; + relate?: Record; + self?: Record; + }; + + const GEOMETRY = new Set([ + "scroll-height", + "scroll-width", + "client-height", + "client-width", + "offset-height", + "offset-width", + ]); + + const STANDARDS = new Set([ + "x", + "y", + "width", + "height", + "background-color", + "color", + "transition", + ]); + + const propertyValue = (element: any, style: any, property: string): string => { + switch (property) { + case "scroll-height": return `${element.scrollHeight}px`; + case "scroll-width": return `${element.scrollWidth}px`; + case "client-height": return `${element.clientHeight}px`; + case "client-width": return `${element.clientWidth}px`; + case "offset-height": return `${element.offsetHeight}px`; + case "offset-width": return `${element.offsetWidth}px`; + default: return style.getPropertyValue(property) ?? ""; + } + }; + + // Which `$refer` and `$self` properties do the shipped stylesheets request? + const styles: string[] = []; + for (const sheet of [...document.styleSheets]) { + try { + for (const rule of [...sheet.cssRules]) styles.push(rule.cssText); + } catch { + // cross-origin stylesheets are unreadable, keep going + } + } + const selfProps: string[] = []; + const seenSelf = new Set(); + for (const text of styles) { + for (const match of text.matchAll(/--muddle-self-([a-zA-Z][\w-]*)/g)) { + if (!seenSelf.has(match[1]!)) { + seenSelf.add(match[1]!); + selfProps.push(match[1]!); + } + } + } + const anchors = [...document.querySelectorAll("[data-muddle-anchor]")] as any[]; + if (!remeasure) { + for (const element of anchors) { + const name = element.getAttribute("data-muddle-anchor") as string | null; + if (!name?.includes(".")) continue; + const parts = name.split("."); + const groups = parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join(".")); + element.setAttribute("data-muddle-group", groups.join(" ")); + } + } + const requests = new Map(); + for (const element of anchors) { + const name = element.getAttribute("data-muddle-anchor"); + if (!name) continue; + const key = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + for (const text of styles) { + const needle = `--muddle-relate-${key}-`; + let index = text.indexOf(needle); + while (index !== -1) { + const property = /^[a-zA-Z][\w-]*/.exec(text.slice(index + needle.length))?.[0]; + if (property && !requests.get(key)?.includes(property)) { + requests.set(key, [...(requests.get(key) ?? []), property]); + } + index = text.indexOf(needle, index + needle.length + (property?.length ?? 0)); + } + } + } + + // Label the measure targets once, they keep their id across viewports. + if (!remeasure) { + const selected = new Set(); + for (const selector of selectors) { + for (const element of document.querySelectorAll(selector)) selected.add(element); + } + for (const [index, element] of [...selected].entries()) { + element.setAttribute("data-muddle-predicted", `predict-${index}`); + } + } + + const entries: Entry[] = []; + for (const [index, element] of ([...document.querySelectorAll("[data-muddle-predicted]")] as any[]).entries()) { + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const entry: Entry = { + selector: `[data-muddle-predicted="${element.getAttribute("data-muddle-predicted")}"]`, + index, + width: box.width, + height: box.height, + x: box.x, + y: box.y, + anchor: element.getAttribute("data-muddle-anchor"), + backgroundColor: style.backgroundColor, + color: style.color, + transition: style.transition, + }; + const name = element.getAttribute("data-muddle-anchor"); + if (name) { + const key = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + const relate: Record = {}; + for (const property of requests.get(key) ?? []) { + if (STANDARDS.has(property)) continue; + const value = propertyValue(element, style, property); + if (value) relate[property] = value; + } + if (Object.keys(relate).length > 0) entry.relate = relate; + const self: Record = {}; + for (const property of selfProps) { + const value = propertyValue(element, style, property); + if (value) self[property] = value; + } + if (Object.keys(self).length > 0) entry.self = self; + } + entries.push(entry); + } + return entries; +}; + +function predictionVariables(prediction: LayoutPrediction, variant: string): string { + return [ + `${prediction.selector} {`, + `--muddle-predicted-${variant}-width: ${prediction.width}px;`, + `--muddle-predicted-${variant}-height: ${prediction.height}px;`, + `--muddle-predicted-${variant}-x: ${prediction.x}px;`, + `--muddle-predicted-${variant}-y: ${prediction.y}px;`, + `--muddle-predicted-width: var(--muddle-predicted-${variant}-width);`, + `--muddle-predicted-height: var(--muddle-predicted-${variant}-height);`, + `--muddle-predicted-x: var(--muddle-predicted-${variant}-x);`, + `--muddle-predicted-y: var(--muddle-predicted-${variant}-y);`, + "}", + ].join(" "); +} + +/** + * Relation variables of one anchor. + * + * The suffixed variables hold the raw measurement of the variant, the plain + * alias resolves to whichever variant applies at the current viewport. + */ +function relationVariables(prediction: LayoutPrediction, variant: string): string { + if (!prediction.anchor) return ""; + const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_"); + const prefix = `--muddle-relate-${key}-${variant}`; + const values = [ + `${prefix}-x: ${prediction.x}px`, + `${prefix}-y: ${prediction.y}px`, + `${prefix}-width: ${prediction.width}px`, + `${prefix}-height: ${prediction.height}px`, + prediction.backgroundColor ? `${prefix}-background-color: ${prediction.backgroundColor}` : "", + prediction.color ? `${prefix}-color: ${prediction.color}` : "", + prediction.transition ? `${prefix}-transition: ${prediction.transition}` : "", + `--muddle-relate-${key}-x: var(${prefix}-x)`, + `--muddle-relate-${key}-y: var(${prefix}-y)`, + `--muddle-relate-${key}-width: var(${prefix}-width)`, + `--muddle-relate-${key}-height: var(${prefix}-height)`, + prediction.backgroundColor ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)` : "", + prediction.color ? `--muddle-relate-${key}-color: var(${prefix}-color)` : "", + prediction.transition ? `--muddle-relate-${key}-transition: var(${prefix}-transition)` : "", + ] + .concat(Object.entries(prediction.relate ?? {}).flatMap(([property, value]) => [ + `${prefix}-${property}: ${value}`, + `--muddle-relate-${key}-${property}: var(${prefix}-${property})`, + ])) + .filter(Boolean); + return `:root {\n\t${values.join(";\n")};\n}`; +} + +/** + * Scoped `$self` mirrors of one anchor element. + * + * `$self[]` resolves through the element itself, so the mirrored + * value cannot leak between elements that show the same anchor name once. + */ +function selfVariables(prediction: LayoutPrediction): string { + if (!prediction.anchor) return ""; + const entries = Object.entries(prediction.self ?? {}); + if (entries.length === 0) return ""; + const values = entries.map(([property, value]) => `--muddle-self-${property}: ${value}`); + return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`; +} + +interface PredictionLayer { + name: string; + media: string; + viewport: { width: number; height: number }; + predictions: LayoutPrediction[]; +} + +function reflectiveAnchorGroups(predictions: LayoutPrediction[]): ReflectiveAnchorGroup[] { + const groups = new Map(); + for (const prediction of predictions) { + const anchor = prediction.anchor; + if (!anchor?.includes(".")) continue; + const parts = anchor.split("."); + for (let index = 1; index < parts.length; index += 1) { + const name = parts.slice(0, index).join("."); + const entries = groups.get(name) ?? []; + if (!entries.includes(anchor)) entries.push(anchor); + groups.set(name, entries); + } + } + return [...groups].map(([name, anchors]) => ({ name, anchors })); +} + +function reflectionCss(groups: ReflectiveAnchorGroup[]): string[] { + return groups.flatMap((group) => { + const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_"); + return [ + `[data-muddle-group~="${group.name}"] { --muddle-reflect-${key}-count: ${group.anchors.length}; }`, + ...group.anchors.map((anchor, index) => `[data-muddle-anchor="${anchor}"] { --muddle-reflect-${key}-index: ${index}; }`), + ]; + }); +} + +function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], groups: ReflectiveAnchorGroup[]): string { + const rules = (predictions: LayoutPrediction[], variant: string) => predictions.flatMap((entry) => [ + predictionVariables(entry, variant), + relationVariables(entry, variant), + selfVariables(entry), + ]); + return [ + ...reflectionCss(groups), + "@media (min-width: 0) {", + ...rules(desktop, "desktop"), + "}", + ...layers.flatMap((layer) => [`@media ${layer.media} {`, ...rules(layer.predictions, layer.name), "}"]), + ] + .filter((line) => line.trim().length > 0) + .join("\n"); +} + +function mobileMediaQuery(html: string, explicit?: string): string { + if (explicit) return explicit.trim().startsWith("(") ? explicit.trim() : `(${explicit.trim()})`; + const candidates = [...html.matchAll(/@media\s*\(\s*max-width\s*:\s*([^)]+)\)/gi)] + .map((match) => match[1]!.trim()) + .sort((left, right) => mediaWidth(right) - mediaWidth(left)); + return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)"; +} + +function mediaWidth(value: string): number { + const match = /^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i.exec(value); + if (!match) return 0; + const amount = Number(match[1]); + return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16); +} + +function viewportForMobileMedia(query: string, fallback: { width: number; height: number }): { width: number; height: number } { + const match = /max-width\s*:\s*([^)\s]+)/i.exec(query); + const width = match ? mediaWidth(match[1]!) : 0; + return width > 0 ? { ...fallback, width: Math.min(fallback.width, Math.max(1, Math.floor(width - 1))) } : fallback; +} + +const BREAKPOINTS: Record = { + XL: { width: 1280, media: "(max-width: 80rem)" }, + LG: { width: 1024, media: "(max-width: 64rem)" }, + MD: { width: 768, media: "(max-width: 48rem)" }, + SM: { width: 640, media: "(max-width: 40rem)" }, +}; + +function predictionLayers(options: LayoutPredictionOptions, html: string): Omit[] { + const requested = options.breakpointAcc + ? [...new Set(Array.isArray(options.breakpointAcc) ? options.breakpointAcc : [options.breakpointAcc])] + : []; + if (requested.length) { + return requested + .sort((left, right) => BREAKPOINTS[right].width - BREAKPOINTS[left].width) + .map((accuracy) => ({ + name: accuracy.toLowerCase(), + media: BREAKPOINTS[accuracy].media, + viewport: { width: BREAKPOINTS[accuracy].width - 1, height: 844 }, + })); + } + const media = mobileMediaQuery(html, options.mobileMediaQuery); + return [{ name: "mobile", media, viewport: options.mobileViewport ?? viewportForMobileMedia(media, { width: 390, height: 844 }) }]; +} + +/** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */ +export function extractPredictSelectors(html: string): string[] { + return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap((match) => (match[1] + ? match[1].split(/[|,]/).map((selector) => selector.trim()).filter(Boolean) + : [])); +} + +/** + * Bakes prediction and relation variables into prerendered html. + * + * A headless chromium renders the page at the desktop viewport, measures it, + * then repeats at the mobile viewport. The results are inlined as a single + * ``), predictions: [...desktop, ...measuredLayers.flatMap((layer) => layer.predictions)], groups }; + } catch (error) { + if (options.strict) throw error; + console.warn(`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`); + return { html, predictions: [], groups: [] }; + } finally { + await browser?.close(); + } +} diff --git a/adapters/server-bun.ts b/adapters/server-bun.ts new file mode 100644 index 0000000..3cb8994 --- /dev/null +++ b/adapters/server-bun.ts @@ -0,0 +1,123 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve } from "node:path"; +import type { App } from "../core/app.ts"; +import { toFetchResponse } from "../core/app.ts"; +import { contentTypeOf } from "../core/http.ts"; +import { predictLayout } from "./prediction.ts"; +import { inlinePredictionStyles } from "./static.ts"; + +export interface BunServerOptions { + /** Port the server listens on, `0` picks a free port. */ + port?: number; + /** Hostname the server binds to. */ + hostname?: string; + /** Directory with the static assets. */ + publicDir?: string; + /** Called for every request, useful for logging. */ + onRequest?: (request: Request) => void; + /** Predict and cache rendered HTML before its first response. Defaults to true. */ + predictViews?: boolean; +} + +interface CachedResponse { + body: ArrayBuffer; + status: number; + statusText: string; + headers: [string, string][]; +} + +/** Serves the application. */ +export function serve(app: App, options: BunServerOptions = {}) { + const publicDir = options.publicDir ? resolve(options.publicDir) : undefined; + const rendered = new Map>(); + + return Bun.serve({ + port: options.port ?? 3000, + hostname: options.hostname ?? "0.0.0.0", + async fetch(request) { + options.onRequest?.(request); + if (publicDir) { + const asset = await serveAsset(request, publicDir); + if (asset) return asset; + } + if (options.predictViews === false || request.method !== "GET") return app.fetch(request); + const key = request.url; + let predicted = rendered.get(key); + if (!predicted) { + predicted = renderPredictedView(app, request).then(cacheResponse); + rendered.set(key, predicted); + } + try { + return await cloneResponse(await predicted); + } catch (error) { + rendered.delete(key); + throw error; + } + }, + }); +} + +async function renderPredictedView(app: App, request: Request): Promise { + const response = await app.fetch(request); + const contentType = response.headers.get("content-type") ?? ""; + if (!response.ok || !contentType.includes("text/html") || response.headers.has("set-cookie") || /no-store/i.test(response.headers.get("cache-control") ?? "")) return response; + const html = await response.text(); + const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); + if (!hasRelations) return responseFromText(html, response); + const assetHtml = await inlinePredictionStyles(app, html, new URL(request.url).origin); + const predicted = await predictLayout(assetHtml, { strict: true }); + return responseFromText(predicted.html, response); +} + +function responseFromText(html: string, source: Response): Response { + const headers = new Headers(source.headers); + headers.delete("content-length"); + return new Response(html, { status: source.status, statusText: source.statusText, headers }); +} + +function cloneResponse(source: CachedResponse): Response { + return new Response(source.body.slice(0), { + status: source.status, + statusText: source.statusText, + headers: source.headers, + }); +} + +async function cacheResponse(source: Response): Promise { + return { + body: await source.arrayBuffer(), + status: source.status, + statusText: source.statusText, + headers: [...source.headers], + }; +} + +/** Serves a static asset of the public directory. */ +export async function serveAsset( + request: Request, + publicDir: string, +): Promise { + const url = new URL(request.url); + if (request.method !== "GET" && request.method !== "HEAD") return undefined; + + const requested = resolve(publicDir, decodeURIComponent(url.pathname).replace(/^[/\\]+/, "")); + const relativePath = relative(publicDir, requested); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined; + const path = existsSync(join(requested, "index.html")) ? join(requested, "index.html") : requested; + if (!existsSync(path)) return undefined; + + let file: Uint8Array; + try { + file = await readFile(path); + } catch { + return undefined; + } + return new Response(file, { + headers: { + "content-type": contentTypeOf(extname(path)), + }, + }); +} + +export { toFetchResponse }; diff --git a/adapters/static.ts b/adapters/static.ts new file mode 100644 index 0000000..54c2d98 --- /dev/null +++ b/adapters/static.ts @@ -0,0 +1,151 @@ +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import type { App } from "../core/app.ts"; +import { getRenderAhead } from "../decorators/RenderAhead.ts"; +import { getRenderRoutes, type RenderRouteSource } from "../decorators/RenderRoute.ts"; +import { ContentRegistry } from "../core/content.ts"; +import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts"; + +export interface PrerenderOptions { + /** Routes that are rendered at build time. */ + routes?: string[]; + /** Directory the html files are written to. */ + outDir: string; + /** Hostname used while rendering, defaults to `localhost`. */ + hostname?: string; + /** Protocol used while rendering. */ + protocol?: string; + /** Adds discovered dynamic routes before rendering. */ + discover?: (pattern: string) => Promise | string[]; + progress?: (event: { current: number; total: number; route: string }) => void; + predict?: LayoutPredictionOptions; +} + +export interface PrerenderResult { + /** Rendered route. */ + route: string; + /** File the html was written to. */ + file: string; + /** Status code of the response. */ + status: number; +} + +/** Renders the routes of the application into static html files. */ +export async function prerender( + app: App, + options: PrerenderOptions, +): Promise { + const outDir = resolve(options.outDir); + const stageDir = `${outDir}.staging`; + await rm(stageDir, { recursive: true, force: true }); + await mkdir(stageDir, { recursive: true }); + const origin = `${options.protocol ?? "http"}://${options.hostname ?? "localhost"}`; + const results: PrerenderResult[] = []; + + const routes = new Set(options.routes ?? []); + const content = app.resolvedConfig ? new ContentRegistry(app.resolvedConfig) : undefined; + if (content) await content.load(); + for (const route of app.router.list()) { + if (route.method !== "GET") continue; + if (!route.path.includes(":")) routes.add(route.path); + else { + for (const source of getRenderRoutes(route.controller, route.methodName)) { + for (const path of resolveRenderRoute(source, content)) routes.add(path); + } + const ahead = getRenderAhead(route.controller, route.methodName); + const values = ahead ? (typeof ahead === "function" ? await ahead() : ahead) : await options.discover?.(route.path); + for (const value of values ?? []) routes.add(value.startsWith("/") ? value : route.path.replace(/:[^/]+\??/, value)); + } + } + const orderedRoutes = [...routes].sort(); + for (const [index, route] of orderedRoutes.entries()) { + if (app.router.match("GET", route).kind === "not-found") { + throw new Error(`Cannot prerender unknown route "${route}"`); + } + options.progress?.({ current: index + 1, total: orderedRoutes.length, route }); + const request = new Request(new URL(route, origin).toString()); + const response = await app.fetch(request); + const contentType = response.headers.get("content-type") ?? ""; + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (location) { + console.log(`muddle: ${route} redirects to ${location}, skipped`); + } + continue; + } + + if (!contentType.includes("text/html")) { + continue; + } + + let html = await response.text(); + // Relation anchors and STCSS prediction markers are static inputs to the + // layout pass. Run Playwright automatically so consumers never need to + // ship a geometry bridge merely because a page uses `$refer`. + const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); + if (options.predict || hasRelations) { + const assetHtml = await inlinePredictionStyles(app, html, origin); + html = (await predictLayout(assetHtml, options.predict ?? {})).html; + } + const file = fileFor(stageDir, route); + await mkdir(dirname(file), { recursive: true }); + const asset = await app.transformAsset({ path: file, contentType, content: html }); + const reports = await app.analyzePage({ route, html: await inlinePredictionStyles(app, html, origin) }); + if (Object.keys(reports).length) await writeFile(`${file}.analysis.json`, JSON.stringify(reports, null, 2)); + await writeFile(file, asset.content); + results.push({ route, file, status: response.status }); + } + + for (const route of app.router.listRaw()) { + if (route.method !== "GET") continue; + if (route.path.includes("*")) continue; + const request = new Request(new URL(route.path, origin).toString()); + const response = await app.fetch(request); + const file = join(stageDir, route.path.replace(/^\//, "")); + await mkdir(dirname(file), { recursive: true }); + const contentType = response.headers.get("content-type") ?? "application/octet-stream"; + const asset = await app.transformAsset({ path: file, contentType, content: new Uint8Array(await response.arrayBuffer()) }); + await writeFile(file, asset.content); + results.push({ route: route.path, file, status: response.status }); + } + + const backup = `${outDir}.previous`; + await rm(backup, { recursive: true, force: true }); + try { + await rename(outDir, backup); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await rename(stageDir, outDir); + return results; +} + +/** Inlines same-origin styles so Playwright can measure a complete document. */ +export async function inlinePredictionStyles(app: App, html: string, origin: string): Promise { + for (const match of [...html.matchAll(/]*href="([^"]+)"[^>]*>/g)]) { + const url = new URL(match[1]!, origin); + if (url.origin !== origin || !match[0].includes('rel="stylesheet"')) continue; + const response = await app.fetch(new Request(url.href)); + if (response.ok) html = html.replace(match[0], ``); + } + return html; +} + +function resolveRenderRoute(source: RenderRouteSource, content?: ContentRegistry): string[] { + if (typeof source === "string") return [source]; + if (Array.isArray(source)) return source; + return (content?.entries(source.collection) ?? []).map((entry) => source.path.replace(/:id\??/g, encodeURIComponent(entry.id))); +} + +/** Path of the html file of a route. */ +export function fileFor(outDir: string, route: string): string { + const clean = route.split("?")[0] ?? "/"; + if (clean === "/" || clean === "") return join(outDir, "index.html"); + const segments = clean.split("/").filter((segment) => segment.length > 0); + const last = segments.pop(); + if (segments.length === 0) { + return join(outDir, `${last as string}.html`); + } + return join(outDir, ...segments, last as string, "index.html"); +} diff --git a/adapters/vercel-vercel.ts b/adapters/vercel-vercel.ts new file mode 100644 index 0000000..6f18243 --- /dev/null +++ b/adapters/vercel-vercel.ts @@ -0,0 +1,68 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { App } from "../core/app.ts"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type NodeHandler = ( + request: IncomingMessage, + response: ServerResponse, +) => Promise; + +/** Creates a minimal Vercel configuration when the project has none. */ +export async function setupVercel(root: string): Promise { + const path = join(resolve(root), "vercel.json"); + if (existsSync(path)) return path; + await writeFile(path, `${JSON.stringify({ cleanUrls: true }, null, 2)}\n`, "utf8"); + return path; +} + +/** Creates the node handler of the application. */ +export function createHandler(app: App): NodeHandler { + return async (request, response) => { + const url = `http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`; + const method = (request.method ?? "GET").toUpperCase(); + + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const entry of value) headers.append(name, entry); + continue; + } + headers.set(name, value); + } + + const body = + method === "GET" || method === "HEAD" + ? undefined + : await readBody(request); + + const fetchRequest = new Request(url, { + method, + headers, + body, + duplex: body ? "half" : undefined, + } as RequestInit); + + const result = await app.fetch(fetchRequest); + response.statusCode = result.status; + result.headers.forEach((value, name) => { + response.setHeader(name, value); + }); + if (result.body) { + const buffer = Buffer.from(await result.arrayBuffer()); + response.end(buffer); + return; + } + response.end(); + }; +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e7d5d46 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5af88b7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1389 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "muddle", + "dependencies": { + "@eslint/js": "^10.0.1", + "lightningcss": "^1.33.0", + "prettier": "^3.9.10", + "sass": "^1.106.1", + "typescript-eslint": "^8.72.0", + }, + "devDependencies": { + "@muddle/amezing": "workspace:*", + "@muddle/analysis": "workspace:*", + "@muddle/astro": "workspace:*", + "@muddle/database": "workspace:*", + "@muddle/latex": "workspace:*", + "@muddle/mdx": "workspace:*", + "@muddle/optimize": "workspace:*", + "@muddle/prettier-plugin": "workspace:*", + "@muddle/tailwind": "workspace:*", + "@types/bun": "latest", + "@types/pg": "^8.15.5", + "playwright": "^1.64.1", + }, + "peerDependencies": { + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", + }, + }, + "modules/amezing": { + "name": "@muddle/amezing", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/analysis": { + "name": "@muddle/analysis", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + "playwright": "^1.0.0", + }, + }, + "modules/astro": { + "name": "@muddle/astro", + "version": "0.1.0", + "dependencies": { + "@astrojs/compiler": "2", + "astro": "5", + "typescript": "5.9.3", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/database": { + "name": "@muddle/database", + "version": "0.1.0", + "dependencies": { + "pg": "^8.23.1", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/latex": { + "name": "@muddle/latex", + "version": "0.1.0", + "dependencies": { + "katex": "^0.19.0", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/mdx": { + "name": "@muddle/mdx", + "version": "0.1.0", + "dependencies": { + "@mdx-js/mdx": "^3.1.1", + "react": "^19.3.1", + "react-dom": "^19.3.1", + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/optimize": { + "name": "@muddle/optimize", + "version": "0.1.0", + "dependencies": { + "@types/csso": "^5.0.5", + "@types/html-minifier-terser": "^7.0.2", + "csso": "^5.0.5", + "html-minifier-terser": "^7.2.0", + "svgo": "^4.1.0", + "terser": "^5.51.2", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/prettier-plugin": { + "name": "@muddle/prettier-plugin", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + "prettier": "^3.0.0", + }, + }, + "modules/tailwind": { + "name": "@muddle/tailwind", + "version": "0.1.0", + "dependencies": { + "tailwindcss": "^4.3.3", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + }, + "packages": { + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + + "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], + + "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@muddle/amezing": ["@muddle/amezing@workspace:modules/amezing"], + + "@muddle/analysis": ["@muddle/analysis@workspace:modules/analysis"], + + "@muddle/astro": ["@muddle/astro@workspace:modules/astro"], + + "@muddle/database": ["@muddle/database@workspace:modules/database"], + + "@muddle/latex": ["@muddle/latex@workspace:modules/latex"], + + "@muddle/mdx": ["@muddle/mdx@workspace:modules/mdx"], + + "@muddle/optimize": ["@muddle/optimize@workspace:modules/optimize"], + + "@muddle/prettier-plugin": ["@muddle/prettier-plugin@workspace:modules/prettier-plugin"], + + "@muddle/tailwind": ["@muddle/tailwind@workspace:modules/tailwind"], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@parcel/watcher": ["@parcel/watcher@2.6.0", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.4" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.6.0", "@parcel/watcher-darwin-arm64": "2.6.0", "@parcel/watcher-darwin-x64": "2.6.0", "@parcel/watcher-freebsd-x64": "2.6.0", "@parcel/watcher-linux-arm-glibc": "2.6.0", "@parcel/watcher-linux-arm-musl": "2.6.0", "@parcel/watcher-linux-arm64-glibc": "2.6.0", "@parcel/watcher-linux-arm64-musl": "2.6.0", "@parcel/watcher-linux-x64-glibc": "2.6.0", "@parcel/watcher-linux-x64-musl": "2.6.0", "@parcel/watcher-win32-arm64": "2.6.0", "@parcel/watcher-win32-x64": "2.6.0" } }, "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.6.0", "", { "os": "android", "cpu": "arm64" }, "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.6.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.6.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.6.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.6.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.6.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.5.1", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.65.0", "", { "os": "android", "cpu": "arm" }, "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.65.0", "", { "os": "android", "cpu": "arm64" }, "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.65.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.65.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.65.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.65.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.65.0", "", { "os": "linux", "cpu": "arm" }, "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.65.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.65.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.65.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.65.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.65.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.65.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.65.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.65.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.1.0", "", {}, "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ=="], + + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + + "@types/css-tree": ["@types/css-tree@2.3.11", "", {}, "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg=="], + + "@types/csso": ["@types/csso@5.0.5", "", { "dependencies": { "@types/css-tree": "^2" } }, "sha512-Et/B3W1/1XMjG9SxwitiokWDabA9N8otVoTBJF2RmZ8itU0c5h7qmuZcTpp17BqfAnsdfkGQ3hwizff3nJvWSQ=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/html-minifier-terser": ["@types/html-minifier-terser@7.0.2", "", {}, "sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/type-utils": "8.72.0", "@typescript-eslint/utils": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.72.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.72.0", "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0" } }, "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.72.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.72.0", "", {}, "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.72.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.72.0", "@typescript-eslint/tsconfig-utils": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.72.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA=="], + + "@typescript/native": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "@typescript/old": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "astro": ["astro@5.18.2", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + + "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], + + "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], + + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], + + "css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], + + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.13.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immutable": ["immutable@5.1.9", "", {}, "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "katex": ["katex@0.19.0", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q=="], + + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muddle": ["muddle@1.0.0", "", {}, "sha512-fcfBNyWEuTQWexBVuXTnbdrZsRJ7w/C5kUEp1NAXnaNeegW2sfYDOfbU9ItCzflFR/ijC32xKn/D20Ky0DTwVg=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], + + "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="], + + "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="], + + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], + + "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], + + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-smartypants": ["remark-smartypants@3.0.3", "", { "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], + + "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + + "rollup": ["rollup@4.65.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.65.0", "@rollup/rollup-android-arm64": "4.65.0", "@rollup/rollup-darwin-arm64": "4.65.0", "@rollup/rollup-darwin-x64": "4.65.0", "@rollup/rollup-freebsd-arm64": "4.65.0", "@rollup/rollup-freebsd-x64": "4.65.0", "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", "@rollup/rollup-linux-arm-musleabihf": "4.65.0", "@rollup/rollup-linux-arm64-gnu": "4.65.0", "@rollup/rollup-linux-arm64-musl": "4.65.0", "@rollup/rollup-linux-loong64-gnu": "4.65.0", "@rollup/rollup-linux-loong64-musl": "4.65.0", "@rollup/rollup-linux-ppc64-gnu": "4.65.0", "@rollup/rollup-linux-ppc64-musl": "4.65.0", "@rollup/rollup-linux-riscv64-gnu": "4.65.0", "@rollup/rollup-linux-riscv64-musl": "4.65.0", "@rollup/rollup-linux-s390x-gnu": "4.65.0", "@rollup/rollup-linux-x64-gnu": "4.65.0", "@rollup/rollup-linux-x64-musl": "4.65.0", "@rollup/rollup-openbsd-x64": "4.65.0", "@rollup/rollup-openharmony-arm64": "4.65.0", "@rollup/rollup-win32-arm64-msvc": "4.65.0", "@rollup/rollup-win32-ia32-msvc": "4.65.0", "@rollup/rollup-win32-x64-gnu": "4.65.0", "@rollup/rollup-win32-x64-msvc": "4.65.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw=="], + + "sass": ["sass@1.106.1", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kD+Gi66vycZcbJt+42NC4z4PXNMcCajVrJPhqoKKXy5cOBMmJQt052CxIBcRjwYXeImHYOpu/ydWWGZQIenl7A=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["@typescript/typescript6@6.0.2", "", { "dependencies": { "@typescript/old": "npm:typescript@^6" }, "bin": { "tsc6": "bin/tsc6" } }, "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w=="], + + "typescript-eslint": ["typescript-eslint@8.72.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.72.0", "@typescript-eslint/parser": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici": ["undici@8.12.3", "", {}, "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unstorage": ["unstorage@1.17.6", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.11", "lru-cache": "^11.5.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.4" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], + + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@muddle/astro/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.13", "", {}, "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q=="], + + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "unifont/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/cli.ts b/cli.ts new file mode 100755 index 0000000..54cdfa9 --- /dev/null +++ b/cli.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { generate, collectTables } from "./contracts/typegen/generator.ts"; +import { format } from "./core/format.ts"; +import { loadConfig } from "./core/config.ts"; +import { setupWrangler } from "./adapters/cloudflare-wrangler.ts"; +import { setupVercel } from "./adapters/vercel-vercel.ts"; +import { backportProject } from "./core/backport.ts"; +import { createTemplate, muddleTemplates, type MuddleTemplate } from "./core/templates.ts"; + +function progress(event: { current: number; total: number; route: string }): void { + const width = 20; + const filled = Math.round((event.current / Math.max(1, event.total)) * width); + console.log(`[${"#".repeat(filled)}${".".repeat(width - filled)}] ${event.current}/${event.total} ${event.route}`); +} + +async function dev(entry: string, root: string, port: number): Promise { + const moduleUrl = pathToFileURL(entry).href; + const source = `const project = await import(${JSON.stringify(moduleUrl)}); if (!project.app || typeof project.app.fetch !== "function") throw new Error("Project entry must export an app with fetch(request)"); const server = Bun.serve({ port: ${port}, fetch: (request) => project.app.fetch(request) }); console.log(\`muddle dev listening on \${server.url}\`); await new Promise(() => {});`; + const child = Bun.spawn([process.execPath, "--watch", "--eval", source], { + cwd: root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const stop = () => child.kill(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + await child.exited; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0] ?? "help"; + const portFlag = args.indexOf("--port"); + const requestedPort = portFlag === -1 ? undefined : Number(args[portFlag + 1]); + const root = args.slice(1).find((argument, index, values) => { + if (argument.startsWith("--")) return false; + return values[index - 1] !== "--port"; + }) ?? process.cwd(); + if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)) { + throw new Error("--port must be an integer from 1 through 65535"); + } + if (command === "typegen") { + const result = await generate({ root }); + for (const file of result.written) console.log(`muddle generated ${relative(process.cwd(), file)}`); + return; + } + if (command === "backport") { + const source = args[1] ? resolve(args[1]) : process.cwd(); + const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle"); + const result = await backportProject(source, destination); + console.log(`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`); + return; + } + if (command === "templates") { + const name = args[1] as MuddleTemplate | undefined; + if (!name) { + console.log(`Available templates: ${muddleTemplates.join(", ")}`); + console.log("Usage: muddle templates [destination]"); + return; + } + if (!muddleTemplates.includes(name)) throw new Error(`Unknown template ${name}. Choose one of: ${muddleTemplates.join(", ")}`); + const result = await createTemplate(name, args[2] ? resolve(args[2]) : resolve(process.cwd(), name)); + console.log(`muddle created ${result.name} template at ${result.output} (${result.files.length} files)`); + return; + } + if (command === "repo") { + const target = resolve(root); + const tables = await collectTables(target, ["**/*.table.ts"]); + const out = join(target, "repositories"); + await mkdir(out, { recursive: true }); + for (const table of tables) { + const rowType = table.fields + .map((field) => { + const tsType = field.type; + const optional = field.nullable ? "?" : ""; + return `\t${field.property}${optional}: ${tsType};`; + }) + .join("\n"); + const typeName = `${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Row`; + const source = `import { Repository } from "@muddle/database";\n\nexport type ${typeName} = {\n${rowType}\n};\n\nexport class ${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Repository extends Repository<${typeName}> {}\n`; + const file = join(out, `${table.name}.repository.ts`); + await writeFile(file, source); + console.log(`muddle generated ${relative(target, file)}`); + } + return; + } + if (command === "fmt") { + const check = process.argv.includes("--check"); + const changed = await format(resolve(root === "--check" ? process.cwd() : root), { check }); + for (const file of changed) console.log(`${check ? "Unformatted" : "Formatted"} ${file}`); + if (check && changed.length) process.exitCode = 1; + return; + } + if (command === "build" || command === "dev") { + const target = resolve(root); + const config = await loadConfig(target); + const entry = resolve(target, config.entry ?? "index.ts"); + if (!existsSync(entry)) throw new Error(`No index.ts found in ${root}`); + if (config.adapter?.name === "cloudflare-wrangler") await setupWrangler(target); + if (config.adapter?.name === "vercel-vercel") await setupVercel(target); + const project = await import(entry) as { build?: (options?: { progress?: typeof progress }) => Promise; app?: unknown }; + if (command === "build") { + if (!project.build) throw new Error("Project must export build() for muddle build"); + await project.build({ progress }); + return; + } else if (command === "dev") { + if (!project.app || typeof (project.app as { fetch?: unknown }).fetch !== "function") { + throw new Error("Project entry must export an app with fetch(request) for muddle dev"); + } + await dev(entry, target, requestedPort ?? config.adapter?.port ?? 3000); + return; + } + return; + } + console.log("muddle [project-root] [--port number]"); +} + +await main(); diff --git a/contracts/index.ts b/contracts/index.ts new file mode 100644 index 0000000..a07cc70 --- /dev/null +++ b/contracts/index.ts @@ -0,0 +1,229 @@ +import type { RequestContext } from "./typegen/http/request.ts"; + +export * from "./typegen/http/index.ts"; +export * from "./typegen/orm/index.ts"; +export * from "./typegen/minify.ts"; +export type { ViewPath, ViewProps, ViewPropsOf } from "./typegen/views.ts"; + +/** A file handled by a loader. */ +export interface LoaderFile { + /** Absolute path of the file. */ + path: string; + /** Path relative to the project root. */ + relativePath: string; + /** Raw content of the file. */ + source: string; +} + +/** Result of a loader. */ +export interface LoadedFile { + /** Data exposed to the application, e.g. frontmatter and rendered html. */ + data: Record; + /** Rendered representation, e.g. the compiled css or the markdown html. */ + content?: string; +} + +export interface Loader { + /** Name of the loader, used in error messages. */ + name: string; + /** File extensions handled by the loader, e.g. `['.md']`. */ + extensions: string[]; + /** Loads a single file. */ + load(file: LoaderFile): Promise | LoadedFile; +} + +/** Options shared by all loaders. */ +export interface LoaderOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** Configuration of the content collections. */ +export interface CollectionConfig { + /** Name of the collection, e.g. `notes`. */ + name: string; + /** Directory of the collection, relative to the project root. */ + dir: string; + /** Loader used to read the files of the collection, by name or inline. */ + loader: Loader | string; + /** Schema of the frontmatter, validated at load time. */ + schema?: ContentSchema; +} + +/** Schema of a content collection. */ +export interface ContentSchema { + /** Required frontmatter fields. */ + required?: string[]; + /** Allowed values of a field. */ + enum?: Record; + /** Maximum length of a string field. */ + maxLength?: Record; +} + +/** A single entry of a content collection. */ +export interface CollectionEntry< + Frontmatter extends Record = Record, +> { + /** Identifier of the entry, derived from the file name. */ + id: string; + /** Path of the file, relative to the project root. */ + path: string; + /** Parsed frontmatter. */ + frontmatter: Frontmatter; + /** Rendered body. */ + body: string; + /** Raw markdown source. */ + content: string; +} + +/** Minimal view registry handed to integration mounts. */ +export interface MountableViews { + /** Registers a named view component. */ + component( + name: string, + component: { render: (props: unknown, context: unknown) => string | Promise }, + ): unknown; +} + +/** A self-contained unit an integration mounts into the application. */ +export interface Mounts { + /** Registers view components, e.g. the primitives of a theme. */ + views?(views: MountableViews): void | Promise; + /** Adds routes, e.g. assets, fonts and endpoints. */ + routes?( + route: (method: string, path: string, handler: (request: Request) => Response | Promise) => void, + ): void | Promise; + /** Served inline stylesheets by route. */ + styles?: Record string | Promise)>; +} + +/** Integration hook, called while the application is created. */ +export interface Integration { + /** Transforms a staged build asset. Promises run in configured order. */ + transform?(asset: BuildAsset): BuildAsset | Promise; + /** Analyzes a fully rendered page before the output is published. */ + analyze?(page: { route: string; html: string }): unknown | Promise; + /** Runs before routing, including raw integration endpoints. */ + request?(context: RequestContext): void | Promise; + /** Name of the integration. */ + name: string; + /** Called with the resolved configuration. */ + setup?(context: IntegrationContext): void | Promise; + /** Full-stack units mounted while the application boots. */ + mounts?: Mounts[]; + /** Called before the server starts serving requests. */ + ready?(): void | Promise; +} + +export interface BuildAsset { + path: string; + contentType: string; + content: string | Uint8Array; +} + +/** Context handed to an integration. */ +export interface IntegrationContext { + /** Resolved project configuration. */ + config: ResolvedConfig; + /** Adds a route to the application. */ + route( + method: string, + path: string, + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response, + ): void; + /** Adds a loader to the project. */ + loader(name: string, loader: Loader): void; +} + +/** Adapter configuration, e.g. the port of the http server. */ +export interface AdapterOptions { + /** Port the server listens on. */ + port?: number; + /** Hostname the server binds to. */ + hostname?: string; + /** Name of the adapter, e.g. `server-bun`. */ + name?: string; +} + +/** Database configuration. */ +export interface DatabaseOptions { + /** Dialect of the database. */ + dialect: "sqlite" | "postgres" | "d1"; + /** Path of the sqlite file or the connection string of postgres. */ + url?: string; + /** Cloudflare D1 binding configuration. */ + d1?: { accountId: string; databaseId: string; apiToken: string }; +} + +/** Prerendering configuration. */ +export interface PrerenderOptions { + /** Routes rendered at build time. */ + routes: string[]; + /** Directory the html files are written to. */ + outDir?: string; + /** Writes every route, even when the handler answers with a redirect. */ + fallback?: boolean; +} + +/** Project configuration, as written in `ct.config.ts`. */ +export interface MuddleConfig { + /** Root directory of the project. */ + root?: string; + /** Server entry module, relative to the project root. Defaults to `index.ts`. */ + entry?: string; + /** Directory with the templates. */ + views?: string; + /** Directory with the components. */ + components?: string; + /** Directory with the markdown collections. */ + content?: string; + /** Directory with the structured css files. */ + styles?: string; + /** Directory with static assets. */ + publicDir?: string; + /** Directories scanned for templates, relative to the project root. */ + viewRoots?: string[]; + /** Loaders available to the content collections. */ + loaders?: Record; + /** Content collections of the project. */ + collections?: CollectionConfig[]; + /** Integrations of the project. */ + integrations?: Integration[]; + /** Prerendering configuration. */ + prerender?: PrerenderOptions; + /** Adapter used to serve the application. */ + adapter?: AdapterOptions; + /** Database configuration. */ + database?: DatabaseOptions; + /** Model files of the project, relative to the project root. */ + tables?: string[]; + /** Where the generated contracts are written to. */ + typegen?: { out?: string }; +} + +/** Configuration with every default applied. */ +export interface ResolvedConfig extends MuddleConfig { + root: string; + views: string; + components: string; + content: string; + styles: string; + publicDir: string; + viewRoots: string[]; + loaders: Record; + collections: CollectionConfig[]; + integrations: Integration[]; +} + +/** + * Declares the configuration of a project. + * + * The helper only exists for the editor: it checks the configuration while + * it is written in `ct.config.ts`. + */ +export function defineConfig(config: MuddleConfig): MuddleConfig { + return config; +} diff --git a/contracts/typegen/generator.ts b/contracts/typegen/generator.ts new file mode 100644 index 0000000..19a7187 --- /dev/null +++ b/contracts/typegen/generator.ts @@ -0,0 +1,427 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, relative, resolve } from "node:path"; +import { lex } from "../../views/engine/lexer.ts"; +import { collectProps, parse } from "../../views/engine/parser.ts"; +import type { Node, PropDeclaration } from "../../views/engine/types.ts"; +import { compactTypeSource } from "./minify.ts"; +import { getMetadata } from "../../core/metadata.ts"; +import type { MuddleConfig } from "../index.ts"; + +export interface GeneratorOptions { + /** Root directory of the project. */ + root?: string; + /** Directory the generated contracts are written to. */ + out?: string; + /** Glob like patterns of the model files, relative to the root. */ + tables?: string[]; + /** Directories scanned for templates, relative to the root. */ + viewRoots?: string[]; +} + +export interface ViewContract { + /** Key of the contract, relative to the project root. */ + key: string; + /** Absolute path of the template. */ + path: string; + /** Props declared by the template. */ + props: PropDeclaration[]; +} + +export interface GeneratorResult { + /** Contracts of the views. */ + views: ViewContract[]; + /** Contracts of the tables. */ + tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[]; + /** Files written by the generator. */ + written: string[]; +} + +/** Default directory of contracts generated for a project. */ +export const defaultOut = "_muddle/contracts"; + +/** Recursively lists the pug files of a directory. */ +export async function listTemplates(dir: string): Promise { + if (!existsSync(dir)) return []; + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTemplates(path))); + continue; + } + if (entry.name.endsWith(".spug")) files.push(path); + } + return files; +} + +/** Reads the props of a template. */ +export async function readTemplate(path: string): Promise { + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + return collectProps(parsed.nodes); +} + +/** Collects the component usages of a template. */ +export async function readComponentUsages( + path: string, +): Promise<{ name: string; props: Record }[]> { + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + const usages: { name: string; props: Record }[] = []; + walk(parsed.nodes, (node) => { + if (node.kind === "component" || node.kind === "island") { + usages.push({ name: node.name, props: node.props }); + } + }); + return usages; +} + +function walk(nodes: Node[], visit: (node: Node) => void): void { + for (const node of nodes) { + visit(node); + const children = childrenOf(node); + if (children) walk(children, visit); + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} + +/** Reads the configuration of a project. */ +export async function readConfig(root: string): Promise { + const configPath = join(root, "ct.config.ts"); + if (!existsSync(configPath)) return {}; + const module = (await import(configPath)) as { default?: MuddleConfig }; + return module.default ?? {}; +} + +/** Renders the props of a view as a typescript member. */ +export function renderProps(props: PropDeclaration[]): string { + if (props.length === 0) return "Record"; + const members = props.map((prop) => { + const type = compactTypeSource(prop.type || "unknown"); + const optional = prop.optional ? "?" : ""; + const fallback = prop.defaultValue ? ` = ${prop.defaultValue}` : ""; + return `${prop.name}${optional}: ${type}${fallback}`; + }); + return `{ ${members.join("; ")} }`; +} + +/** Renders the contract of the views. */ +export function renderViewContracts(views: ViewContract[]): string { + const entries = views + .map((view) => `\t${JSON.stringify(view.key)}: ${renderProps(view.props)};`) + .join("\n"); + return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run \`bun run typegen \` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { +${entries} +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; +`; +} + +/** Maps a field type onto its typescript counterpart. */ +export function fieldTypeToTypeScript(type: string, nullable: boolean): string { + const base = (() => { + switch (type) { + case "int": + case "bigint": + case "real": + return "number"; + case "bool": + return "boolean"; + case "json": + return "unknown"; + case "blob": + return "Uint8Array"; + default: + return "string"; + } + })(); + return nullable ? `${base} | null` : base; +} + +/** Renders the contract of the tables. */ +export function renderTableContracts( + tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[], +): string { + const rows = tables + .map((table) => { + const members = table.fields + .map( + (field) => + `\t${JSON.stringify(field.property)}: ${fieldTypeToTypeScript(field.type, field.nullable)};`, + ) + .join("\n"); + return `export interface ${pascalCase(table.name)}Row {\n${members}\n}`; + }) + .join("\n\n"); + + const map = tables + .map( + (table) => + `\t${JSON.stringify(table.name)}: ${pascalCase(table.name)}Row;`, + ) + .join("\n"); + + return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run \`bun run typegen \` to update this file. + +${rows} + +export interface TableRows { +${map} +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; +`; +} + +/** Converts a table name into a typescript identifier. */ +export function pascalCase(value: string): string { + return value + .split(/[^a-zA-Z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(""); +} + +/** + * Validates the component usages of the templates. + * + * Every prop handed to a component must be declared by the component and + * every required prop of the component must be handed over. + */ +export async function validateComponents( + root: string, + views: ViewContract[], + componentsDir: string, +): Promise { + const errors: string[] = []; + const componentProps = new Map(); + + for (const file of await listTemplates(componentsDir)) { + componentProps.set(basename(file, ".spug"), await readTemplate(file)); + } + + for (const view of views) { + for (const usage of await readComponentUsages(view.path)) { + const declared = componentProps.get(usage.name); + if (!declared) continue; + const names = new Set(declared.map((prop) => prop.name)); + for (const prop of Object.keys(usage.props)) { + if (!names.has(prop)) { + errors.push( + `${relative(root, view.path)}: component "${usage.name}" has no prop "${prop}"`, + ); + } + } + for (const prop of declared) { + if (prop.optional) continue; + if (!(prop.name in usage.props)) { + errors.push( + `${relative(root, view.path)}: component "${usage.name}" requires the prop "${prop.name}"`, + ); + } + } + } + } + + return errors; +} + +/** Runs the generator. */ +export async function generate( + options: GeneratorOptions = {}, +): Promise { + const root = resolve(options.root ?? process.cwd()); + const config = await readConfig(root); + const configuredOut = options.out ?? config.typegen?.out ?? defaultOut; + const out = resolve(root, configuredOut); + + const viewRoots = ( + options.viewRoots ?? + config.viewRoots ?? [config.views ?? "views"] + ).map((entry) => resolve(root, entry)); + + const views: ViewContract[] = []; + for (const viewRoot of viewRoots) { + for (const file of await listTemplates(viewRoot)) { + views.push({ + key: relative(root, file) + .replace(/\.spug$/, "") + .replace(/\\/g, "/"), + path: file, + props: await readTemplate(file), + }); + } + } + views.sort((left, right) => left.key.localeCompare(right.key)); + + const errors = await validateComponents( + root, + views, + resolve( + root, + config.components ?? join(config.views ?? "views", "components"), + ), + ); + if (errors.length > 0) { + throw new Error( + `Invalid component usage:\n${errors.map((error) => ` - ${error}`).join("\n")}`, + ); + } + + const tables = await collectTables(root, options.tables ?? config.tables); + + await mkdir(join(out, "orm"), { recursive: true }); + const minifyFile = join(out, "minify.ts"); + await writeFile(minifyFile, `export type Simplify = { [K in keyof T]: T[K] } & {};\nexport type Minify = Simplify;\n`, "utf8"); + const viewsFile = join(out, "views.ts"); + await writeFile(viewsFile, renderViewContracts(views), "utf8"); + + const tablesFile = join(out, "orm", "tables.ts"); + await writeFile(tablesFile, renderTableContracts(tables), "utf8"); + const indexFile = join(out, "index.ts"); + await writeFile(indexFile, `export * from "./views.ts";\nexport * from "./orm/tables.ts";\nexport * from "./minify.ts";\n`, "utf8"); + + return { views, tables, written: [minifyFile, viewsFile, tablesFile, indexFile] }; +} + +/** Collects the tables of the project by importing the model files. */ +export async function collectTables( + root: string, + patterns: string[] | undefined, +): Promise< + { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[] +> { + if (!patterns || patterns.length === 0) return []; + + const files: string[] = []; + for (const pattern of patterns) { + if (pattern.includes("**")) { + const suffix = pattern.slice(pattern.lastIndexOf("*") + 1); + files.push(...(await listFiles(root, suffix))); + continue; + } + const star = pattern.indexOf("*"); + const dirPart = star === -1 ? pattern : pattern.slice(0, star); + const suffix = star === -1 ? ".ts" : pattern.slice(star + 1); + const dir = resolve(root, dirPart); + const entries = existsSync(dir) ? await readdir(dir) : []; + for (const entry of entries) { + if (!entry.endsWith(suffix)) continue; + files.push(join(dir, entry)); + } + } + + const tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[] = []; + + for (const file of files) { + const module = (await import(file)) as Record; + for (const exported of Object.values(module)) { + if (typeof exported !== "function") continue; + const metadata = getMetadata("muddle:orm:table", exported as object) as { name: string; fields: Map } | undefined; + if (!metadata) continue; + tables.push({ + name: metadata.name, + fields: [...metadata.fields.values()].map((field) => ({ + property: field.property, + column: field.column, + type: field.type, + nullable: field.nullable, + })), + }); + } + } + + return tables; +} + +async function listFiles(dir: string, suffix: string): Promise { + const entries = existsSync(dir) ? await readdir(dir, { withFileTypes: true }) : []; + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listFiles(path, suffix))); + else if (entry.name.endsWith(suffix)) files.push(path); + } + return files; +} + +/** Entry point of the generator. */ +if (import.meta.main) { + const root = process.argv[2]; + try { + const result = await generate({ root }); + for (const file of result.written) { + console.log(`muddle: generated ${relative(process.cwd(), file)}`); + } + } catch (error) { + console.error(`muddle: ${(error as Error).message}`); + process.exit(1); + } +} diff --git a/contracts/typegen/http/headers.ts b/contracts/typegen/http/headers.ts new file mode 100644 index 0000000..43654d1 --- /dev/null +++ b/contracts/typegen/http/headers.ts @@ -0,0 +1,48 @@ +export type HeaderValue = string; + +export type Headers = Record; + +export type KnownHeaderName = + | "accept" + | "accept-charset" + | "accept-encoding" + | "authorization" + | "cache-control" + | "content-length" + | "content-type" + | "cookie" + | "etag" + | "host" + | "if-none-match" + | "location" + | "origin" + | "referer" + | "set-cookie" + | "user-agent" + | "vary" + | "x-forwarded-for" + | "x-request-id"; + +/** Any header name; known names are suggested by the editor. */ +export type HeaderName = KnownHeaderName | (string & {}); + +export type ContentType = + | "text/html; charset=utf-8" + | "text/plain; charset=utf-8" + | "application/json; charset=utf-8" + | "application/x-www-form-urlencoded" + | "multipart/form-data" + | "application/octet-stream"; + +export const JSON_CONTENT_TYPE = "application/json; charset=utf-8" as const; +export const HTML_CONTENT_TYPE = "text/html; charset=utf-8" as const; +export const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8" as const; +export const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" as const; + +export function mediaType(contentType: string | undefined): string { + if (!contentType) return ""; + const semi = contentType.indexOf(";"); + return (semi === -1 ? contentType : contentType.slice(0, semi)) + .trim() + .toLowerCase(); +} diff --git a/contracts/typegen/http/index.ts b/contracts/typegen/http/index.ts new file mode 100644 index 0000000..157d3b6 --- /dev/null +++ b/contracts/typegen/http/index.ts @@ -0,0 +1,5 @@ +export * from "./headers.ts"; +export * from "./method.ts"; +export * from "./request.ts"; +export * from "./respcode.ts"; +export * from "./response.ts"; diff --git a/contracts/typegen/http/method.ts b/contracts/typegen/http/method.ts new file mode 100644 index 0000000..42244cc --- /dev/null +++ b/contracts/typegen/http/method.ts @@ -0,0 +1,33 @@ +export const HTTP_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] as const; + +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** Methods that are allowed to carry a request body. */ +export type BodyMethod = Extract< + HttpMethod, + "POST" | "PUT" | "PATCH" | "DELETE" +>; + +/** Methods that never mutate server state. */ +export type SafeMethod = Extract; + +export function isHttpMethod(value: string): value is HttpMethod { + return (HTTP_METHODS as readonly string[]).includes(value); +} + +/** Normalises user supplied verbs (`post`, `Post`) to the canonical form. */ +export function normalizeMethod(value: string): HttpMethod { + const upper = value.toUpperCase() as HttpMethod; + if (!isHttpMethod(upper)) { + throw new Error(`Unsupported HTTP method: ${value}`); + } + return upper; +} diff --git a/contracts/typegen/http/request.ts b/contracts/typegen/http/request.ts new file mode 100644 index 0000000..0a98272 --- /dev/null +++ b/contracts/typegen/http/request.ts @@ -0,0 +1,90 @@ +import type { Headers } from "./headers.ts"; +import type { StatusCode } from "./respcode.ts"; +import type { HttpMethod } from "./method.ts"; +import type { HttpResponse } from "./response.ts"; + +/** Extracts `:param` names from a route pattern. */ +export type ParamNames = + Path extends `${string}:${infer Param}/${infer Rest}` + ? ParamNames<`/${Rest}`> | Param + : Path extends `${string}:${infer Param}` + ? Param + : never; + +/** Query string keys of a route pattern (`/search?q=…` are not part of the path). */ +export type QueryNames = + Path extends `${string}?${infer Query}` ? Query : never; + +export type PathParams = { + [K in ParamNames]: string; +}; + +export type QueryParams = { + [K in QueryNames]: string; +}; + +/** Everything a handler may read from an incoming request. */ +export interface HttpRequest< + Path extends string = string, + Body = unknown, + ResponseBody = unknown, +> { + method: HttpMethod; + /** Full request URL including the query string. */ + url: string; + /** Path portion of the URL, without query string. */ + path: string; + /** Route parameters extracted from `Path`. */ + params: PathParams; + /** Parsed query string. */ + query: Record; + /** Parsed body (JSON / form / text / raw bytes). */ + body: Body; + /** Fields of a submitted form. */ + form: FormFields; + /** Lower-cased request headers. */ + headers: Headers; + /** Abort signal, wired to the platform request lifecycle. */ + signal: AbortSignal; + /** Typed response helper, the handler may return `response.json(...)` directly. */ + respond: ResponseBuilder; +} + +/** Typed response factory bound to a single request. */ +export interface ResponseBuilder { + html(body: string, status?: StatusCode): HttpResponse; + json(body: unknown, status?: StatusCode): HttpResponse; + text(body: string, status?: StatusCode): HttpResponse; + redirect(location: string, status?: StatusCode): HttpResponse; + status(code: StatusCode): ResponseBuilder; + header(name: string, value: string): ResponseBuilder; + send(body?: ResponseBody): HttpResponse; +} + +/** Fields of a submitted form. */ +export interface FormFields { + /** Value of a single field. */ + get(name: string): string | undefined; + /** All fields of the form. */ + all(): Record; + /** `true` when the form contains the field. */ + has(name: string): boolean; + /** All fields of the form as strings. */ + values(): Record; +} + +/** Mutable per-request scratch space, shared by guards, handlers and views. */ +export interface RequestLocals { + [key: string]: unknown; +} + +/** The object handed to a controller method. */ +export interface RequestContext< + Path extends string = string, + Body = unknown, + ResponseBody = unknown, +> extends HttpRequest { + locals: RequestLocals; + /** Platform specific request object (`BunRequest`, `Request`, …). */ + raw: unknown; +} diff --git a/contracts/typegen/http/respcode.ts b/contracts/typegen/http/respcode.ts new file mode 100644 index 0000000..e43e6a6 --- /dev/null +++ b/contracts/typegen/http/respcode.ts @@ -0,0 +1,39 @@ +export const STATUS_CODES = [ + 200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404, 405, + 406, 409, 410, 415, 418, 422, 429, 500, 501, 502, 503, 504, +] as const; + +export type StatusCode = (typeof STATUS_CODES)[number]; + +export type InfoStatus = Extract; +export type RedirectStatus = Extract< + StatusCode, + 301 | 302 | 303 | 304 | 307 | 308 +>; +export type ClientErrorStatus = Extract< + StatusCode, + 400 | 401 | 403 | 404 | 405 | 406 | 409 | 410 | 415 | 418 | 422 | 429 +>; +export type ServerErrorStatus = Extract< + StatusCode, + 500 | 501 | 502 | 503 | 504 +>; + +export function isStatusCode(value: number): value is StatusCode { + return (STATUS_CODES as readonly number[]).includes(value); +} + +/** Default status code for a given request method. */ +export function defaultStatusFor(method: string): StatusCode { + switch (method.toUpperCase()) { + case "POST": + return 201; + case "PUT": + case "PATCH": + return 200; + case "DELETE": + return 204; + default: + return 200; + } +} diff --git a/contracts/typegen/http/response.ts b/contracts/typegen/http/response.ts new file mode 100644 index 0000000..2a8740a --- /dev/null +++ b/contracts/typegen/http/response.ts @@ -0,0 +1,52 @@ +import type { Headers } from "./headers.ts"; +import type { StatusCode } from "./respcode.ts"; + +/** Response body union: everything the platform adapters know how to send. */ +export type ResponseBody = + | string + | Uint8Array + | ArrayBuffer + | ReadableStream + | null + | undefined; + +/** + * Response contract. + * + * Handlers may return this shape directly, or return a plain value which is + * then wrapped by the router using the route's declared content type. + */ +export interface HttpResponse { + status: StatusCode; + headers: Headers; + body: Body; +} + +/** A route that renders a template instead of returning a value. */ +export interface ViewResponse extends HttpResponse { + /** Template path relative to the views directory, e.g. `pages/index`. */ + template: string; + /** Props handed to the template. */ + props: Record; +} + +/** + * A chunk of html that is rendered after the shell of the response. + * + * Deferred components never block the first byte, the response is streamed + * and the chunk is appended as soon as it is ready. + */ +export interface DeferredChunk { + /** Identifier of the chunk, used as the marker in the html. */ + id: string; + /** Content of the chunk. */ + content: Promise; +} + +/** Result of a view render. */ +export interface RenderedView { + /** Html of the shell. */ + html: string; + /** Chunks rendered after the shell. */ + deferred?: DeferredChunk[]; +} diff --git a/contracts/typegen/minify.ts b/contracts/typegen/minify.ts new file mode 100644 index 0000000..edff86d --- /dev/null +++ b/contracts/typegen/minify.ts @@ -0,0 +1,45 @@ +/** Flattens an intersection into one object type. */ +export type Minify = T extends (...args: never[]) => unknown + ? T + : { [K in keyof T]: T[K] } & {}; + +/** Alias of {@link Minify}, kept for readability in generated output. */ +export type Simplify = Minify; + +/** Makes every property of `T` readonly, recursively. */ +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +/** Removes `null` and `undefined` from a union. */ +export type Defined = Exclude; + +/** Marks the selected keys of `T` as optional. */ +export type WithOptional = Omit & + Partial>; + +/** Marks the selected keys of `T` as required. */ +export type WithRequired = Omit & + Required>; + +export function compactTypeSource(source: string): string { + const arrow = source.replace(/=>/g, "__ARROW__"); + return arrow + .replace(/\s+/g, " ") + .replace(/\s*([|&=<>?:,()[\]{}])\s*/g, "$1") + .replace(/;\s*$/, "") + .trim() + .replace(/__ARROW__/g, "=>"); +} + +export function indent(source: string, depth = 1): string { + const pad = " ".repeat(depth); + return source + .split("\n") + .map((line) => (line.length > 0 ? pad + line : line)) + .join("\n"); +} diff --git a/contracts/typegen/orm/field.ts b/contracts/typegen/orm/field.ts new file mode 100644 index 0000000..1cc46b0 --- /dev/null +++ b/contracts/typegen/orm/field.ts @@ -0,0 +1,80 @@ +/** Storage types, mapped per dialect by the adapters. */ +export const FIELD_TYPES = [ + "text", + "int", + "bigint", + "real", + "bool", + "blob", + "json", + "uuid", + "timestamp", + "date", +] as const; + +export type FieldType = (typeof FIELD_TYPES)[number]; + +export type ReferentialAction = + | "CASCADE" + | "SET NULL" + | "RESTRICT" + | "NO ACTION" + | "SET DEFAULT"; + +export interface ReferenceOptions { + /** Target table. */ + table: string; + /** Target column, defaults to the primary key of the target table. */ + column?: string; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; +} + +/** Options accepted by the `@Field()` decorator. */ +export interface FieldOptions { + /** Storage type of the column. */ + type: FieldType; + /** Column name, defaults to the snake_cased property name. */ + column?: string; + /** Marks the column as part of the primary key. */ + primary?: boolean; + /** Adds a unique constraint. */ + unique?: boolean; + /** Allows `NULL` values. */ + nullable?: boolean; + /** Column default, emitted into the `CREATE TABLE` statement. */ + default?: unknown; + /** Foreign key reference. */ + references?: ReferenceOptions; + /** + * Transformation applied to the value right before it is written to the + * database (after the write, before the commit). + */ + encode?: (value: any) => unknown; + /** + * Transformation applied to the raw column value while the row is hydrated, + * i.e. before application code can access the property. + */ + decode?: (value: any) => Value; +} + +/** Runtime description of a single field, collected by the `@Field()` decorator. */ +export interface FieldDescriptor { + /** Property name on the model class. */ + property: string; + /** Column name in the database. */ + column: string; + options: FieldOptions; +} + +/** Flattened column description used by the schema builder and the repository. */ +export interface ColumnMapping { + property: string; + column: string; + type: FieldType; + nullable: boolean; + primary: boolean; + unique: boolean; + default: unknown; + references?: ReferenceOptions; +} diff --git a/contracts/typegen/orm/index.ts b/contracts/typegen/orm/index.ts new file mode 100644 index 0000000..8a0dd3c --- /dev/null +++ b/contracts/typegen/orm/index.ts @@ -0,0 +1,3 @@ +export * from "./field.ts"; +export * from "./repository.ts"; +export * from "./table.ts"; diff --git a/contracts/typegen/orm/repository.ts b/contracts/typegen/orm/repository.ts new file mode 100644 index 0000000..675125e --- /dev/null +++ b/contracts/typegen/orm/repository.ts @@ -0,0 +1,108 @@ +/** Comparison operators supported by the query builder. */ +export const WHERE_OPERATORS = [ + "=", + "!=", + "<", + "<=", + ">", + ">=", + "like", + "ilike", + "in", + "not in", + "is null", + "is not null", +] as const; + +export type WhereOperator = (typeof WHERE_OPERATORS)[number]; + +export interface WhereCondition { + column: string; + operator: WhereOperator; + value?: unknown; +} + +/** Where input accepted by the fluent builder. */ +export type WhereInput = + | Partial, unknown>> + | WhereCondition[]; + +export type OrderDirection = "asc" | "desc"; + +export interface OrderBy { + column: string; + direction: OrderDirection; +} + +/** Column names of a row, used to keep the builder type safe. */ +export type ColumnName = Extract; + +/** The value handed to `create()`. */ +export type CreateData = Partial< + Record, unknown> +>; + +export interface Repository { + /** Restricts the selected columns. */ + select(...columns: ColumnName[]): Repository; + /** Adds a condition, implicitly combined with `AND`. */ + where(column: ColumnName, value: unknown): Repository; + /** Adds a condition with an explicit operator. */ + where( + column: ColumnName, + operator: WhereOperator, + value?: unknown, + ): Repository; + /** Adds a raw condition list. */ + where(conditions: WhereCondition[]): Repository; + /** Adds a group of conditions combined with `AND`. */ + and(conditions: WhereCondition[]): Repository; + /** Adds a group of conditions combined with `OR`. */ + or(conditions: WhereCondition[]): Repository; + /** Orders the result set. */ + orderBy(column: ColumnName, direction?: OrderDirection): Repository; + /** Limits the result set. */ + limit(count: number): Repository; + /** Skips rows. */ + offset(count: number): Repository; + /** Runs a raw statement and returns the rows. */ + query>( + sql: string, + params?: readonly unknown[], + ): Promise; + /** Executes the query and hydrates every row. */ + all(): Promise; + /** Executes the query and hydrates the first row. */ + first(): Promise; + /** Counts the rows matching the current conditions. */ + count(): Promise; + /** Inserts a row, encoding every value before it is committed. */ + create(data: Partial): Promise; + /** Updates the matching rows, encoding every value before the commit. */ + update(data: Partial): Promise; + /** Deletes the matching rows. */ + delete(): Promise; +} + +/** Dialect neutral SQL surface implemented by the adapters. */ +export interface SqlDriver { + readonly dialect: "sqlite" | "postgres"; + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning: boolean; + /** Runs a statement and returns all rows. */ + query>( + sql: string, + params?: readonly unknown[], + ): Promise; + /** Runs a statement and returns the number of affected rows. */ + execute( + sql: string, + params?: readonly unknown[], + ): Promise<{ changes: number }>; + /** Runs `fn` inside a transaction. */ + transaction(fn: (driver: SqlDriver) => Promise): Promise; + /** Releases the underlying resources. */ + close(): Promise; +} diff --git a/contracts/typegen/orm/table.ts b/contracts/typegen/orm/table.ts new file mode 100644 index 0000000..7f0a84a --- /dev/null +++ b/contracts/typegen/orm/table.ts @@ -0,0 +1,31 @@ +import type { ColumnMapping } from "./field.ts"; + +/** Options accepted by the `@Table()` decorator. */ +export interface TableOptions { + /** Optional schema/namespace, e.g. `public`. */ + schema?: string; + /** Primary key columns, defaults to the columns flagged with `primary: true`. */ + primaryKey?: string | string[]; + /** Adds `created_at` / `updated_at` columns. */ + timestamps?: boolean; + /** Emit `IF NOT EXISTS` when creating the table. */ + ifNotExists?: boolean; + /** Additional `CREATE TABLE` suffix, e.g. `WITHOUT ROWID`. */ + suffix?: string; +} + +/** Runtime description of a table, collected by the `@Table()` decorator. */ +export interface TableDescriptor { + /** Table name. */ + name: string; + options: TableOptions; + /** Columns in declaration order. */ + columns: ColumnMapping[]; + /** Property names that are hydrated but never persisted. */ + hydrated: string[]; +} + +/** A model class decorated with `@Table()`. */ +export type TableClass = { + new (...args: any[]): Row; +} & { readonly __table?: TableDescriptor }; diff --git a/contracts/typegen/orm/tables.ts b/contracts/typegen/orm/tables.ts new file mode 100644 index 0000000..80e1a7c --- /dev/null +++ b/contracts/typegen/orm/tables.ts @@ -0,0 +1,4 @@ +/** Fallback table contracts. Project contracts are generated under `_muddle/contracts`. */ +export interface TableRows {} +export type TableName = keyof TableRows; +export type TableRowOf = TableRows[T]; diff --git a/contracts/typegen/views.ts b/contracts/typegen/views.ts new file mode 100644 index 0000000..b255d6c --- /dev/null +++ b/contracts/typegen/views.ts @@ -0,0 +1,6 @@ +import type { Minify } from "./minify.ts"; + +/** Fallback view contracts. Project contracts are generated under `_muddle/contracts`. */ +export interface ViewProps { [path: string]: Record } +export type ViewPath = string; +export type ViewPropsOf

= Minify; diff --git a/core/app.ts b/core/app.ts new file mode 100644 index 0000000..645c308 --- /dev/null +++ b/core/app.ts @@ -0,0 +1,201 @@ +import { ModuleRegistry } from "./modules.ts"; +import { Router, type ViewDescriptor } from "./router.ts"; +import { createRequestContext, parseRequestBody } from "./http.ts"; +import { toFormFields } from "./form.ts"; +import type { Type } from "./container.ts"; +import type { HttpResponse } from "../contracts/typegen/http/index.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { + Integration, + IntegrationContext, + Mounts, + ResolvedConfig, + BuildAsset, +} from "../contracts/index.ts"; +import type { ServerBuildContext } from "./context.ts"; +import type { RoutePipeline } from "./pipeline.ts"; + +/** Renders the templates of the application. */ +export interface ViewRenderer { + renderDescriptor( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise | string | RenderedView; + /** Registry of named components, mount targets of the integrations. */ + components?: Map; +} + +export interface AppOptions { + /** Root modules of the application. */ + modules?: Type[]; + /** Renderer used for the templates of the routes. */ + views?: ViewRenderer; + /** Resolved configuration of the project. */ + config?: ResolvedConfig; + /** Integrations of the project. */ + integrations?: Integration[]; + server?: ServerBuildContext; + pipeline?: RoutePipeline; +} + +export class App { + readonly registry: ModuleRegistry; + readonly router: Router; + private readonly config?: ResolvedConfig; + private readonly integrations: Integration[]; + private readonly views: ViewRenderer | undefined; + private readonly server; + + constructor(options: AppOptions = {}) { + this.config = options.config; + this.integrations = options.integrations ?? []; + this.views = options.views; + this.server = options.server?.build() ?? { headers: {}, locals: {} }; + this.registry = new ModuleRegistry(); + const views = options.views; + this.router = new Router({ + pipeline: options.pipeline, + renderView: views + ? (descriptor, context) => views.renderDescriptor(descriptor, context) + : undefined, + }); + + for (const moduleType of options.modules ?? []) { + this.registry.register(moduleType); + } + } + + get resolvedConfig(): ResolvedConfig | undefined { + return this.config; + } + + async transformAsset(asset: BuildAsset): Promise { + for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset); + return asset; + } + + async analyzePage(page: { route: string; html: string }): Promise> { + const reports: Record = {}; + for (const integration of this.integrations) if (integration.analyze) reports[integration.name] = await integration.analyze(page); + return reports; + } + + /** Creates the application and awaits the asynchronous providers. */ + static async create(options: AppOptions = {}): Promise { + const app = new App(options); + await app.init(); + return app; + } + + /** Runs the integrations and instantiates the controllers. */ + async init(): Promise { + const integrationContext: IntegrationContext = { + config: this.config ?? ({} as ResolvedConfig), + route: (method, path, handler) => this.router.raw(method, path, handler), + loader: (name, loader) => { + if (this.config) this.config.loaders[name] = loader; + }, + }; + for (const integration of this.integrations) { + await integration.setup?.(integrationContext); + for (const mount of integration.mounts ?? []) { + if (mount.views && this.views) { + await mount.views(this.views as unknown as Parameters>[0]); + } + if (mount.routes) { + await mount.routes((method, path, handler) => + this.router.raw(method, path, (_request, _context) => handler(_request)), + ); + } + if (mount.styles) { + for (const [path, value] of Object.entries(mount.styles)) { + this.router.raw("GET", path, async () => + new Response(typeof value === "function" ? await value() : value, { + headers: { "content-type": "text/css; charset=utf-8" }, + }), + ); + } + } + } + } + + await this.registry.bootstrapAsync(); + for (const { context, controller } of this.registry.controllers()) { + const instance = await context.container.resolveAsync(controller); + this.router.register(controller, instance); + } + + for (const integration of this.integrations) { + await integration.ready?.(); + } + } + + /** Adds a route that is not bound to a controller. */ + route( + method: string, + path: string, + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response, + ): void { + this.router.raw(method, path, handler); + } + + /** Handles a fetch request and returns the platform response. */ + async fetch(request: Request): Promise { + const context = createRequestContext(request); + Object.assign(context.locals, this.server.locals); + const response = await this.handle(request, context); + const fetchResponse = toFetchResponse(response); + for (const [name, value] of Object.entries(this.server.headers)) fetchResponse.headers.set(name, value); + return fetchResponse; + } + + /** Handles a request with a pre-built context. */ + async handle( + request: Request, + context: RequestContext, + ): Promise { + for (const integration of this.integrations) await integration.request?.(context); + context.body = await parseRequestBody(request); + context.form = toFormFields( + context.body && + typeof context.body === "object" && + !Array.isArray(context.body) + ? context.body + : undefined, + ); + return this.router.handle(context); + } +} + +/** Converts the framework response into a fetch `Response`. */ +export function toFetchResponse(response: HttpResponse): Response { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers ?? {})) { + headers.set(name, value); + } + + const status = response.status; + const nullBody = status === 204 || status === 304; + const body = response.body; + + if (nullBody || body === undefined || body === null) { + return new Response(null, { status, headers }); + } + if (typeof body === "string") { + return new Response(body, { status, headers }); + } + if (body instanceof Uint8Array) { + return new Response(body, { status, headers }); + } + if (body instanceof ArrayBuffer) { + return new Response(body, { status, headers }); + } + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + return new Response(body, { status, headers }); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/core/backport.ts b/core/backport.ts new file mode 100644 index 0000000..ce490d6 --- /dev/null +++ b/core/backport.ts @@ -0,0 +1,497 @@ +import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { transform } from "lightningcss"; +import ts from "typescript"; + +export type BackportSource = "astro" | "zola"; + +export interface BackportResult { + source: BackportSource; + output: string; + files: string[]; +} + +const ASSET_EXTENSIONS = new Set([ + ".avif", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".webp", + ".woff", + ".woff2", + ".ttf", + ".pdf", + ".txt", +]); + +export function detectBackportSource(root: string): BackportSource { + if ( + existsSync(join(root, "config.toml")) || + existsSync(join(root, "content")) && existsSync(join(root, "templates")) + ) return "zola"; + if ( + existsSync(join(root, "astro.config.mjs")) || + existsSync(join(root, "astro.config.ts")) || + existsSync(join(root, "src", "pages")) + ) return "astro"; + throw new Error(`Could not identify a Zola or Astro project in ${root}`); +} + +export async function backportProject( + input: string, + output: string, + source = detectBackportSource(input), +): Promise { + const inputRoot = resolve(input); + const outputRoot = resolve(output); + if (inputRoot === outputRoot) { + throw new Error( + "The Muddle destination must be different from the source directory", + ); + } + const files: string[] = []; + const write = async (path: string, content: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + files.push(relative(outputRoot, path).replaceAll("\\", "/")); + }; + + const contentRoot = join(inputRoot, source === "astro" ? "src/content" : "content"); + await writeProjectShell(outputRoot, write, await collectionNames(contentRoot), source === "zola" ? await zolaSiteConfig(join(inputRoot, "config.toml")) : {}); + const mappings = source === "astro" + ? [ + { from: "src/pages", to: "modules/app/views" }, + { from: "src/layouts", to: "modules/app/views/layouts" }, + { from: "src/components", to: "modules/app/views/components" }, + { from: "src/content", to: "content" }, + { from: "public", to: "public" }, + { from: "src/assets", to: "public/assets" }, + { from: "src/styles", to: "styles" }, + ] + : [ + { from: "templates", to: "modules/app/views" }, + { from: "content", to: "content" }, + { from: "static", to: "public" }, + { from: "sass", to: "styles" }, + ]; + for (const mapping of mappings) { + const folder = join(inputRoot, mapping.from); + if (existsSync(folder)) { + await convertFolder(folder, join(outputRoot, mapping.to), write, source, folder); + } + } + return { source, output: outputRoot, files }; +} + +async function collectionNames(root: string): Promise { + if (!existsSync(root)) return []; + const names: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory()) names.push(entry.name.replace(/[^\w-]/g, "-")); + } + return names.sort(); +} + +async function zolaSiteConfig(path: string): Promise> { + if (!existsSync(path)) return {}; + const source = await readFile(path, "utf8"); + const values: Record = {}; + for (const key of ["title", "base_url", "description", "default_language"]) { + const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`, "m").exec(source); + if (match?.[1] !== undefined) values[key] = match[1]; + } + return values; +} + +async function writeProjectShell( + root: string, + write: (path: string, content: string) => Promise, + collections: string[], + site: Record, +): Promise { + const collectionConfig = collections.length + ? `,\n collections: [${collections.map((name) => `{ name: ${JSON.stringify(name)}, dir: ${JSON.stringify(`content/${name}`)}, loader: "markdown" }`).join(", ")}]` + : ""; + await write( + join(root, "package.json"), + `{\n "name": "muddle-backport",\n "private": true,\n "type": "module",\n "scripts": { "dev": "muddle dev", "build": "muddle build", "typegen": "muddle typegen" },\n "dependencies": { "muddle": "latest" }\n}\n`, + ); + await write( + join(root, "ct.config.ts"), + `import { defineConfig } from "muddle/contracts";\nimport { markdownIntegration, stcssIntegration, sassIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n root: import.meta.dir,\n views: "modules/app/views",\n viewRoots: ["modules/app/views"],\n integrations: [markdownIntegration(), stcssIntegration(), sassIntegration()]${collectionConfig},\n prerender: { routes: ["/"], outDir: "dist" },\n});\n`, + ); + await write( + join(root, "index.ts"), + `import { join } from "node:path";\nimport { App } from "muddle/core/app";\nimport { prerender } from "muddle/adapters/static";\nimport { createViews } from "muddle/views/api";\nimport { resolved } from "./config.ts";\nimport { AppModule } from "./modules/app/app.module.ts";\n\nexport const app = await App.create({ modules: [AppModule], config: resolved, integrations: resolved.integrations, views: createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components }) });\nexport async function build() {\n if (resolved.prerender) await prerender(app, { ...resolved.prerender, outDir: join(resolved.root, resolved.prerender.outDir ?? "dist") });\n}\n`, + ); + await write( + join(root, "config.ts"), + `import { resolveConfig } from "muddle/core/config";\nimport config from "./ct.config.ts";\nexport const resolved = resolveConfig(config);\n`, + ); + await write( + join(root, "modules/app/app.module.ts"), + `import { Module } from "muddle/decorators";\nimport { AppController } from "./app.controller.ts";\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, + ); + await write( + join(root, "modules/app/site.config.ts"), + `/** Static values imported from the source site's configuration. */\nexport const site = ${JSON.stringify({ title: site.title ?? "Muddle backport", baseUrl: site.base_url ?? "", description: site.description ?? "", language: site.default_language ?? "en" }, null, 2)} as const;\n`, + ); + await write( + join(root, "modules/app/app.controller.ts"), + `import { Controller, View, ViewRoot } from "muddle/decorators";\nimport { site } from "./site.config.ts";\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n @View("index.spug")\n index() { return { title: site.title, site }; }\n}\n`, + ); +} + +async function convertFolder( + source: string, + destination: string, + write: (path: string, content: string) => Promise, + kind: BackportSource, + sourceRoot: string, +): Promise { + for (const entry of await readdir(source, { withFileTypes: true })) { + const from = join(source, entry.name); + const to = join(destination, entry.name); + if (entry.isDirectory()) { + await convertFolder(from, to, write, kind, sourceRoot); + continue; + } + const extension = extname(entry.name).toLowerCase(); + if ( + extension === ".astro" || extension === ".html" || + extension === ".htm" || extension === ".tera" + ) { + await write( + to.replace(/\.(astro|html?|tera)$/i, ".spug"), + kind === "astro" + ? astroToSpug(await readFile(from, "utf8"), from, sourceRoot) + : zolaToSpug(await readFile(from, "utf8")), + ); + } else if (extension === ".tsx" || extension === ".jsx") { + await write(to.replace(/\.(tsx|jsx)$/i, ".spug"), reactToSpug(await readFile(from, "utf8"), from)); + } else if (extension === ".css") { + await write( + to.replace(/\.css$/i, ".stcss"), + cssToStcss(await readFile(from, "utf8"), from), + ); + } else if (extension === ".scss" || extension === ".sass") { + await write( + to.replace(/\.(scss|sass)$/i, ".scss"), + await readFile(from, "utf8"), + ); + } else if ( + extension === ".md" || extension === ".markdown" || + ASSET_EXTENSIONS.has(extension) + ) { + await mkdir(dirname(to), { recursive: true }); + await cp(from, to); + } + } +} + +export function reactToSpug(source: string, filename = "component.tsx"): string { + const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const analysis = analyzeReactComponent(source, filename); + const imports = new Set(); + let root: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { + for (const element of node.importClause.namedBindings.elements) imports.add(element.name.text); + } + if (ts.isImportDeclaration(node) && node.importClause?.name) imports.add(node.importClause.name.text); + if (ts.isReturnStatement(node) && node.expression && !root) { + const expression = unwrapJsxExpression(node.expression); + if (isJsxNode(expression)) root = expression; + } + if (ts.isArrowFunction(node) && !root && ts.isExpression(node.body)) { + const expression = unwrapJsxExpression(node.body); + if (isJsxNode(expression)) root = expression; + } + ts.forEachChild(node, visit); + }; + visit(file); + if (!root) throw new Error(`No JSX return value found in ${filename}`); + const aot = analysis.state.map((state) => ` const ${state.name} = ${state.initial};`); + const script = aot.length || analysis.hooks.length + ? `javascript!\n // React hooks lowered during static component composition: ${analysis.hooks.join(", ") || "none"}\n${aot.join("\n")}\nend\n` + : ""; + return `${script}${jsxToSpug(root, imports).trim()}\n`; +} + +export interface ReactHookAnalysis { + hooks: string[]; + state: { name: string; initial: string }[]; +} + +/** Finds React hooks and extracts AOT-safe initial state from a component. */ +export function analyzeReactComponent(source: string, filename = "component.tsx"): ReactHookAnalysis { + const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hooks = new Set(); + const state: { name: string; initial: string }[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = ts.isIdentifier(node.expression) ? node.expression.text + : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ""; + if (/^use[A-Z]/.test(name)) hooks.add(name); + if (name === "useState" && ts.isVariableDeclaration(node.parent) && ts.isArrayBindingPattern(node.parent.name)) { + const binding = node.parent.name.elements[0]; + if (binding && ts.isBindingElement(binding) && ts.isIdentifier(binding.name)) { + state.push({ name: binding.name.text, initial: node.arguments[0]?.getText() ?? "undefined" }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(file); + return { hooks: [...hooks].sort(), state }; +} + +function unwrapJsxExpression(node: ts.Expression): ts.Expression { + return ts.isParenthesizedExpression(node) ? unwrapJsxExpression(node.expression) : node; +} + +function isJsxNode(node: ts.Node): node is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment { + return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node); +} + +function jsxToSpug(node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment, imports: Set, depth = 0): string { + const pad = " ".repeat(depth); + if (ts.isJsxText(node)) { + const text = node.getText().replace(/\s+/g, " ").trim(); + return text ? `${pad}| ${text}` : ""; + } + if (ts.isJsxExpression(node)) { + if (!node.expression) return ""; + return `${pad}= ${node.expression.getText()}`; + } + if (ts.isJsxFragment(node)) return node.children.map((child) => jsxToSpug(child, imports, depth)).filter(Boolean).join("\n"); + const opening = ts.isJsxElement(node) ? node.openingElement : node; + const rawName = opening.tagName.getText(); + const name = rawName === "Comp" ? "button" : rawName; + const component = ( /^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp"; + const attributes = jsxAttributes(opening.attributes.properties, component); + const line = component ? `${pad}component ${kebab(name)}` : `${pad}${name}${attributes.inline}`; + const props = component && attributes.props.length ? `\n${attributes.props.map((property) => `${pad} ${property}`).join("\n")}` : ""; + if (ts.isJsxSelfClosingElement(node)) { + return depth === 0 ? `${line}${props}\n${pad} if content\n${pad} != await content()` : `${line}${props}`; + } + const children = node.children.map((child) => jsxToSpug(child, imports, depth + 1)).filter(Boolean).join("\n"); + return [line + props, children].filter(Boolean).join("\n"); +} + +function jsxAttributes(properties: ts.NodeArray, component = false): { inline: string; props: string[] } { + const inline: string[] = []; + const props: string[] = []; + for (const property of properties) { + if (ts.isJsxSpreadAttribute(property)) continue; + const name = property.name.getText(); + if (name === "ref") continue; + const initializer = property.initializer; + if (!initializer) { inline.push(`${name}=true`); continue; } + const value = ts.isStringLiteral(initializer) ? JSON.stringify(initializer.text) + : ts.isJsxExpression(initializer) && initializer.expression ? `(${initializer.expression.getText()})` + : JSON.stringify(initializer.getText()); + const normalized = name === "className" ? "class" : name; + const classValue = normalized === "class" && ts.isJsxExpression(initializer) && initializer.expression + ? staticClassValue(initializer.expression) ?? JSON.stringify("muddle-react") + : value; + if (component) props.push(`${normalized}: ${classValue}`); + else inline.push(`${normalized}=${classValue}`); + } + return { inline: inline.length ? `(${inline.join(", ")})` : "", props }; +} + +function staticClassValue(expression: ts.Expression): string | undefined { + if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text); + if (!ts.isCallExpression(expression)) return undefined; + const classes = expression.arguments.flatMap((argument) => ts.isStringLiteral(argument) ? [argument.text] : []); + return classes.length ? JSON.stringify(classes.join(" ")) : undefined; +} + +function kebab(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); +} + +export function cssToStcss(source: string, filename = "style.css"): string { + const css = transform({ + filename, + code: Buffer.from(source), + minify: false, + }).code.toString(); + let depth = 0; + const lines: string[] = []; + for (const token of css.replace(/\r/g, "").split(/([{};])/)) { + const value = token.trim(); + if (!value) continue; + if (value === "}") { + depth = Math.max(0, depth - 1); + lines.push(`${" ".repeat(depth)}}`); + continue; + } + if (value === "{") { + const previous = lines.pop() ?? ""; + lines.push(`${previous} {`); + depth += 1; + continue; + } + if (value === ";") { + const previous = lines.pop() ?? ""; + lines.push(`${previous};`); + continue; + } + lines.push(`${" ".repeat(depth)}${value}`); + } + return `${lines.join("\n").trim()}\n`; +} + +export function htmlToSpug(source: string): string { + const body = source.replace(/^---[\s\S]*?---\s*/m, "").replace( + /^---[\s\S]*?---\s*/m, + "", + ).replace(//g, ""); + const tokens = body.match(/<[^>]+>|[^<]+/g) ?? []; + const lines: string[] = []; + let depth = 0; + for (const token of tokens) { + if (/^<\//.test(token)) { + depth = Math.max(0, depth - 1); + continue; + } + if (/^$/.exec(token); + if (!match) continue; + const tag = match[1]!; + const attrs = attributesToSpug(match[2] ?? ""); + lines.push(`${" ".repeat(depth)}${tag}${attrs}`); + if ( + !/\/$/.test(token) && + !new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", + ]).has(tag) + ) depth += 1; + } else { + const text = token.replace(/\s+/g, " ").trim(); + if (text) { + lines.push( + `${" ".repeat(depth)}| ${ + text.replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }").replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }") + }`, + ); + } + } + } + return `${lines.join("\n")}\n`; +} + +/** Converts an Astro source file without evaluating its frontmatter. */ +export function astroToSpug(source: string, file = "page.astro", pagesRoot = ""): string { + const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? ""; + let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ""); + const imports = new Map(); + for (const entry of frontmatter.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g)) { + imports.set(entry[1]!, entry[2]!); + } + const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body); + let prefix = ""; + if (layout && imports.has(layout[1]!)) { + const imported = imports.get(layout[1]!)!; + if (/(?:^|\/)layouts\//.test(imported)) { + const absoluteLayout = resolve(dirname(file), imported); + const pageRelative = relative(pagesRoot, file); + const layoutRelative = relative(dirname(pagesRoot), absoluteLayout).replace(/\.astro$/, ".spug"); + const target = relative(dirname(pageRelative), layoutRelative).replaceAll("\\", "/"); + prefix = `extends ${target}\n\nblock content\n`; + body = layout[3]!; + } + } + body = body + .replace(//g, "") + .replace(//g, ""); + const converted = htmlToSpug(body) + .replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2") + .replace(/^(\s*)muddle-slot\s*$/gm, "$1block content"); + const componentized = componentCalls(converted, imports); + return prefix ? `${prefix}${indent(componentized)}` : componentized; +} + +/** Converts Tera inheritance, blocks, includes and expressions into SPUG. */ +export function zolaToSpug(source: string): string { + let result = source + .replace(/\{#([\s\S]*?)#\}/g, "") + .replace(/\{%\s*extends\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `extends ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) + .replace(/\{%\s*include\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `include ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) + .replace(/\{%\s*block\s+([\w-]+)\s*%\}/g, "block $1") + .replace(/\{%\s*endblock\s*%\}/g, "") + .replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1") + .replace(/\{%\s*endif\s*%\}/g, "") + .replace(/\{%\s*for\s+([\w$]+)\s+in\s+(.+?)\s*%\}/g, "each $1 in $2") + .replace(/\{%\s*endfor\s*%\}/g, "") + .replace(/\{\{\s*([^}]+?)\s*\}\}/g, "#{ $1 }"); + result = htmlToSpug(result); + return result.replace(/\| (extends|include|block|if|each) (.+)$/gm, "$1 $2"); +} + +function componentCalls(source: string, imports: Map): string { + const output: string[] = []; + for (const line of source.split("\n")) { + const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line); + if (!match || !imports.has(match[2]!)) { output.push(line); continue; } + const imported = imports.get(match[2]!)!; + const name = imported.replace(/^.*\//, "").replace(/\.astro$/, "").replace(/[A-Z]/g, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`); + const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1]; + output.push(`${match[1]}component ${name}`); + for (const pair of attributeText?.split(/\s*,\s*/) ?? []) { + const [key = "", raw = "true"] = pair.split(/=(.+)/); + const expression = /^\((.*)\)$/.exec(raw.trim())?.[1] ?? raw.trim().replace(/^\{\s*|\s*\}$/g, ""); + const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("("); + output.push(`${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`); + } + } + return `${output.filter(Boolean).join("\n").trim()}\n`; +} + +function indent(source: string): string { + return source.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n") + "\n"; +} + +function attributesToSpug(raw: string): string { + const classes: string[] = []; + const attributes: string[] = []; + for ( + const match of raw.matchAll( + /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g, + ) + ) { + const name = match[1]!; + const value = match[2] ?? match[3] ?? match[4]; + if (name === "class" && value) { + classes.push(...value.split(/\s+/).filter(Boolean)); + continue; + } + if (name === "id" && value && !value.startsWith("{")) { + attributes.push(`id=${JSON.stringify(value)}`); + continue; + } + if (value === undefined) attributes.push(`${name}=true`); + else if (/^\{[\s\S]*\}$/.test(value)) attributes.push(`${name}=(${value.slice(1, -1).trim()})`); + else attributes.push(`${name}=${JSON.stringify(value)}`); + } + return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`; +} diff --git a/core/cache.ts b/core/cache.ts new file mode 100644 index 0000000..362cd08 --- /dev/null +++ b/core/cache.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export interface Cache { + get(key: string): Promise; + put(key: string, value: Uint8Array, options?: { ttl?: number }): Promise; + delete(key: string): Promise; +} + +export interface CloudflareCacheLike { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; + delete(request: Request): Promise; +} + +function cacheKey(key: string): string { + return encodeURIComponent(key); +} + +export function filesystemCache(dir: string): Cache { + return { + async get(key) { + const path = join(dir, cacheKey(key)); + if (!existsSync(path)) return undefined; + return new Uint8Array(await readFile(path)); + }, + async put(key, value) { + await mkdir(dir, { recursive: true }); + const path = join(dir, cacheKey(key)); + const staged = `${path}.next`; + await writeFile(staged, value); + await rename(staged, path); + }, + async delete(key) { + const path = join(dir, cacheKey(key)); + if (existsSync(path)) await rename(path, `${path}.deleted`); + }, + }; +} + +export function cloudflareCache(cache: CloudflareCacheLike, origin = "https://muddle.cache"): Cache { + const request = (key: string) => new Request(new URL(`/${encodeURIComponent(key)}`, origin).toString()); + return { + async get(key) { + const response = await cache.match(request(key)); + return response ? new Uint8Array(await response.arrayBuffer()) : undefined; + }, + async put(key, value, options) { + const headers = options?.ttl ? { "cache-control": `max-age=${options.ttl}` } : undefined; + await cache.put(request(key), new Response(value, { headers })); + }, + async delete(key) { await cache.delete(request(key)); }, + }; +} diff --git a/core/config.ts b/core/config.ts new file mode 100644 index 0000000..164807e --- /dev/null +++ b/core/config.ts @@ -0,0 +1,36 @@ +import { join, resolve } from "node:path"; +import type { MuddleConfig, ResolvedConfig } from "../contracts/index.ts"; + +/** Applies the defaults of a project configuration. */ +export function resolveConfig( + config: MuddleConfig = {}, + root: string = process.cwd(), +): ResolvedConfig { + const resolvedRoot = resolve(config.root ?? root); + const views = config.views ?? "views"; + return { + ...config, + root: resolvedRoot, + views: resolve(resolvedRoot, views), + components: resolve( + resolvedRoot, + config.components ?? join(views, "components"), + ), + content: resolve(resolvedRoot, config.content ?? "content"), + styles: resolve(resolvedRoot, config.styles ?? "styles"), + publicDir: resolve(resolvedRoot, config.publicDir ?? "public"), + viewRoots: (config.viewRoots ?? [views]).map((entry) => + resolve(resolvedRoot, entry), + ), + loaders: config.loaders ?? {}, + collections: config.collections ?? [], + integrations: config.integrations ?? [], + }; +} + +/** Reads the configuration of a project from its `ct.config.ts`. */ +export async function loadConfig(root: string): Promise { + const configPath = join(root, "ct.config.ts"); + const module = (await import(configPath)) as { default?: MuddleConfig }; + return module.default ?? {}; +} diff --git a/core/container.ts b/core/container.ts new file mode 100644 index 0000000..3624c62 --- /dev/null +++ b/core/container.ts @@ -0,0 +1,307 @@ +import { UnknownProviderError } from "./errors.ts"; +import { getLatestMetadata, getMetadata } from "./metadata.ts"; + +export type Type = new (...args: never[]) => T; +export type AbstractType = abstract new (...args: never[]) => T; +export type Token = Type | AbstractType | symbol | string; + +export type ProviderScope = "singleton" | "request" | "transient"; + +export interface ProviderDefinition { + /** Token the provider is registered under, defaults to `useClass`. */ + provide?: Token; + /** Static value. */ + useValue?: T; + /** Class instantiated by the container. */ + useClass?: AbstractType; + /** Factory function, dependencies are resolved through `inject`. */ + useFactory?: (...dependencies: unknown[]) => T; + /** Tokens handed to `useFactory` (or used for `useClass` overrides). */ + inject?: Token[]; + /** Lifetime of the provider. */ + scope?: ProviderScope; +} + +interface FactoryEntry { + token: Token; + scope: ProviderScope; + owner: Container; + produce: (scope?: Container) => unknown; + /** Asynchronous variant, used by {@link Container.resolveAsync}. */ + produceAsync?: (scope?: Container) => Promise; +} + +export const PARAM_TYPES_METADATA = "design:paramtypes"; +export const INJECT_TOKENS_METADATA = "muddle:inject"; +export const OPTIONAL_PARAMS_METADATA = "muddle:optional"; + +interface InjectEntry { + index: number; + token: Token; +} + +/** Reads the `@Inject()` overrides of a constructor. */ +export function getInjectTokens(target: object): Map { + const entries = getLatestMetadata(INJECT_TOKENS_METADATA, target) as + | InjectEntry[] + | undefined; + const map = new Map(); + for (const entry of entries ?? []) { + map.set(entry.index, entry.token); + } + return map; +} + +/** Reads the parameters marked with `@Optional()`. */ +export function getOptionalParams(target: object): Set { + const entries = getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as + | number[] + | undefined; + return new Set(entries ?? []); +} + +export class Container { + private readonly factories = new Map(); + readonly instances = new Map(); + + /** Called when a token cannot be found in this container or its parents. */ + fallbackResolver?: (token: Token, scope?: Container) => unknown; + + constructor(readonly parent?: Container) {} + + /** Creates a child container that shares factories but isolates instances. */ + createScope(): Container { + const scope = new Container(this); + scope.fallbackResolver = (token, inner) => + this.lookupFallback(token, inner); + return scope; + } + + /** Registers a factory entry. */ + set(token: Token, entry: Omit): void { + this.factories.set(token, { ...entry, token, owner: this }); + } + + /** `true` when the token is known to this container or one of its parents. */ + has(token: Token): boolean { + return this.findEntry(token) !== undefined; + } + + /** Every token known to this container. */ + tokens(): Token[] { + return [...this.factories.keys()]; + } + + /** + * Resolves a token. + * Providers built with an asynchronous factory return a promise, use + * {@link resolveAsync} to await them. + */ + resolve(token: Token, scope?: Container): T { + const entry = this.findEntry(token); + if (!entry) { + const fallback = this.lookupFallback(token, scope); + if (fallback !== undefined) return fallback as T; + throw new UnknownProviderError(token); + } + + if (entry.scope === "transient") { + return entry.produce(scope) as T; + } + + const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; + const cached = cache.instances.get(token); + if (cached !== undefined) return cached as T; + + const value = entry.produce(scope); + cache.instances.set(token, value); + return value as T; + } + + /** Resolves a token, awaits async providers. */ + async resolveAsync(token: Token, scope?: Container): Promise { + const entry = this.findEntry(token); + if (!entry) { + const fallback = this.lookupFallback(token, scope); + if (fallback !== undefined) return (await fallback) as T; + throw new UnknownProviderError(token); + } + + if (entry.scope === "transient") { + const value = entry.produceAsync + ? await entry.produceAsync(scope) + : await entry.produce(scope); + return value as T; + } + + const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; + const cached = cache.instances.get(token); + if (cached !== undefined) return (await cached) as T; + + const value = entry.produceAsync + ? await entry.produceAsync(scope) + : await entry.produce(scope); + cache.instances.set(token, value); + return value as T; + } + + private findEntry(token: Token): FactoryEntry | undefined { + const own = this.factories.get(token); + if (own) return own; + return this.parent?.findEntry(token); + } + + private lookupFallback(token: Token, scope?: Container): unknown { + const fallback = this.fallbackResolver ?? this.parent?.fallbackResolver; + if (!fallback) return undefined; + return fallback(token, scope); + } + + /** Registers a class, resolving its constructor dependencies automatically. */ + registerClass( + ctor: AbstractType, + options: { scope?: ProviderScope; inject?: Token[] } = {}, + ): void { + this.set(ctor as Token, { + scope: options.scope ?? "singleton", + produce: (scope) => this.instantiate(ctor, options.inject, scope), + produceAsync: async (scope) => + this.instantiateAsync(ctor, options.inject, scope), + }); + } + + /** Registers a plain value. */ + registerValue(token: Token, value: T): void { + this.set(token as Token, { scope: "singleton", produce: () => value }); + } + + /** Registers a factory function. */ + registerFactory( + token: Token, + factory: (...dependencies: unknown[]) => T | Promise, + options: { inject?: Token[]; scope?: ProviderScope } = {}, + ): void { + this.set(token as Token, { + scope: options.scope ?? "singleton", + produce: (scope) => { + const dependencies = (options.inject ?? []).map((dependency) => + this.resolve(dependency, scope), + ); + return factory(...dependencies); + }, + produceAsync: async (scope) => { + const dependencies = await Promise.all( + (options.inject ?? []).map((dependency) => + this.resolveAsync(dependency, scope), + ), + ); + return factory(...dependencies); + }, + }); + } + + /** Registers a provider definition. */ + registerProvider(provider: ProviderDefinition): void { + if (provider.useValue !== undefined) { + this.registerValue( + (provider.provide ?? provider.useClass) as Token, + provider.useValue, + ); + return; + } + if (provider.useFactory) { + const token = provider.provide ?? provider.useFactory; + this.registerFactory(token as Token, provider.useFactory, { + inject: provider.inject, + scope: provider.scope, + }); + return; + } + if (provider.useClass) { + this.registerClass(provider.useClass, { + scope: provider.scope, + inject: provider.inject, + }); + return; + } + throw new UnknownProviderError(provider.provide); + } + + /** Instantiates a class, resolving constructor parameters by type. */ + instantiate( + ctor: AbstractType, + inject?: Token[], + scope?: Container, + ): T { + const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? + []) as unknown[]; + const overrides = getInjectTokens(ctor as object); + const optional = getOptionalParams(ctor as object); + const dependencies: unknown[] = []; + + const length = Math.max( + paramTypes.length, + overrides.size, + inject?.length ?? 0, + ); + for (let index = 0; index < length; index++) { + const token = + overrides.get(index) ?? + inject?.[index] ?? + (paramTypes[index] as Token | undefined); + if (token === undefined || token === Object || token === Function) { + if (optional.has(index)) { + dependencies.push(undefined); + continue; + } + throw new UnknownProviderError( + `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, + ); + } + dependencies.push(this.resolve(token, scope)); + } + + return new (ctor as unknown as new (...args: unknown[]) => T)( + ...dependencies, + ); + } + + /** Instantiates a class and awaits asynchronous dependencies. */ + async instantiateAsync( + ctor: AbstractType, + inject?: Token[], + scope?: Container, + ): Promise { + const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? + []) as unknown[]; + const overrides = getInjectTokens(ctor as object); + const optional = getOptionalParams(ctor as object); + const dependencies: unknown[] = []; + + const length = Math.max( + paramTypes.length, + overrides.size, + inject?.length ?? 0, + ); + for (let index = 0; index < length; index++) { + const token = + overrides.get(index) ?? + inject?.[index] ?? + (paramTypes[index] as Token | undefined); + if (token === undefined || token === Object || token === Function) { + if (optional.has(index)) { + dependencies.push(undefined); + continue; + } + throw new UnknownProviderError( + `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, + ); + } + dependencies.push(await this.resolveAsync(token, scope)); + } + + return new (ctor as unknown as new (...args: unknown[]) => T)( + ...dependencies, + ); + } +} diff --git a/core/content.ts b/core/content.ts new file mode 100644 index 0000000..43d1554 --- /dev/null +++ b/core/content.ts @@ -0,0 +1,163 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { + CollectionConfig, + CollectionEntry, + ContentSchema, + LoadedFile, + ResolvedConfig, +} from "../contracts/index.ts"; +import { Service } from "../decorators/Service.ts"; + +/** Validates the frontmatter of an entry against the schema of a collection. */ +export function validateFrontmatter( + schema: ContentSchema | undefined, + frontmatter: Record, +): string[] { + if (!schema) return []; + const errors: string[] = []; + + for (const field of schema.required ?? []) { + const value = frontmatter[field]; + if (value === undefined || value === "") { + errors.push(`${field} is required`); + } + } + + for (const [field, allowed] of Object.entries(schema.enum ?? {})) { + const value = frontmatter[field]; + if (value === undefined) continue; + if (!allowed.includes(String(value))) { + errors.push(`${field} must be one of ${allowed.join(", ")}`); + } + } + + for (const [field, length] of Object.entries(schema.maxLength ?? {})) { + const value = frontmatter[field]; + if (value === undefined) continue; + if (String(value).length > length) { + errors.push(`${field} must be at most ${length} characters`); + } + } + + return errors; +} + +/** Registry of the content collections of a project. */ +export class ContentRegistry { + private readonly collections = new Map(); + + constructor(private readonly config: ResolvedConfig) {} + + /** Loads every collection of the project. */ + async load(): Promise { + for (const collection of this.config.collections ?? []) { + this.collections.set( + collection.name, + await this.loadCollection(collection), + ); + } + } + + /** Entries of a collection. */ + entries = Record>( + name: string, + ): CollectionEntry[] { + return (this.collections.get(name) ?? []) as CollectionEntry[]; + } + + /** A single entry of a collection. */ + get = Record>( + name: string, + id: string, + ): CollectionEntry | undefined { + return this.entries(name).find((entry) => entry.id === id); + } + + /** Names of the loaded collections. */ + names(): string[] { + return [...this.collections.keys()]; + } + + private async loadCollection( + collection: CollectionConfig, + ): Promise { + const dir = resolve(this.config.root, collection.dir); + if (!existsSync(dir)) return []; + + const loader = + typeof collection.loader === "string" + ? this.config.loaders[collection.loader] + : collection.loader; + if (!loader) { + throw new Error( + `Unknown loader "${collection.loader}" of the collection "${collection.name}"`, + ); + } + + const files = await listCollectionFiles(dir, loader.extensions); + const entries: CollectionEntry[] = []; + + for (const file of files.sort()) { + const path = join(dir, file); + const source = await readFile(path, "utf8"); + const loaded: LoadedFile = await loader.load({ + path, + relativePath: relative(this.config.root, path).replace(/\\/g, "/"), + source, + }); + + const errors = validateFrontmatter(collection.schema, loaded.data); + if (errors.length > 0) { + throw new Error( + `Invalid frontmatter in ${relative(this.config.root, path)}:\n${errors + .map((error) => ` - ${error}`) + .join("\n")}`, + ); + } + + const id = file.replace(/\.[^.]+$/, "").replace(/\\/g, "/"); + entries.push({ + id, + path: relative(this.config.root, path).replace(/\\/g, "/"), + frontmatter: loaded.data, + body: loaded.content ?? "", + content: source, + }); + } + + return entries; + } +} + +async function listCollectionFiles(dir: string, extensions: string[], prefix = ""): Promise { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const relativePath = join(prefix, entry.name); + if (entry.isDirectory()) files.push(...(await listCollectionFiles(join(dir, entry.name), extensions, relativePath))); + else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(relativePath); + } + return files.sort(); +} + +/** Service exposing the content collections to the controllers. */ +@Service() +export class ContentService { + constructor(private readonly registry: ContentRegistry) {} + + /** Entries of a collection. */ + list = Record>( + name: string, + ): CollectionEntry[] { + return this.registry.entries(name); + } + + /** A single entry of a collection. */ + get = Record>( + name: string, + id: string, + ): CollectionEntry | undefined { + return this.registry.get(name, id); + } +} diff --git a/core/context.ts b/core/context.ts new file mode 100644 index 0000000..4657acf --- /dev/null +++ b/core/context.ts @@ -0,0 +1,24 @@ +import type { Headers } from "../contracts/typegen/http/headers.ts"; + +export class ServerBuildContext { + private readonly values: { headers: Headers; locals: Record } = { headers: {}, locals: {} }; + + header(name: string, value: string): this { this.values.headers[name.toLowerCase()] = value; return this; } + headers(values: Headers): this { Object.assign(this.values.headers, values); return this; } + local(name: string, value: unknown): this { this.values.locals[name] = value; return this; } + build(): Readonly<{ headers: Headers; locals: Record }> { return { headers: { ...this.values.headers }, locals: { ...this.values.locals } }; } +} + +export class TemplateContext { + readonly imports = new Map, context: { locals: Record; params: Record }): Promise | string }>(); + import(name: string, component: { render(props: Record, context: { locals: Record; params: Record }): Promise | string }): this { + if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error("Invalid component import name"); + this.imports.set(name, component); + return this; + } + private readonly values: { templates: string[]; inputs: Record; contracts: Record } = { templates: [], inputs: {}, contracts: {} }; + template(path: string): this { this.values.templates.push(path); return this; } + input(name: string, value: unknown): this { this.values.inputs[name] = value; return this; } + contract(name: string, type: string): this { this.values.contracts[name] = type; return this; } + build(): Readonly<{ templates: string[]; inputs: Record; contracts: Record }> { return { templates: [...this.values.templates], inputs: { ...this.values.inputs }, contracts: { ...this.values.contracts } }; } +} diff --git a/core/errors.ts b/core/errors.ts new file mode 100644 index 0000000..9e2a92f --- /dev/null +++ b/core/errors.ts @@ -0,0 +1,46 @@ +/** Base error for every failure raised by the framework. */ +export class MuddleError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "MuddleError"; + } +} + +/** Raised when a dependency cannot be resolved from the current module graph. */ +export class UnknownProviderError extends MuddleError { + constructor(token: unknown) { + super(`Cannot resolve dependency: ${describeToken(token)}`); + this.name = "UnknownProviderError"; + } +} + +/** Raised when a request does not match any route. */ +export class RouteNotFoundError extends MuddleError { + constructor(method: string, path: string) { + super(`No route matches ${method} ${path}`); + this.name = "RouteNotFoundError"; + } +} + +/** Raised while compiling or rendering a template. */ +export class TemplateError extends MuddleError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TemplateError"; + } +} + +/** Raised when the ORM cannot find a table definition. */ +export class SchemaError extends MuddleError { + constructor(message: string) { + super(message); + this.name = "SchemaError"; + } +} + +/** Human readable representation of an injection token. */ +export function describeToken(token: unknown): string { + if (typeof token === "function") return token.name || "anonymous class"; + if (typeof token === "symbol") return token.toString(); + return String(token); +} diff --git a/core/form.ts b/core/form.ts new file mode 100644 index 0000000..023a1f3 --- /dev/null +++ b/core/form.ts @@ -0,0 +1,145 @@ +import type { FormFields } from "../contracts/typegen/http/request.ts"; + +/** Reads the fields of a parsed form body. */ +export function toFormFields(body: unknown): FormFields { + const source = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : {}; + return { + get(name: string) { + const value = source[name]; + if (value === undefined || value === null) return undefined; + return Array.isArray(value) ? String(value[0]) : String(value); + }, + all() { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + result[key] = Array.isArray(value) + ? value.map((entry) => String(entry)) + : String(value); + } + return result; + }, + has(name: string) { + return name in source; + }, + values() { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + result[key] = Array.isArray(value) + ? String(value[0] ?? "") + : String(value); + } + return result; + }, + }; +} + +/** Rules of a single form field. */ +export interface FieldRule { + /** The field has to be filled in. */ + required?: boolean; + /** Type of the field. */ + type?: "string" | "number" | "boolean" | "email"; + /** Minimum value of a number field. */ + min?: number; + /** Maximum value of a number field. */ + max?: number; + /** Maximum length of a string field. */ + maxLength?: number; + /** Allowed values. */ + enum?: string[]; + /** Value used when the field is empty. */ + default?: unknown; +} + +export type FormSchema = Record; + +/** Result of a validation. */ +export type ValidationResult = + | { ok: true; value: T; errors: Record } + | { ok: false; value: undefined; errors: Record }; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Validates the fields of a form. */ +export function validate>( + fields: FormFields, + schema: FormSchema, +): ValidationResult { + const errors: Record = {}; + const value: Record = {}; + + for (const [name, rule] of Object.entries(schema)) { + const raw = fields.get(name); + const empty = raw === undefined || raw.trim().length === 0; + + if (empty) { + if (rule.default !== undefined) { + value[name] = rule.default; + continue; + } + if (rule.required) { + errors[name] = `${name} is required`; + } + continue; + } + + let parsed: unknown = raw; + if (rule.type === "number") { + const numeric = Number(raw); + if (Number.isNaN(numeric)) { + errors[name] = `${name} must be a number`; + continue; + } + if (rule.min !== undefined && numeric < rule.min) { + errors[name] = `${name} must be at least ${rule.min}`; + continue; + } + if (rule.max !== undefined && numeric > rule.max) { + errors[name] = `${name} must be at most ${rule.max}`; + continue; + } + parsed = numeric; + } else if (rule.type === "boolean") { + parsed = raw === "on" || raw === "true" || raw === "1"; + } else if (rule.type === "email") { + if (!EMAIL.test(raw)) { + errors[name] = `${name} must be a valid email address`; + continue; + } + } + + if ( + rule.maxLength !== undefined && + String(parsed).length > rule.maxLength + ) { + errors[name] = `${name} must be at most ${rule.maxLength} characters`; + continue; + } + if (rule.enum && !rule.enum.includes(String(parsed))) { + errors[name] = `${name} must be one of ${rule.enum.join(", ")}`; + continue; + } + + value[name] = parsed; + } + + if (Object.keys(errors).length > 0) { + return { ok: false, value: undefined, errors }; + } + return { ok: true, value: value as T, errors }; +} + +/** + * Builds the location of a redirect that carries a message. + */ +export function redirectWithMessage( + location: string, + message?: string, +): string { + if (!message) return location; + const separator = location.includes("?") ? "&" : "?"; + return `${location}${separator}message=${encodeURIComponent(message)}`; +} diff --git a/core/format-source.ts b/core/format-source.ts new file mode 100644 index 0000000..3120e4b --- /dev/null +++ b/core/format-source.ts @@ -0,0 +1,63 @@ +import { foldLines } from "../views/engine/lexer.ts"; + +export interface FormatOptions { tabWidth?: number; useTabs?: boolean; printWidth?: number } +export type FormatLanguage = "spug" | "stpug" | "stug" | "stcss"; + +/** Shared by the CLI and Prettier plugin, with no subprocesses or write side effects. */ +export async function formatSource(source: string, language: FormatLanguage, options: FormatOptions = {}): Promise { + const prettier = await import("prettier"); + const scripts: string[] = []; + let protectedSource = source.replace(/(^[\t ]*)(javascript!|typescript!|---)\s*\n([\s\S]*?)^\s*(end|---)\s*$/gm, (_match, indentation: string, directive: string, code: string, ending: string) => { + const index = scripts.length; + scripts.push(JSON.stringify({ directive, code, ending })); + return language === "stcss" ? `/* MUDDLE_SCRIPT_${index} */` : `${indentation}MUDDLE_SCRIPT_${index}`; + }); + const rawBlocks: { marker: string; body: string[]; prefix: string }[] = []; + if (language !== "stcss") { + const sourceLines = protectedSource.split("\n"); + const output: string[] = []; + for (let index = 0; index < sourceLines.length; index++) { + const line = sourceLines[index]!; + const match = /^(\s*)(?:[\w.#-]+(?:\([^]*\))?\.|\/\/-?|\$\$\$)\s*$/.exec(line); + if (!match) { output.push(line); continue; } + const prefix = match[1]!; + const body: string[] = [line.slice(prefix.length)]; + while (index + 1 < sourceLines.length) { + const next = sourceLines[index + 1]!; + if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= prefix.length) break; + body.push(next.startsWith(prefix) ? next.slice(prefix.length) : next); + index++; + } + const marker = `MUDDLE_RAW_${rawBlocks.length}`; + rawBlocks.push({ marker, body, prefix }); + output.push(prefix + marker); + } + protectedSource = output.join("\n"); + } + const unit = options.useTabs ? "\t" : " ".repeat(options.tabWidth ?? 2); + let formatted: string; + if (language === "stcss") { + formatted = await prettier.format(protectedSource, { parser: "scss", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false, printWidth: options.printWidth ?? 80 }); + } else { + const stack = [0]; + const lines: string[] = []; + for (const line of foldLines(protectedSource)) { + while (stack.length > 1 && line.indent < stack[stack.length - 1]!) stack.pop(); + if (line.indent > stack[stack.length - 1]!) stack.push(line.indent); + const indent = unit.repeat(stack.length - 1); + lines.push(`${indent}${line.text.trimEnd()}`); + } + formatted = `${lines.join("\n")}\n`; + } + for (let index = 0; index < scripts.length; index++) { + const script = JSON.parse(scripts[index]!) as { directive: string; code: string; ending: string }; + const code = await prettier.format(script.code, { parser: "typescript", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false }); + const marker = language === "stcss" ? new RegExp(`^[\\t ]*/\\* MUDDLE_SCRIPT_${index} \\*/`, "m") : new RegExp(`^([\\t ]*)MUDDLE_SCRIPT_${index}`, "m"); + formatted = formatted.replace(marker, (match) => { + const indentation = match.match(/^[\t ]*/)?.[0] ?? ""; + return `${indentation}${script.directive}\n${code.trimEnd().split("\n").map((line) => `${indentation}${line}`).join("\n")}\n${indentation}${script.ending}`; + }); + } + for (const block of rawBlocks) formatted = formatted.replace(new RegExp(`^([\\t ]*)${block.marker}$`, "m"), (_match, prefix: string) => block.body.map((line) => prefix + line).join("\n")); + return formatted; +} diff --git a/core/format.ts b/core/format.ts new file mode 100644 index 0000000..6fe6a02 --- /dev/null +++ b/core/format.ts @@ -0,0 +1,22 @@ +import { readdir, readFile, writeFile, stat } from "node:fs/promises"; +import { join, extname } from "node:path"; +import { formatSource, type FormatLanguage, type FormatOptions } from "./format-source.ts"; + +export async function format(root: string, options: FormatOptions & { check?: boolean } = {}): Promise { + const changed: string[] = []; + if ((await stat(root)).isFile()) { + const language = extname(root).slice(1) as FormatLanguage; + if (!["spug", "stpug", "stug", "stcss"].includes(language)) return changed; + const source = await readFile(root, "utf8"); + const next = await formatSource(source, language, options); + if (next !== source) { changed.push(root); if (!options.check) await writeFile(root, next, "utf8"); } + return changed; + } + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".") || ["node_modules", "dist", "dist.previous", "shadcn-svelte", "_muddle"].includes(entry.name)) continue; + const path = join(root, entry.name); + changed.push(...await format(path, options)); + } + return changed; +} diff --git a/core/http.ts b/core/http.ts new file mode 100644 index 0000000..3d9c1de --- /dev/null +++ b/core/http.ts @@ -0,0 +1,239 @@ +import type { + Headers, + HttpResponse, + ResponseBuilder, + StatusCode, +} from "../contracts/typegen/http/index.ts"; +import { + FORM_CONTENT_TYPE, + HTML_CONTENT_TYPE, + JSON_CONTENT_TYPE, + TEXT_CONTENT_TYPE, + mediaType, +} from "../contracts/typegen/http/index.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import { toFormFields } from "./form.ts"; +import { existsSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; + +/** Creates an HTML response. */ +export function html( + body: string, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": HTML_CONTENT_TYPE }, body }; +} + +/** Creates a JSON response. */ +export function json( + body: unknown, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": JSON_CONTENT_TYPE }, body }; +} + +/** Creates a plain text response. */ +export function text( + body: string, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": TEXT_CONTENT_TYPE }, body }; +} + +/** Creates a redirect response. */ +export function redirect( + location: string, + status: StatusCode = 302, +): HttpResponse { + return { status, headers: { location }, body: undefined as never }; +} + +/** Creates a `404` response. */ +export function notFound(message = "Not Found"): HttpResponse { + return text(message, 404); +} + +/** Creates a response builder bound to a status code and a set of headers. */ +export function createResponseBuilder( + initialStatus: StatusCode = 200, + initialHeaders: Headers = {}, +): ResponseBuilder { + let status = initialStatus; + const headers: Headers = { ...initialHeaders }; + const builder: ResponseBuilder = { + html(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": HTML_CONTENT_TYPE }, + body, + }; + }, + json(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": JSON_CONTENT_TYPE }, + body, + }; + }, + text(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": TEXT_CONTENT_TYPE }, + body, + }; + }, + redirect(location, code) { + return { + status: code ?? 302, + headers: { ...headers, location }, + body: undefined as never, + }; + }, + status(code) { + status = code as StatusCode; + return builder; + }, + header(name, value) { + headers[name.toLowerCase()] = value; + return builder; + }, + send(body) { + return { status, headers: { ...headers }, body }; + }, + }; + return builder; +} + +/** Parses the body of a fetch `Request` according to its content type. */ +export async function parseRequestBody(request: Request): Promise { + const type = mediaType(request.headers.get("content-type") ?? ""); + if (request.method === "GET" || request.method === "HEAD") return undefined; + if (type === "application/json") { + const raw = await request.text(); + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch (cause) { + throw new Error(`Invalid JSON body: ${(cause as Error).message}`, { + cause, + }); + } + } + if (type === FORM_CONTENT_TYPE) { + const form = await request.formData(); + const result: Record = {}; + for (const [key, value] of form.entries()) { + result[key] = typeof value === "string" ? value : value.name; + } + return result; + } + if (type === "text/plain") { + return await request.text(); + } + if ( + type === "" || + type === "multipart/form-data" || + type === "application/octet-stream" + ) { + const buffer = await request.arrayBuffer(); + return buffer.byteLength === 0 ? undefined : buffer; + } + return await request.text(); +} + +/** Parses a query string into a plain object. */ +export function parseQuery(search: string): Record { + const params = new URLSearchParams(search); + const result: Record = {}; + for (const [key, value] of params.entries()) { + result[key] = value; + } + return result; +} + +/** Lower-cases every header name of a header record. */ +export function normalizeHeaders( + headers: Record, +): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + result[name.toLowerCase()] = value; + } + return result; +} + +/** Content type of a file extension. */ +export function contentTypeOf(extension: string): string { + switch (extension) { + case ".html": + case ".htm": + return "text/html; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".js": + case ".mjs": + return "text/javascript; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".webp": + return "image/webp"; + case ".ico": + return "image/x-icon"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".woff2": + return "font/woff2"; + default: + return "application/octet-stream"; + } +} + +/** Serves a file of a static root. */ +export async function serveStaticFile( + root: string, + path: string, +): Promise { + const relative = decodeURIComponent(path.split("?")[0] ?? ""); + const target = resolve(root, `.${relative.startsWith("/") ? relative : `/${relative}`}`); + if (!target.startsWith(resolve(root))) return undefined; + if (!existsSync(target)) return undefined; + if (!statSync(target).isFile()) return undefined; + + const file = await readFile(target); + return { + status: 200, + headers: { "content-type": contentTypeOf(extname(target)) }, + body: new Uint8Array(file), + }; +} + +/** Builds the request context handed to controllers. */ +export function createRequestContext(request: Request): RequestContext { + const url = new URL(request.url); + const headers = normalizeHeaders( + Object.fromEntries(request.headers.entries()), + ); + const context: RequestContext = { + method: request.method as RequestContext["method"], + url: request.url, + path: url.pathname, + params: {}, + query: parseQuery(url.search), + body: undefined, + form: toFormFields(undefined), + headers, + signal: request.signal, + locals: {}, + raw: request, + respond: createResponseBuilder(), + }; + return context; +} diff --git a/core/index.ts b/core/index.ts new file mode 100644 index 0000000..a9c0c27 --- /dev/null +++ b/core/index.ts @@ -0,0 +1,7 @@ +export * from "./app.ts"; +export * from "./container.ts"; +export * from "./errors.ts"; +export * from "./http.ts"; +export * from "./metadata.ts"; +export * from "./modules.ts"; +export * from "./router.ts"; diff --git a/core/metadata.ts b/core/metadata.ts new file mode 100644 index 0000000..8dcbbd4 --- /dev/null +++ b/core/metadata.ts @@ -0,0 +1,179 @@ +export type MetadataKey = string | symbol; + +type MetadataMap = Map; +type PropertyMetadataMap = Map; + +const metadataStore = new WeakMap(); +const propertyMetadataStore = new WeakMap(); + +function mapFor(target: object): MetadataMap { + let map = metadataStore.get(target); + if (!map) { + map = new Map(); + metadataStore.set(target, map); + } + return map; +} + +function propertyMapFor(target: object, propertyKey: MetadataKey): MetadataMap { + let properties = propertyMetadataStore.get(target); + if (!properties) { + properties = new Map(); + propertyMetadataStore.set(target, properties); + } + let map = properties.get(propertyKey); + if (!map) { + map = new Map(); + properties.set(propertyKey, map); + } + return map; +} + +/** Stores `metadataValue` for `metadataKey` on `target` (and `propertyKey`). */ +export function defineMetadata( + metadataKey: MetadataKey, + metadataValue: unknown, + target: object, + propertyKey?: MetadataKey, +): void { + const map = mapFor(target); + const existing = map.get(metadataKey) ?? []; + existing.push(metadataValue); + map.set(metadataKey, existing); + if (propertyKey !== undefined) { + const propertyMap = propertyMapFor(target, propertyKey); + const propertyExisting = propertyMap.get(metadataKey) ?? []; + propertyExisting.push(metadataValue); + propertyMap.set(metadataKey, propertyExisting); + } +} + +/** Reads metadata declared directly on `target`. */ +export function getOwnMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + if (propertyKey !== undefined) { + const properties = propertyMetadataStore.get(target); + return properties?.get(propertyKey)?.get(metadataKey)?.[0]; + } + return metadataStore.get(target)?.get(metadataKey)?.[0]; +} + +/** Reads metadata declared on `target` or inherited from its prototype chain. */ +export function getMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + let current: object | null = target; + while (current) { + const value = getOwnMetadata(metadataKey, current, propertyKey); + if (value !== undefined) return value; + current = Object.getPrototypeOf(current); + } + return undefined; +} + +/** `true` when metadata is declared on `target` or an ancestor. */ +export function hasMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): boolean { + return getMetadata(metadataKey, target, propertyKey) !== undefined; +} + +/** Removes all metadata registered for `target`. */ +export function deleteMetadata(target: object): void { + metadataStore.delete(target); +} + +/** Reads the most recent metadata entry declared for `target`. */ +export function getLatestMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + if (propertyKey !== undefined) { + const properties = propertyMetadataStore.get(target); + const entries = properties?.get(propertyKey)?.get(metadataKey); + return entries?.[entries.length - 1]; + } + const entries = metadataStore.get(target)?.get(metadataKey); + return entries?.[entries.length - 1]; +} + +/** Owner of a decorated member. */ +export function ownerOf(target: object): object { + if (typeof target === "function") return target as object; + return (target as { constructor: object }).constructor; +} + +/** Stores metadata of a method or a parameter on the owning class. */ +export function defineMemberMetadata( + metadataKey: MetadataKey, + metadataValue: unknown, + target: object, + propertyKey?: MetadataKey, +): void { + defineMetadata(metadataKey, metadataValue, ownerOf(target), propertyKey); +} + +/** Reads every metadata entry stored for a method. */ +export function getMemberMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey: MetadataKey, +): unknown[] { + const properties = propertyMetadataStore.get(target); + const map = properties?.get(propertyKey); + if (!map) return []; + return map.get(metadataKey) ?? []; +} + +/** Reads every metadata entry of a class, together with its property key. */ +export function getAllMemberMetadata( + metadataKey: MetadataKey, + target: object, +): { propertyKey: MetadataKey; value: unknown }[] { + const properties = propertyMetadataStore.get(target); + if (!properties) return []; + const entries: { propertyKey: MetadataKey; value: unknown }[] = []; + for (const [propertyKey, map] of properties) { + for (const value of map.get(metadataKey) ?? []) { + entries.push({ propertyKey, value }); + } + } + return entries; +} + +/** Installs the proposal API on `Reflect` when the runtime does not provide it. */ +export function installMetadataPolyfill(): void { + const reflect = Reflect as unknown as Record; + if (typeof reflect["metadata"] !== "function") { + reflect["metadata"] = ( + metadataKey: MetadataKey, + metadataValue: unknown, + ) => { + return (target: object, propertyKey?: MetadataKey): void => { + defineMetadata(metadataKey, metadataValue, target, propertyKey); + }; + }; + } + if (typeof reflect["defineMetadata"] !== "function") { + reflect["defineMetadata"] = defineMetadata; + } + if (typeof reflect["getOwnMetadata"] !== "function") { + reflect["getOwnMetadata"] = getOwnMetadata; + } + if (typeof reflect["getMetadata"] !== "function") { + reflect["getMetadata"] = getMetadata; + } + if (typeof reflect["hasMetadata"] !== "function") { + reflect["hasMetadata"] = hasMetadata; + } +} + +installMetadataPolyfill(); diff --git a/core/modules.ts b/core/modules.ts new file mode 100644 index 0000000..f93fe44 --- /dev/null +++ b/core/modules.ts @@ -0,0 +1,154 @@ +import { + Container, + type AbstractType, + type ProviderDefinition, + type Token, + type Type, +} from "./container.ts"; +import { + getInjectableOptions, + getModuleOptions, +} from "../decorators/Module.ts"; + +export interface ModuleContext { + type: Type; + container: Container; + exports: Token[]; + controllers: Type[]; + imports: ModuleContext[]; +} + +/** Per request container graph, one scoped container per module. */ +export class ScopeGraph { + private readonly containers = new Map(); + + constructor(private readonly registry: ModuleRegistry) { + for (const context of registry.all()) { + const scope = context.container.createScope(); + scopeGraphs.set(scope, this); + this.containers.set(context, scope); + } + } + + /** Scoped container of a module. */ + containerFor(context: ModuleContext): Container { + const container = this.containers.get(context); + if (!container) + throw new Error(`Module ${context.type.name} is not registered`); + return container; + } +} + +const scopeGraphs = new WeakMap(); + +export class ModuleRegistry { + private readonly contexts = new Map(); + private readonly order: ModuleContext[] = []; + + /** Every registered module, in registration order. */ + all(): ModuleContext[] { + return [...this.order]; + } + + /** Registers a module and all of its imports. */ + register(moduleType: Type): ModuleContext { + const existing = this.contexts.get(moduleType); + if (existing) return existing; + + const options = getModuleOptions(moduleType); + const context: ModuleContext = { + type: moduleType, + container: new Container(), + exports: [], + controllers: [...(options.controllers ?? [])], + imports: [], + }; + this.contexts.set(moduleType, context); + + for (const imported of options.imports ?? []) { + context.imports.push(this.register(imported)); + } + + for (const provider of options.providers ?? []) { + if (typeof provider === "function") { + const scope = getInjectableOptions(provider).scope; + context.container.registerClass(provider as AbstractType, { scope }); + } else { + context.container.registerProvider(provider as ProviderDefinition); + } + } + + for (const controller of context.controllers) { + context.container.registerClass(controller); + } + + context.exports = [...(options.exports ?? [])]; + context.container.fallbackResolver = (token, scope) => + this.resolveFromImports(context, token, scope); + + this.order.push(context); + return context; + } + + /** Resolves a token from the exported providers of the imported modules. */ + resolveFromImports( + context: ModuleContext, + token: Token, + scope?: Container, + ): unknown { + for (const imported of context.imports) { + const resolved = this.resolveExported(imported, token, scope); + if (resolved !== undefined) return resolved; + } + return undefined; + } + + /** Resolves a token that an imported module re-exports. */ + resolveExported( + context: ModuleContext, + token: Token, + scope?: Container, + ): unknown { + if (context.exports.includes(token)) { + return context.container.resolve(token, scope); + } + for (const exported of context.exports) { + if (typeof exported !== "function") continue; + const exportedContext = this.contexts.get(exported as Type); + if (!exportedContext) continue; + const resolved = this.resolveExported(exportedContext, token, scope); + if (resolved !== undefined) return resolved; + } + return undefined; + } + + /** Controllers of every module together with the module they belong to. */ + controllers(): { context: ModuleContext; controller: Type }[] { + const result: { context: ModuleContext; controller: Type }[] = []; + for (const context of this.order) { + for (const controller of context.controllers) { + result.push({ context, controller }); + } + } + return result; + } + + /** Instantiates every controller, surfacing dependency errors at boot time. */ + bootstrap(): void { + for (const { context, controller } of this.controllers()) { + context.container.resolve(controller); + } + } + + /** Instantiates every controller asynchronously. */ + async bootstrapAsync(): Promise { + for (const { context, controller } of this.controllers()) { + await context.container.resolveAsync(controller); + } + } + + /** Creates the per request container graph. */ + createScope(): ScopeGraph { + return new ScopeGraph(this); + } +} diff --git a/core/paths.ts b/core/paths.ts new file mode 100644 index 0000000..bf537e0 --- /dev/null +++ b/core/paths.ts @@ -0,0 +1,81 @@ +import { dirname, isAbsolute, resolve } from "node:path"; + +/** Root directory of the framework package. */ +export const frameworkRoot = resolve(import.meta.dir, ".."); + +const INTERNAL_MARKERS = ["/bun:", "node:internal", "node_modules"]; + +export const frameworkSources = [ + "adapters", + "contracts", + "core", + "decorators", + "integrations", + "orm", + "views", +].map((folder) => resolve(frameworkRoot, folder)); + +/** Extracts the file path of a stack frame. */ +function frameFile(frame: string): string | undefined { + let text = frame.trim(); + text = text.replace(/^at\s+/, ""); + + const parenthesised = /\((.*)\)$/.exec(text); + if (parenthesised) text = parenthesised[1] as string; + + text = text.replace(/:\d+(?::\d+)?$/, ""); + if (text.length === 0) return undefined; + if (text.startsWith("file://")) return text.slice(7); + if (!text.startsWith("/")) return undefined; + return text; +} + +/** Returns the file that called the caller of this function. */ +export function callerFile( + skipDirs: string[] = frameworkSources, + skip = 0, +): string | undefined { + const stack = new Error("caller").stack; + if (!stack) return undefined; + const frames = stack.split("\n").slice(1); + let seen = 0; + for (const frame of frames) { + const file = frameFile(frame); + if (!file) continue; + if (INTERNAL_MARKERS.some((marker) => file.startsWith(marker))) continue; + if (skipDirs.some((dir) => file.startsWith(dir))) continue; + if (seen < skip) { + seen += 1; + continue; + } + return file; + } + return undefined; +} + +/** Directory of the file that called the caller of this function. */ +export function callerDirectory( + skipDirs: string[] = frameworkSources, + skip = 0, +): string | undefined { + const file = callerFile(skipDirs, skip + 1); + return file ? dirname(file) : undefined; +} + +/** Resolves a path relative to the project root. */ +export function fromRoot(root: string, path: string): string { + return isAbsolute(path) ? path : resolve(root, path); +} + +/** Normalises a path to posix separators, relative to the project root. */ +export function relativeTo(root: string, path: string): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(path); + if (!resolvedPath.startsWith(resolvedRoot)) return resolvedPath; + return resolvedPath.slice(resolvedRoot.length + 1).replace(/\\/g, "/"); +} + +/** Removes the extension of a path. */ +export function stripExtension(path: string): string { + return path.replace(/\.[^./]+$/, ""); +} diff --git a/core/pipeline.ts b/core/pipeline.ts new file mode 100644 index 0000000..0f2fc60 --- /dev/null +++ b/core/pipeline.ts @@ -0,0 +1,14 @@ +import type { HttpResponse } from "../contracts/typegen/http/response.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RouteDefinition } from "./router.ts"; + +export type RouteGuard = (context: RequestContext, route: RouteDefinition) => Promise | void | HttpResponse; + +export class RoutePipeline { + private readonly guards: RouteGuard[] = []; + use(guard: RouteGuard): this { this.guards.push(guard); return this; } + async run(context: RequestContext, route: RouteDefinition): Promise { + for (const guard of this.guards) { const result = await guard(context, route); if (result) return result; } + return undefined; + } +} diff --git a/core/router.ts b/core/router.ts new file mode 100644 index 0000000..8bf1254 --- /dev/null +++ b/core/router.ts @@ -0,0 +1,522 @@ +import { getControllerOptions, getControllerPrefix } from "../decorators/Controller.ts"; +import { getRoot, resolveViewRoot } from "../decorators/Root.ts"; +import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts"; +import { getRoutes, type RouteOptions } from "../decorators/Route.ts"; +import { getViews, type ViewMetadata } from "../decorators/View.ts"; +import type { + Headers, + HttpMethod, + HttpResponse, + StatusCode, +} from "../contracts/typegen/http/index.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import { serveStaticFile } from "./http.ts"; +import { existsSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts"; +import { RoutePipeline } from "./pipeline.ts"; +import { getAuth } from "../decorators/Auth.ts"; + +/** Template rendered by a controller, the props are checked by the typegen. */ +export interface ViewDescriptor

{ + /** Template path, relative to the views directory or absolute. */ + template: P; + /** Props handed to the template. */ + props?: P extends ViewPath ? ViewPropsOf

: Record; + /** Status code of the response. */ + status?: StatusCode; + /** Headers of the response. */ + headers?: Headers; +} + +export interface RouterOptions { + /** Renders a view descriptor; injected by the application. */ + renderView?: ( + descriptor: ViewDescriptor, + context: RequestContext, + ) => Promise | string | RenderedView; + pipeline?: RoutePipeline; +} + +export interface RouteDefinition { + method: HttpMethod; + /** Full path, controller prefix included. */ + path: string; + controller: object; + methodName: string; + options: RouteOptions; + /** Template rendered by the route, declared with `@View()`. */ + view?: ResolvedView; + /** Invokes the controller method with the resolved arguments. */ + invoke: (context: RequestContext) => Promise; +} + +export type RouteMatch = + | { kind: "match"; route: RouteDefinition; params: Record } + | { kind: "method-not-allowed"; allowed: HttpMethod[] } + | { kind: "not-found" }; + +/** Joins a controller prefix and a route path into a canonical path. */ +export function joinPath(prefix: string, path: string): string { + const raw = `${prefix}/${path}`.replace(/\/{2,}/g, "/"); + const trimmed = raw.replace(/\/+$/, ""); + return trimmed.length === 0 ? "/" : trimmed; +} + +/** Splits a path into its segments. */ +export function pathSegments(path: string): string[] { + return path.split("/").filter((segment) => segment.length > 0); +} + +/** + * Matches the segments of a route against the segments of a request path. + */ +export function matchSegments( + routeSegments: string[], + requestSegments: string[], +): Record | null { + const params: Record = {}; + + for (let index = 0; index < routeSegments.length; index++) { + const segment = routeSegments[index] as string; + const actual = requestSegments[index]; + + if (segment === "*") { + params["*"] = requestSegments.slice(index).join("/"); + return params; + } + + if (segment.startsWith(":")) { + const optional = segment.endsWith("?"); + const name = segment.slice(1, optional ? -1 : undefined); + if (actual === undefined) { + if (optional) continue; + return null; + } + params[name] = actual; + continue; + } + + if (actual === undefined || segment !== actual) return null; + } + + if (requestSegments.length > routeSegments.length) return null; + return params; +} + +export interface RawRoute { + method: string; + path: string; + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response; +} + +export interface StaticRoot { + /** Prefix of the controller the files are served below. */ + prefix: string; + /** Absolute path of the directory. */ + path: string; +} + +/** A view of a controller, resolved against the view folder. */ +export interface ResolvedView { + /** Absolute path of the template. */ + path: string; + /** Route of the template, derived from its path. */ + route: string; + /** Name of the method the view belongs to. */ + propertyKey: string; + method: HttpMethod; + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +/** + * Resolves the template of a view. + */ +export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView { + const absolute = isAbsolute(view.path) + ? view.path + : resolve(viewRoot, view.path); + const path = existsSync(absolute) + ? absolute + : existsSync(`${absolute}.spug`) + ? `${absolute}.spug` + : absolute; + + let relative = path.startsWith(`${viewRoot}/`) + ? path.slice(viewRoot.length + 1) + : path.split("/").pop() ?? path; + relative = relative.replace(/\.[^./]+$/, ""); + + const segments = relative.split("/").filter((segment) => segment.length > 0); + if (segments.length > 0 && segments[segments.length - 1] === "index") { + segments.pop(); + } + + return { + path, + route: segments.length === 0 ? "/" : `/${segments.join("/")}`, + propertyKey: view.propertyKey, + method: view.method, + status: view.status, + name: view.name, + description: view.description, + params: view.params, + }; +} + +export class Router { + private readonly routes: RouteDefinition[] = []; + private readonly rawRoutes: RawRoute[] = []; + private readonly staticRoots: StaticRoot[] = []; + + constructor(private readonly options: RouterOptions = {}) {} + + /** + * Adds a route that is not bound to a controller. + */ + raw( + method: string, + path: string, + handler: RawRoute["handler"], + ): void { + this.rawRoutes.push({ method: method.toUpperCase(), path, handler }); + } + + /** Registers every route declared on a controller instance. */ + register(controllerType: object, instance: unknown): void { + const prefix = getControllerPrefix(controllerType); + const routes = getRoutes(controllerType); + const views = getViews(controllerType); + const viewRoot = resolveViewRoot( + controllerType, + getControllerOptions(controllerType).dir ?? process.cwd(), + ); + + const root = getRoot(controllerType); + if (root) { + this.staticRoots.push({ prefix, path: root.path }); + } + + for (const route of routes) { + const view = views.find( + (entry) => entry.propertyKey === route.propertyKey, + ); + this.routes.push({ + method: route.method, + path: joinPath(prefix, route.path), + controller: controllerType, + methodName: route.propertyKey, + options: route.options, + view: view ? resolveView(view, viewRoot) : undefined, + invoke: (context) => + this.invokeHandler( + instance, + route.propertyKey, + getParamMetadata(controllerType, route.propertyKey), + context, + ), + }); + } + + for (const view of views) { + if (routes.some((route) => route.propertyKey === view.propertyKey)) { + continue; + } + const resolved = resolveView(view, viewRoot); + this.routes.push({ + method: view.method, + path: joinPath(prefix, resolved.route ?? "/"), + controller: controllerType, + methodName: view.propertyKey, + options: {}, + view: resolved, + invoke: (context) => + this.invokeHandler( + instance, + view.propertyKey, + getParamMetadata(controllerType, view.propertyKey), + context, + ), + }); + } + } + + /** Every registered route definition. */ + list(): RouteDefinition[] { + return [...this.routes]; + } + + /** Every route that is not bound to a controller. */ + listRaw(): RawRoute[] { + return [...this.rawRoutes]; + } + + /** Public route metadata for client navigation and route-aware components. */ + clientRoutes(): { name: string; description?: string; path: string; method: HttpMethod; params: Record }[] { + return this.routes.map((route) => { + const view = route.view; + const params = Object.fromEntries(pathSegments(route.path).filter((part) => part.startsWith(":")).map((part) => { + const key = part.slice(1).replace(/[?*]$/, ""); + return [key, route.options.params?.[key] ?? view?.params?.[key] ?? ""]; + })); + return { name: route.options.name ?? view?.name ?? route.methodName, description: route.options.description ?? view?.description, path: route.path, method: route.method, params }; + }); + } + + /** Finds the route matching a method and path. */ + match(method: string, path: string): RouteMatch { + const segments = pathSegments(path); + const allowed = new Set(); + for (const route of this.routes) { + const params = matchSegments(pathSegments(route.path), segments); + if (!params) continue; + if (route.method === method) { + return { kind: "match", route, params }; + } + allowed.add(route.method); + } + if (allowed.size > 0) { + return { kind: "method-not-allowed", allowed: [...allowed] }; + } + return { kind: "not-found" }; + } + + /** Executes a request and normalises the handler result into a response. */ + async handle(context: RequestContext): Promise { + for (const route of this.rawRoutes) { + if (route.method !== context.method) continue; + if (route.path.endsWith("*") ? !context.path.startsWith(route.path.slice(0, -1)) : route.path !== context.path) continue; + const response = await route.handler( + context.raw as Request, + context, + ); + return { + status: response.status as StatusCode, + headers: Object.fromEntries(response.headers.entries()), + body: new Uint8Array(await response.arrayBuffer()), + }; + } + + const match = this.match(context.method, context.path); + if (match.kind === "not-found") { + const asset = await this.serveStatic(context.path); + if (asset) return asset; + return { + status: 404, + headers: { "content-type": "text/plain; charset=utf-8" }, + body: "Not Found", + }; + } + if (match.kind === "method-not-allowed") { + return { + status: 405, + headers: { + "content-type": "text/plain; charset=utf-8", + allow: match.allowed.join(", "), + }, + body: "Method Not Allowed", + }; + } + + context.params = match.params; + context.locals.routes = this.clientRoutes(); + const blocked = await this.options.pipeline?.run(context, match.route); + if (blocked) return blocked; + const auth = getAuth(match.route.controller, match.route.methodName); + if (auth) { + const identity = context.locals.auth as { roles?: string[] } | undefined; + if (!identity || (auth.roles?.some((role) => !identity.roles?.includes(role)) ?? false)) { + return { status: auth.redirect ? 302 : identity ? 403 : 401, headers: auth.redirect ? { location: auth.redirect } : { "content-type": "text/plain; charset=utf-8" }, body: auth.redirect ? undefined : "Unauthorized" }; + } + } + const result = await match.route.invoke(context); + return this.toResponse(result, context, match.route); + } + + /** + * Serves a file of the static root of a controller. + */ + private async serveStatic(path: string): Promise { + const candidates = this.staticRoots + .filter((root) => path.startsWith(root.prefix)) + .sort((left, right) => right.prefix.length - left.prefix.length); + + for (const candidate of candidates) { + const relative = path.slice(candidate.prefix.length) || "/"; + const file = await serveStaticFile(candidate.path, relative); + if (file) return file; + } + return undefined; + } + + private async invokeHandler( + instance: unknown, + methodName: string, + paramMetadata: Map, + context: RequestContext, + ): Promise { + const handler = (instance as Record)[methodName]; + if (typeof handler !== "function") { + throw new Error(`Controller method ${methodName} is not callable`); + } + + if (paramMetadata.size === 0) { + return (handler as (ctx: RequestContext) => unknown).call( + instance, + context, + ); + } + + const args: unknown[] = []; + for (const [index, meta] of paramMetadata) { + args[index] = resolveParam(meta, context); + } + return (handler as (...args: unknown[]) => unknown).apply(instance, args); + } + + private async toResponse( + result: unknown, + context: RequestContext, + route: RouteDefinition, + ): Promise { + const status = route.options.status ?? 200; + + if (route.view) { + const descriptor: ViewDescriptor = isViewDescriptor(result) + ? result + : { + template: route.view.path, + props: (result ?? {}) as Record, + }; + const rendered = await this.renderView(descriptor, context); + return { + status: descriptor.status ?? status, + headers: { + "content-type": "text/html; charset=utf-8", + ...(descriptor.headers ?? {}), + }, + body: rendered, + }; + } + + if (result === undefined || result === null) { + return { status, headers: {}, body: result ?? undefined }; + } + + if (isHttpResponse(result)) { + return { + status: result.status, + headers: result.headers ?? {}, + body: result.body, + }; + } + + if (isViewDescriptor(result)) { + const rendered = await this.renderView(result, context); + return { + status: result.status ?? status, + headers: { + "content-type": "text/html; charset=utf-8", + ...(result.headers ?? {}), + }, + body: rendered, + }; + } + + if (typeof result === "string") { + return { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + body: result, + }; + } + + return { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + body: result, + }; + } + + private async renderView( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise> { + const renderer = this.options.renderView; + if (!renderer) { + throw new Error( + `Template "${descriptor.template}" cannot be rendered: no view renderer configured`, + ); + } + const rendered = await renderer(descriptor, context); + if (typeof rendered === "string") return rendered; + if (rendered.deferred && rendered.deferred.length > 0) { + return toStream(rendered); + } + return rendered.html; + } +} + +/** Builds a stream that sends the shell first and the deferred chunks after. */ +export function toStream(rendered: RenderedView): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(rendered.html)); + for (const chunk of rendered.deferred ?? []) { + const content = await chunk.content; + controller.enqueue( + encoder.encode( + `

${content}
`, + ), + ); + } + controller.close(); + }, + }); +} + +function resolveParam(meta: ParamMetadata, context: RequestContext): unknown { + switch (meta.kind) { + case "param": + return meta.name + ? (context.params as Record)[meta.name] + : context.params; + case "body": + return context.body; + case "form": + return context.form; + case "query": + return meta.name ? context.query[meta.name] : context.query; + case "request": + return context; + case "response": + return context.respond; + case "locals": + return context.locals; + } +} + +function isHttpResponse(value: unknown): value is HttpResponse { + return ( + typeof value === "object" && + value !== null && + typeof (value as HttpResponse).status === "number" && + "headers" in value + ); +} + +function isViewDescriptor(value: unknown): value is ViewDescriptor { + return ( + typeof value === "object" && + value !== null && + typeof (value as ViewDescriptor).template === "string" + ); +} diff --git a/core/sessions.ts b/core/sessions.ts new file mode 100644 index 0000000..128cab3 --- /dev/null +++ b/core/sessions.ts @@ -0,0 +1,28 @@ +export interface SessionRecord { + value: Record; + expires: number; +} + +export interface SessionStore { + get(key: string): Promise; + set(key: string, record: SessionRecord): Promise; + delete(key: string): Promise; + /** Atomically removes and returns a transaction, preventing callback replay. */ + consume(key: string): Promise; +} + +export class MemorySessionStore implements SessionStore { + private readonly records = new Map(); + async get(key: string): Promise { + const record = this.records.get(key); + if (!record || record.expires <= Date.now()) { this.records.delete(key); return undefined; } + return structuredClone(record); + } + async set(key: string, record: SessionRecord): Promise { this.records.set(key, structuredClone(record)); } + async delete(key: string): Promise { this.records.delete(key); } + async consume(key: string): Promise { + const record = this.records.get(key); + this.records.delete(key); + return record && record.expires > Date.now() ? structuredClone(record) : undefined; + } +} diff --git a/core/templates.ts b/core/templates.ts new file mode 100644 index 0000000..32d5f56 --- /dev/null +++ b/core/templates.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type MuddleTemplate = "docs" | "minimal"; + +export interface TemplateResult { + name: MuddleTemplate; + output: string; + files: string[]; +} + +const sharedFiles: Record = { + "package.json": `${JSON.stringify({ + name: "my-muddle-site", + private: true, + type: "module", + scripts: { dev: "muddle dev .", build: "muddle build ." }, + dependencies: { muddle: "latest" }, + }, null, 2)}\n`, + "tsconfig.json": `${JSON.stringify({ + compilerOptions: { target: "ESNext", module: "Preserve", moduleResolution: "bundler", strict: true, noEmit: true, experimentalDecorators: true, allowImportingTsExtensions: true }, + }, null, 2)}\n`, + "config.ts": `import { resolveConfig } from "muddle";\nimport config from "./ct.config.ts";\n\nexport const resolved = resolveConfig(config);\n`, + "modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, +}; + +const minimalFiles: Record = { + "ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist" },\n});\n`, + "index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist") });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle demo on \${serve(app, { port: 3000 }).url}\`);\n}\n`, + "modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() {\n\t\treturn { title: "Muddle", message: "A small, fast Muddle project." };\n\t}\n}\n`, + "modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n main.shell\n a.mark(href="/") muddle\n section.hero@demo.hero()\n p.kicker Vite-like starter\n h1= message\n p Edit the Spug view and STCSS stylesheet, then run muddle dev ..\n a.action(href="#next") Open the canvas\n section#next.card@demo.next()\n h2 Start with one route.\n p Build a static site with ordinary HTML, CSS, and server-side composition.\n`, + "styles/app.stcss": `:root { --ink: #1f2937; --muted: #64748b; --surface: #ffffff; --accent: #646cff; }\n* { box-sizing: border-box; }\nbody { margin: 0; color: var(--ink); background: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.shell { width: min(100% - 2rem, 68rem); margin: 0 auto; padding: 1.5rem 0 5rem; }\n.mark { color: var(--ink); font-weight: 800; letter-spacing: -0.06em; text-decoration: none; }\n.hero { max-width: 48rem; padding: 8rem 0 5rem; }\n.kicker { margin: 0; color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }\nh1 { margin: .7rem 0 1rem; font-size: clamp(3.5rem, 9vw, 7.25rem); line-height: .94; letter-spacing: -.075em; }\n.hero p { max-width: 34rem; color: var(--muted); font-size: 1.1rem; line-height: 1.65; }\n.action { display: inline-block; margin-top: 1rem; padding: .8rem 1rem; border-radius: .55rem; background: var(--accent); color: white; font-weight: 700; text-decoration: none; }\n.card { max-width: 38rem; padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; background: var(--surface); }\n.card h2 { margin: 0; letter-spacing: -.04em; }\n.card p { color: var(--muted); line-height: 1.6; }\n`, + "README.md": `# Minimal Muddle\n\nRun \`bun install\`, then \`bun run dev\`. Build the static output with \`bun run build\`.\n`, +}; + +const docsFiles: Record = { + "ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist", predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } },\n});\n`, + "index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist"), predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle docs on \${serve(app, { port: 3000 }).url}\`);\n}\n`, + "modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\nconst sections = [\n\t{ id: "overview", title: "Overview", body: "Muddle composes static pages from Spug and STCSS." },\n\t{ id: "predict", title: "Reflective prediction", body: "Dotted anchors form groups, so the renderer discovers matching elements before it writes the page." },\n\t{ id: "deploy", title: "Deploy", body: "The finished bundle is HTML and CSS with precomputed responsive relations." },\n];\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() { return { title: "Muddle Docs", sections }; }\n}\n`, + "modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n header.topbar\n a.brand(href="/") muddle\n span.topbar__tag Documentation\n div.docs\n aside.sidebar\n p.sidebar__title Guide\n nav.sidebar__nav(aria-label="Documentation")\n each section in sections\n a.sidebar__link@docs.{section.id}(href='#' + section.id)= section.title\n main.content\n p.eyebrow Muddle documentation\n h1 Static interfaces, with measured relationships.\n p.lede This template uses responsive layout prediction and reflective anchor groups. Resize it, then follow the highlighted section.\n each section in sections\n section.doc-section@docs.{section.id}(id=section.id)\n h2= section.title\n p= section.body\n if section.id === "predict"\n pre\n code .sidebar:reflect(@docs) { transition: background 180ms ease; }\n`, + "styles/app.stcss": `:root { --ink: #20212a; --muted: #6d6f7c; --line: #e7e7ec; --paper: #fff; --wash: #f7f7fb; --accent: #7656df; }\n* { box-sizing: border-box; }\nhtml { scroll-behavior: smooth; }\nbody { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.topbar { position: sticky; z-index: 2; top: 0; display: flex; justify-content: space-between; align-items: center; height: 3.75rem; padding: 0 max(1rem, calc((100% - 78rem) / 2)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); }\n.brand { color: var(--ink); font-weight: 800; letter-spacing: -.055em; text-decoration: none; }\n.topbar__tag, .sidebar__title, .eyebrow { color: var(--muted); font-size: .72rem; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; }\n.docs { display: grid; grid-template-columns: 15rem minmax(0, 48rem); gap: clamp(2rem, 7vw, 7rem); width: min(100% - 2rem, 78rem); margin: 0 auto; }\n.sidebar { position: sticky; top: 5.5rem; align-self: start; padding: 2rem 0; }\n.sidebar__title { margin: 0 0 .65rem; }\n.sidebar__nav { display: grid; gap: .18rem; }\n.sidebar__link { position: relative; display: block; overflow: hidden; padding: .55rem .7rem; border-radius: .45rem; color: var(--muted); text-decoration: none; transition: color 180ms ease, transform 180ms ease; }\n.sidebar__link::before { position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: #ede8ff; content: ""; transform: scaleX(0); transform-origin: left; transition: transform 220ms cubic-bezier(.2,.8,.2,1); }\n.sidebar__link:hover, .sidebar__link:focus-visible { color: var(--accent); transform: translateX(.18rem); }\n.sidebar__link:hover::before, .sidebar__link:focus-visible::before { transform: scaleX(1); }\n.sidebar__link:target, .sidebar__link:target::before { color: var(--accent); transform: scaleX(1); }\n:reflect(@docs) { scroll-margin-top: 5.5rem; }\n.content { padding: clamp(3rem, 10vw, 7rem) 0 8rem; }\nh1 { max-width: 42rem; margin: .7rem 0 1.2rem; font-size: clamp(3rem, 6vw, 5.8rem); line-height: .95; letter-spacing: -.075em; }\n.lede { max-width: 42rem; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }\n.doc-section { padding: 5rem 0; border-bottom: 1px solid var(--line); }\n.doc-section h2 { margin: 0 0 .8rem; font-size: 1.7rem; letter-spacing: -.045em; }\n.doc-section p { max-width: 38rem; color: var(--muted); line-height: 1.7; }\npre { overflow: auto; padding: 1rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--wash); }\n@media (max-width: 48rem) { .docs { display: block; } .sidebar { position: sticky; top: 3.75rem; z-index: 1; display: block; margin: 0 -1rem; padding: .65rem 1rem; border-bottom: 1px solid var(--line); background: var(--paper); } .sidebar__title { display: none; } .sidebar__nav { display: flex; overflow: auto; } .sidebar__link { white-space: nowrap; } .content { padding-top: 3rem; } }\n`, + "README.md": `# Muddle Docs\n\nRun \`bun install\`, then \`bun run dev\`. The docs shell has a responsive sidebar and uses the \`docs\` reflective anchor group. Build it with \`bun run build\`.\n`, +}; + +export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"]; + +export async function createTemplate(name: MuddleTemplate, destination: string): Promise { + const output = resolve(destination); + if (existsSync(output) && (await readdir(output)).length > 0) { + throw new Error(`Refusing to overwrite non-empty directory ${output}`); + } + const files = { ...sharedFiles, ...(name === "docs" ? docsFiles : minimalFiles) }; + const written: string[] = []; + for (const [relative, source] of Object.entries(files)) { + const file = join(output, relative); + await mkdir(join(file, ".."), { recursive: true }); + await writeFile(file, source); + written.push(file); + } + return { name, output, files: written }; +} diff --git a/ct.config.ts b/ct.config.ts new file mode 100644 index 0000000..ce33fb3 --- /dev/null +++ b/ct.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "./contracts"; +import { stcssIntegration } from "./views/integrations.ts"; + +export default defineConfig({ + root: import.meta.dir, + entry: "samples/hello-world/index.ts", + + views: "samples/hello-world/modules/app/views", + components: "samples/hello-world/modules/app/views/components", + viewRoots: ["samples/hello-world/modules/app/views"], + + integrations: [ + stcssIntegration({ dir: "samples/hello-world/styles" }), + ], + + prerender: { + routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], + outDir: "samples/hello-world/dist", + }, + +}); diff --git a/decorators/Astro.ts b/decorators/Astro.ts new file mode 100644 index 0000000..5d58f25 --- /dev/null +++ b/decorators/Astro.ts @@ -0,0 +1,8 @@ +import { Route, type RouteOptions } from "./Route.ts"; + +export interface AstroOptions extends RouteOptions { partial?: boolean; action?: boolean } + +/** Declares an Astro-compatible endpoint. */ +export function Astro(path: string, options: AstroOptions = {}): MethodDecorator { + return Route(path, { ...options, method: options.action ? "POST" : options.method ?? "GET" }); +} diff --git a/decorators/Auth.ts b/decorators/Auth.ts new file mode 100644 index 0000000..5ac178f --- /dev/null +++ b/decorators/Auth.ts @@ -0,0 +1,12 @@ +import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:auth"; +export interface AuthOptions { roles?: string[]; redirect?: string } + +export function Auth(options: AuthOptions = {}): MethodDecorator { + return (target, propertyKey) => defineMemberMetadata(KEY, options, target as object, propertyKey as string); +} + +export function getAuth(target: object, propertyKey: string): AuthOptions | undefined { + return getMemberMetadata(KEY, target, propertyKey)[0] as AuthOptions | undefined; +} diff --git a/decorators/Component.ts b/decorators/Component.ts new file mode 100644 index 0000000..a7d1717 --- /dev/null +++ b/decorators/Component.ts @@ -0,0 +1,51 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +export const COMPONENT_METADATA_KEY = "muddle:component"; + +export interface ComponentOptions { + /** Name used inside the templates, defaults to the class name. */ + name?: string; + /** + * Renders the component after the page, the response is streamed. + */ + deferred?: boolean; + description?: string; + params?: Record; +} + +/** Runtime description of a server component. */ +export interface ComponentMetadata { + name: string; + deferred: boolean; + description?: string; + params?: Record; +} + +/** Declares a server component. */ +export function Component(options: ComponentOptions = {}): ClassDecorator { + return (target) => { + const metadata: ComponentMetadata = { + name: options.name ?? target.name, + deferred: options.deferred === true, + description: options.description, + params: options.params, + }; + defineMetadata(COMPONENT_METADATA_KEY, metadata, target as object); + return target; + }; +} + +/** Reads the metadata of a server component. */ +export function getComponentMetadata( + target: object, +): ComponentMetadata | undefined { + return getMetadata(COMPONENT_METADATA_KEY, target) as + | ComponentMetadata + | undefined; +} + +/** `true` when the class is a server component. */ +export function isComponent(target: object): boolean { + return getComponentMetadata(target) !== undefined; +} diff --git a/decorators/Controller.ts b/decorators/Controller.ts new file mode 100644 index 0000000..6e0948d --- /dev/null +++ b/decorators/Controller.ts @@ -0,0 +1,59 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { dirname } from "node:path"; + +/** + * `@Controller()` groups HTTP handlers under a path prefix. + */ +export const CONTROLLER_METADATA_KEY = "muddle:controller"; + +export interface ControllerOptions { + /** Path prefix of every route of the controller, e.g. `/users`. */ + prefix?: string; + /** Optional tag used by documentation adapters. */ + tag?: string; + /** Directory of the file that declares the controller. */ + dir?: string; +} + +/** Normalises a prefix to `/segment` shape (no trailing slash). */ +export function normalizePrefix(prefix: string): string { + if (!prefix || prefix === "/") return ""; + const withLeading = prefix.startsWith("/") ? prefix : `/${prefix}`; + return withLeading.replace(/\/+$/, ""); +} + +/** Declares a controller. */ +export function Controller( + prefix?: string | ControllerOptions, +): ClassDecorator { + return (target) => { + const options: ControllerOptions = + typeof prefix === "string" ? { prefix } : (prefix ?? {}); + const caller = callerFile(); + defineMetadata( + CONTROLLER_METADATA_KEY, + { + ...options, + dir: options.dir ?? (caller ? dirname(caller) : undefined), + }, + target as object, + ); + return target; + }; +} + +/** Reads the options of a `@Controller()` decorated class. */ +export function getControllerOptions(target: object): ControllerOptions { + return ( + (getMetadata(CONTROLLER_METADATA_KEY, target) as + | ControllerOptions + | undefined) ?? {} + ); +} + +/** Reads the normalised path prefix of a controller. */ +export function getControllerPrefix(target: object): string { + return normalizePrefix(getControllerOptions(target).prefix ?? ""); +} diff --git a/decorators/Inject.ts b/decorators/Inject.ts new file mode 100644 index 0000000..df8ae9d --- /dev/null +++ b/decorators/Inject.ts @@ -0,0 +1,35 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getLatestMetadata } from "../core/metadata.ts"; +import type { Token } from "../core/container.ts"; +import { + INJECT_TOKENS_METADATA, + OPTIONAL_PARAMS_METADATA, +} from "../core/container.ts"; + +/** + * `@Inject()` overrides the injection token of a constructor parameter. + */ +export function Inject(token: Token): ParameterDecorator { + return (target, _propertyKey, index) => { + const entries = + (getLatestMetadata(INJECT_TOKENS_METADATA, target) as + | { index: number; token: Token }[] + | undefined) ?? []; + entries.push({ index, token }); + defineMemberMetadata(INJECT_TOKENS_METADATA, entries, target as object); + }; +} + +/** + * `@Optional()` allows a dependency to be missing. + */ +export function Optional(): ParameterDecorator { + return (target, _propertyKey, index) => { + const entries = + (getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as + | number[] + | undefined) ?? []; + entries.push(index); + defineMemberMetadata(OPTIONAL_PARAMS_METADATA, entries, target as object); + }; +} diff --git a/decorators/Module.ts b/decorators/Module.ts new file mode 100644 index 0000000..9e22b3b --- /dev/null +++ b/decorators/Module.ts @@ -0,0 +1,69 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import type { + AbstractType, + ProviderScope, + Token, + Type, +} from "../core/container.ts"; + +export const MODULE_METADATA_KEY = "muddle:module"; + +export interface ModuleOptions { + /** Controllers instantiated by this module. */ + controllers?: Type[]; + /** Providers available inside this module. */ + providers?: (Type | AbstractType | ProviderDefinitionLike)[]; + /** Modules whose exported providers are visible here. */ + imports?: Type[]; + /** Tokens (or modules) re-exported to importers of this module. */ + exports?: Token[]; +} + +export interface ProviderDefinitionLike { + provide?: Token; + useValue?: unknown; + useClass?: AbstractType; + useFactory?: (...dependencies: unknown[]) => unknown; + inject?: Token[]; + scope?: ProviderScope; +} + +/** Marks a class as injectable and therefore eligible for type based injection. */ +export const INJECTABLE_METADATA_KEY = "muddle:injectable"; + +export function Injectable( + options: { scope?: ProviderScope } = {}, +): ClassDecorator { + return (target) => { + defineMetadata(INJECTABLE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Declares a module. */ +export function Module(options: ModuleOptions): ClassDecorator { + return (target) => { + defineMetadata(MODULE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Reads the options of a `@Module()` decorated class. */ +export function getModuleOptions(target: object): ModuleOptions { + return ( + (getMetadata(MODULE_METADATA_KEY, target) as ModuleOptions | undefined) ?? + {} + ); +} + +/** Reads the options of an `@Injectable()` decorated class. */ +export function getInjectableOptions(target: object): { + scope?: ProviderScope; +} { + return ( + (getMetadata(INJECTABLE_METADATA_KEY, target) as + | { scope?: ProviderScope } + | undefined) ?? {} + ); +} diff --git a/decorators/Params.ts b/decorators/Params.ts new file mode 100644 index 0000000..0f08788 --- /dev/null +++ b/decorators/Params.ts @@ -0,0 +1,125 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts"; + +export const PARAM_METADATA_KEY = "muddle:params"; + +export type ParamKind = + | "param" + | "body" + | "query" + | "request" + | "response" + | "locals" + | "form"; + +export interface ParamMetadata { + index: number; + kind: ParamKind; + /** Name of the route parameter or query key. */ + name?: string; +} + +/** `@Param('id')`, a route parameter. */ +export function Param(name?: string): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "param", name } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Body()`, the parsed request body. */ +export function Body(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "body" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Form()`, the submitted form fields. */ +export function Form(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "form" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Query('page')`, a query string entry. */ +export function Query(name?: string): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "query", name } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Req()`, the full request context. */ +export function Req(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "request" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Res()`, the response builder. */ +export function Res(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "response" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Locals()`, the mutable per request scratch space. */ +export function Locals(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "locals" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** + * Reads the parameter descriptors of a controller method. + */ +export function getParamMetadata( + target: object, + propertyKey?: string, +): Map { + const entries = propertyKey + ? (getMemberMetadata( + PARAM_METADATA_KEY, + target, + propertyKey, + ) as ParamMetadata[]) + : []; + const map = new Map(); + for (const entry of entries) { + map.set(entry.index, entry); + } + return map; +} diff --git a/decorators/RenderAhead.ts b/decorators/RenderAhead.ts new file mode 100644 index 0000000..1fea63f --- /dev/null +++ b/decorators/RenderAhead.ts @@ -0,0 +1,15 @@ +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:render-ahead"; + +export type RenderAheadSource = string[] | (() => Promise | string[]); + +export function RenderAhead(routes: RenderAheadSource): MethodDecorator { + return (target, propertyKey) => { + defineMetadata(KEY, routes, target, propertyKey); + }; +} + +export function getRenderAhead(target: object, propertyKey: string): RenderAheadSource | undefined { + return getMetadata(KEY, target, propertyKey) as RenderAheadSource | undefined; +} diff --git a/decorators/RenderRoute.ts b/decorators/RenderRoute.ts new file mode 100644 index 0000000..2c47574 --- /dev/null +++ b/decorators/RenderRoute.ts @@ -0,0 +1,26 @@ +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:render-route"; + +export interface CollectionRenderRoute { + collection: string; + path: string; +} + +export type RenderRouteSource = string | string[] | CollectionRenderRoute; + +export function routeFolder(...segments: string[]): string { + return `/${segments.filter(Boolean).map((segment) => segment.replace(/^\/+|\/+$/g, "")).join("/")}`; +} + +export function collectionRoute(collection: string, path: string): CollectionRenderRoute { + return { collection, path }; +} + +export function RenderRoute(routes: RenderRouteSource[]): MethodDecorator { + return (target, propertyKey) => defineMetadata(KEY, routes, target, propertyKey); +} + +export function getRenderRoutes(target: object, propertyKey: string): RenderRouteSource[] { + return (getMetadata(KEY, target, propertyKey) as RenderRouteSource[] | undefined) ?? []; +} diff --git a/decorators/Root.ts b/decorators/Root.ts new file mode 100644 index 0000000..f5200c6 --- /dev/null +++ b/decorators/Root.ts @@ -0,0 +1,75 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export const ROOT_METADATA_KEY = "muddle:root"; + +export interface RootMetadata { + /** Absolute path of the directory. */ + path: string; +} + +/** Declares the static root of a controller. */ +export function Root(path: string): ClassDecorator { + return (target) => { + const caller = callerFile(); + const base = caller ? dirname(caller) : process.cwd(); + const absolute = isAbsolute(path) ? path : resolve(base, path); + defineMetadata( + ROOT_METADATA_KEY, + { path: absolute } satisfies RootMetadata, + target as object, + ); + return target; + }; +} + +/** Reads the static root of a controller. */ +export function getRoot(target: object): RootMetadata | undefined { + return getMetadata(ROOT_METADATA_KEY, target) as RootMetadata | undefined; +} + +export const VIEW_ROOT_METADATA_KEY = "muddle:view-root"; + +export interface ViewRootMetadata { + /** Absolute path of the folder. */ + path: string; +} + +/** Declares the view folder of a controller. */ +export function ViewRoot(path: string): ClassDecorator { + return (target) => { + const caller = callerFile(); + const base = caller ? dirname(caller) : process.cwd(); + const absolute = isAbsolute(path) ? path : resolve(base, path); + defineMetadata( + VIEW_ROOT_METADATA_KEY, + { path: absolute } satisfies ViewRootMetadata, + target as object, + ); + return target; + }; +} + +/** Reads the view folder of a controller. */ +export function getViewRoot(target: object): ViewRootMetadata | undefined { + return getMetadata(VIEW_ROOT_METADATA_KEY, target) as + | ViewRootMetadata + | undefined; +} + +/** + * Resolves the folder of the templates of a controller. + */ +export function resolveViewRoot(target: object, fallback: string): string { + const root = getViewRoot(target); + if (root) return root.path; + return fallback; +} + +/** `true` when the directory exists. */ +export function isDirectory(path: string): boolean { + return existsSync(path) && existsSync(join(path, ".")); +} diff --git a/decorators/Route.ts b/decorators/Route.ts new file mode 100644 index 0000000..b866551 --- /dev/null +++ b/decorators/Route.ts @@ -0,0 +1,80 @@ +import "../core/metadata.ts"; +import { + defineMemberMetadata, + getAllMemberMetadata, +} from "../core/metadata.ts"; +import type { HttpMethod } from "../contracts/typegen/http/method.ts"; +import { normalizeMethod } from "../contracts/typegen/http/method.ts"; +import type { StatusCode } from "../contracts/typegen/http/respcode.ts"; + +/** + * `@Route()` binds a controller method to an HTTP route. + */ +export const ROUTE_METADATA_KEY = "muddle:route"; + +export interface RouteOptions { + /** HTTP method(s) handled by the route, defaults to `GET`. */ + method?: HttpMethod | HttpMethod[]; + /** Status code used when the handler returns a plain value. */ + status?: StatusCode; + /** Route name, used by the fluent view helpers. */ + name?: string; + /** Human-readable route description. */ + description?: string; + /** Parameter description by parameter name. */ + params?: Record; +} + +export interface RouteMetadata { + /** Path relative to the controller prefix. */ + path: string; + method: HttpMethod; + options: RouteOptions; + /** Name of the decorated method. */ + propertyKey: string; +} + +function normalizeMethods(method: RouteOptions["method"]): HttpMethod[] { + if (method === undefined) return ["GET"]; + const list = Array.isArray(method) ? method : [method]; + return list.map((entry) => normalizeMethod(entry)); +} + +/** Declares one or more routes for a controller method. */ +export function Route( + path: string, + options: RouteOptions = {}, +): MethodDecorator { + return (target, propertyKey, descriptor) => { + for (const httpMethod of normalizeMethods(options.method)) { + defineMemberMetadata( + ROUTE_METADATA_KEY, + { + path, + method: httpMethod, + options, + propertyKey: String(propertyKey), + } satisfies RouteMetadata, + target as object, + propertyKey as string, + ); + } + return descriptor; + }; +} + +/** + * Reads every route declared on a controller class. + * + * When a property key is given, only the routes of that method are returned. + */ +export function getRoutes( + target: object, + propertyKey?: string, +): RouteMetadata[] { + return getAllMemberMetadata(ROUTE_METADATA_KEY, target) + .filter( + (entry) => propertyKey === undefined || entry.propertyKey === propertyKey, + ) + .map((entry) => entry.value as RouteMetadata); +} diff --git a/decorators/Service.ts b/decorators/Service.ts new file mode 100644 index 0000000..c83675b --- /dev/null +++ b/decorators/Service.ts @@ -0,0 +1,26 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import type { ProviderScope } from "../core/container.ts"; + +export const SERVICE_METADATA_KEY = "muddle:service"; + +export interface ServiceOptions { + /** Lifetime of the service. */ + scope?: ProviderScope; +} + +/** Declares a service. */ +export function Service(options: ServiceOptions = {}): ClassDecorator { + return (target) => { + defineMetadata(SERVICE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Reads the options of a `@Service()` decorated class. */ +export function getServiceOptions(target: object): ServiceOptions { + return ( + (getMetadata(SERVICE_METADATA_KEY, target) as ServiceOptions | undefined) ?? + {} + ); +} diff --git a/decorators/View.ts b/decorators/View.ts new file mode 100644 index 0000000..a413e3b --- /dev/null +++ b/decorators/View.ts @@ -0,0 +1,83 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getAllMemberMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { dirname } from "node:path"; +import type { HttpMethod } from "../contracts/typegen/http/method.ts"; +import { normalizeMethod } from "../contracts/typegen/http/method.ts"; +import type { StatusCode } from "../contracts/typegen/http/respcode.ts"; + +export const VIEW_METADATA_KEY = "muddle:view"; + +export interface ViewOptions { + /** HTTP method of the route, defaults to `GET`. */ + method?: HttpMethod | HttpMethod[]; + /** Route path, derived from the template when omitted. */ + path?: string; + /** Status code of the response. */ + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +export interface ViewMetadata { + /** Name of the method the view belongs to. */ + propertyKey: string; + /** Path of the template, relative to the view folder. */ + path: string; + /** Directory of the file that declared the view. */ + dir: string; + method: HttpMethod; + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +/** Declares the template rendered by a controller method. */ +export function View(path?: string, options: ViewOptions = {}): MethodDecorator { + return (target, propertyKey, descriptor) => { + const caller = callerFile(); + const method = String(propertyKey); + const methods = options.method + ? Array.isArray(options.method) + ? options.method + : [options.method] + : ["GET" as HttpMethod]; + + for (const httpMethod of methods) { + defineMemberMetadata( + VIEW_METADATA_KEY, + { + propertyKey: method, + path: path ?? `./${method}.spug`, + dir: caller ? dirname(caller) : process.cwd(), + method: normalizeMethod(httpMethod), + status: options.status, + name: options.name, + description: options.description, + params: options.params, + } satisfies ViewMetadata, + target as object, + propertyKey as string, + ); + } + return descriptor; + }; +} + +/** + * Reads the view metadata of a controller class. + * + * When a property key is given, only the views of that method are returned. + */ +export function getViews( + target: object, + propertyKey?: string, +): ViewMetadata[] { + return getAllMemberMetadata(VIEW_METADATA_KEY, target) + .filter( + (entry) => propertyKey === undefined || entry.propertyKey === propertyKey, + ) + .map((entry) => entry.value as ViewMetadata); +} diff --git a/decorators/index.ts b/decorators/index.ts new file mode 100644 index 0000000..db3792e --- /dev/null +++ b/decorators/index.ts @@ -0,0 +1,13 @@ +export * from "./Component.ts"; +export * from "./Controller.ts"; +export * from "./Inject.ts"; +export * from "./Module.ts"; +export * from "./Params.ts"; +export * from "./Root.ts"; +export * from "./Route.ts"; +export * from "./Service.ts"; +export * from "./View.ts"; +export * from "./RenderAhead.ts"; +export * from "./RenderRoute.ts"; +export * from "./Astro.ts"; +export * from "./Auth.ts"; diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore new file mode 100644 index 0000000..f98339b --- /dev/null +++ b/editors/vscode/.vscodeignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock \ No newline at end of file diff --git a/editors/vscode/bun.lock b/editors/vscode/bun.lock new file mode 100644 index 0000000..23a1e7a --- /dev/null +++ b/editors/vscode/bun.lock @@ -0,0 +1,285 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "muddle-language-tools", + "dependencies": { + "@vscode/vsce": "^4.0.0", + }, + }, + }, + "packages": { + "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], + + "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="], + + "@azure/core-client": ["@azure/core-client@1.11.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ=="], + + "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="], + + "@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="], + + "@azure/identity": ["@azure/identity@4.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^11.0.0", "tslib": "^2.2.0" } }, "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw=="], + + "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="], + + "@azure/msal-browser": ["@azure/msal-browser@5.26.0", "", { "dependencies": { "@azure/msal-common": "16.14.3" } }, "sha512-GYKRquzDZmZggCdStWy2bNJKnPui1upssSuznJgP+/h6Mb0ldkUkaGvaY6uSGGmZOm0CGiBQ3b5BiGy43BpXSg=="], + + "@azure/msal-common": ["@azure/msal-common@16.14.3", "", {}, "sha512-tz3GvMrR9V/plb2lDdW8qaO8CPpfZevdExBKjltzzbAtyhK0eB2UIvji1aU+gkyHj3fxQ2ie+LNHyMXdmo8wOg=="], + + "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], + + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + + "@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="], + + "@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], + + "@secretlint/secretlint-rule-no-dotenv": ["@secretlint/secretlint-rule-no-dotenv@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg=="], + + "@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@10.2.2", "", {}, "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA=="], + + "@secretlint/source-creator": ["@secretlint/source-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2", "istextorbinary": "^9.5.0" } }, "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw=="], + + "@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], + + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.9", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A=="], + + "@vscode/vsce": ["@vscode/vsce@4.0.0", "", { "dependencies": { "@azure/identity": "^4.13.2", "@napi-rs/keyring": "^1.3.0", "@secretlint/core": "^10.2.2", "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "@vscode/vsce-sign": "^2.1.0", "azure-devops-node-api": "^12.5.0", "cockatiel": "^3.2.1", "commander": "^12.1.0", "hosted-git-info": "^4.1.0", "jsonc-parser": "^3.3.1", "marked": "^18.0.11", "mime": "^1.6.0", "minimatch": "^10.2.6", "parse5": "^8.0.1", "proper-lockfile": "^4.1.2", "read": "^1.0.7", "semver": "^7.8.5", "tinyglobby": "^0.2.17", "typed-rest-client": "^1.8.11", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.4.0", "yazl": "^2.5.1" }, "bin": { "vsce": "vsce" } }, "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A=="], + + "@vscode/vsce-sign": ["@vscode/vsce-sign@2.1.0", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g=="], + + "@vscode/vsce-sign-alpine-arm64": ["@vscode/vsce-sign-alpine-arm64@2.0.6", "", { "os": "none", "cpu": "arm64" }, "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q=="], + + "@vscode/vsce-sign-alpine-x64": ["@vscode/vsce-sign-alpine-x64@2.0.6", "", { "os": "none", "cpu": "x64" }, "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w=="], + + "@vscode/vsce-sign-darwin-arm64": ["@vscode/vsce-sign-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ=="], + + "@vscode/vsce-sign-darwin-x64": ["@vscode/vsce-sign-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw=="], + + "@vscode/vsce-sign-linux-arm": ["@vscode/vsce-sign-linux-arm@2.0.6", "", { "os": "linux", "cpu": "arm" }, "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA=="], + + "@vscode/vsce-sign-linux-arm64": ["@vscode/vsce-sign-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA=="], + + "@vscode/vsce-sign-linux-x64": ["@vscode/vsce-sign-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA=="], + + "@vscode/vsce-sign-win32-arm64": ["@vscode/vsce-sign-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg=="], + + "@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], + + "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "editions": ["editions@6.22.0", "", { "dependencies": { "version-range": "^4.15.0" } }, "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ=="], + + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], + + "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="], + + "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + + "typed-rest-client": ["typed-rest-client@1.8.11", "", { "dependencies": { "qs": "^6.9.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA=="], + + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + + "version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="], + + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + + "yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="], + + "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], + } +} diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json new file mode 100644 index 0000000..e3e27e6 --- /dev/null +++ b/editors/vscode/language-configuration.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vscode/master/src/vscode/language-configuration/_languages.schema.json", + "comments": { "lineComment": "//", "blockComment": ["/*", "*/"] }, + "brackets": [["{", "}"], ["(", ")"], ["[", "]"]], + "autoClosingPairs": [ + { "open": "{", "close": "}", "notIn": ["string", "comment"] }, + { "open": "(", "close": ")", "notIn": ["string", "comment"] }, + { "open": "[", "close": "]", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] }, + { "open": "\"", "close": "\"", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["{", "}"], ["(", ")"], ["[", "]"], ["'", "'"], ["\"", "\""] + ], + "indentationRules": { + "increaseIndentPattern": "^\\s*(?:@\\w+[^;{]*)?[^{};]*\\{\\s*$|^(?:mixin|if|each|for|component)\\s+.*$", + "decreaseIndentPattern": "^\\s*\\}" + }, + "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\s\\w\\{\\}\\(\\)\\[\\]:;,\"']+)", + "onEnterRules": [ + { + "beforeText": "\\{\\s*$", + "afterText": "^(\\s*\\}).*$", + "action": { "indent": "indentOutdent" } + } + ] +} diff --git a/editors/vscode/muddle-language-tools-0.1.0.vsix b/editors/vscode/muddle-language-tools-0.1.0.vsix new file mode 100644 index 0000000..b692bef Binary files /dev/null and b/editors/vscode/muddle-language-tools-0.1.0.vsix differ diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 0000000..f852cd3 --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,69 @@ +{ + "name": "muddle-language-tools", + "displayName": "Muddle Language Tools", + "repository": { + "type": "git", + "url": "https://git.crit.rip/mdl/muddle.git" + }, + "version": "0.1.0", + "description": "Language support for Muddle SPUG templates and STCSS stylesheets", + "main": "./src/extension.js", + "activationEvents": [ + "onLanguage:spug", + "onLanguage:stcss" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + } + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Programming Languages" + ], + "contributes": { + "languages": [ + { + "id": "spug", + "extensions": [".spug", ".stpug", ".stug"], + "configuration": "./language-configuration.json" + }, + { + "id": "stcss", + "extensions": [".stcss"], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "spug", + "scopeName": "source.spug", + "path": "./syntaxes/spug.tmLanguage.json" + }, + { + "language": "stcss", + "scopeName": "source.stcss", + "path": "./syntaxes/stcss.tmLanguage.json" + } + ], + "snippets": [ + { + "language": "spug", + "path": "./snippets/spug.json" + }, + { + "language": "stcss", + "path": "./snippets/stcss.json" + } + ] + }, + "dependencies": { + "@vscode/vsce": "^4.0.0" + }, + "scripts": { + "package": "vsce package", + "publish": "vsce publish" + } +} diff --git a/editors/vscode/snippets/spug.json b/editors/vscode/snippets/spug.json new file mode 100644 index 0000000..de72a79 --- /dev/null +++ b/editors/vscode/snippets/spug.json @@ -0,0 +1,18 @@ +{ + "Typed props": { "prefix": "props", "body": ["props", " ${1:name}: ${2:string}"], "description": "Declare typed SPUG props" }, + "Optional prop": { "prefix": "props?", "body": ["props", " ${1:name}: ${2:string}?", ""], "description": "Declare an optional SPUG prop" }, + "Relation anchor": { "prefix": "anchor", "body": ["${1:a}.class@${2:name}(${3:id})(${0})"], "description": "Spawn a relation anchor consumed by $refer / :with / :when in STCSS" }, + "Dynamic anchor": { "prefix": "anchor+", "body": ["li@row(item.id)(${0})"], "description": "Dynamic relation anchor from an each-loop item" }, + "TypeScript block": { "prefix": "javascript", "body": ["javascript!", " ${1:const value = true;}", "end"], "description": "Embed a TypeScript block" }, + "HTML block": { "prefix": "html-block", "body": ["html!\n ${1:

raw

}\nend"], "description": "Embed a raw HTML block" }, + "Component render": { "prefix": "component", "body": ["component ${1:name}", " ${2:prop}: ${3:value}", " render ${4:slot}", " ${0}", " end? (see docs)"], "description": "Render a component with props and a slot" }, + "Extend layout": { "prefix": "extends", "body": ["extends ./layout.spug", "", "block content", " ${0}"], "description": "Extend a layout and fill the content block" }, + "Render slot": { "prefix": "render", "body": ["render ${1:content}", " ${0}"], "description": "Pass a slot into a component" }, + "Frontmatter": { "prefix": "fm", "body": ["---", "${0}", "---"], "description": "Front matter block with TypeScript imports" }, + "Escape expression": { "prefix": "expr", "body": ["#{${1:expression}}"], "description": "Inline interpolated expression" }, + "Raw expression": { "prefix": "raw", "body": ["!{${1:expression}}"], "description": "Inline raw unescaped expression" }, + "Iterate": { "prefix": "each", "body": ["each ${1:item} in ${2:items}", " ${0}"], "description": "Iterate over a list" }, + "Condition": { "prefix": "if", "body": ["if ${1:condition}", " ${0}"], "description": "Conditional block" }, + "Iso if-choice": { "prefix": "unless", "body": ["unless ${1:condition}", " ${0}"], "description": "Inverse conditional block" }, + "Configuration block": { "prefix": "config", "body": ["$$$", " ${1:block}", "notes", " ${0}"], "description": "Template test configuration and notes" } +} diff --git a/editors/vscode/snippets/stcss.json b/editors/vscode/snippets/stcss.json new file mode 100644 index 0000000..98abbab --- /dev/null +++ b/editors/vscode/snippets/stcss.json @@ -0,0 +1,25 @@ +{ + "Predicted geometry": { "prefix": "predict", "body": ["@predict max-height max-width;"], "description": "Transition prerendered geometry" }, + "Predicted transitions": { "prefix": "predict-full", "body": ["@predict left top width height opacity transform;"], "description": "Full prediction transition list (pill/ribbon style)" }, + "Measure prediction target": { "prefix": "measure", "body": ["@measure;"], "description": "Mark this selector for prerender geometry prefill (inline prediction source)" }, + "Measure anchors": { "prefix": "measure-anchor", "body": ["@measure @${1:anchor}, ${0:.selector};"], "description": "Measure elements by relation anchor or class" }, + "Responsive rule": { "prefix": "responsive", "body": ["@responsive ${1:md} {", " ${2}", "}"], "description": "Add a responsive STCSS rule" }, + "Media rule": { "prefix": "media", "body": ["@media ${1:(max-width: 48rem)} {", " ${2}", "}"], "description": "Add a media query" }, + "Each loop": { "prefix": "each", "body": ["@each $${1:item} in ${2:items} {", " ${0}", "}"], "description": "Repeat a block over a value list" }, + "For loop": { "prefix": "for", "body": ["@for $${1:i} from ${2:1} through ${3:5} {", " ${0}", "}"], "description": "Count a range" }, + "Mixin declaration": { "prefix": "mixin", "body": ["@mixin ${1:name}($${2:arg}) {", " ${0}", "}"], "description": "Declare an STCSS mixin" }, + "Include mixin": { "prefix": "include", "body": ["@include ${1:name}(${2:arg});"], "description": "Use an STCSS mixin" }, + "Interpolation": { "prefix": "interp", "body": ["#{$${1:scale} * 2}rem"], "description": "Compile-time value interpolation" }, + "d-string": { "prefix": "d", "body": ["d\"${1:calc(#{} )}\""], "description": "String with brace expressions evaluated at compile time" }, + "$refer geometry": { "prefix": "refer", "body": ["$refer[${1:left}](@${2:anchor})"], "description": "Read pre-computed anchor geometry (left/top/width/height)" }, + "$refer interpolated location": { "prefix": "refer-between", "body": ["calc($refer[${1:left}](@${2:anchor}) - $refer[${3:left}](@${4:relative}))"], "description": "Compute location between two anchors" }, + "$relate metrics": { "prefix": "relate", "body": ["$relate(${1:@anchor})"], "description": "Legacy relation value of an anchor" }, + "Computed mirror": { "prefix": "computed", "body": ["@${1:anchor}.computed.${2:backgroundColor}"], "description": "Mirror a live computed style of an anchor" }, + "Relation state selector": { "prefix": "with-state", "body": [":with(@${1:anchor}:${2|active,clicked,focus|})"], "description": "Anchor state selection (:has under the hood)" }, + "When selector": { "prefix": "when-state", "body": [":when(${1:root} @${2:anchor}:${3|active,clicked,focus|})"], "description": "Scoped anchor state selection" }, + "Concentric radius": { "prefix": "concentric", "body": ["border-radius: concentric ${1:0.75rem};"], "description": "Concentric nested corner radii" }, + "Freeform gradient": { "prefix": "freeform-gradient", "body": ["background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:#f00} ${5:70%} ${6:40%});"], "description": "Spawn layered freeform radial gradients" }, + "Concentric block": { "prefix": "concentric-block", "body": ["@concentric ${1:1rem} {", " ${0}", "}"], "description": "Concentric radius at-rule" }, + "View transition": { "prefix": "view-transition", "body": ["@view-transition ${1:name};"], "description": "Emit a css view-transition-name" }, + "Compile-time values": { "prefix": "javascript", "body": ["javascript!", " const ${1:space} = ${2:1};", "end"], "description": "Add compile-time values" } +} diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js new file mode 100644 index 0000000..7bc1369 --- /dev/null +++ b/editors/vscode/src/extension.js @@ -0,0 +1,466 @@ +const vscode = require("vscode"); + +const AT_RULES = new Set([ + "each", "for", "from", "through", "include", "responsive", "media", "supports", + "concentric", "view-transition", "predict", "measure", "property", "mixin", + "stcss", "import", "charset", "document", "font-face", "keyframes", "layer", + "namespace", "page", "scope", "starting-style", "container", +]); + +const STATE_WORDS = new Set(["active", "clicked", "focus", "hover", "open", "closed", "selected", "loading", "visible", "hidden"]); + +const AT_RULE_DOCS = new Map([ + ["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"], + ["include", "Expand a mixin: `@include name(10%);`"], + ["each", "Repeat rules over values: `@each $item in a b c { … }`"], + ["for", "Count a range: `@for $i from 1 through 5 { … }`"], + ["responsive", "Wrap rules in a named breakpoint from the config."], + ["media", "Standard `@media` query wrapper."], + ["supports", "Standard `@supports` feature wrapper."], + ["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"], + ["view-transition", "Emit `view-transition-name` on the attached rule."], + ["predict", "Declare which properties transition when prediction prefill changes (emits a smooth transition rule)."], + ["measure", "Mark the attached rule (or listed selectors / `@anchor`s) as a prediction target. Prerender measures the elements and writes geometry. Bare `@anchor` arguments compile to `[data-muddle-anchor=\"anchor\"]`."], +]); + +const REFERENCE_DOC = "Reads pre-computed geometry recorded from a `@sample` anchor: compiles to `var(--muddle-relate-…)`.\n\nMetrics: `left`, `top`, `width`, `height`. `@anchor` compiles to `[data-muddle-anchor=\"anchor\"]`."; + +const RELATE_DOC = "Legacy relation value of an anchor (deprecated in favour of `$refer[metric](@anchor)`)."; + +const CSS_PROPERTIES = [ + "background", "background-color", "background-image", "border", "border-color", "border-radius", "border-width", + "bottom", "box-shadow", "box-sizing", "color", "cursor", "display", "flex", "flex-basis", "flex-direction", + "flex-shrink", "flex-wrap", "font-family", "font-size", "font-weight", "gap", "grid-area", "height", "inset", + "justify-content", "left", "letter-spacing", "line-height", "margin", "max-height", "max-width", "min-height", + "min-width", "opacity", "overflow", "padding", "pointer-events", "position", "right", "text-decoration", + "text-transform", "top", "transform", "transition", "vertical-align", "visibility", "white-space", "width", + "z-index", +]; + +const HTML_ATTRIBUTES = [ + "alt", "aria-controls", "aria-current", "aria-description", "aria-expanded", "aria-hidden", "aria-label", + "aria-live", "autocomplete", "class", "content", "href", "id", "placeholder", "rel", "role", "src", "style", + "tabindex", "target", "title", "type", +]; + +const INDEX = { + anchors: new Map(), // name -> [{ uri, line, kind }] + variables: new Map(), // $name -> [{ uri, line }] + spugFiles: [], + styleFiles: [], +}; + +let indexToken = 0; + +function relativeToWorkspace(uri) { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder) return uri.fsPath.split(/[\\/]/).pop(); + return uri.fsPath.slice(folder.uri.fsPath.length + 1); +} + +function debounce(callback, wait) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => callback(...args), wait); + }; +} + +async function readText(uri) { + try { + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); + } catch { + return ""; + } +} + +function posFromOffset(text, offset) { + const before = text.slice(0, offset); + const newline = before.lastIndexOf("\n"); + return new vscode.Position(before.split("\n").length - 1, offset - newline - 1); +} + +function rangeOf(text, offset, length) { + return new vscode.Range(posFromOffset(text, offset), posFromOffset(text, offset + Math.max(length, 1))); +} + +function isStcss(document) { + return document.languageId === "stcss"; +} + +function rememberAnchor(name, uri, line, kind) { + const list = INDEX.anchors.get(name) ?? []; + list.push({ uri, line, kind }); + INDEX.anchors.set(name, list); +} + +function indexSpugDocument(uri, text) { + const lines = text.split("\n"); + const anchorRegex = /@([A-Za-z_][\w-]*)(?:\(([^(){}]*)\))?/g; + lines.forEach((line, index) => { + let match; + anchorRegex.lastIndex = 0; + while ((match = anchorRegex.exec(line))) { + if (AT_RULES.has(match[1]) || match[1] === "sample") continue; + rememberAnchor(match[1], uri, index + 1, "declared in SPUG"); + } + }); +} + +function indexStcssDocument(uri, text) { + const variableRegex = /(\$[\w-]+)\s*:/g; + let match; + while ((match = variableRegex.exec(text))) { + const position = posFromOffset(text, match.index); + const list = INDEX.variables.get(match[1]) ?? []; + list.push({ uri, line: position.line + 1 }); + INDEX.variables.set(match[1], list); + } + + const anchorRegex = /@([A-Za-z_][\w-]*)/g; + while ((match = anchorRegex.exec(text))) { + if (AT_RULES.has(match[1])) continue; + rememberAnchor(match[1], uri, posFromOffset(text, match.index).line + 1, "referenced in STCSS"); + } +} + +async function indexWorkspace() { + const token = ++indexToken; + if (!vscode.workspace.workspaceFolders) return; + const [spugFiles, styleFiles] = await Promise.all([ + vscode.workspace.findFiles("**/*.spug", "**/node_modules/**"), + vscode.workspace.findFiles("**/*.stcss", "**/node_modules/**"), + ]); + if (token !== indexToken) return; + INDEX.anchors = new Map(); + INDEX.variables = new Map(); + INDEX.spugFiles = spugFiles; + INDEX.styleFiles = styleFiles; + await Promise.all([ + ...spugFiles.map(async (uri) => indexSpugDocument(uri, await readText(uri))), + ...styleFiles.map(async (uri) => indexStcssDocument(uri, await readText(uri))), + ]); +} + +function anchorCompletions() { + return [...INDEX.anchors.keys()].map((name) => { + const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Keyword); + item.detail = "relation anchor"; + const first = INDEX.anchors.get(name)[0]; + if (first) item.documentation = new vscode.MarkdownString(`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``); + return item; + }); +} + +function stcssCompletions() { + const atRules = [...AT_RULE_DOCS.keys()].map((name) => { + const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Struct); + item.detail = "stcss at-rule"; + item.insertText = new vscode.SnippetString(name === "concentric" || name === "media" ? `${name} $1 {\n\t$0\n}` : `${name};`); + item.documentation = new vscode.MarkdownString(AT_RULE_DOCS.get(name)); + return item; + }); + const metrics = ["left", "top", "width", "height"].map((metric) => { + const item = new vscode.CompletionItem(`$refer[${metric}](@)`, vscode.CompletionItemKind.Function); + item.insertText = new vscode.SnippetString(`$refer[${metric}](@\${1:anchor})`); + item.documentation = new vscode.MarkdownString(REFERENCE_DOC); + return item; + }); + const relate = new vscode.CompletionItem("$relate(@)", vscode.CompletionItemKind.Function); + relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})"); + relate.documentation = new vscode.MarkdownString(RELATE_DOC); + const states = [...STATE_WORDS].map((state) => { + const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.EnumMember); + item.detail = "anchor state"; + return item; + }); + const properties = CSS_PROPERTIES.map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); + item.insertText = new vscode.SnippetString(`${name}: $0;`); + return item; + }); + const variables = [...INDEX.variables.keys()].map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable); + item.detail = "stcss variable"; + return item; + }); + return [...atRules, ...metrics, relate, ...states, ...properties, ...variables]; +} + +function spugCompletions() { + const keywords = ["extends", "include", "block", "append", "prepend", "mixin", "slot", "yield", "island", "component", "render", "doctype"].map((word) => { + const item = new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword); + return item; + }); + const javascript = new vscode.CompletionItem("javascript!", vscode.CompletionItemKind.Keyword); + javascript.insertText = new vscode.SnippetString("javascript!\n\t$0\nend"); + const control = ["if", "else", "unless", "each", "while", "case", "when", "default", "props"].map((word) => new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword)); + const attributes = HTML_ATTRIBUTES.map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); + item.insertText = new vscode.SnippetString(`${name}=$0`); + return item; + }); + return [...keywords, javascript, ...control, ...attributes, ...anchorCompletions()]; +} + +function stcssHover(document, position) { + const line = document.lineAt(position.line).text; + const preceding = line.slice(0, position.character); + const anchor = /(?:^|[^\w$])@([A-Za-z_][\w-]*)$/.exec(preceding)?.[1]; + if (anchor) return hoverAnchor(anchor); + const variable = /\$[\w-]+$/.exec(preceding)?.[0]; + if (variable) { + const hits = INDEX.variables.get(variable) ?? []; + return new vscode.Hover(new vscode.MarkdownString( + hits.length ? `**${variable}**: defined in ${hits.map((h) => `${relativeToWorkspace(h.uri)}:${h.line}`).join(", ")}` : `**${variable}**`, + )); + } + const refer = /(?:\$\w+(?:\[[\w-]+\])?)$/.exec(preceding)?.[0]; + if (refer) { + if (refer.startsWith("$refer")) return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC)); + if (refer.startsWith("$relate")) return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC)); + } + return null; +} + +function hoverAnchor(name) { + const hits = INDEX.anchors.get(name) ?? []; + if (!hits.length) return null; + const body = hits.slice(0, 8).map((hit) => `- ${hit.kind}: \`${relativeToWorkspace(hit.uri)}:${hit.line}\``).join("\n"); + return new vscode.Hover(new vscode.MarkdownString(`**@${name}**: relation anchor\n\n${body}`)); +} + +function spugHover(document, position) { + const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/); + if (!range) return null; + const word = document.getText(range); + if (word.startsWith("@") || document.getText(new vscode.Range(range.start.translate({ characterDelta: -1 }), range.start)) === "@") { + return hoverAnchor(word.replace(/^@/, "")); + } + return null; +} + +function anchorDefinition(name) { + return (INDEX.anchors.get(name) ?? []) + .filter((hit) => hit.kind === "declared in SPUG") + .map((hit) => new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0))); +} + +function stcssDefinition(document, position) { + const line = document.lineAt(position.line).text.slice(0, position.character + 1); + const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1]; + if (anchor) return anchorDefinition(anchor); + return null; +} + +function spugDefinition(document, position) { + const before = document.lineAt(position.line).text.slice(0, position.character + 1); + const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1]; + if (anchor) { + // jump from anchor to its STCSS references + const refs = (INDEX.anchors.get(anchor) ?? []).filter((hit) => hit.kind === "referenced in STCSS"); + return refs.map((hit) => new vscode.Location(hit.uri, new vscode.Range(hit.line - 1, 0, hit.line - 1, 200))); + } + return null; +} + +function diagnosticCollection() { + return vscode.languages.createDiagnosticCollection("muddle"); +} + +function lintDocument(document, collection) { + if (document.languageId !== "stcss" && document.languageId !== "spug") return []; + const text = document.getText(); + const issues = []; + + // balanced braces in both languages + let depth = 0; + let lastOpen = 0; + for (let index = 0; index < text.length && issues.length < 40; index += 1) { + const char = text[index]; + if (char === "{") { + depth += 1; + lastOpen = index; + } else if (char === "}") { + depth -= 1; + if (depth < 0) { + issues.push(new vscode.Diagnostic(rangeOf(text, index, 1), "Unexpected `}`.", vscode.DiagnosticSeverity.Error)); + depth = 0; + } + } + } + if (depth > 0) { + issues.push(new vscode.Diagnostic(rangeOf(text, lastOpen, 1), "Unclosed `{` block.", vscode.DiagnosticSeverity.Error)); + } + + if (document.languageId === "stcss") { + const anchorRegex = /@([A-Za-z_][\w-]*)/g; + let match; + while ((match = anchorRegex.exec(text))) { + const name = match[1]; + const offset = match.index; + const previous = text[offset - 1]; + const dynamic = text.slice(offset, text.indexOf("\n", offset) === -1 ? text.length : text.indexOf("\n", offset)).includes("("); + if (AT_RULES.has(name) || previous === "$" || dynamic) continue; + if (!INDEX.anchors.has(name)) { + issues.push(new vscode.Diagnostic( + rangeOf(text, offset, match[0].length), + `No SPUG file declares relation anchor \`@${name}\`.`, + vscode.DiagnosticSeverity.Warning, + )); + } + } + } + + collection.set(document.uri, issues); + return issues; +} + +function lintVisible() { + for (const editor of vscode.window.visibleTextEditors) lintDocument(editor.document, diagnostics); +} + +function symbolKindFor(document) { + return isStcss(document) ? vscode.SymbolKind.Class : vscode.SymbolKind.Module; +} + +function symbolsOf(document) { + const flat = []; + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + const match = /^(\s*)(@?[A-Za-z][^\n{;]*?|@[\w-]+)\s*\{?\s*$/.exec(text); + if (!match || !text.trim()) continue; + if (isStcss(document) && !text.includes("{")) continue; + if (!isStcss(document) && text.includes("{")) continue; + const label = match[2].trim() || "root"; + const symbol = new vscode.DocumentSymbol( + label, + "", + symbolKindFor(document), + new vscode.Range(line, 0, line, text.length), + new vscode.Range(line, match[1].length, line, match[1].length + label.length), + ); + flat.push({ symbol, indent: match[1].length }); + } + const roots = []; + const stack = []; + for (const entry of flat) { + while (stack.length && entry.indent <= stack[stack.length - 1].indent) stack.pop(); + if (stack.length) stack[stack.length - 1].symbol.children.push(entry.symbol); + else roots.push(entry.symbol); + stack.push(entry); + } + return roots; +} + +function foldingOf(document) { + const ranges = []; + const stack = []; + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + if (isStcss(document)) { + const opens = (text.match(/\{/g) ?? []).length; + const closes = (text.match(/\}/g) ?? []).length; + for (let i = 0; i < opens; i += 1) stack.push(line); + for (let i = 0; i < closes && stack.length; i += 1) { + const start = stack.pop(); + if (line > start) ranges.push(new vscode.FoldingRange(start, line)); + } + } else { + const indent = text.match(/^(\s*)/)[1].length; + if (!text.trim()) continue; + while (stack.length && indent <= stack[stack.length - 1].indent) { + const start = stack.pop(); + if (line - 1 > start) ranges.push(new vscode.FoldingRange(start, line - 1)); + } + stack.push({ indent, line }); + } + } + while (stack.length) { + const start = typeof stack[stack.length - 1] === "number" ? stack.pop() : stack.pop().line; + if (document.lineCount - 1 > start) ranges.push(new vscode.FoldingRange(start, document.lineCount - 1)); + } + return ranges; +} + +let diagnostics; + +function completionsFor(document, position) { + const line = document.lineAt(position.line); + const before = line.text.slice(0, position.character); + if (line.isEmptyOrWhitespace) return []; + const prefix = before.trim(); + if (prefix.length === 0 && position.character > 0) return []; + // completions popped while just typing whitespace mid-line are noise + if (before.length > 0 && /\s$/.test(before) && !/[$@:(\[=,{]\s*$/.test(before)) return []; + return isStcss(document) ? stcssCompletions(before) : spugCompletions(before); +} + +function activate(context) { + diagnostics = diagnosticCollection(); + + const refresh = debounce(async () => { + await indexWorkspace(); + lintVisible(); + }, 500); + + Promise.resolve(refresh()); + + context.subscriptions.push( + diagnostics, + + vscode.languages.registerCompletionItemProvider( + ["stcss", "spug"], + { provideCompletionItems(document, position) { return completionsFor(document, position); } }, + "@", "$", ".", "-", ":", "=", + ), + + vscode.languages.registerHoverProvider(["stcss", "spug"], { + provideHover(document, position) { + const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/); + if (!word) return null; + try { + return isStcss(document) ? stcssHover(document, position) : spugHover(document, position); + } catch { + return null; + } + }, + }), + + vscode.languages.registerDefinitionProvider(["stcss", "spug"], { + provideDefinition(document, position) { + try { + return isStcss(document) ? stcssDefinition(document, position) : spugDefinition(document, position); + } catch { + return null; + } + }, + }), + + vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], { + provideDocumentSymbols(document) { return symbolsOf(document); }, + }), + + vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], { + provideFoldingRanges(document) { return foldingOf(document); }, + }), + + vscode.workspace.onDidOpenTextDocument((document) => lintDocument(document, diagnostics)), + vscode.workspace.onDidChangeTextDocument((event) => { + lintDocument(event.document, diagnostics); + refresh(); + }), + vscode.workspace.onDidSaveTextDocument(refresh), + vscode.workspace.onDidCreateFiles(refresh), + vscode.workspace.onDidDeleteFiles(refresh), + ); + + lintVisible(); +} + +function deactivate() { + return undefined; +} + +module.exports = { activate, deactivate }; diff --git a/editors/vscode/syntaxes/spug.tmLanguage.json b/editors/vscode/syntaxes/spug.tmLanguage.json new file mode 100644 index 0000000..755d656 --- /dev/null +++ b/editors/vscode/syntaxes/spug.tmLanguage.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json", + "name": "SPUG", + "scopeName": "source.spug", + "patterns": [ + { "include": "#frontmatter" }, + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#doctype" }, + { "include": "#control-line" }, + { "include": "#keyword-line" }, + { "include": "#element" } + ], + "repository": { + "comment": { + "patterns": [ + { + "name": "comment.block.spug", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.block.html.spug", + "begin": "" + } + ] + }, + "frontmatter": { + "name": "meta.embedded.block.ts.spug", + "begin": "^---\\s*$", + "beginCaptures": { "0": { "name": "punctuation.definition.frontmatter.begin.spug" } }, + "end": "^---\\s*$", + "endCaptures": { "0": { "name": "punctuation.definition.frontmatter.end.spug" } }, + "patterns": [{ "include": "source.ts" }] + }, + "javascript-block": { + "name": "meta.embedded.block.ts.spug", + "begin": "^\\s*(javascript|typescript)!\\s*$", + "beginCaptures": { "1": { "name": "keyword.control.spug" } }, + "end": "^\\s*end\\s*$", + "endCaptures": { "0": { "name": "keyword.control.spug" } }, + "patterns": [{ "include": "source.ts" }] + }, + "doctype": { + "match": "^\\s*(doctype)\\b\\s*(\\S+)?", + "captures": { + "1": { "name": "keyword.control.spug" }, + "2": { "name": "constant.other.doctype.spug" } + } + }, + "control-line": { + "name": "meta.spug-control.spug", + "begin": "^\\s*(each|if|unless|else if|else|while|case|when|default)\\b", + "beginCaptures": { "1": { "name": "keyword.control.spug" } }, + "end": "$", + "patterns": [ + { "match": "\\bin\\b", "name": "keyword.operator.of.spug" }, + { "include": "#interpolation" }, + { "include": "#expression" } + ] + }, + "keyword-line": { + "match": "^\\s*(extends|include|block|append|prepend|mixin|slot|yield|island|component|render|props)\\b\\s*([\\w./@-]*)", + "captures": { + "1": { "name": "keyword.control.spug" }, + "2": { + "patterns": [ + "string.quoted.other.path.spug", + { "match": "[\\w./@-]+", "name": "variable.other.predefined.spug" } + ] + } + } + }, + "anchor": { + "match": "@(?!(?:each|if|unless|else|while|case|when|default|props|doctype|javascript|typescript|end)\\b)[A-Za-z_][\\w-]*(?:\\([^(){}]*\\))?", + "name": "entity.other.relation-anchor.spug" + }, + "element": { + "name": "meta.element.spug", + "begin": "^\\s*([A-Za-z][A-Za-z0-9:_.-]*)", + "beginCaptures": { + "1": { "name": "entity.name.tag.spug" } + }, + "end": "$", + "patterns": [ + { "include": "#class-id" }, + { "include": "#anchor" }, + { "include": "#attributes" }, + { "include": "#output" }, + { "include": "#interpolation" }, + { "include": "#text" } + ] + }, + "class-id": { + "patterns": [ + { + "match": "(\\.)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.tag.spug" }, + "2": { "name": "entity.other.attribute-name.class.spug" } + } + }, + { + "match": "(#)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.tag.spug" }, + "2": { "name": "entity.other.attribute-name.id.spug" } + } + } + ] + }, + "attributes": { + "name": "meta.attributes.spug", + "begin": "\\(", + "end": "\\)", + "patterns": [ + { "include": "#anchor" }, + { "include": "#attributes" }, + { + "match": "([A-Za-z_][\\w:-]*)\\s*(?==)", + "name": "entity.other.attribute-name.spug" + }, + { "include": "#d-string" }, + { "include": "#expression" } + ] + }, + "d-string": { + "name": "string.quoted.double.stcss", + "begin": "([dD])(\"|')", + "beginCaptures": { + "1": { "name": "keyword.other.stcss" }, + "2": { "name": "punctuation.definition.string.begin.stcss" } + }, + "end": "(?=!]=[?]?|[?:,+\\-*/%<>=!]", "name": "keyword.operator.js" } + ] + } + } +} diff --git a/editors/vscode/syntaxes/stcss.tmLanguage.json b/editors/vscode/syntaxes/stcss.tmLanguage.json new file mode 100644 index 0000000..94fbcf9 --- /dev/null +++ b/editors/vscode/syntaxes/stcss.tmLanguage.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json", + "name": "Structured CSS (STCSS)", + "scopeName": "source.stcss", + "patterns": [ + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#variable-definition" }, + { "include": "#mixin-declaration" }, + { "include": "#rule" }, + { "include": "#at-rule" }, + { "include": "#value" } + ], + "repository": { + "comment": { + "patterns": [ + { + "name": "comment.block.stcss", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.line.double-slash.stcss", + "match": "//[^{]*$" + } + ] + }, + "javascript-block": { + "name": "meta.embedded.block.ts.stcss", + "begin": "^\\s*(javascript|typescript)!\\s*$", + "beginCaptures": { "1": { "name": "keyword.control.stcss" } }, + "end": "^\\s*end\\s*$", + "endCaptures": { "0": { "name": "keyword.control.stcss" } }, + "patterns": [{ "include": "source.ts" }] + }, + "variable-definition": { + "match": "^\\s*(\\$[\\w-]+)\\s*:", + "captures": { + "1": { "name": "variable.definition.stcss" } + } + }, + "mixin-declaration": { + "match": "^\\s*(\\$[\\w-]+)\\s*\\(|^\\s*(mixin)\\s+([\\w-]+)\\s*(\\([^()]*\\))?", + "captures": { + "1": { "name": "variable.definition.stcss" }, + "2": { "name": "keyword.control.stcss" }, + "3": { "name": "entity.name.function.stcss" }, + "4": { "name": "meta.function.parameters.stcss" } + } + }, + "rule": { + "name": "meta.rule.stcss", + "begin": "^([^\\n{}]*?)\\s*\\{", + "end": "\\}", + "beginCaptures": { + "1": { "name": "meta.selector.stcss", "patterns": [{ "include": "#selector-content" }] } + }, + "endCaptures": { "0": { "name": "punctuation.definition.block.end.bracket.curly.stcss" } }, + "patterns": [ + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#variable-definition" }, + { "include": "#mixin-declaration" }, + { "include": "#rule" }, + { "include": "#at-rule" }, + { "include": "#declaration" }, + { "include": "#value" } + ] + }, + "at-rule": { + "match": "(@)(each|for|include|responsive|media|supports|concentric|view-transition|predict|measure|property|import|keyframes|layer|container|font-face|charset|document|page|scope|starting-style)(\\b[^;{\\n]*)?", + "captures": { + "1": { "name": "punctuation.definition.at-rule.stcss" }, + "2": { + "patterns": [ + { + "match": "^(?:each|for|include|responsive|concentric|predict|measure|property)$", + "name": "keyword.other.at-rule.muddle" + }, + { + "match": "^.+$", + "name": "keyword.control.at-rule.stcss" + } + ] + }, + "3": { + "patterns": [ + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#value" } + ] + } + } + }, + "selector-content": { + "patterns": [ + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#variable" }, + { + "match": "(\\.)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.class.stcss" }, + "2": { "name": "entity.other.attribute-name.class.stcss" } + } + }, + { + "match": "(#)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.id.stcss" }, + "2": { "name": "entity.other.attribute-name.id.stcss" } + } + }, + { "match": "(&)", "name": "keyword.operator.parent-reference.stcss" }, + { "match": "[>+~]", "name": "keyword.operator.combinator.stcss" }, + { + "match": "[\\w-]+(?=\\s*(?:$|[.#:&,*]|[\\[{]|\\s|,))", + "name": "entity.name.tag.stcss" + }, + { "include": "#pseudoclass" }, + { "include": "#attribute-selector" } + ] + }, + "pseudoclass": { + "patterns": [ + { + "match": ":with|:when", + "name": "keyword.other.relation-selector.stcss" + } + ] + }, + "attribute-selector": { + "name": "meta.attribute-selector.stcss", + "begin": "\\[", + "beginCaptures": { "0": { "name": "punctuation.definition.attribute-selector.begin.stcss" } }, + "end": "\\]", + "endCaptures": { "0": { "name": "punctuation.definition.attribute-selector.end.stcss" } }, + "patterns": [ + { "include": "#interpolation" }, + { "include": "#variable" }, + { + "match": "([a-zA-Z-][\\w-]*)\\s*(?=(?:[~^|$]?=)|\\])", + "captures": { + "1": { "name": "entity.other.attribute-name.attribute.stcss" } + } + }, + { "match": "[~^|$]?=", "name": "keyword.operator.stcss" }, + { "include": "#string" }, + { "include": "#number" } + ] + }, + "declaration": { + "match": "^\\s*([-a-zA-Z][\\w-]*)\\s*:", + "captures": { + "1": { "name": "support.type.property-name.css" } + } + }, + "anchor": { + "match": "@[A-Za-z_][\\w-]*(?:\\.computed\\.[\\w-]+)?(?::(?:active|clicked|focus|hover|open|closed|selected|loading|visible|hidden)\\b)?", + "name": "entity.other.relation-anchor.stcss" + }, + "refer": { + "match": "\\$(?:refer(\\[[\\w-]+\\])?|relate)", + "captures": { + "1": { "name": "keyword.other.unit.reference-metric.stcss" } + }, + "name": "support.function.refer.stcss" + }, + "interpolation": { + "name": "meta.interpolation.stcss", + "begin": "#\\{", + "beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.stcss" } }, + "end": "\\}", + "endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.stcss" } }, + "patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }] + }, + "curly-nesting": { + "begin": "\\{", + "beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, + "end": "\\}", + "endCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, + "patterns": [ + { "include": "#curly-nesting" }, + { "include": "#expression" } + ] + }, + "d-string": { + "name": "string.quoted.stcss", + "begin": "(?=?|[*/%+-]", + "name": "keyword.operator.stcss" + }, + "expression": { + "patterns": [ + { "include": "#comment" }, + { "include": "#d-string" }, + { "include": "#string" }, + { "include": "#refer" }, + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#number" }, + { "include": "#color" }, + { "include": "#variable" }, + { "match": "--[\\w-]+", "name": "variable.other.css.custom-property.stcss" }, + { "include": "#function-call" }, + { "include": "#operator" } + ] + }, + "value": { + "patterns": [{ "include": "#expression" }] + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b323215 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["node_modules/**", "dist/**", "**/dist/**"], + }, + { + files: ["editors/vscode/src/**"], + languageOptions: { + sourceType: "commonjs", + globals: { + require: "readonly", + module: "writable", + setTimeout: "readonly", + clearTimeout: "readonly", + TextDecoder: "readonly", + console: "readonly", + }, + }, + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + }, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-object-type": "off", + }, + }, +); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4a995a4 --- /dev/null +++ b/index.ts @@ -0,0 +1,237 @@ +// Contracts +export type { + AdapterOptions, + MuddleConfig, + CollectionConfig, + CollectionEntry, + ContentSchema, + DatabaseOptions, + Integration, + IntegrationContext, + MountableViews, + Mounts, + Loader, + LoaderFile, + LoadedFile, + PrerenderOptions as PrerenderConfig, + ResolvedConfig, +} from "./contracts/index.ts"; +export { defineConfig } from "./contracts/index.ts"; +export type { + HttpMethod, + HttpRequest, + HttpResponse, + RequestContext, + ResponseBuilder, + StatusCode, +} from "./contracts/typegen/http/index.ts"; +export type { + ColumnMapping, + FieldOptions, + FieldType, + TableClass, + TableOptions, + WhereCondition, + WhereOperator, +} from "./contracts/typegen/orm/index.ts"; +export type { + TableName, + TableRowOf, + TableRows, +} from "./contracts/typegen/orm/tables.ts"; +export type { + ViewPath, + ViewProps, + ViewPropsOf, +} from "./contracts/typegen/views.ts"; +export type { Minify, Simplify } from "./contracts/typegen/minify.ts"; + +// Core +export { App, type AppOptions, type ViewRenderer } from "./core/app.ts"; +export { + Container, + type AbstractType, + type ProviderDefinition, + type ProviderScope, + type Token, + type Type, +} from "./core/container.ts"; +export { resolveConfig, loadConfig } from "./core/config.ts"; +export { ServerBuildContext, TemplateContext } from "./core/context.ts"; +export { MemorySessionStore, type SessionStore, type SessionRecord } from "./core/sessions.ts"; +export { RoutePipeline, type RouteGuard } from "./core/pipeline.ts"; +export { format } from "./core/format.ts"; +export { createTemplate, muddleTemplates, type MuddleTemplate, type TemplateResult } from "./core/templates.ts"; +export { + MuddleError, + RouteNotFoundError, + SchemaError, + TemplateError, + UnknownProviderError, +} from "./core/errors.ts"; +export { + createRequestContext, + html, + json, + notFound, + redirect, + parseQuery, + parseRequestBody, + text, +} from "./core/http.ts"; +export { + defineMetadata, + getMetadata, + getMemberMetadata, + installMetadataPolyfill, +} from "./core/metadata.ts"; +export { ModuleRegistry, type ModuleContext } from "./core/modules.ts"; +export { + Router, + type RouteDefinition, + type RouteMatch, + type ViewDescriptor, +} from "./core/router.ts"; +export { + redirectWithMessage, + toFormFields, + validate, + type FormSchema, + type ValidationResult, +} from "./core/form.ts"; +export { + ContentRegistry, + ContentService, + validateFrontmatter, +} from "./core/content.ts"; + +// Decorators +export { Component, getComponentMetadata } from "./decorators/Component.ts"; +export { Controller, getControllerPrefix } from "./decorators/Controller.ts"; +export { Inject, Optional } from "./decorators/Inject.ts"; +export { Injectable, Module, getModuleOptions } from "./decorators/Module.ts"; +export { + Body, + Form, + Locals, + Param, + Query, + Req, + Res, +} from "./decorators/Params.ts"; +export { Route, getRoutes } from "./decorators/Route.ts"; +export { Service } from "./decorators/Service.ts"; +export { View, getViews } from "./decorators/View.ts"; +export { RenderAhead, getRenderAhead, type RenderAheadSource } from "./decorators/RenderAhead.ts"; +export { RenderRoute, getRenderRoutes, routeFolder, collectionRoute, type CollectionRenderRoute, type RenderRouteSource } from "./decorators/RenderRoute.ts"; +export { Astro, type AstroOptions } from "./decorators/Astro.ts"; +export { Auth, getAuth, type AuthOptions } from "./decorators/Auth.ts"; + +// Views +export { + createViews, + ViewBuilder, + Views, + type SpugComponentOptions, + type RenderOptions, + type RenderResult, + type ViewsOptions, +} from "./views/api.ts"; +export { + defineComponent, + type ComponentContext, + type ComponentDefinition, + type ComponentRegistry, + type ServerComponent, + type SpugComponentRender, +} from "./views/components.ts"; +export { compileCss, stcssLoader, sassLoader, styleTag, type SassLoaderOptions } from "./views/css/index.ts"; +export { analyzeReactComponent, backportProject, cssToStcss, detectBackportSource, htmlToSpug, reactToSpug, type BackportResult, type BackportSource, type ReactHookAnalysis } from "./core/backport.ts"; +export { + markdownLoader, + parseFrontmatter, + renderLatex, + renderMarkdown, + type Frontmatter, +} from "./views/markdown.ts"; +export { + TemplateEngine, + helpers, + mergeBlocks, + type CompiledTemplate, + type RenderContext, + type TemplateRuntime, +} from "./views/engine/runtime.ts"; +export { SPUG_COMPLETIONS, STCSS_COMPLETIONS, type LanguageCompletion } from "./views/language.ts"; +export { stage, StageManager, type KeyframeStep, type ScrollSceneOptions, type TransitionPortalOptions } from "./views/stage.ts"; +export { + createHooks, + defineHooks, + MuddleHooks, + type HookBuilder, + type SpugExpansion, + type SpugHookContext, + type StcssAtRuleExpansion, + type StcssAtRuleHookContext, + type StcssCalculationExpansion, + type StcssCalculationHookContext, + type StcssDeclarationExpansion, + type StcssDeclarationHookContext, + type StcssValueExpansion, + type StcssValueHookContext, +} from "./views/hooks.ts"; + +export { filesystemCache, cloudflareCache, type Cache, type CloudflareCacheLike } from "./core/cache.ts"; + +// Adapters +export { serve, type BunServerOptions } from "./adapters/server-bun.ts"; +export { + createWorker, + setupWrangler, + type WorkerEnv, + type WranglerSetupOptions, +} from "./adapters/cloudflare-wrangler.ts"; +export { createHandler, setupVercel, type NodeHandler } from "./adapters/vercel-vercel.ts"; +export { + prerender, + type PrerenderOptions, + type PrerenderResult, +} from "./adapters/static.ts"; +export { predictLayout, type BreakpointAcc, type LayoutPrediction, type LayoutPredictionOptions, type ReflectiveAnchorGroup } from "./adapters/prediction.ts"; + +// Integrations +export { + checkboxMarkup, + mouseTrackerMarkup, + mouseTrackerCss, + reactCss, + reactIntegration, + reactStylesheet, + STATES, + type ReactIntegrationOptions, + type TrackerOptions, +} from "./integrations/react.ts"; +export { concentric, layoutHooks, type Utility, type UtilityContext } from "./integrations/hooks.ts"; +export { googleFonts, type GoogleFontsOptions } from "./integrations/fonts.ts"; +export { responsivityIntegration, DEFAULT_BREAKPOINTS, type ResponsivityOptions } from "./integrations/responsivity.ts"; +export { authIntegration, authFromRequest, authGuard, type AuthIdentity, type OAuthProvider, type AuthIntegrationOptions } from "./integrations/auth.ts"; + +// Testing +export { + collectTemplateTests, + readAssertions, + runTemplateTests, + type TemplateTest, + type TemplateTestFailure, + type TemplateTestOptions, + type TemplateTestRun, +} from "./views/testing.ts"; + +// Type generation +export { + generate, + readConfig, + type GeneratorOptions, + type GeneratorResult, + type ViewContract, +} from "./contracts/typegen/generator.ts"; diff --git a/integrations/auth.ts b/integrations/auth.ts new file mode 100644 index 0000000..2a98d5a --- /dev/null +++ b/integrations/auth.ts @@ -0,0 +1,95 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import type { RouteGuard } from "../core/pipeline.ts"; +import { createHash } from "node:crypto"; +import { MemorySessionStore, type SessionStore } from "../core/sessions.ts"; + +export const authGuard: RouteGuard = async (context) => { if (!context.locals.auth) { const identity = await authFromRequest(context.raw as Request); if (identity) context.locals.auth = identity; } }; + +export interface AuthIdentity { id: string; email?: string; roles?: string[] } +export interface OAuthProvider { name: string; authorizationUrl: string; clientId: string; redirectUri: string; scope?: string[]; tokenUrl: string; clientSecret?: string; userInfoUrl: string; identity(profile: Record): AuthIdentity } +export interface AuthIntegrationOptions { + login?: (email: string, password: string) => Promise | AuthIdentity | undefined; + providers?: OAuthProvider[]; + route?: string; + store?: SessionStore | ((context: IntegrationContext) => Promise | SessionStore); + sessionTtl?: number; +} + +const defaultStore = new MemorySessionStore(); +const sessionKey = (id: string) => `session:${createHash("sha256").update(id).digest("hex")}`; +async function cookie(identity: AuthIdentity, store: SessionStore, ttl: number): Promise { + const id = crypto.randomUUID(); + await store.set(sessionKey(id), { value: { identity }, expires: Date.now() + ttl * 1000 }); + return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`; +} + +/** Form and OAuth entry points. Token exchange remains provider-owned. */ +export function authIntegration(options: AuthIntegrationOptions = {}): Integration { + const route = options.route ?? "/auth"; + let store: SessionStore = defaultStore; + const ttl = options.sessionTtl ?? 3600; + return { + name: "auth", + async request(context) { const identity = await authFromRequest(context.raw as Request, store); if (identity) context.locals.auth = identity; }, + async setup(context: IntegrationContext) { + store = typeof options.store === "function" ? await options.store(context) : options.store ?? defaultStore; + context.route("GET", `${route}/login`, () => new Response('
', { headers: { "content-type": "text/html; charset=utf-8" } })); + context.route("POST", `${route}/login`, async (request, ctx) => { + if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); + if (!ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body)) return new Response("Invalid credentials", { status: 400 }); + const data = ctx.body as Record; + const identity = await options.login?.(String(data.email ?? ""), String(data.password ?? "")); + if (request.headers.get("content-type")?.includes("application/json")) return identity ? Response.json({ user: identity }, { headers: { "set-cookie": await cookie(identity, store, ttl) } }) : Response.json({ error: "Unauthorized" }, { status: 401 }); + return identity ? new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(identity, store, ttl) } }) : new Response("Unauthorized", { status: 401 }); + }); + context.route("POST", `${route}/logout`, async (request) => { + if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); + const id = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; + if (id) await store.delete(sessionKey(id)); + return new Response(null, { status: 302, headers: { location: "/", "set-cookie": "muddle_auth=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" } }); + }); + for (const provider of options.providers ?? []) { + context.route("GET", `${route}/${provider.name}`, async () => { + const state = crypto.randomUUID(); + const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); + await store.set(`oauth:${state}`, { value: { verifier, provider: provider.name }, expires: Date.now() + 600_000 }); + const url = new URL(provider.authorizationUrl); + url.searchParams.set("client_id", provider.clientId); + url.searchParams.set("redirect_uri", provider.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", (provider.scope ?? []).join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")); + url.searchParams.set("code_challenge_method", "S256"); + return new Response(null, { status: 302, headers: { location: url.toString(), "set-cookie": `muddle_oauth=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600` } }); + }); + context.route("GET", `${route}/${provider.name}/callback`, async (request) => { + const url = new URL(request.url); + const state = url.searchParams.get("state") ?? ""; + const bound = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_oauth=([^;]+)/)?.[1]; + if (bound !== state || !url.searchParams.get("code")) return new Response("Invalid OAuth state", { status: 400 }); + const record = await store.consume(`oauth:${state}`); + const transaction = record?.value as { provider: string; verifier: string } | undefined; + if (!transaction || record!.expires < Date.now() || transaction.provider !== provider.name) return new Response("Invalid OAuth state", { status: 400 }); + const body = new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code")!, client_id: provider.clientId, redirect_uri: provider.redirectUri, code_verifier: transaction.verifier }); + if (provider.clientSecret) body.set("client_secret", provider.clientSecret); + const tokenResponse = await fetch(provider.tokenUrl, { method: "POST", body }); + if (!tokenResponse.ok) return new Response("OAuth exchange failed", { status: 502 }); + const token = await tokenResponse.json() as { access_token?: string }; + if (!token.access_token) return new Response("Missing OAuth token", { status: 502 }); + const profile = await fetch(provider.userInfoUrl, { headers: { authorization: `Bearer ${token.access_token}` } }); + if (!profile.ok) return new Response("OAuth profile failed", { status: 502 }); + return new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(provider.identity(await profile.json() as Record), store, ttl) } }); + }); + } + }, + }; +} + +export async function authFromRequest(request: Request, store: SessionStore = defaultStore): Promise { + const value = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; + if (!value) return undefined; + const session = await store.get(sessionKey(value)); + if (!session || session.expires < Date.now()) { await store.delete(sessionKey(value)); return undefined; } + return session.value.identity as AuthIdentity; +} diff --git a/integrations/fonts.ts b/integrations/fonts.ts new file mode 100644 index 0000000..8c1c734 --- /dev/null +++ b/integrations/fonts.ts @@ -0,0 +1,65 @@ +import type { Cache } from "../core/cache.ts"; +import type { Integration, IntegrationContext } from "../contracts/index.ts"; + +export interface GoogleFontsOptions { + families: string[]; + route?: string; + cache?: Cache; + display?: "auto" | "block" | "swap" | "fallback" | "optional"; +} + +function memoryCache(): Cache { + const entries = new Map(); + return { + async get(key) { return entries.get(key); }, + async put(key, value) { entries.set(key, value); }, + async delete(key) { entries.delete(key); }, + }; +} + +function text(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function googleUrl(options: GoogleFontsOptions): string { + const family = options.families.map((value) => `family=${encodeURIComponent(value.replace(/\s+/g, "+"))}`).join("&"); + return `https://fonts.googleapis.com/css2?${family}&display=${options.display ?? "swap"}`; +} + +/** Serves Google Fonts through project routes and caches both CSS and font files. */ +export function googleFonts(options: GoogleFontsOptions): Integration { + const route = options.route ?? "/_muddle/fonts.css"; + const proxyPrefix = `${route.replace(/\.css$/, "")}/`; + const cache = options.cache ?? memoryCache(); + return { + name: "google-fonts", + setup(context: IntegrationContext) { + context.route("GET", route, async () => { + const key = `css:${googleUrl(options)}`; + let css = await cache.get(key); + if (!css) { + const response = await fetch(googleUrl(options)); + if (!response.ok) return new Response("", { status: 502 }); + css = new Uint8Array(await response.arrayBuffer()); + await cache.put(key, css, { ttl: 86400 }); + } + const proxied = text(css).replace(/url\((https:\/\/[^)]+)\)/g, (_match, url: string) => `url(${proxyPrefix}${encodeURIComponent(url)})`); + return new Response(proxied, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=86400" } }); + }); + context.route("GET", `${proxyPrefix}*`, async (request) => { + const encoded = new URL(request.url).pathname.slice(proxyPrefix.length); + const url = decodeURIComponent(encoded); + if (!url.startsWith("https://fonts.gstatic.com/")) return new Response("Not Found", { status: 404 }); + const key = `font:${url}`; + let font = await cache.get(key); + if (!font) { + const response = await fetch(url); + if (!response.ok) return new Response("", { status: 502 }); + font = new Uint8Array(await response.arrayBuffer()); + await cache.put(key, font, { ttl: 31536000 }); + } + return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" } }); + }); + }, + }; +} diff --git a/integrations/hooks.ts b/integrations/hooks.ts new file mode 100644 index 0000000..7455d2b --- /dev/null +++ b/integrations/hooks.ts @@ -0,0 +1,48 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; + +export interface UtilityContext { + name: string; + value?: string; + selector: string; +} + +export type Utility = (context: UtilityContext) => string | undefined; + +export interface LayoutHookContext { + utilities: (name: string, utility: Utility) => void; + concentric: (name?: string) => void; +} + +export function concentric(name = "concentric"): Utility { + return ({ selector, value }) => { + const radius = value ?? "1rem"; + return `${selector} { border-radius: ${radius}; }\n${selector} > * { border-radius: inherit; }`; + }; +} + +export function layoutHooks( + hook: (context: LayoutHookContext) => void, +): Integration { + return { + name: "layout-hooks", + setup(context: IntegrationContext) { + const utilities = new Map(); + hook({ + utilities: (name, utility) => utilities.set(name, utility), + concentric: (name) => utilities.set(name ?? "concentric", concentric(name)), + }); + context.loader("utilities", { + name: "utilities", + extensions: [".utilities.css"], + load(file) { + const css = file.source.split(/\s+/).flatMap((token) => { + const [name, value] = token.split(":", 2); + const utility = utilities.get(name ?? ""); + return utility?.({ name: name ?? "", value, selector: `.${token}` }) ?? []; + }); + return { data: {}, content: css.join("\n") }; + }, + }); + }, + }; +} diff --git a/integrations/optimize.ts b/integrations/optimize.ts new file mode 100644 index 0000000..3494d30 --- /dev/null +++ b/integrations/optimize.ts @@ -0,0 +1 @@ +export * from "../modules/optimize/index.ts"; diff --git a/integrations/react.ts b/integrations/react.ts new file mode 100644 index 0000000..3498bfd --- /dev/null +++ b/integrations/react.ts @@ -0,0 +1,192 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { compileCss } from "../views/css/index.ts"; + +/** The states the utilities know. */ +export const STATES = [ + "open", + "closed", + "active", + "disabled", + "loading", + "checked", + "selected", +] as const; + +/** The size of the grid that tracks the pointer. */ +export interface TrackerOptions { + /** Columns of the grid. */ + columns?: number; + /** Rows of the grid. */ + rows?: number; + /** Extra grid detail, multiplied into each axis. */ + detail?: number; + /** Enables the CSS grid to receive pointer events. Disabled by default. */ + capture?: boolean; + /** Stable name for a tracker mounted inside one component. */ + scope?: string; +} + +/** The list of a range, used by the loops of the stylesheet. */ +function range(count: number): string { + return Array.from({ length: count }, (_, index) => index + 1).join(" "); +} + +/** + * The stylesheet of the integration. + * + * The mouse position is tracked by a grid of cells, the parent hack moves the + * custom properties of the root element: every cell knows its position, the + * `:has()` selector of the parent picks the hovered one. + */ +export function reactStylesheet(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const states = STATES.join(" "); + + return ` +$columns: ${columns}; +$rows: ${rows}; + +@each $state in ${states} { + .state-#{$state} { + --muddle-state: #{$state}; + } + + [data-state="#{$state}"] { + --muddle-state: #{$state}; + } +} + +[data-state] { + --muddle-state-opacity: 1; + --muddle-state-visibility: visible; +} + +[data-state="closed"] { + --muddle-state-opacity: 0; + --muddle-state-visibility: hidden; +} + +[data-state="disabled"] { + --muddle-state-opacity: 0.5; +} + +[data-state="loading"] { + --muddle-state-opacity: 0.7; +} + +.state-open { + --muddle-state-opacity: 1; + --muddle-state-visibility: visible; +} + +.state-closed { + --muddle-state-opacity: 0; + --muddle-state-visibility: hidden; +} + +.peer:checked ~ [data-state="checked"] { + display: block; +} + +.peer:not(:checked) ~ [data-state="unchecked"] { + display: none; +} + +`; +} + +/** CSS for one explicitly mounted tracker. */ +export function mouseTrackerCss(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const scope = options.scope ?? "default"; + const selector = `[data-muddle-tracker="${scope}"]`; + const rules = Array.from({ length: columns * rows }, (_, index) => { + const x = Math.floor(index / rows) + 1; + const y = (index % rows) + 1; + return `${selector}:has(.mouse-tracker__cell:nth-child(${index + 1}):hover) { --muddle-mouse-x: ${x / columns * 100}%; --muddle-mouse-y: ${y / rows * 100}%; }`; + }); + return `${selector} { position: relative; --muddle-mouse-x: 50%; --muddle-mouse-y: 50%; }\n${selector} > .mouse-tracker { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr); pointer-events: none; }\n${selector}[data-muddle-capture="true"] > .mouse-tracker .mouse-tracker__cell { pointer-events: auto; }\n${rules.join("\n")}`; +} + +/** The compiled stylesheet of the integration. */ +export function reactCss(options: TrackerOptions = {}): string { + return compileCss(reactStylesheet(options)); +} + +/** The markup of the grid that tracks the pointer. */ +export function mouseTrackerMarkup(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const cells = Array.from( + { length: columns * rows }, + () => '
', + ).join(""); + const scope = options.scope ?? "default"; + return `
`; +} + +/** The markup of a checkbox, the label is the control. */ +export function checkboxMarkup(options: { + name: string; + label: string; + value?: string; + checked?: boolean; + disabled?: boolean; + class?: string; +}): string { + const attributes = [ + `type="checkbox"`, + `name="${options.name}"`, + options.value ? `value="${options.value}"` : "", + options.checked ? "checked" : "", + options.disabled ? "disabled" : "", + 'class="checkbox__input"', + ] + .filter((entry) => entry.length > 0) + .join(" "); + return ``; +} + +export interface ReactIntegrationOptions extends TrackerOptions { + /** Route the stylesheet is served at. */ + route?: string; +} + +/** + * Reactivity integration. + */ +export function reactIntegration( + options: ReactIntegrationOptions = {}, +): Integration { + const route = options.route ?? "/_muddle/react.css"; + + return { + name: "react", + setup(context: IntegrationContext) { + context.loader("react", { + name: "react", + extensions: [".react.css"], + load(file) { + return { + data: {}, + content: compileCss(`${file.source}\n${reactStylesheet(options)}`), + }; + }, + }); + + context.route("GET", route, () => + new Response(reactCss(options), { + headers: { + "content-type": "text/css; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }), + ); + }, + }; +} diff --git a/integrations/responsivity.ts b/integrations/responsivity.ts new file mode 100644 index 0000000..caf82fa --- /dev/null +++ b/integrations/responsivity.ts @@ -0,0 +1,24 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { compileCss } from "../views/css/index.ts"; + +export interface ResponsivityOptions { + breakpoints?: Record; + route?: string; +} + +export const DEFAULT_BREAKPOINTS = { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem" }; + +/** Adds mobile-first `@responsive name {}` blocks to STCSS. */ +export function responsivityIntegration(options: ResponsivityOptions = {}): Integration { + const breakpoints = { ...DEFAULT_BREAKPOINTS, ...options.breakpoints }; + return { + name: "responsivity", + setup(context: IntegrationContext) { + context.loader("responsive-stcss", { + name: "responsive-stcss", + extensions: [".responsive.stcss"], + load(file) { return { data: { breakpoints }, content: compileCss(file.source, { breakpoints }) }; }, + }); + }, + }; +} diff --git a/integrations/shadcn.ts b/integrations/shadcn.ts new file mode 100644 index 0000000..1817e03 --- /dev/null +++ b/integrations/shadcn.ts @@ -0,0 +1,60 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, extname, join, resolve } from "node:path"; +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { cssToStcss, reactToSpug } from "../core/backport.ts"; + +export interface ShadcnIntegrationOptions { + /** Registry component names, for example `button`, `card`, or `dialog`. */ + components: string[]; + /** Registry origin or `{name}` URL template. Defaults to ShadCN's New York registry. */ + registry?: string; + /** Destination relative to the Muddle project root. */ + dir?: string; + /** Injectable for offline registries and tests. */ + fetch?: (input: string) => Promise; +} + +interface RegistryItem { + files?: { path: string; content: string; type?: string }[]; + registryDependencies?: string[]; +} + +/** + * Pulls declaratively selected ShadCN React components and backports them + * into SPUG and STCSS during application setup. + */ +export function shadcnIntegration(options: ShadcnIntegrationOptions): Integration { + return { + name: "shadcn", + async setup(context: IntegrationContext) { + const fetcher = options.fetch ?? globalThis.fetch; + const destination = resolve(context.config.root, options.dir ?? "modules/app/views/components"); + const styles = resolve(context.config.root, "styles/shadcn"); + const seen = new Set(); + const pull = async (name: string): Promise => { + if (seen.has(name)) return; + seen.add(name); + const base = options.registry ?? "https://ui.shadcn.com/r/styles/new-york/{name}.json"; + const url = base.includes("{name}") + ? base.replace("{name}", encodeURIComponent(name)) + : `${base.replace(/\/$/, "")}/${encodeURIComponent(name)}.json`; + const response = await fetcher(url); + if (!response.ok) throw new Error(`shadcn registry request failed for ${name} with ${response.status}`); + const item = await response.json() as RegistryItem; + for (const dependency of item.registryDependencies ?? []) await pull(dependency.replace(/^@[^/]+\//, "")); + for (const file of item.files ?? []) { + const extension = extname(file.path).toLowerCase(); + const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, ""); + if (extension === ".tsx" || extension === ".jsx") { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, `${stem}.spug`), reactToSpug(file.content, file.path)); + } else if (extension === ".css") { + await mkdir(styles, { recursive: true }); + await writeFile(join(styles, `${stem}.stcss`), cssToStcss(file.content, file.path)); + } + } + }; + for (const component of options.components) await pull(component); + }, + }; +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..ec5a3c2 --- /dev/null +++ b/justfile @@ -0,0 +1,31 @@ +set dotenv-load +set positional-arguments + +default: + @just --list + +test: + bun test ./tests + +loc: + @echo "Loc: counting source lines in the codebase..." + @find . \ + \( -name '*.ts' -o -name '*.scss' -o -name '*.sass' -o -name '*.md' \ + -o -name '*.html' -o -name '*.toml' -o -name '*.js' -o -name '*.css' \) \ + -not -path '*/node_modules/*' \ + -not -path './editors/vscode/node_modules/*' \ + -not -path './dist/*' \ + -not -path './.git/*' \ + -not -path '*/bun.lock' \ + -not -path '*/.git/*' \ + -not -path '*/.cache/*' \ + -not -path '*/.next/*' \ + -not -path '*/dist/*' \ + -not -path '*/.vscode/*' \ + -not -path '*/.idea/*' \ + -not -path '*/ametrine/*' \ + -not -path '*/tests/*' \ + -not -path '*/README.md' \ + -not -path './modules/amezing/*' \ + -not -path './.opencode/*' \ + | xargs wc -l | tail -n +2 \ No newline at end of file diff --git a/modules/amezing/README.md b/modules/amezing/README.md new file mode 100644 index 0000000..76e888b --- /dev/null +++ b/modules/amezing/README.md @@ -0,0 +1,3 @@ +# @muddle/amezing + +Port of the Ametrine theme (by Daudix) as a muddle module. \ No newline at end of file diff --git a/modules/amezing/components/alert.spug b/modules/amezing/components/alert.spug new file mode 100644 index 0000000..40b4184 --- /dev/null +++ b/modules/amezing/components/alert.spug @@ -0,0 +1,8 @@ +props + type: 'note' | 'tip' | 'important' | 'warning' | 'caution' + content?: string | (() => string | Promise) +blockquote(class='markdown-alert markdown-alert-' + type) + if typeof content === 'function' + != await content() + else + = content ?? '' diff --git a/modules/amezing/components/article-list.spug b/modules/amezing/components/article-list.spug new file mode 100644 index 0000000..01c62d9 --- /dev/null +++ b/modules/amezing/components/article-list.spug @@ -0,0 +1,29 @@ +props + pages: { title: string; href: string; description?: string; date?: string; updated?: string; banner?: string; bannerStyle?: string; articleClass?: string; draft?: boolean; categories?: { name: string; href: string; description?: string }[] }[] +div.article-list + each page in pages + article(class=page.articleClass style=page.bannerStyle) + if page.banner + a.open-post(href=page.href aria-label=page.title) + img.has-alpha.no-hover(src=page.banner decoding="async" loading="lazy") + div + a(href=page.href) + h3= page.title + if page.description + div.description + != page.description + div.article-details(data-slot="article-details") + div.items + if page.date + div.item + i.ph.ph-calendar(aria-hidden="true") + time(datetime=page.date pubdate)= page.date + if page.updated + div.item + i.ph.ph-clock-clockwise(aria-hidden="true") + time(datetime=page.updated pubdate)= page.updated + if page.categories + div.item + i.ph.ph-book-bookmark(aria-hidden="true") + each category in page.categories + a(href=category.href title=category.description)= category.name diff --git a/modules/amezing/components/article-nav.spug b/modules/amezing/components/article-nav.spug new file mode 100644 index 0000000..295512e --- /dev/null +++ b/modules/amezing/components/article-nav.spug @@ -0,0 +1,14 @@ +props + previousHref?: string + previousTitle?: string + nextHref?: string + nextTitle?: string +nav.article-nav + if previousHref + a.article-nav-item.prev(href=previousHref) + div.nav-arrow Previous + span.article-title= previousTitle + if nextHref + a.article-nav-item.next(href=nextHref) + div.nav-arrow Next + span.article-title= nextTitle diff --git a/modules/amezing/components/article.spug b/modules/amezing/components/article.spug new file mode 100644 index 0000000..083afb9 --- /dev/null +++ b/modules/amezing/components/article.spug @@ -0,0 +1,31 @@ +props + title: string + description?: string +article + h1 + = title + div.article-details(data-slot="article-details") + div.items + if date + div.item + i.ph.ph-calendar(aria-hidden="true") + time(datetime=isoDate pubdate)= date + if updated + div.item + i.ph.ph-clock-clockwise(aria-hidden="true") + time(datetime=isoUpdated pubdate)= updated + if authors + div.item + i.ph.ph-users(aria-hidden="true") + span= authors + if readingTime + div.item + i.ph.ph-clock-countdown(aria-hidden="true") + span= readingTime + ' min read' + if body + div.article-body + != body + if notes + ul.statements + each statement in notes + li= statement diff --git a/modules/amezing/components/audio-button.spug b/modules/amezing/components/audio-button.spug new file mode 100644 index 0000000..4980a7c --- /dev/null +++ b/modules/amezing/components/audio-button.spug @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/modules/amezing/components/audio.spug b/modules/amezing/components/audio.spug new file mode 100644 index 0000000..1c0abae --- /dev/null +++ b/modules/amezing/components/audio.spug @@ -0,0 +1,7 @@ +props + url: string + name?: string +button.audio(type="button" data-audio=url) + if name + span= name + i.ph.ph-speaker-high(aria-hidden="true") diff --git a/modules/amezing/components/banner.spug b/modules/amezing/components/banner.spug new file mode 100644 index 0000000..91a2761 --- /dev/null +++ b/modules/amezing/components/banner.spug @@ -0,0 +1,7 @@ +props + src: string + alt?: string + bannerPixels?: boolean +div#banner(data-slot="banner" class=bannerPixels ? 'pixels' : '') + div + img.has-alpha.no-hover(src=src decoding="async" loading="lazy" alt=alt ?? '') diff --git a/modules/amezing/components/base.spug b/modules/amezing/components/base.spug new file mode 100644 index 0000000..27527e4 --- /dev/null +++ b/modules/amezing/components/base.spug @@ -0,0 +1,133 @@ +doctype html +html(lang="en" style=accentStyle ?? '') + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + meta(name="description" content=description ?? '') + title= title + link(rel="stylesheet" href="/_muddle/amezing.css") + link(rel="stylesheet" href="/_muddle/styles.css") + link(rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css") + body + a#skip-to-content.link(href="#main-content" tabindex="0") Skip to content + header#site-header.firefox-unblur + ul#site-sidebar-controls + li#expand-sidebar-li + input#expand-sidebar.visually-hidden(type="checkbox" name="expand-sidebar" autocomplete="off") + label(for="expand-sidebar" title="Toggle sidebar") + i.ph.ph-sidebar(aria-hidden="true") + ul#site-sidebar-modes(role="radiogroup") + li + input#mode-nav.visually-hidden(type="radio" name="mode" checked) + label(for="mode-nav" title="Navigation mode") + i.ph.ph-compass(aria-hidden="true") + li + input#mode-meta.visually-hidden(type="radio" name="mode") + label(for="mode-meta" title="Meta mode") + i.ph.ph-info(aria-hidden="true") + span.title= title + ul.buttons + li + details.closable + summary(title="Feed") + i.ph.ph-rss-simple(aria-hidden="true") + ul.dropdown + li + a(href="/atom.xml") Atom + li + a(href="/rss.xml") RSS + if searchHref + li + button#search(title="Search" aria-haspopup="dialog") + i.ph.ph-magnifying-glass(aria-hidden="true") + div#site-sidebar + a#site-sidebar-header(href="#top" class=logo ? 'has-icon' : '') + if logo + img(width="36" height="36" class="has-alpha no-hover" src=logo alt="") + else + i.ph.ph-arrow-line-up(aria-hidden="true") + div + span= siteTitle ?? title + span Back to top + div#site-sidebar-content + nav#sidebar-nav.overshoot + ul + li + a(href="/" aria-current=(currentPath ?? '/') === '/' ? 'page' : '') + i.ph.ph-house(aria-hidden="true") + = siteTitle ?? title + each entry in nav + if entry.children + li + details.closable(open=entry.open) + summary + = entry.name + div.line + ul.dropdown + each child in entry.children + li + a(href=child.href aria-current=child.active ? 'page' : '' class=child.external ? 'external' : '') + if child.icon + i.ph.ph-(child.icon)(aria-hidden="true") + if child.label + span= child.label + else + span= child.name + else + li + a(href=entry.href aria-current=entry.active ? 'page' : '' class=(entry.external ?? entry.href.indexOf('http') === 0) ? 'external' : '') + if entry.icon + i.ph.ph-(entry.icon)(aria-hidden="true") + if entry.label + span= entry.label + else + span= entry.name + div#sidebar-meta.overshoot + if metaTitle ?? note + div.card + div.card-title + div.line + span.title= metaTitle ?? 'Meta' + div.line + if note + div.note= note + main#main-content + if content + != await content() + footer#site-footer + div + if copyright + p.copyright= copyright + if poweredBy ?? undefined + p= 'Powered by ' + a.link.external(href="https://www.getzola.org") Zola + = ' and ' + a.link.external(href="https://ametrine.daudix.one") Ametrine + if sourceHref ?? undefined + p + a.link.external(href=sourceHref) Source + = ' | Updated ' + timestamp + ' UTC' + if socials + ul.socials + each social in socials + li + a(href=social.href rel="me external") + i.ph.ph-(social.name)(aria-hidden="true") + if webrings + ul.webrings + each webring in webrings + li.webring + a.link.external(href=webring.href)= webring.name + a.link.previous(href=webring.previous title="Previous") + i.ph.ph-arrow-left + if webring.random + a.link.random(href=webring.random title="Random") + i.ph.ph-dice-three + a.link.next(href=webring.next title="Next") + i.ph.ph-arrow-right + if note + div.note= note + script(src="/_muddle/amezing/closable.js" defer) + script(src="/_muddle/amezing/paginator.js" defer) + script(src="/_muddle/amezing/deanchor.js" defer) + script(src="/_muddle/amezing/marquee.js" defer) diff --git a/modules/amezing/components/comments.spug b/modules/amezing/components/comments.spug new file mode 100644 index 0000000..6ac2b62 --- /dev/null +++ b/modules/amezing/components/comments.spug @@ -0,0 +1,32 @@ +{%- set alert_icon = load_data(path="icons/phosphor/" ~ icon ~ ".svg", required=false) -%} +{%- if not alert_icon -%} + {%- set alert_icon = "info" -%} +{%- endif -%} +
+ +
+ {%- set comments = load_data(path="data/comments.toml", required=false) -%} + {%- if comments -%} + {%- for comment in comments -%} +
+
+ {{ comment.author }} + {{ comment.date }} +
+

{{ comment.content }}

+
+ {%- endfor -%} + {%- else -%} +
+
💭
+

No comments yet. Be the first to share your thoughts!

+
+ {%- endif -%} +
+
\ No newline at end of file diff --git a/modules/amezing/components/crt.spug b/modules/amezing/components/crt.spug new file mode 100644 index 0000000..4f17fdd --- /dev/null +++ b/modules/amezing/components/crt.spug @@ -0,0 +1,11 @@ +props + fontSize?: string + label?: string + content?: string | (() => string | Promise) +pre.crt(style=fontSize ? 'font-size: ' + fontSize + ';' : '' role=label ? 'img' : false aria-label=label ? label : false) + code(aria-hidden=label ? false : true) + span + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/deanchor.spug b/modules/amezing/components/deanchor.spug new file mode 100644 index 0000000..0fea7f5 --- /dev/null +++ b/modules/amezing/components/deanchor.spug @@ -0,0 +1 @@ +{{ text | striptags }} \ No newline at end of file diff --git a/modules/amezing/components/emoji.spug b/modules/amezing/components/emoji.spug new file mode 100644 index 0000000..f25fe29 --- /dev/null +++ b/modules/amezing/components/emoji.spug @@ -0,0 +1,5 @@ +props + path: string + big?: boolean + title?: string +img.emoji(class=big ? 'big' : false src=path title=title ?? '' width="24" height="24") diff --git a/modules/amezing/components/footnotes.spug b/modules/amezing/components/footnotes.spug new file mode 100644 index 0000000..a99881a --- /dev/null +++ b/modules/amezing/components/footnotes.spug @@ -0,0 +1,15 @@ +
+ {%- set footnotes = load_data(path="data/footnotes.toml", required=false) -%} + {%- if footnotes -%} +
+ {%- for footnote in footnotes -%} +
+ + {{ loop.index }} + +

{{ footnote.content }}

+
+ {%- endfor -%} +
+ {%- endif -%} +
\ No newline at end of file diff --git a/modules/amezing/components/fuse.spug b/modules/amezing/components/fuse.spug new file mode 100644 index 0000000..7f6c9f0 --- /dev/null +++ b/modules/amezing/components/fuse.spug @@ -0,0 +1,4 @@ +
+ +
+
\ No newline at end of file diff --git a/modules/amezing/components/icon.spug b/modules/amezing/components/icon.spug new file mode 100644 index 0000000..c9414cb --- /dev/null +++ b/modules/amezing/components/icon.spug @@ -0,0 +1,3 @@ +props + name: string +i.ph.ph-(name)(aria-hidden="true") diff --git a/modules/amezing/components/image.spug b/modules/amezing/components/image.spug new file mode 100644 index 0000000..268960b --- /dev/null +++ b/modules/amezing/components/image.spug @@ -0,0 +1,22 @@ +props + src: string + alt?: string + url?: string + caption?: string + width?: number + height?: number + lazy?: boolean +if caption + figure + if url + a(href=url) + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + else + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + figcaption!= caption +else + if url + a(href=url) + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + else + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) diff --git a/modules/amezing/components/link.spug b/modules/amezing/components/link.spug new file mode 100644 index 0000000..5e59bbb --- /dev/null +++ b/modules/amezing/components/link.spug @@ -0,0 +1,14 @@ +props + name: string + href: string + icon?: string + external?: boolean + active?: boolean + afterActive?: boolean +li + a(href=href + (!external && !active && afterActive ? '#↓' : !external && !active ? '#↑' : '') aria-current=active ? 'page' : false class=external ? 'external' : false) + if icon + i.ph.ph-(icon)(aria-hidden="true") + else + i.icon.fallback= name.slice(0, 2) + = name diff --git a/modules/amezing/components/mastodon.spug b/modules/amezing/components/mastodon.spug new file mode 100644 index 0000000..09b7636 --- /dev/null +++ b/modules/amezing/components/mastodon.spug @@ -0,0 +1,5 @@ +props + host: string + user: string + id: string +iframe.mastodon-embed(src='https://' + host + '/@' + user + '/' + id + '/embed') diff --git a/modules/amezing/components/overflow-auto.spug b/modules/amezing/components/overflow-auto.spug new file mode 100644 index 0000000..3667d16 --- /dev/null +++ b/modules/amezing/components/overflow-auto.spug @@ -0,0 +1,9 @@ +props + overshoot?: boolean + overshootRow?: boolean + content?: string | (() => string | Promise) +div.overflow-auto(class=overshoot ? 'overshoot' : false class=overshootRow ? 'overshoot-row' : false) + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/paginator.spug b/modules/amezing/components/paginator.spug new file mode 100644 index 0000000..ead0a48 --- /dev/null +++ b/modules/amezing/components/paginator.spug @@ -0,0 +1,32 @@ +props + counter: string + first?: string + previous?: string + next?: string + last?: string +nav.paginator + if first + a.paginator-item.first(href=first title="First page") + i.ph.ph-caret-line-left(aria-hidden="true") + else + span.paginator-item.first(aria-disabled="true") + i.ph.ph-caret-line-left(aria-hidden="true") + if previous + a.paginator-item.previous(href=previous rel="prev" title="Previous page") + i.ph.ph-caret-left(aria-hidden="true") + else + span.paginator-item.previous(aria-disabled="true") + i.ph.ph-caret-left(aria-hidden="true") + span.paginator-counter= counter + if next + a.paginator-item.next(href=next rel="next" title="Next page") + i.ph.ph-caret-right(aria-hidden="true") + else + span.paginator-item.next(aria-disabled="true") + i.ph.ph-caret-right(aria-hidden="true") + if last + a.paginator-item.last(href=last title="Last page") + i.ph.ph-caret-line-right(aria-hidden="true") + else + span.paginator-item.last(aria-disabled="true") + i.ph.ph-caret-line-right(aria-hidden="true") diff --git a/modules/amezing/components/search-fuse.spug b/modules/amezing/components/search-fuse.spug new file mode 100644 index 0000000..5c1bf7e --- /dev/null +++ b/modules/amezing/components/search-fuse.spug @@ -0,0 +1,10 @@ +
+
+ + + + + +
+
+
\ No newline at end of file diff --git a/modules/amezing/components/search-modal.spug b/modules/amezing/components/search-modal.spug new file mode 100644 index 0000000..1a34e1e --- /dev/null +++ b/modules/amezing/components/search-modal.spug @@ -0,0 +1,11 @@ +props + indexHref?: string +dialog#search-modal(data-slot="search-modal" closed ? aria-hidden="true") + div#search-modal-content + label(for="search-input" class="visually-hidden") Search + div + input#search-input(placeholder="Search" autocomplete="off" type="search") + div(title="Press / to search") + i.ph.ph-keyboard(aria-hidden="true") + div#search-results.overshoot + span#more-matches-text(hidden) More matches diff --git a/modules/amezing/components/skip-to-content.spug b/modules/amezing/components/skip-to-content.spug new file mode 100644 index 0000000..ffde958 --- /dev/null +++ b/modules/amezing/components/skip-to-content.spug @@ -0,0 +1,4 @@ + + {%- set icon = load_data(path="icons/phosphor/" ~ "arrow-up-right" ~ ".svg", required=false) -%} + {%- if icon -%}{{ icon | safe }}{{ else }}Skip to content{{ end }} + \ No newline at end of file diff --git a/modules/amezing/components/statement.spug b/modules/amezing/components/statement.spug new file mode 100644 index 0000000..f72e5c9 --- /dev/null +++ b/modules/amezing/components/statement.spug @@ -0,0 +1,11 @@ +props + kind: 'archive' | 'trigger' | 'disclaimer' + content?: string | (() => string | Promise) +div.statement-container(class=kind) + strong.title + i.ph.ph-(kind === 'archive' ? 'box-arrow-down' : 'warning')(aria-hidden="true") + span= kind === 'archive' ? 'Archived' : kind === 'trigger' ? 'Trigger warning' : 'Disclaimer' + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/tag-chips.spug b/modules/amezing/components/tag-chips.spug new file mode 100644 index 0000000..3a99494 --- /dev/null +++ b/modules/amezing/components/tag-chips.spug @@ -0,0 +1,9 @@ +props + items: { name: string; href: string; count?: number }[] +ul#tag-chips + each item in items + li + a(href=item.href) + = item.name + if item.count + span.counter= item.count diff --git a/modules/amezing/components/video.spug b/modules/amezing/components/video.spug new file mode 100644 index 0000000..b0de71f --- /dev/null +++ b/modules/amezing/components/video.spug @@ -0,0 +1,21 @@ +props + sources: { src: string, type?: string }[] + caption?: string + width?: number + height?: number + autoplay?: boolean + controls?: boolean + loop?: boolean + muted?: boolean + playsinline?: boolean + alt?: string +if caption + figure + video(preload="auto" aria-title=alt ?? '' width=width ?? false height=height ?? false autoplay=autoplay ?? false controls=controls ?? true loop=loop ?? false muted=muted ?? false playsinline=playsinline ?? false) + each source in sources + source(src=source.src type='video/' + (source.type ?? source.src.split('.').pop())) + figcaption!= caption +else + video(preload="auto" aria-title=alt ?? '' width=width ?? false height=height ?? false autoplay=autoplay ?? false controls=controls ?? true loop=loop ?? false muted=muted ?? false playsinline=playsinline ?? false) + each source in sources + source(src=source.src type='video/' + (source.type ?? source.src.split('.').pop())) diff --git a/modules/amezing/components/vimeo.spug b/modules/amezing/components/vimeo.spug new file mode 100644 index 0000000..b8befb8 --- /dev/null +++ b/modules/amezing/components/vimeo.spug @@ -0,0 +1,4 @@ +props + id: string + autoplay?: boolean +iframe.vimeo-embed(src='https://player.vimeo.com/video/' + id + (autoplay ? '?autoplay=1' : '') allow="autoplay; fullscreen; picture-in-picture" allowfullscreen=true) diff --git a/modules/amezing/components/youtube.spug b/modules/amezing/components/youtube.spug new file mode 100644 index 0000000..909cc1e --- /dev/null +++ b/modules/amezing/components/youtube.spug @@ -0,0 +1,5 @@ +props + id: string + autoplay?: boolean + start?: number +iframe.youtube-embed(src='https://www.youtube-nocookie.com/embed/' + id + (autoplay && start ? '?autoplay=1&start=' + start : autoplay ? '?autoplay=1' : start ? '?start=' + start : '') allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=true) diff --git a/modules/amezing/fonts/commissioner.woff2 b/modules/amezing/fonts/commissioner.woff2 new file mode 100644 index 0000000..f02d31b Binary files /dev/null and b/modules/amezing/fonts/commissioner.woff2 differ diff --git a/modules/amezing/fonts/commit-mono.woff2 b/modules/amezing/fonts/commit-mono.woff2 new file mode 100644 index 0000000..f9ac1ee Binary files /dev/null and b/modules/amezing/fonts/commit-mono.woff2 differ diff --git a/modules/amezing/index.ts b/modules/amezing/index.ts new file mode 100644 index 0000000..2c358c5 --- /dev/null +++ b/modules/amezing/index.ts @@ -0,0 +1,161 @@ +/** + * Muddle integration of the Ametrine theme. + * + * Registers the theme's component set, exposes the compiled theme css and + * serves the vendored behavior scripts / self-hosted fonts. + */ +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import type { + Integration, + Mounts, +} from "muddle"; +import type { Views } from "muddle/views"; + + +export interface AmezingAccentPreset { name: string; light: string; dark: string } + +/** Accent presets from ametrine/data/accent_presets.toml. */ +export const ACCENT_PRESETS: Record = { + red: { name: "red", light: "hsl(342.0779, 72%, 58%)", dark: "hsl(345.18988, 100%, 69%)" }, + orange: { name: "orange", light: "hsl(15.771428, 74%, 54%)", dark: "hsl(19.731544, 96%, 70%)" }, + yellow: { name: "yellow", light: "hsl(34.639175, 91%, 42%)", dark: "hsl(44.70588, 100%, 70%)" }, + green: { name: "green", light: "hsl(153.7815, 61%, 38%)", dark: "hsl(90, 59%, 66%)" }, + blue: { name: "blue", light: "hsl(192, 71%, 38%)", dark: "hsl(186.42857, 71%, 69%)" }, + purple: { name: "purple", light: "hsl(254.11766, 44%, 55%)", dark: "hsl(249.88234, 77%, 78%)" }, + teal: { name: "teal", light: "hsl(180, 65%, 35%)", dark: "hsl(183, 85%, 65%)" }, + pink: { name: "pink", light: "hsl(333.88236, 69%, 58%)", dark: "hsl(336.78634, 100%, 70%)" }, + cyan: { name: "cyan", light: "hsl(195, 71%, 35%)", dark: "hsl(193.41046, 72%, 70%)" }, + indigo: { name: "indigo", light: "hsl(232.05884, 61%, 42%)", dark: "hsl(234.61538, 73%, 71%)" }, + amber: { name: "amber", light: "hsl(38.57695, 94%, 44%)", dark: "hsl(40.35484, 100%, 72%)" }, + ochrea: { name: "ochrea", light: "hsl(45.75298, 95%, 45%)", dark: "hsl(46.09091, 100%, 78%)" }, + lime: { name: "lime", light: "hsl(86.25406, 60%, 42%)", dark: "hsl(86.84211, 93%, 71%)" }, + maroon: { name: "maroon", light: "hsl(321.81819, 26%, 41%)", dark: "hsl(322.10526, 66%, 66%)" }, + navy: { name: "navy", light: "hsl(216, 44%, 38%)", dark: "hsl(218, 69%, 69%)" }, + olive: { name: "olive", light: "hsl(81, 36%, 41%)", dark: "hsl(81, 64%, 65%)" }, + slate: { name: "slate", light: "hsl(212.53197, 15%, 44%)", dark: "hsl(212.90323, 61%, 72%)" }, +}; + +function parseHsl(value: string): [string, string, string] | undefined { + const match = /hsl\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*\)/.exec(value); + return match ? [match[1]!, match[2]!, match[3]!] : undefined; +} + +/** Builds the inline accent variables used by the theme, mirrors components/accent_color.html. */ +export function accentStyle(accent: string | [string, string?]): string { + const inputs = Array.isArray(accent) ? accent : [accent]; + const light = ACCENT_PRESETS[inputs[0] ?? ""]?.light ?? inputs[0]; + const dark = ACCENT_PRESETS[inputs[1] ?? inputs[0] ?? ""]?.dark ?? inputs[1] ?? ACCENT_PRESETS[inputs[0] ?? ""]?.dark ?? inputs[0]; + const lightHsl = parseHsl(light ?? ""); + const darkHsl = parseHsl(dark ?? ""); + if (!lightHsl || !darkHsl) throw new Error(`Unknown accent color: ${accent}`); + return `--accent-light-h:${lightHsl[0]};--accent-light-s:${lightHsl[1]};--accent-light-l:${lightHsl[2]};--accent-dark-h:${darkHsl[0]};--accent-dark-s:${darkHsl[1]};--accent-dark-l:${darkHsl[2]};`; +} + +const COMPONENTS = [ + "base", + "article", + "article-list", + "article-details", + "article-nav", + "paginator", + "alert", + "banner", + "icon", + "search-modal", + "tag-chips", + "audio", + "image", + "video", + "vimeo", + "youtube", + "mastodon", + "link", + "overflow-auto", + "crt", + "emoji", + "statement", + "comments", + "footnotes", + "skip-to-content", + "marquee", + "audio-button", + "deanchor", + "fuse", + "search-fuse", +] as const; + +function componentTemplates(): [string, string][] { + return COMPONENTS.map((name) => [`amezing-${name}`, fileURLToPath(new URL(`./components/${name}.spug`, import.meta.url))]); +} + +interface TemplateViews { + component(name: string, component: unknown): unknown; + engine?: { renderComponent(path: string, props: Record, context: { locals: Record; params: Record }): Promise }; +} + +function registerAll(views: TemplateViews, accent: string | undefined): void { + for (const [name, template] of componentTemplates()) { + views.component(name, { + async render(props: Record, context: { locals: Record; params: Record }) { + return views.engine!.renderComponent(template, { accentStyle: accent, ...props }, context); + }, + }); + } +} + +/** Registers the Ametrine components on the views registry. */ +export function registerAmezing(views: Views, options: { accentColor?: string | [string, string?] } = {}): void { + registerAll(views as unknown as Parameters[0], options.accentColor ? accentStyle(options.accentColor) : undefined); +} + +/** Compiled theme stylesheet (Citrine base + Ametrine layout). */ +export async function amezingStyles(): Promise { + const [theme, vars] = await Promise.all([ + readFile(new URL("./styles/ametrine.css", import.meta.url), "utf8"), + readFile(new URL("./styles/vars.css", import.meta.url), "utf8"), + ]); + return vars + "\n" + theme; +} + +export const amezingScripts: Record = { + "closable.js": "scripts/closable.js", + "paginator.js": "scripts/paginator.js", + "deanchor.js": "scripts/deanchor.js", + "marquee.js": "scripts/marquee.js", + "audio-button.js": "scripts/audio-button.js", + "fuse.js": "scripts/fuse.js", + "search-fuse.js": "scripts/search-fuse.js", +}; + +export const amezingFonts: Record = { + "/fonts/commissioner.woff2": "fonts/commissioner.woff2", + "/fonts/commit-mono.woff2": "fonts/commit-mono.woff2", +}; + +export function amezingMount(options: { accentColor?: string | [string, string?] } = {}): Mounts { + const accent = options.accentColor ? accentStyle(options.accentColor) : undefined; + return { + views: (views) => registerAll(views as unknown as Parameters[0], accent), + routes: (route) => { + for (const [routePath, file] of Object.entries(amezingScripts)) { + route("GET", `/_muddle/amezing/${routePath}`, async () => + new Response(await readFile(new URL(`./${file}`, import.meta.url), "utf8"), { + headers: { "content-type": "text/javascript; charset=utf-8" }, + })); + } + for (const [routePath, file] of Object.entries(amezingFonts)) { + route("GET", routePath, async () => + new Response(await readFile(new URL(`./${file}`, import.meta.url)), { + headers: { "content-type": "font/woff2" }, + })); + } + }, + styles: { "/_muddle/amezing.css": () => amezingStyles() }, + }; +} + +/** Convenience integration. */ +export function amezingIntegration(options: { accentColor?: string | [string, string?] } = {}): Integration { + return { name: "amezing", mounts: [amezingMount(options)] }; +} diff --git a/modules/amezing/package.json b/modules/amezing/package.json new file mode 100644 index 0000000..f153655 --- /dev/null +++ b/modules/amezing/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/amezing", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./index.ts", "./styles": "./styles/ametrine.css" }, + "peerDependencies": { "muddle": "*" } +} diff --git a/modules/amezing/sass/amezing.scss b/modules/amezing/sass/amezing.scss new file mode 100644 index 0000000..224f2d1 --- /dev/null +++ b/modules/amezing/sass/amezing.scss @@ -0,0 +1,7 @@ +/* +* Ametrine port for muddle. Combines the Citrine base with the Ametrine layout. +*/ + +@use "fonts"; +@use "citrine/style"; +@use "style" as ametrine; diff --git a/modules/amezing/sass/citrine/abstracts/_functions.scss b/modules/amezing/sass/citrine/abstracts/_functions.scss new file mode 100644 index 0000000..dc7e8f9 --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_functions.scss @@ -0,0 +1,5 @@ +// Converts px values to rem +// rpx(1) --> 0.0625rem +@function rpx($px-value) { + @return ($px-value / 16) + rem; +} diff --git a/modules/amezing/sass/citrine/abstracts/_mixins.scss b/modules/amezing/sass/citrine/abstracts/_mixins.scss new file mode 100644 index 0000000..6a0739b --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_mixins.scss @@ -0,0 +1,82 @@ +@mixin icon($mode: normal) { + @if $mode == normal { + font-style: normal; + font-variant: normal; + font-weight: normal; + line-height: 1; + user-select: none; + text-transform: none; + -webkit-user-select: none; + display: inline-block; + box-sizing: content-box; + } @else if $mode == pseudo { + -webkit-mask-image: var(--icon); + -webkit-mask-size: cover; + display: block; + mask-image: var(--icon); + mask-size: cover; + background-color: currentColor; + width: 1em; + height: 1em; + content: ""; + } +} + +@mixin gradient-fill($mode: normal) { + @if $mode == normal { + box-shadow: var(--edge-highlight), var(--shadow-glow); + background-image: linear-gradient( + to bottom right, + var(--accent-color), + var(--accent-highlight) + ); + color: var(--contrast-color); + text-shadow: var(--text-shadow-glow); + } @else if $mode == hover { + box-shadow: var(--edge-highlight); + background-color: var(--accent-color); + color: var(--contrast-color); + } +} + +@mixin card { + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; +} + +@mixin chip { + transition: + all var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding-inline: 0.375rem; + text-decoration: none; +} + +@mixin easing-mask($direction: to bottom) { + --mask-image: linear-gradient( + to #{$direction}, + hsl(0, 0%, 0%) 0%, + hsla(0, 0%, 0%, 0.987) 8.1%, + hsla(0, 0%, 0%, 0.951) 15.5%, + hsla(0, 0%, 0%, 0.896) 22.5%, + hsla(0, 0%, 0%, 0.825) 29%, + hsla(0, 0%, 0%, 0.741) 35.3%, + hsla(0, 0%, 0%, 0.648) 41.2%, + hsla(0, 0%, 0%, 0.55) 47.1%, + hsla(0, 0%, 0%, 0.45) 52.9%, + hsla(0, 0%, 0%, 0.352) 58.8%, + hsla(0, 0%, 0%, 0.259) 64.7%, + hsla(0, 0%, 0%, 0.175) 71%, + hsla(0, 0%, 0%, 0.104) 77.5%, + hsla(0, 0%, 0%, 0.049) 84.5%, + hsla(0, 0%, 0%, 0.013) 91.9%, + hsla(0, 0%, 0%, 0) 100% + ); + mask-image: var(--mask-image); + -webkit-mask-image: var(--mask-image); +} diff --git a/modules/amezing/sass/citrine/abstracts/_variables.scss b/modules/amezing/sass/citrine/abstracts/_variables.scss new file mode 100644 index 0000000..0a7019d --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_variables.scss @@ -0,0 +1,570 @@ +@use "functions" as *; +@use "mixins" as *; + +@mixin variables($modes: "light") { + @each $mode in $modes { + @if $mode == "light" { + // COLORS + --bg-overlay: linear-gradient( + rgba(255, 255, 255, var(--dim-opacity)), + rgba(255, 255, 255, var(--dim-opacity)) + ); + --fg-color: #29242a; + --fg-contrast: #fcfcfa; + --red-fg: hsl(342.0779, 72%, 58%); + --red-h: 342.0779; + --red-s: 72%; + --red-l: 58%; + --orange-fg: hsl(15.771428, 74%, 54%); + --orange-h: 15.771428; + --orange-s: 74%; + --orange-l: 54%; + --yellow-fg: hsl(34.639175, 91%, 42%); + --yellow-h: 34.639175; + --yellow-s: 91%; + --yellow-l: 42%; + --green-fg: hsl(153.7815, 61%, 38%); + --green-h: 153.7815; + --green-s: 61%; + --green-l: 38%; + --blue-fg: hsl(192, 71%, 38%); + --blue-h: 192; + --blue-s: 71%; + --blue-l: 38%; + --purple-fg: hsl(254.11766, 44%, 55%); + --purple-h: 254.11766; + --purple-s: 44%; + --purple-l: 55%; + --red-bg-1: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--color-opacity-1) + ); + --red-bg-2: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--color-opacity-2) + ); + --orange-bg-1: hsla( + var(--orange-h), + var(--orange-s), + var(--orange-l), + var(--color-opacity-1) + ); + --orange-bg-2: hsla( + var(--orange-h), + var(--orange-s), + var(--orange-l), + var(--color-opacity-2) + ); + --yellow-bg-1: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--color-opacity-1) + ); + --yellow-bg-2: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--color-opacity-2) + ); + --green-bg-1: hsla( + var(--green-h), + var(--green-s), + var(--green-l), + var(--color-opacity-1) + ); + --green-bg-2: hsla( + var(--green-h), + var(--green-s), + var(--green-l), + var(--color-opacity-2) + ); + --blue-bg-1: hsla( + var(--blue-h), + var(--blue-s), + var(--blue-l), + var(--color-opacity-1) + ); + --blue-bg-2: hsla( + var(--blue-h), + var(--blue-s), + var(--blue-l), + var(--color-opacity-2) + ); + --purple-bg-1: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--color-opacity-1) + ); + --purple-bg-2: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--color-opacity-2) + ); + --red-fg-muted-1: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) - var(--red-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --red-fg-muted-2: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) - var(--red-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --orange-fg-muted-1: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) - var(--orange-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --orange-fg-muted-2: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) - var(--orange-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --yellow-fg-muted-1: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) - var(--yellow-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --yellow-fg-muted-2: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) - var(--yellow-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --green-fg-muted-1: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) - var(--green-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --green-fg-muted-2: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) - var(--green-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --blue-fg-muted-1: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) - var(--blue-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --blue-fg-muted-2: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) - var(--blue-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --purple-fg-muted-1: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) - var(--purple-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --purple-fg-muted-2: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) - var(--purple-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --shade-color: rgba(0, 0, 0, 0.07); + + // COLORS (NEUTRAL) + --bg-muted-neutral-1: rgba(0, 0, 0, var(--color-opacity-1)); + --bg-muted-neutral-2: rgba(0, 0, 0, var(--color-opacity-2)); + --fg-muted-neutral-1: rgba(0, 0, 0, var(--fg-muted-1-opacity)); + --fg-muted-neutral-2: rgba(0, 0, 0, var(--fg-muted-2-opacity)); + + // OPACITY + --scanlines-opacity: 0.4; + --fg-muted-1-opacity: 0.5; + --fg-muted-2-opacity: 0.6; + + // SHADOWS + --edge-highlight: inset + 0 + #{rpx(-1)} + 0 + rgba(0, 0, 0, var(--color-opacity-1)); + } @else if $mode == "dark" { + // COLORS + --bg-overlay: linear-gradient( + rgba(0, 0, 0, var(--dim-opacity)), + rgba(0, 0, 0, var(--dim-opacity)) + ); + --fg-color: #fcfcfa; + --fg-contrast: #29242a; + --red-fg: hsl(345.18988, 100%, 69%); + --red-h: 345.18988; + --red-s: 100%; + --red-l: 69%; + --orange-fg: hsl(19.731544, 96%, 70%); + --orange-h: 19.731544; + --orange-s: 96%; + --orange-l: 70%; + --yellow-fg: hsl(44.70588, 100%, 70%); + --yellow-h: 44.70588; + --yellow-s: 100%; + --yellow-l: 70%; + --green-fg: hsl(90, 59%, 66%); + --green-h: 90; + --green-s: 59%; + --green-l: 66%; + --blue-fg: hsl(186.42857, 71%, 69%); + --blue-h: 186.42857; + --blue-s: 71%; + --blue-l: 69%; + --purple-fg: hsl(249.88234, 77%, 78%); + --purple-h: 249.88234; + --purple-s: 77%; + --purple-l: 78%; + + --red-fg-muted-1: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) + (100% - var(--red-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --red-fg-muted-2: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) + (100% - var(--red-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --orange-fg-muted-1: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --orange-fg-muted-2: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --yellow-fg-muted-1: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --yellow-fg-muted-2: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --green-fg-muted-1: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) + (100% - var(--green-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --green-fg-muted-2: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) + (100% - var(--green-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --blue-fg-muted-1: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --blue-fg-muted-2: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --purple-fg-muted-1: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --purple-fg-muted-2: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --shade-color: rgba(0, 0, 0, 0.25); + + // COLORS (NEUTRAL) + --bg-muted-neutral-1: rgba(255, 255, 255, var(--color-opacity-1)); + --bg-muted-neutral-2: rgba(255, 255, 255, var(--color-opacity-2)); + --fg-muted-neutral-1: rgba(255, 255, 255, var(--fg-muted-1-opacity)); + --fg-muted-neutral-2: rgba(255, 255, 255, var(--fg-muted-2-opacity)); + + // OPACITY + --scanlines-opacity: 1; + --fg-muted-1-opacity: 0.6; + --fg-muted-2-opacity: 0.7; + + // SHADOWS + --edge-highlight: inset + 0 + #{rpx(1)} + 0 + rgba(255, 255, 255, var(--color-opacity-1)); + } @else if $mode == "common" { + // WIDTHS + --content-width: #{rpx(720)}; + --sidebar-width: clamp( + calc(var(--content-width) / 3), + 20vw, + calc(var(--content-width) / 2) + ); + + // CORNERS + --rounded-corner: 0.75rem; + --rounded-corner-small: 0.5rem; + + // FILTERS + --blur: blur(0.75rem) saturate(180%); + --printed: contrast(2) brightness(0.75) grayscale(1); + --drop-shadow: drop-shadow(0 0.08rem 0.1rem rgba(0, 0, 0, 0.35)) + drop-shadow(0 0.4rem 0.7rem rgba(0, 0, 0, 0.18)); + + // FONTS + --font-system-ui: system-ui, sans-serif; + --font-ui-monospace: ui-monospace, monospace; + --font-emoji: + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + + // FONT SIZES + --font-size-xx-small: 0.625rem; + --font-size-x-small: 0.75rem; + --font-size-small: 0.875rem; + --font-size-medium: 1rem; + --font-size-large: 1.25rem; + --font-size-x-large: 1.5rem; + --font-size-xx-large: 2rem; + --font-size-xxx-large: 3rem; + + // FONT WEIGHTS + --font-weight-thin: 100; + --font-weight-extra-light: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semi-bold: 600; + --font-weight-bold: 700; + --font-weight-extra-bold: 800; + --font-weight-black: 900; + + // OPACITY + --color-opacity-1: 0.1; + --color-opacity-2: 0.2; + --dim-opacity: 0.8; + --disabled-opacity: 0.4; + + // SHADOWS + --shadow: + 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03), + 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); + --shadow-inset: + inset 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03), + inset 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), + inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); + --shadow-raised: + 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.06), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.14), + 0 #{rpx(4)} #{rpx(12)} #{rpx(4)} rgba(0, 0, 0, 0.06); + --shadow-glass: 0 #{rpx(6)} #{rpx(12)} #{rpx(-6)} rgba(0, 0, 0, 0.1); + + // STATES + --hover: scale(1.1); + --active: scale(0.9); + + // TRANSITIONS + --transition: 0.2s; + --transition-longer: 0.4s; + --transition-long: 0.8s; + --transition-bezier: 0.35s cubic-bezier(0.17, 0.89, 0.32, 1.28); + --transition-bezier-longer: 0.525s cubic-bezier(0.17, 0.89, 0.32, 1.28); + + // ICONS + --icon-arrow-square-out: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M228%2C104a12%2C12%2C0%2C0%2C1-24%2C0V69l-59.51%2C59.51a12%2C12%2C0%2C0%2C1-17-17L187%2C52H152a12%2C12%2C0%2C0%2C1%2C0-24h64a12%2C12%2C0%2C0%2C1%2C12%2C12Zm-44%2C24a12%2C12%2C0%2C0%2C0-12%2C12v64H52V84h64a12%2C12%2C0%2C0%2C0%2C0-24H48A20%2C20%2C0%2C0%2C0%2C28%2C80V208a20%2C20%2C0%2C0%2C0%2C20%2C20H176a20%2C20%2C0%2C0%2C0%2C20-20V140A12%2C12%2C0%2C0%2C0%2C184%2C128Z%22/%3E%3C/svg%3E); + --icon-caret-down: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M216.49%2C104.49l-80%2C80a12%2C12%2C0%2C0%2C1-17%2C0l-80-80a12%2C12%2C0%2C0%2C1%2C17-17L128%2C159l71.51-71.52a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E); + --icon-check: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M232.49%2C80.49l-128%2C128a12%2C12%2C0%2C0%2C1-17%2C0l-56-56a12%2C12%2C0%2C1%2C1%2C17-17L96%2C183%2C215.51%2C63.51a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E); + } + } +} + +@mixin variables-accent($modes: "light") { + @each $mode in $modes { + @if $mode == "light" { + --accent-color: hsl( + var(--accent-light-h), + var(--accent-light-s), + var(--accent-light-l) + ); + --accent-h: var(--accent-light-h); + --accent-s: var(--accent-light-s); + --accent-l: var(--accent-light-l); + + --bg-color: hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) + ); + --fg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - var(--accent-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --fg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - var(--accent-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --glass-bg-1: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.7), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), + var(--dim-opacity) + ); + --glass-bg-2: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), + var(--dim-opacity) + ); + --crt-bg: radial-gradient( + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) + ), + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) + ) + 80%, + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) + ) + ); + } @else if $mode == "dark" { + --accent-color: hsl( + var(--accent-dark-h), + var(--accent-dark-s), + var(--accent-dark-l) + ); + --accent-h: var(--accent-dark-h); + --accent-s: var(--accent-dark-s); + --accent-l: var(--accent-dark-l); + + --bg-color: hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.7), + calc(var(--accent-l) - var(--accent-l) * 0.9) + ); + --fg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --fg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --glass-bg-1: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) - var(--accent-l) * 0.8), + var(--dim-opacity) + ); + --glass-bg-2: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.7), + var(--dim-opacity) + ); + --crt-bg: radial-gradient( + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.7) + ), + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.9) + ) + 80%, + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.95) + ) + ); + } @else if $mode == "common" { + // COLORS + --accent-highlight: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--disabled-opacity) + ); + + // TODO: put this all in sub-mixins + --bg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--color-opacity-1) + ); + + --bg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--color-opacity-2) + ); + + // CONTRAST COLOR + --l: clamp(0, (l / 0.623 - 1) * -infinity, 1); + --a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l)))); + --contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a)); + + // SHADOWS + --shadow-glow: + 0 0 0 #{rpx(1)} var(--bg-muted-2), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), + 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); + --shadow-glow-inset: + inset 0 0 0 #{rpx(1)} var(--bg-muted-2), + inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), + inset 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); + --text-shadow-glow: + var(--bg-muted-2) 0 0 #{rpx(4)}, var(--accent-color) 0 0 #{rpx(12)}; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_animations.scss b/modules/amezing/sass/citrine/base/_animations.scss new file mode 100644 index 0000000..1c61ed2 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_animations.scss @@ -0,0 +1,83 @@ +@keyframes slide-in-up { + from { + transform: translateY(-1rem); + opacity: 0; + } +} + +@keyframes slide-in-left { + from { + transform: translateX(1rem); + opacity: 0; + } +} + +@keyframes slide-in-right { + from { + transform: translateX(-1rem); + opacity: 0; + } +} + +@keyframes slide-in-up-longer { + from { + transform: translateY(-2rem); + opacity: 0; + } +} + +@keyframes slide-in-down-longer { + from { + transform: translateY(2rem); + opacity: 0; + } +} + +@keyframes slide-in-left-longer { + from { + transform: translateX(-2rem); + opacity: 0; + } +} + +@keyframes slide-in-right-longer { + from { + transform: translateX(2rem); + opacity: 0; + } +} + +@keyframes slide-out-down-longer { + to { + transform: translateY(-2rem); + opacity: 0; + } +} + +@keyframes expand-up-left { + from { + transform: scale(0.5) translate(-1rem, -1rem); + opacity: 0; + } +} + +@keyframes expand-up-right { + from { + transform: scale(0.5) translate(1rem, -1rem); + opacity: 0; + } +} + +@keyframes expand-down-left { + from { + transform: scale(0.5) translate(-1rem, 1rem); + opacity: 0; + } +} + +@keyframes expand-down-right { + from { + transform: scale(0.5) translate(1rem, 1rem); + opacity: 0; + } +} diff --git a/modules/amezing/sass/citrine/base/_base.scss b/modules/amezing/sass/citrine/base/_base.scss new file mode 100644 index 0000000..01ea5eb --- /dev/null +++ b/modules/amezing/sass/citrine/base/_base.scss @@ -0,0 +1,59 @@ +@use "../abstracts/functions" as *; + +// Make focused anchor not get covered by header +:target:not(#main-content) { + scroll-margin-block-start: 6.5rem; +} + +:focus-visible, +:target:not(#main-content) { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + + @keyframes focus-in { + from { + outline: 0.5rem solid transparent; + outline-offset: 0.25rem; + } + } +} + +// Style text selection to use accent color +::selection { + background-color: var(--accent-color); + color: var(--contrast-color); +} + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--bg-color); + color: var(--fg-color); + font-family: var(--font-sans-serif), var(--font-system-ui), var(--font-emoji); + overflow-wrap: break-word; + + @supports (min-height: 100dvh) { + min-height: 100dvh; + } +} + +@media (prefers-reduced-motion) { + *, + *::before, + *::after { + animation-duration: 0s !important; + transition-duration: 0s !important; + } +} + +// Disable blur on Firefox Mobile +@-moz-document url-prefix() { + @media only screen and (max-width: 960px) { + .firefox-unblur, + .firefox-unblur::before, + .firefox-unblur::after { + backdrop-filter: none !important; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_layout.scss b/modules/amezing/sass/citrine/base/_layout.scss new file mode 100644 index 0000000..ffbe1e5 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_layout.scss @@ -0,0 +1,36 @@ +@use "../abstracts/functions" as *; + +body > main, +body > nav, +body > footer { + --padding: 1rem; + margin-inline: auto; + padding-inline: 1rem; + max-width: calc(var(--content-width) + var(--padding) * 2); +} + +body > footer { + margin-block-start: 4rem; +} + +body > nav { + margin-block-end: 4rem; + border-block-end: rpx(1) solid var(--bg-muted-2); + + ul { + display: flex; + gap: 0.25rem; + margin-block: 0.625rem; + padding-inline-start: 0; + list-style: none; + + @media only screen and (max-width: 480px) { + flex-direction: column; + } + } + + li { + margin-block: 0; + padding-inline-start: 0; + } +} diff --git a/modules/amezing/sass/citrine/base/_reset.scss b/modules/amezing/sass/citrine/base/_reset.scss new file mode 100644 index 0000000..2365866 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_reset.scss @@ -0,0 +1,135 @@ +// Based on css-init, with a few important adjustments +// https://github.com/sir-sharkey/css-init + +*, +:after, +:before { + vertical-align: baseline; + box-sizing: border-box; + margin: 0; + border: none; + background: none; + padding: 0; + color: inherit; + font: inherit; + -webkit-tap-highlight-color: transparent; +} + +button, +select { + appearance: none; +} + +body { + -webkit-text-size-adjust: none; + text-size-adjust: none; + width: 100%; + min-height: 100vh; + line-height: 1.5; +} + +a, +button, +summary, +input[type="button"], +input[type="checkbox"], +input[type="color"], +input[type="file"], +input[type="image"], +input[type="radio"], +input[type="range"], +input[type="reset"], +input[type="submit"], +label, +select { + cursor: pointer; +} + +article, +aside, +audio, +canvas, +details, +figcaption, +figure, +footer, +header, +main, +nav, +rt, +ruby, +section, +summary { + display: block; +} + +bdi, +mark, +meter, +output, +progress, +time { + display: inline-block; +} + +[hidden], +datalist, +dialog:not([open]), +rp { + display: none !important; +} + +embed, +iframe, +img, +object, +picture, +svg, +video { + display: block; + max-width: 100%; + height: auto; +} + +blockquote, +q { + quotes: none; +} + +blockquote:after, +blockquote:before, +q:after, +q:before { + content: none; +} + +strong { + font-weight: 700; +} + +em { + font-style: italic; +} + +code, +kbd, +pre, +samp { + font-family: monospace; +} + +sub { + vertical-align: sub; +} + +sup { + vertical-align: super; +} + +small { + font-size: small; +} + +table { + border-collapse: collapse; +} diff --git a/modules/amezing/sass/citrine/base/_root.scss b/modules/amezing/sass/citrine/base/_root.scss new file mode 100644 index 0000000..e245c1d --- /dev/null +++ b/modules/amezing/sass/citrine/base/_root.scss @@ -0,0 +1,20 @@ +@use "../abstracts/variables" as *; + +:root { + @include variables(("light", "common")); + @include variables-accent(("light", "common")); + scrollbar-color: var(--accent-color) transparent; + accent-color: var(--accent-color); + + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } + + @media (prefers-color-scheme: dark) { + & { + @include variables("dark"); + @include variables-accent(("dark")); + color-scheme: dark; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_typography.scss b/modules/amezing/sass/citrine/base/_typography.scss new file mode 100644 index 0000000..0415adb --- /dev/null +++ b/modules/amezing/sass/citrine/base/_typography.scss @@ -0,0 +1,400 @@ +@use "../abstracts/functions" as *; +@use "../abstracts/mixins" as *; + +p, +ul, +ol, +dl, +blockquote, +pre { + margin-block: 1rem; +} + +ul ul, +ul ol, +ol ul, +ol ol { + margin-block: 0; +} + +p { + text-wrap: pretty; + overflow-wrap: break-word; +} + +:disabled, +[aria-disabled="true"] { + opacity: var(--disabled-opacity); + cursor: not-allowed; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + transition-duration: var(--transition); + transition-property: text-shadow, font-weight; + margin-block: 2rem 1rem; + color: var(--accent-color); + font-weight: var(--font-weight-bold); + line-height: 1; + font-variation-settings: "FLAR" 100; + letter-spacing: -0.05em; + text-decoration: underline; + text-decoration-color: var(--bg-muted-1); + text-decoration-thickness: 0.125em; + text-wrap: balance; + overflow-wrap: break-word; + + &:target { + animation: none !important; + outline: none !important; + font-weight: var(--font-weight-light); + text-shadow: var(--text-shadow-glow); + } +} + +h1 { + font-size: var(--font-size-xxx-large); +} + +h2 { + font-size: var(--font-size-xx-large); +} + +h3 { + font-size: var(--font-size-x-large); +} + +h4 { + font-size: var(--font-size-large); +} + +h5 { + font-size: var(--font-size-medium); +} + +h6 { + font-size: var(--font-size-small); +} + +@media only screen and (max-width: 480px) { + h1 { + font-size: var(--font-size-xx-large); + } + + h2 { + font-size: var(--font-size-x-large); + } + + h3 { + font-size: var(--font-size-large); + } + + h4 { + font-size: var(--font-size-medium); + } + + h5 { + font-size: var(--font-size-small); + } + + h6 { + font-size: var(--font-size-x-small); + } +} + +small { + color: var(--fg-muted-2); + font-size: var(--font-size-small); +} + +abbr[title] { + cursor: help; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-thickness: rpx(1); +} + +figcaption { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + text-align: center; +} + +blockquote { + margin: 1rem 0; + border-inline-start: 0.25rem solid var(--accent-color); + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 0.75rem; + color: var(--fg-muted-2); +} + +pre, +code, +kbd, +samp { + font-family: var(--font-monospace), var(--font-ui-monospace); +} + +pre { + margin: 1rem 0; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + overflow-x: auto; + scrollbar-width: thin; + line-height: 1.25; +} + +code:not(pre code), +mark, +samp, +q { + @include chip; +} + +code:not(pre code) { + background-color: var(--red-bg-1); + padding-block: rpx(3); + color: var(--red-fg); + font-size: var(--font-size-small); +} + +mark { + color: var(--accent-color); +} + +del { + color: var(--red-fg); + text-decoration: line-through; + text-decoration-thickness: rpx(1); +} + +ins { + color: var(--green-fg); + text-decoration: underline; + text-decoration-thickness: rpx(1); +} + +samp { + color: var(--fg-muted-2); + font-size: var(--font-size-small); +} + +q { + color: var(--fg-muted-2); + font-style: italic; +} + +u { + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: var(--red-fg); + text-decoration-thickness: rpx(1); +} + +progress { + appearance: none; + box-shadow: var(--edge-highlight); + border: none; + border-radius: 9999px; + background-color: var(--bg-muted-1); + padding: 0.25rem; + width: 100%; + height: 1rem; + color: var(--accent-color); + + &:indeterminate { + background-color: var(--bg-muted-1); + } + + &:indeterminate::-moz-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-moz-progress-bar { + border-radius: 9999px; + background-color: var(--accent-color); + } + + &::-webkit-progress-value { + border-radius: 9999px; + background-color: var(--accent-color); + } +} + +kbd { + display: inline-block; + transition-duration: var(--transition); + transition-property: transform, box-shadow, background-color; + cursor: pointer; + box-shadow: + var(--edge-highlight), + inset 0 -0.125rem 0 var(--bg-muted-2); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0 0.375rem; + font-size: var(--font-size-small); + + // Small nice thingy, keys can be pressed! + &:active { + transform: translateY(0.125rem); + box-shadow: inset 0 rpx(1) 0 var(--bg-muted-2); + background-color: var(--bg-muted-2); + } +} + +a { + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + text-decoration-color: var(--accent-highlight); + text-decoration-thickness: rpx(1); + + &:hover { + text-decoration-style: wavy; + } +} + +hr { + margin: 2rem auto; + border: 0.125rem solid var(--bg-muted-2); + border-radius: 9999px; +} + +dt { + font-weight: var(--font-weight-semi-bold); +} + +dd { + margin-inline-start: 1.5rem; + margin-block-end: 1rem; +} + +aside { + float: inline-end; + margin-inline-start: 1rem; + margin-block-end: 1rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + width: calc(var(--content-width) / 3); + + @media only screen and (max-width: 720px) { + float: none; + margin-inline-start: 0; + width: 100%; + } + + :first-child { + margin-block-start: 0; + } + + :last-child { + margin-block-end: 0; + } +} + +details { + display: flow-root; + transition: padding-block-end var(--transition); + margin-block-end: 1rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + + &::details-content { + display: block; + opacity: 0; + transition-behavior: allow-discrete; + transition-duration: var(--transition); + transition-property: opacity, block-size, content-visibility; + block-size: 0; + overflow: hidden; + } + + &[open] { + padding-block-end: 1rem; + + &::details-content { + opacity: 1; + block-size: auto; + block-size: calc-size(auto, size); + } + + summary { + color: var(--accent-color); + + &::before { + transform: scaleY(-1); + } + + @supports not (block-size: calc-size(auto, size)) { + & ~ * { + animation: slide-in-up var(--transition-bezier); + } + } + } + } + + summary { + transition: var(--transition); + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + @include icon(pseudo); + --icon: var(--icon-caret-down); + display: inline-block; + vertical-align: rpx(-2); + transition: + all var(--transition), + transform var(--transition-bezier); + margin-inline-end: 0.25rem; + } + } + + & > :last-child, + & > :last-child img { + margin-block-end: 0; + } +} + +figure { + margin-inline: 0; +} + +ol, +ul { + padding-inline-start: 1.5rem; +} + +li { + margin-block: 0.25rem; + padding-inline-start: 0.25rem; + + &::marker { + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + } +} + +b, +strong { + font-weight: var(--font-weight-semi-bold); +} diff --git a/modules/amezing/sass/citrine/components/_buttons.scss b/modules/amezing/sass/citrine/components/_buttons.scss new file mode 100644 index 0000000..9667b7e --- /dev/null +++ b/modules/amezing/sass/citrine/components/_buttons.scss @@ -0,0 +1,100 @@ +@use "../abstracts/mixins" as *; + +.buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.25rem; + margin-block-start: 2rem; + + &.centered { + justify-content: center; + } + + &.start { + justify-content: flex-start; + } + + &.end { + justify-content: flex-end; + } + + &.big { + gap: 0.5rem; + } +} + +.buttons a, +a.button, +button { + display: inline-block; + transition: + background-color var(--transition), + color var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.5rem 1rem; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-small); + line-height: 1.5rem; + text-decoration: none; + + &:not(:disabled, [aria-disabled="true"]):hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:not(:disabled, [aria-disabled="true"]):active { + transform: var(--active); + } + + &.suggested { + @include gradient-fill; + + &:not(:disabled, [aria-disabled="true"]):hover { + @include gradient-fill(hover); + } + } + + &.subtle { + box-shadow: none; + background-color: transparent; + + &:not(:disabled, [aria-disabled="true"]):hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + } + + &[aria-busy="true"] { + animation: progress-shimmer var(--transition-long) ease-in-out alternate + infinite; + background-image: linear-gradient( + to right, + transparent 50%, + var(--bg-muted-1) 75%, + transparent 100% + ); + background-size: 200%; + + @keyframes progress-shimmer { + to { + background-position-x: -200%; + } + } + } +} + +.buttons.big a, +.buttons.big button, +a.button.big, +button.big { + border-radius: 9999px; + padding: 0.625rem 1.25rem; +} diff --git a/modules/amezing/sass/citrine/components/_card.scss b/modules/amezing/sass/citrine/components/_card.scss new file mode 100644 index 0000000..6b39ea9 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_card.scss @@ -0,0 +1,5 @@ +@use "../abstracts/mixins" as *; + +.card { + @include card; +} diff --git a/modules/amezing/sass/citrine/components/_crt.scss b/modules/amezing/sass/citrine/components/_crt.scss new file mode 100644 index 0000000..5ea199c --- /dev/null +++ b/modules/amezing/sass/citrine/components/_crt.scss @@ -0,0 +1,90 @@ +.crt, +pre:has(code[data-crt], code[data-lang=""]) { + position: relative; + animation: flicker 0.25s alternate infinite; + margin-inline: auto; + box-shadow: var(--shadow-glow); + background-image: var(--crt-bg); + background-color: transparent !important; + padding: 0; + width: fit-content; + max-width: 100%; + overflow: hidden; + color: var(--accent-color) !important; + font-weight: var(--font-weight-medium); + text-shadow: var(--text-shadow-glow); + + @keyframes flicker { + 25% { + opacity: 0.95; + } + + 50% { + opacity: 0.85; + } + + 75% { + opacity: 1; + } + + to { + opacity: 0.9; + } + } + + &::before, + &::after { + position: absolute; + inset: 0; + content: ""; + } + + &::before { + opacity: var(--scanlines-opacity); + z-index: 1; + animation: scanlines 0.1s linear infinite; + background-image: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.25), + rgba(0, 0, 0, 0.25) 0.125rem, + transparent 0.125rem, + transparent 0.25rem + ); + background-position-y: -0.125rem; + background-repeat: no-repeat; + pointer-events: none; + + @keyframes scanlines { + to { + background-position-y: 0.125rem; + } + } + } + + &::after { + opacity: 0.05; + animation: scanline 5s linear infinite; + background-image: linear-gradient( + to bottom, + transparent, + var(--accent-color) 16rem + ); + background-position-y: -16rem; + background-size: 100% 16rem; + pointer-events: none; + + @keyframes scanline { + to { + background-position-y: calc(100% + 16rem); + } + } + } + + & > code { + display: block; + border-radius: inherit; + padding: 0.75rem 1rem; + overflow-x: auto; + scrollbar-width: thin; + } +} diff --git a/modules/amezing/sass/citrine/components/_external.scss b/modules/amezing/sass/citrine/components/_external.scss new file mode 100644 index 0000000..494f317 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_external.scss @@ -0,0 +1,12 @@ +@use "../abstracts/functions" as *; +@use "../abstracts/mixins" as *; + +a.external:not(:has(img, svg, video, picture, figure))::after { + @include icon(pseudo); + --icon: var(--icon-arrow-square-out); + display: inline-block; + vertical-align: -0.125rem; + opacity: var(--dim-opacity); + margin-inline-start: 0.125rem; + font-size: max(1rem, 1em); +} diff --git a/modules/amezing/sass/citrine/components/_iframe.scss b/modules/amezing/sass/citrine/components/_iframe.scss new file mode 100644 index 0000000..2334efe --- /dev/null +++ b/modules/amezing/sass/citrine/components/_iframe.scss @@ -0,0 +1,20 @@ +iframe { + display: block; + margin: 1rem auto; + box-shadow: var(--edge-highlight), var(--shadow); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + width: 100%; + max-width: 100%; + + &:fullscreen { + box-shadow: none; + border-radius: 0; + } + + &:-webkit-full-screen { + box-shadow: none; + border-radius: 0; + } +} diff --git a/modules/amezing/sass/citrine/components/_input.scss b/modules/amezing/sass/citrine/components/_input.scss new file mode 100644 index 0000000..d696a13 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_input.scss @@ -0,0 +1,261 @@ +@use "../abstracts/mixins" as *; + +input { + appearance: none; + background-color: var(--bg-muted-1); +} + +input:not([type]), +input[type="text"], +input[type="search"], +input[type="password"], +input[type="submit"] { + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + font-size: inherit; + line-height: inherit; + font-family: inherit; +} + +input:not([type]), +input[type="text"], +input[type="search"], +input[type="password"] { + padding: 0.5rem 0.75rem; + width: 100%; + + &::placeholder { + opacity: 1; + color: var(--fg-muted-1); + } +} + +input[type="submit"] { + transition: + background-color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + padding: 0.375rem 0.75rem; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-small); + + &:enabled:hover { + background-color: var(--bg-muted-2); + } + + &:enabled:active { + transform: var(--active); + } +} + +input[type="radio"], +input[type="checkbox"], +input[type="color"] { + position: relative; + transition: background-color var(--transition); + border: 0.125rem solid var(--bg-muted-2); + width: 1rem; + height: 1rem; + + &:not(:checked):enabled:hover { + background-color: var(--bg-muted-2); + } +} + +input[type="radio"], +input[type="checkbox"] { + vertical-align: -0.125em; + margin-inline-end: 0.25rem; + + &::before { + display: block; + position: absolute; + transform: scale(0.5); + opacity: 0; + transition: + opacity var(--transition), + transform var(--transition-bezier); + content: ""; + } + + &:checked { + border: 0.15rem solid transparent; + background-color: var(--accent-color); + + &::before { + transform: scale(1); + opacity: 1; + } + } +} + +input[type="radio"] { + border-radius: 50%; + + &::before { + inset-block-start: 0.125rem; + inset-inline-start: 0.125rem; + border-radius: 50%; + background-color: var(--contrast-color); + width: 0.5rem; + height: 0.5rem; + } +} + +input[type="checkbox"] { + border-radius: calc(var(--rounded-corner-small) / 2); + + &::before { + @include icon(pseudo); + --icon: var(--icon-check); + transform-origin: bottom left; + inset-block-start: -0.125rem; + inset-inline-start: -0.125rem; + color: var(--contrast-color); + font-size: 1rem; + } + + &.switch { + vertical-align: -0.375rem; + box-shadow: var(--edge-highlight); + border: none; + border-radius: 9999px; + width: 2.5rem; + height: 1.5rem; + + &.big { + vertical-align: -0.625rem; + width: 3rem; + height: 2rem; + + &::before { + width: 1.5rem; + height: 1.5rem; + } + } + + &::before { + transform: none; + transform-origin: center; + opacity: 1; + mask-image: none; + inset-block-start: 0.25rem; + inset-inline-start: 0.25rem; + box-shadow: var(--shadow); + border-radius: 50%; + background-color: white; + width: 1rem; + height: 1rem; + content: ""; + } + + &:checked { + background-color: var(--accent-color); + + &::before { + transform: translateX(1rem); + background-color: var(--contrast-color); + + :root[dir*="rtl"] & { + transform: translateX(-1rem); + } + } + } + + &:disabled::before { + box-shadow: none; + } + } +} + +input[type="color"] { + vertical-align: -0.375em; + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner-small); + padding: 0.25rem; + width: 3rem; + height: 2rem; + + &::-moz-color-swatch { + border: none; + border-radius: calc(var(--rounded-corner-small) - 0.25rem); + } + + &::-webkit-color-swatch-wrapper { + padding: 0; + } + + &::-webkit-color-swatch { + border-radius: calc(var(--rounded-corner-small) - 0.25rem); + } +} + +input[type="range"] { + appearance: none; + box-shadow: var(--edge-highlight); + border-radius: 9999px; + background: var(--bg-muted-1); + padding: 0.5rem 0.25rem; + width: 100%; + height: 1rem; + + &::-webkit-slider-runnable-track { + cursor: pointer; + border-radius: 9999px; + background: var(--accent-color); + width: 100%; + } + + &::-moz-range-track { + cursor: pointer; + border-radius: 9999px; + background: var(--accent-color); + width: 100%; + height: 0.5rem; + } + + &::-webkit-slider-thumb { + appearance: none; + cursor: ew-resize; + margin-block: -0.5rem; + box-shadow: var(--shadow); + border: none; + border-radius: 0.25rem; + background-color: white; + width: 0.5rem; + height: 1.5rem; + } + + &::-moz-range-thumb { + appearance: none; + cursor: ew-resize; + margin-block: -0.5rem; + box-shadow: var(--shadow); + border: none; + border-radius: 0.25rem; + background-color: white; + width: 0.5rem; + height: 1.5rem; + } + + &.hollow { + &::-webkit-slider-thumb { + margin-block: 0; + outline: 0.25rem solid white; + background-color: transparent; + height: 0.5rem; + } + + &::-moz-range-thumb { + margin-block: 0; + outline: 0.25rem solid white; + background-color: transparent; + height: 0.5rem; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_media.scss b/modules/amezing/sass/citrine/components/_media.scss new file mode 100644 index 0000000..3409071 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_media.scss @@ -0,0 +1,165 @@ +img, +video { + display: block; + margin: 1rem auto; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + max-width: 100%; + max-height: calc(100vh - 2rem); + + &.full, + &[src*="#full"] { + width: 100%; + } + + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + width: calc(var(--content-width) / 3); + } + + &.start, + &[src*="#start"] { + float: inline-start; + transform-origin: left; + margin-inline-end: 1rem; + + :root[dir*="rtl"] & { + transform-origin: right; + } + } + + &.end, + &[src*="#end"] { + float: inline-end; + transform-origin: right; + margin-inline-start: 1rem; + + :root[dir*="rtl"] & { + transform-origin: left; + } + } + + &.pixels, + &[src*="#pixels"] { + image-rendering: pixelated; + } + + &.has-alpha, + &[src*="#has-alpha"] { + box-shadow: none; + border-radius: 0; + background-color: transparent; + } + + &.drop-shadow, + &[src*="#drop-shadow"] { + filter: var(--drop-shadow); + } + + &.spoiler, + &[src*="#spoiler"] { + opacity: var(--dim-opacity); + clip-path: inset(0 0 0 0 round var(--rounded-corner)); + filter: blur(1.5rem); + + &:hover, + &:active { + opacity: 1; + clip-path: inset( + -0.75rem -0.75rem -0.75rem -0.75rem round var(--rounded-corner-small) + ); + filter: none; + } + + &.solid, + &[src*="#solid"] { + clip-path: none; + filter: brightness(0) contrast(0.5); + box-shadow: none; + + &:hover, + &:active { + filter: none; + } + } + } + + @media only screen and (max-width: 720px) { + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + float: none; + margin-inline-start: auto; + margin-inline-end: auto; + width: auto; + } + } +} + +img { + transition: + box-shadow var(--transition), + border-radius var(--transition), + transform var(--transition-bezier); + + &:not(.no-hover, [src*="#no-hover"], .emoji) { + cursor: zoom-in; + + &:hover { + position: relative; + transform: var(--hover); + z-index: 1; + box-shadow: var(--edge-highlight), var(--shadow-raised); + border-radius: var(--rounded-corner-small); + } + + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + transition: transform var(--transition-bezier-longer); + + &:hover { + transform: scale(2); + } + + @media only screen and (max-width: 720px) { + transform-origin: center; + transition: transform var(--transition-bezier); + + &:hover { + transform: var(--hover); + } + } + } + + &.has-alpha, + &[src*="#has-alpha"] { + &:hover { + box-shadow: none; + } + } + } +} + +a img:not(.no-hover, [src*="#no-hover"]) { + cursor: pointer; +} + +video:fullscreen { + box-shadow: none; + border-radius: 0; +} + +video:-webkit-full-screen { + box-shadow: none; + border-radius: 0; +} + +picture { + display: inline-block; +} diff --git a/modules/amezing/sass/citrine/components/_overflow-auto.scss b/modules/amezing/sass/citrine/components/_overflow-auto.scss new file mode 100644 index 0000000..712ba4b --- /dev/null +++ b/modules/amezing/sass/citrine/components/_overflow-auto.scss @@ -0,0 +1,13 @@ +.overflow-auto { + overflow: auto; + + &.overshoot { + margin-block: -1rem; + padding-block: 1rem; + } + + &.overshoot-row { + margin-inline: -1rem; + padding-inline: 1rem; + } +} diff --git a/modules/amezing/sass/citrine/components/_overshoot.scss b/modules/amezing/sass/citrine/components/_overshoot.scss new file mode 100644 index 0000000..af8447f --- /dev/null +++ b/modules/amezing/sass/citrine/components/_overshoot.scss @@ -0,0 +1,23 @@ +.overshoot { + --mask: linear-gradient( + to bottom, + transparent, + black 1rem, + black calc(100% - 1rem), + transparent + ); + -webkit-mask-image: var(--mask); + mask-image: var(--mask); + + &-row { + --mask: linear-gradient( + to right, + transparent, + black 1rem, + black calc(100% - 1rem), + transparent + ); + -webkit-mask-image: var(--mask); + mask-image: var(--mask); + } +} diff --git a/modules/amezing/sass/citrine/components/_skip-to-content.scss b/modules/amezing/sass/citrine/components/_skip-to-content.scss new file mode 100644 index 0000000..1e217af --- /dev/null +++ b/modules/amezing/sass/citrine/components/_skip-to-content.scss @@ -0,0 +1,23 @@ +#skip-to-content { + position: absolute; + transform: translateX(-50%) translateY(calc(-100% - 1rem)); + z-index: 9999; + transition: + backdrop-filter var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + inset-inline-start: 50vw; + border-radius: 9999px; + background-color: var(--glass-bg-1); + padding: 0.375rem 0.75rem; + width: max-content; + text-decoration: none; + + &:focus-visible, + &:focus { + -webkit-backdrop-filter: var(--blur); + transform: translateX(-50%) translateY(1rem); + backdrop-filter: var(--blur); + box-shadow: var(--edge-highlight), var(--shadow); + } +} diff --git a/modules/amezing/sass/citrine/components/_spoiler.scss b/modules/amezing/sass/citrine/components/_spoiler.scss new file mode 100644 index 0000000..3b3c91b --- /dev/null +++ b/modules/amezing/sass/citrine/components/_spoiler.scss @@ -0,0 +1,25 @@ +span.spoiler { + filter: blur(0.25rem); + transition: filter var(--transition); + + &:focus, + &:hover { + filter: none; + } + + &.solid { + filter: none; + transition: + background-color var(--transition), + color var(--transition); + border-radius: var(--rounded-corner-small); + background-color: var(--fg-muted-1); + color: transparent; + + &:focus, + &:hover { + background-color: transparent; + color: inherit; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_table.scss b/modules/amezing/sass/citrine/components/_table.scss new file mode 100644 index 0000000..569a914 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_table.scss @@ -0,0 +1,36 @@ +@use "../abstracts/functions" as *; + +table { + width: 100%; + overflow: hidden; +} + +thead { + border-block-start: none; + color: var(--fg-muted-2); + font-weight: var(--font-weight-semi-bold); +} + +tr { + &:nth-child(even) { + background-color: var(--bg-muted-1); + } + + th, + td { + padding: 0.5rem 1rem; + } +} + +tbody tr { + border-block: rpx(1) solid var(--bg-muted-2); + + &:last-child { + border-block-end: none; + } +} + +.overflow-auto table { + width: max-content; + min-width: 100%; +} diff --git a/modules/amezing/sass/citrine/components/_textarea.scss b/modules/amezing/sass/citrine/components/_textarea.scss new file mode 100644 index 0000000..58c355a --- /dev/null +++ b/modules/amezing/sass/citrine/components/_textarea.scss @@ -0,0 +1,17 @@ +textarea { + appearance: none; + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + resize: none; + font-size: inherit; + line-height: inherit; + font-family: inherit; + + &::placeholder { + opacity: 1; + color: var(--fg-muted-1); + } +} diff --git a/modules/amezing/sass/citrine/components/_title.scss b/modules/amezing/sass/citrine/components/_title.scss new file mode 100644 index 0000000..16b8c89 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_title.scss @@ -0,0 +1,5 @@ +strong.title { + color: var(--accent-color); + font-size: var(--font-size-large); + line-height: 1; +} diff --git a/modules/amezing/sass/citrine/components/_tooltip.scss b/modules/amezing/sass/citrine/components/_tooltip.scss new file mode 100644 index 0000000..72aa4c1 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_tooltip.scss @@ -0,0 +1,41 @@ +// attr() hack from https://cyrneko.eu/css-tricks.html +@media (pointer: coarse), (hover: none) { + .tooltip, + abbr[title] { + display: inline-flex; + position: relative; + justify-content: center; + + &::after { + position: absolute; + top: calc(100% + 0.25rem); + z-index: 9999; + backdrop-filter: var(--blur); + transition: + visibility var(--transition), + opacity var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner-small); + background-color: var(--glass-bg-1); + padding: 0.125rem 0.375rem; + width: max-content; + content: attr(title); + -webkit-backdrop-filter: var(--blur); + transform: translateY(-0.5rem) scale(0.5); + transform-origin: top; + visibility: hidden; + opacity: 0; + max-width: calc(var(--content-width) / 3); + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + &:focus::after, + &:hover::after { + transform: none; + visibility: visible; + opacity: 1; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_visually-hidden.scss b/modules/amezing/sass/citrine/components/_visually-hidden.scss new file mode 100644 index 0000000..fbf292a --- /dev/null +++ b/modules/amezing/sass/citrine/components/_visually-hidden.scss @@ -0,0 +1,11 @@ +.visually-hidden { + position: absolute !important; + clip-path: inset(50%) !important; + margin: -1px !important; + border-width: 0 !important; + padding: 0 !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + white-space: nowrap !important; +} diff --git a/modules/amezing/sass/citrine/style.scss b/modules/amezing/sass/citrine/style.scss new file mode 100644 index 0000000..77ddca1 --- /dev/null +++ b/modules/amezing/sass/citrine/style.scss @@ -0,0 +1,29 @@ +/* +* Citrine v1.0.0 (https://citrine.daudix.one) +* Copyright 2026 David "Daudix" Lapshin and contributors +* Licensed under MIT (https://codeberg.org/daudix/citrine/raw/branch/main/LICENSE.txt) +*/ + +@use "base/root"; +@use "base/reset"; +@use "base/animations"; +@use "base/base"; +@use "base/layout"; +@use "base/typography"; + +@use "components/buttons"; +@use "components/card"; +@use "components/crt"; +@use "components/external"; +@use "components/iframe"; +@use "components/input"; +@use "components/media"; +@use "components/overflow-auto"; +@use "components/overshoot"; +@use "components/skip-to-content"; +@use "components/spoiler"; +@use "components/table"; +@use "components/textarea"; +@use "components/title"; +@use "components/tooltip"; +@use "components/visually-hidden"; diff --git a/modules/amezing/sass/components/_alerts.scss b/modules/amezing/sass/components/_alerts.scss new file mode 100644 index 0000000..d9f5ed5 --- /dev/null +++ b/modules/amezing/sass/components/_alerts.scss @@ -0,0 +1,153 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; +@use "../citrine/abstracts/variables" as *; + +[class^="markdown-alert"] { + display: flow-root; + position: relative; + + &::before { + margin-inline-start: 1.5rem; + content: var(--alert-title); + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + } + + &::after { + @include icon(pseudo); + --icon: var(--alert-icon); + position: absolute; + margin-inline-end: 0.25rem; + inset-block-start: rpx(1); + inset-inline-start: 0.75rem; + color: var(--accent-color); + font-size: 1.25rem; + } + + & > :first-child { + margin-block-start: 0.25rem; + } + + & > :last-child { + margin-block-end: 0; + } +} + +.markdown-alert { + // GitHub flavored alerts + &-note, + &-tip, + &-important, + &-warning, + &-caution, + // Generic colors + &-red, + &-orange, + &-yellow, + &-green, + &-blue, + &-purple { + @include variables-accent(("light", "common")); + + @media (prefers-color-scheme: dark) { + @include variables-accent("dark"); + } + } + + &-note { + &::before { + content: var(--alert-note-title); + } + + &::after { + --icon: var(--icon-info); + } + } + + &-tip { + &::before { + content: var(--alert-tip-title); + } + + &::after { + --icon: var(--icon-lightbulb); + } + } + + &-important { + &::before { + content: var(--alert-important-title); + } + + &::after { + --icon: var(--icon-warning-diamond); + } + } + + &-warning { + &::before { + content: var(--alert-warning-title); + } + + &::after { + --icon: var(--icon-warning); + } + } + + &-caution { + &::before { + content: var(--alert-caution-title); + } + + &::after { + --icon: var(--icon-warning-octagon); + } + } + + &-caution, + &-red { + --accent-h: var(--red-h); + --accent-s: var(--red-s); + --accent-l: var(--red-l); + --accent-color: var(--red-fg); + } + + &-orange { + --accent-h: var(--orange-h); + --accent-s: var(--orange-s); + --accent-l: var(--orange-l); + --accent-color: var(--orange-fg); + } + + &-warning, + &-yellow { + --accent-h: var(--yellow-h); + --accent-s: var(--yellow-s); + --accent-l: var(--yellow-l); + --accent-color: var(--yellow-fg); + } + + &-tip, + &-green { + --accent-h: var(--green-h); + --accent-s: var(--green-s); + --accent-l: var(--green-l); + --accent-color: var(--green-fg); + } + + &-note, + &-blue { + --accent-h: var(--blue-h); + --accent-s: var(--blue-s); + --accent-l: var(--blue-l); + --accent-color: var(--blue-fg); + } + + &-important, + &-purple { + --accent-h: var(--purple-h); + --accent-s: var(--purple-s); + --accent-l: var(--purple-l); + --accent-color: var(--purple-fg); + } +} diff --git a/modules/amezing/sass/components/_article-details.scss b/modules/amezing/sass/components/_article-details.scss new file mode 100644 index 0000000..b3ca1ff --- /dev/null +++ b/modules/amezing/sass/components/_article-details.scss @@ -0,0 +1,71 @@ +@use "../citrine/abstracts/functions" as *; + +.article-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-block-end: 2rem; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + + .items { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.25rem 0.5rem; + } + + .item { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: var(--rounded-corner-small); + } + + a { + color: inherit; + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + text-decoration-color: transparent; + + &:hover { + text-decoration-style: solid; + text-decoration-color: var(--fg-muted-1); + } + } + + .tooltip { + cursor: help; + } + + .icon { + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0.375rem; + color: var(--accent-color); + font-size: 1.25rem; + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 0 0.5rem; + margin: 0; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding-inline-start: 0; + padding-block-start: 0.5rem; + width: 100%; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding: 0; + } + + a::before { + content: "#"; + } + } +} diff --git a/modules/amezing/sass/components/_article-list.scss b/modules/amezing/sass/components/_article-list.scss new file mode 100644 index 0000000..e597ecc --- /dev/null +++ b/modules/amezing/sass/components/_article-list.scss @@ -0,0 +1,254 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/variables" as *; +@use "../citrine/abstracts/mixins" as *; + +.article-list { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(calc(var(--content-width) / 2.5), 1fr) + ); + gap: 1rem; + margin-block-start: 2rem; + + &.categories article { + position: relative; + + &:hover .icon:last-child { + transform: translateY(-50%) rotate(-10deg) scale(1.5); + } + + .icon:last-child { + position: absolute; + transform: translateY(-50%); + z-index: -1; + transition: transform var(--transition-bezier); + inset-block-start: 50%; + inset-inline-end: -3rem; + color: var(--bg-muted-1); + font-size: 12rem; + } + } + + article { + display: flex; + position: relative; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: 1.25rem; + background-color: var(--bg-muted-1); + padding: 0; + overflow: hidden; + + &.draft { + opacity: var(--disabled-opacity); + filter: grayscale(1); + transition-duration: var(--transition); + transition-property: opacity, filter; + + &:hover { + opacity: 1; + filter: none; + } + } + + &.has-banner.has-accent-color { + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: cover; + } + + &.has-accent-color { + @include variables-accent(("light", "common")); + + @media (prefers-color-scheme: dark) { + & { + @include variables-accent("dark"); + } + } + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + margin: 1rem; + + & > a { + text-decoration: none; + } + } + + h3 { + margin: 0; + border-inline-start: 0.25rem solid; + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 0.5rem; + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + text-decoration: none; + + &:hover::after { + opacity: 1; + } + + &::after { + opacity: 0; + transition: opacity var(--transition); + content: " →"; + } + } + + .description p { + margin-block-end: 0.75rem; + } + + .open-post { + @include easing-mask(bottom); + display: flex; + position: relative; + justify-content: center; + align-items: center; + margin-block-end: -2rem; + box-shadow: var(--edge-highlight); + border-radius: 0; + aspect-ratio: 2 / 1; + overflow: hidden; + text-decoration: none; + + &:hover { + &::before { + background-color: var(--shade-color); + } + + img { + transform: var(--hover); + filter: blur(0.25rem); + } + + .icon { + transform: none; + opacity: 1; + } + } + + &:active img { + transform: none; + } + + &::before { + position: absolute; + z-index: 1; + transition: background-color var(--transition); + inset: 0; + border-radius: inherit; + content: ""; + } + + img { + position: absolute; + transition-duration: var(--transition); + transition-property: transform, filter; + margin: 0; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .icon { + transform: scale(1.5); + opacity: 0; + z-index: 1; + transition: + transform var(--transition-bezier), + opacity var(--transition); + color: white; + font-size: 1.25rem; + } + } + + .article-details { + flex: 1; + justify-content: flex-end; + margin-block-end: 0; + + .tags { + margin-block-start: auto; + margin-block-end: -0.25rem; + } + } + } +} + +.paginator { + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + margin-block-start: 4rem; + + &:has(a.paginator-item.previous:active) .paginator-counter { + border-start-start-radius: 1rem; + border-end-start-radius: 1rem; + } + + &:has(a.paginator-item.next:active) .paginator-counter { + border-start-end-radius: 1rem; + border-end-end-radius: 1rem; + } + + &-item { + display: inline-block; + transition: + border-radius var(--transition), + border-start-end-radius var(--transition), + border-end-end-radius var(--transition), + border-start-start-radius var(--transition), + border-end-start-radius var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: 1rem; + background-color: var(--bg-muted-1); + padding: 0.5rem; + color: var(--accent-color); + line-height: 0; + text-decoration: none; + + &:not([aria-disabled="true"]):hover { + background-color: var(--bg-muted-2); + } + + &:not([aria-disabled="true"]):active { + transform: var(--active); + border-radius: 1rem; + } + + &-previous { + border-start-end-radius: var(--rounded-corner-small); + border-end-end-radius: var(--rounded-corner-small); + } + + &-next { + border-start-start-radius: var(--rounded-corner-small); + border-end-start-radius: var(--rounded-corner-small); + } + } + + &-counter { + @include gradient-fill; + display: inline-block; + transition: var(--transition-bezier); + transition-property: + border-start-start-radius, border-end-start-radius, + border-start-end-radius, border-end-end-radius; + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0.25rem 0.5rem; + font-weight: var(--font-weight-semi-bold); + font-variant-numeric: tabular-nums; + } +} diff --git a/modules/amezing/sass/components/_article-nav.scss b/modules/amezing/sass/components/_article-nav.scss new file mode 100644 index 0000000..217e350 --- /dev/null +++ b/modules/amezing/sass/components/_article-nav.scss @@ -0,0 +1,66 @@ +.article-nav { + display: flex; + gap: 0.25rem; + + @media only screen and (max-width: 720px) { + flex-direction: column; + } + + &-item { + flex: 1; + transition: + box-shadow var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + border-radius: var(--rounded-corner); + padding: 0.75rem 1rem; + min-width: 0; + text-decoration: none; + + &:hover { + box-shadow: var(--edge-highlight), var(--shadow); + background-color: var(--bg-muted-1); + + .article-title { + color: var(--accent-color); + } + } + + &:active { + transform: var(--active); + } + + &.prev { + text-align: start; + + .nav-arrow::before { + content: "← "; + } + } + + &.next { + text-align: end; + + .nav-arrow::after { + content: " →"; + } + } + } + + .nav-arrow { + margin-block-end: 0.5rem; + color: var(--fg-muted-2); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + } + + .article-title { + display: block; + transition: color var(--transition); + max-width: 100%; + overflow: hidden; + color: var(--fg-muted-1); + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/modules/amezing/sass/components/_audio-button.scss b/modules/amezing/sass/components/_audio-button.scss new file mode 100644 index 0000000..1f4fe43 --- /dev/null +++ b/modules/amezing/sass/components/_audio-button.scss @@ -0,0 +1,42 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.audio-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: var(--font-size-small); + font-weight: var(--font-weight-semi-bold); + color: var(--accent-color); + background-color: var(--accent-bg-1); + border: none; + cursor: pointer; + transition: + background-color var(--transition), + transform var(--transition-bezier), + box-shadow var(--transition); + + &:hover { + background-color: var(--accent-bg-2); + transform: scale(1.03); + } + + &:active { + transform: var(--active); + } + + &:focus-visible { + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.25rem; + } + + .audio-button-label { + display: none; + + @media (min-width: 480px) { + display: inline; + } + } +} diff --git a/modules/amezing/sass/components/_audio.scss b/modules/amezing/sass/components/_audio.scss new file mode 100644 index 0000000..18d8397 --- /dev/null +++ b/modules/amezing/sass/components/_audio.scss @@ -0,0 +1,41 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +// Who doesn't like a party popper mid article? +.audio { + @include chip; + padding-block: 0; + color: var(--accent-color); + font-size: var(--font-size-medium); + + &:hover { + color: var(--accent-color) !important; + + .icon { + animation: speaker var(--transition-long) ease-out infinite; + + @keyframes speaker { + from { + --icon: var(--icon-speaker-none); + } + + 50% { + --icon: var(--icon-speaker-low); + } + + to { + --icon: var(--icon-speaker-high); + } + } + } + } + + &:active { + transform: var(--active); + } + + .icon { + vertical-align: rpx(-3); + margin-inline-start: 0.25rem; + } +} diff --git a/modules/amezing/sass/components/_banner.scss b/modules/amezing/sass/components/_banner.scss new file mode 100644 index 0000000..e261d07 --- /dev/null +++ b/modules/amezing/sass/components/_banner.scss @@ -0,0 +1,98 @@ +@use "../citrine/abstracts/functions" as *; + +body.has-banner { + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: cover; + background-attachment: fixed; +} + +#banner { + --mask: linear-gradient(black calc(100% - 2rem), transparent); + -webkit-mask-image: var(--mask); + display: flex; + grid-area: banner; + justify-content: center; + mask-image: var(--mask); + margin-block-end: -2.5rem; + padding-block: 3.25rem 2.5rem; + width: 100%; + + div { + position: relative; + margin-inline: 1rem; + aspect-ratio: 2 / 1; + width: 100%; + max-width: var(--content-width); + } + + img { + position: absolute; + margin: 0; + inset: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0 !important; + aspect-ratio: 2 / 1; + width: 100%; + object-fit: cover; + } + + img:nth-of-type(1) { + opacity: var(--dim-opacity); + z-index: -1; + filter: blur(1.5rem); + animation: banner-glow-load calc(0.525s + var(--transition-long)); + inset-block-start: 1rem; + + @keyframes banner-glow-load { + from, + 52.5% { + opacity: 0; + } + } + } + + img:nth-of-type(2) { + transform-origin: bottom; + animation: banner-load calc(0.525s + var(--transition-longer)); + + @keyframes banner-load { + from, + 52.5% { + transform: translateY(2rem) scale(0.95); + opacity: 0; + } + } + } + + & + #main-content { + margin-block-start: 0; + + article > h1:first-of-type, + h1:first-of-type { + margin-inline: -1rem; + margin-block: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0; + padding: 1rem; + width: unset; + -webkit-backdrop-filter: blur(1rem); + backdrop-filter: blur(1rem); + animation: banner-heading calc(0.525s + var(--transition-longer)); + box-shadow: inset 0 rpx(1) 0 var(--accent-highlight); + background-image: linear-gradient( + to bottom, + var(--bg-muted-1), + transparent 50% + ); + background-size: auto 200%; + text-decoration: none; + + @keyframes banner-heading { + from, + 52.5% { + box-shadow: none; + background-position-y: 100%; + } + } + } + } +} diff --git a/modules/amezing/sass/components/_comments.scss b/modules/amezing/sass/components/_comments.scss new file mode 100644 index 0000000..e5cc946 --- /dev/null +++ b/modules/amezing/sass/components/_comments.scss @@ -0,0 +1,459 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#comments { + #qrcode { + float: inline-end; + cursor: help; + box-sizing: content-box; + margin-inline-start: 1rem; + margin-block-start: 3rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: white; + padding: 0.75rem; + width: rpx(116); + height: rpx(116); + + svg { + pointer-events: none; + } + + @media only screen and (max-width: 720px) { + display: none; + } + } + + #comments-wrapper { + display: flex; + flex-direction: column; + gap: 2rem; + margin-block-start: 2rem; + + #comments-status { + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-x-large); + text-align: center; + } + + .comment { + display: grid; + // grid-template-columns: min-content; + grid-template-columns: auto 1fr; + grid-template-areas: + "avatar header " + "...... post " + "...... media " + "...... card " + "...... interactions"; + column-gap: 1rem; + justify-items: start; + animation: slide-in-right var(--transition-bezier); + + :root[dir*="rtl"] & { + animation: slide-in-left var(--transition-bezier); + } + + @media only screen and (max-width: 720px) { + grid-template-areas: + "avatar header " + "post post " + "media media " + "card card " + "interactions interactions"; + align-items: start; + } + + &.comment-reply { + position: relative; + border-inline-start: 0.25rem solid var(--bg-muted-2); + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 1rem; + + &:has(+ .comment-reply) { + border-end-start-radius: 0; + } + + & + .comment-reply { + margin-block-start: -2rem; + border-start-start-radius: 0; + padding-block-start: 2rem; + } + } + + .avatar-link { + grid-area: avatar; + width: 4rem; + height: 4rem; + + @media only screen and (max-width: 480px) { + width: 3rem; + height: 3rem; + } + + .avatar { + transition: transform var(--transition-bezier); + margin: 0; + background-size: cover; + width: 100%; + height: 100%; + + &:hover { + transform: rotate(10deg) var(--hover); + border-radius: var(--rounded-corner); + } + + &:active { + transform: var(--active); + } + } + } + + .mention { + @include chip; + display: inline-block; + + &:hover { + background-color: var(--bg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + .invisible { + display: none; + } + + .ellipsis::after { + content: "…"; + } + + header { + display: flex; + grid-area: header; + flex-direction: column; + width: 100%; + min-width: 0; + color: var(--fg-muted-1); + line-height: 1; + + .display, + .instance { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .display { + color: var(--fg-color); + line-height: 1.5rem; + text-decoration: none; + + .emoji { + cursor: unset; + + &:hover { + transform: none; + } + } + } + + .instance { + margin-block-start: 0.25rem; + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-x-small); + + &.op { + cursor: help; + + &::before { + @include icon(pseudo); + --icon: var(--icon-seal-check); + display: inline-block; + vertical-align: text-top; + margin-inline-end: 0.25rem; + font-size: 1rem; + } + } + } + + .timestamp { + margin-block-start: 0.25rem; + + a { + color: inherit; + font-size: var(--font-size-x-small); + text-decoration-color: transparent; + + &:hover { + text-decoration-style: solid; + text-decoration-color: inherit; + } + + &::after { + vertical-align: top; + } + } + } + } + + details { + border-radius: var(--rounded-corner-small); + background-image: + linear-gradient( + to right, + transparent, + transparent 0.5rem, + var(--bg-muted-1) 0.5rem, + var(--bg-muted-1) calc(100% - 0.5rem), + transparent calc(100% - 0.5rem), + transparent + ), + linear-gradient( + to right, + transparent, + transparent 0.5rem, + var(--bg-color) 0.5rem, + var(--bg-color) calc(100% - 0.5rem), + transparent calc(100% - 0.5rem), + transparent + ), + repeating-linear-gradient( + 45deg, + var(--contrast-color), + var(--contrast-color) 0.25rem, + var(--accent-color) 0.25rem, + var(--accent-color) 0.5rem + ); + width: 100%; + + summary { + border-radius: 0; + background-image: none; + } + } + + main { + grid-area: post; + justify-self: stretch; + margin-block-start: 1rem; + padding: 0; + + :first-child { + margin-block-start: 0; + } + + :last-child { + margin-block-end: 0; + } + } + + .attachments { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + grid-area: media; + gap: 0.5rem; + margin-block-start: 1rem; + + img, + video { + margin: 0; + } + } + + .meta-card { + grid-area: card; + transition: transform var(--transition-bezier); + margin-block-start: 1rem; + width: min(calc(var(--content-width) / 2), 100%); + font-weight: var(--font-weight-normal); + text-decoration: none; + + &:hover { + img { + transform: var(--hover); + box-shadow: var(--edge-highlight), var(--shadow-raised); + border-radius: var(--rounded-corner-small); + } + + figcaption { + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + figure { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + + img { + margin: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) + var(--rounded-corner-small) var(--rounded-corner-small); + aspect-ratio: 16 / 9; + object-fit: cover; + + & + figcaption { + border-radius: var(--rounded-corner-small) + var(--rounded-corner-small) var(--rounded-corner) + var(--rounded-corner); + } + } + + figcaption { + display: flex; + flex-direction: column; + gap: 0.25rem; + transition-duration: var(--transition); + transition-property: border-radius, background-color; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + color: var(--fg-color); + font-size: var(--font-size-medium); + text-align: start; + + p { + margin: 0; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + } + } + } + + footer { + display: flex; + grid-area: interactions; + align-items: center; + gap: 0.25rem; + margin-block-start: 1rem; + width: 100%; + min-width: 0; + + @media only screen and (max-width: 480px) { + flex-wrap: wrap; + } + + .boosts, + .faves, + .reaction { + padding: 0.375rem 0.75rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .boosts, + .faves { + transition: + box-shadow var(--transition), + border-radius var(--transition), + transform var(--transition-bezier); + border-radius: rpx(18); + text-decoration: none; + + .icon { + vertical-align: rpx(-2); + transition: transform var(--transition-bezier); + margin-inline-end: 0.25rem; + } + + &:hover { + box-shadow: var(--edge-highlight); + text-decoration: none; + } + + &:active { + transform: var(--active); + border-radius: rpx(18); + } + } + + .boosts { + border-start-end-radius: var(--rounded-corner-small); + border-end-end-radius: var(--rounded-corner-small); + padding-inline-end: 0.625rem; + color: var(--purple-fg); + + .icon { + --icon: var(--icon-repeat); + } + + &:hover { + background-color: var(--purple-bg-1); + + .icon { + transform: rotate(180deg); + } + } + } + + .faves { + border-start-start-radius: var(--rounded-corner-small); + border-end-start-radius: var(--rounded-corner-small); + padding-inline-start: 0.625rem; + color: var(--yellow-fg); + + .icon { + --icon: var(--icon-star); + } + + &:hover { + background-color: var(--yellow-bg-1); + + .icon { + transform: rotate(72deg); + } + } + } + + .reactions { + display: flex; + // flex-wrap: nowrap; + gap: 0.25rem; + margin: -0.5rem -1rem; + padding: 0.5rem 1rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + + .reaction { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.25rem; + cursor: zoom-in; + border: rpx(1) solid var(--bg-muted-2); + border-radius: rpx(19); + + &:hover .emoji { + transform: scale(2); + } + + .emoji { + cursor: unset; + } + + span { + font-variant-numeric: tabular-nums; + } + } + } + } + } + } +} diff --git a/modules/amezing/sass/components/_deanchor.scss b/modules/amezing/sass/components/_deanchor.scss new file mode 100644 index 0000000..1fd7c89 --- /dev/null +++ b/modules/amezing/sass/components/_deanchor.scss @@ -0,0 +1,20 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.deanchor { + display: inline; + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--fg-muted-2); + background-color: var(--bg-muted-1); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + transition: + background-color var(--transition), + color var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-1); + } +} diff --git a/modules/amezing/sass/components/_emoji.scss b/modules/amezing/sass/components/_emoji.scss new file mode 100644 index 0000000..93570ae --- /dev/null +++ b/modules/amezing/sass/components/_emoji.scss @@ -0,0 +1,21 @@ +.emoji { + display: inline-block; + vertical-align: bottom; + transition: transform var(--transition-bezier); + cursor: zoom-in; + margin: 0; + box-shadow: none; + border-radius: 0; + background-color: transparent; + width: 1.5rem; + height: 1.5rem; + + &:hover { + transform: scale(2); + } + + &.big { + width: 3rem; + height: 3rem; + } +} diff --git a/modules/amezing/sass/components/_footnotes.scss b/modules/amezing/sass/components/_footnotes.scss new file mode 100644 index 0000000..c083897 --- /dev/null +++ b/modules/amezing/sass/components/_footnotes.scss @@ -0,0 +1,62 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.footnotes-list p { + margin-block: 0; +} + +.footnote-reference { + display: inline-flex; + vertical-align: baseline; + transition: + background-color var(--transition), + outline-offset calc(var(--transition-long) * 3), + transform var(--transition-bezier); + margin-inline-start: 0.25rem; + outline-offset: calc(var(--content-width) / 2) !important; + border-radius: 0.6875em; + background-color: var(--fg-muted-1); + // width: 1.375em; + line-height: 1.375em; + + &:hover { + background-color: var(--accent-color); + + a::after { + mask-position: center rpx(18); + } + } + + &:active { + transform: var(--active); + } + + &:target { + animation: none !important; + outline-offset: rpx(-3) !important; + background-color: var(--accent-color); + } + + a { + flex: 1; + border-radius: inherit; + padding-inline: rpx(9) rpx(6); + color: var(--bg-color); + font-size: var(--font-size-small); + font-variant-numeric: tabular-nums; + text-align: center; + text-decoration: none; + + &::after { + @include icon(pseudo); + --icon: var(--icon-caret-down); + display: inline-block; + vertical-align: rpx(-7); + mask-position: center; + mask-size: 1em; + transition: mask-position var(--transition); + margin-inline-start: 0.25rem; + height: rpx(22); + } + } +} diff --git a/modules/amezing/sass/components/_fuse.scss b/modules/amezing/sass/components/_fuse.scss new file mode 100644 index 0000000..494ef74 --- /dev/null +++ b/modules/amezing/sass/components/_fuse.scss @@ -0,0 +1,50 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.fuse { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 30rem; + + .fuse-input { + width: 100%; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--bg-muted-2); + background-color: var(--bg-muted-1); + color: var(--fg-color); + font-size: var(--font-size-medium); + transition: + border-color var(--transition), + box-shadow var(--transition); + + &:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px var(--accent-bg-1), 0 0 0 4px var(--accent-color); + } + } + + .fuse-results { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .fuse-result { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--bg-muted-1); + text-decoration: none; + color: var(--fg-color); + transition: + background-color var(--transition), + padding-left var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + padding-left: 1rem; + } + } + } +} diff --git a/modules/amezing/sass/components/_giallo.scss b/modules/amezing/sass/components/_giallo.scss new file mode 100644 index 0000000..0c228b4 --- /dev/null +++ b/modules/amezing/sass/components/_giallo.scss @@ -0,0 +1,16 @@ +.giallo:has(.giallo-ln) { + padding-inline: 0; +} + +.giallo-l { + display: inline-block; + width: 100%; +} + +.giallo-ln { + display: inline-block; + margin-right: 1ch; + min-width: 3ch; + user-select: none; + text-align: right; +} diff --git a/modules/amezing/sass/components/_icon.scss b/modules/amezing/sass/components/_icon.scss new file mode 100644 index 0000000..6768a12 --- /dev/null +++ b/modules/amezing/sass/components/_icon.scss @@ -0,0 +1,9 @@ +@use "../citrine/abstracts/mixins" as *; + +.icon { + @include icon; + + &::before { + @include icon(pseudo); + } +} diff --git a/modules/amezing/sass/components/_iframe.scss b/modules/amezing/sass/components/_iframe.scss new file mode 100644 index 0000000..550da8d --- /dev/null +++ b/modules/amezing/sass/components/_iframe.scss @@ -0,0 +1,6 @@ +iframe { + &.vimeo-embed, + &.youtube-embed { + aspect-ratio: 16 / 9; + } +} diff --git a/modules/amezing/sass/components/_index.scss b/modules/amezing/sass/components/_index.scss new file mode 100644 index 0000000..61889d1 --- /dev/null +++ b/modules/amezing/sass/components/_index.scss @@ -0,0 +1,25 @@ +@use "alerts"; +@use "article-details"; +@use "article-list"; +@use "article-nav"; +@use "audio"; +@use "banner"; +@use "comments"; +@use "emoji"; +@use "footnotes"; +@use "giallo"; +@use "icon"; +@use "iframe"; +@use "marquee"; +@use "not-found"; +@use "pre-container"; +@use "search-modal"; +@use "skip-to-content"; +@use "statements"; +@use "tag-chips"; +@use "zola-anchor"; +@use "zola-error"; +@use "audio-button"; +@use "deanchor"; +@use "fuse"; +@use "search-fuse"; \ No newline at end of file diff --git a/modules/amezing/sass/components/_marquee.scss b/modules/amezing/sass/components/_marquee.scss new file mode 100644 index 0000000..e995c33 --- /dev/null +++ b/modules/amezing/sass/components/_marquee.scss @@ -0,0 +1,35 @@ +.marquee { + margin-inline: -1rem; + padding-inline: 1rem; + overflow: hidden; + + &.overshoot-row > * { + animation: marquee-scroll + calc((var(--text-width) + var(--container-width)) / 40 * 1s) linear + infinite; + + @keyframes marquee-scroll { + 0%, + 10% { + transform: translateX(0); + } + + 45%, + 55% { + transform: translateX( + calc((var(--container-width) - var(--text-width)) * 1px) + ); + } + + 90%, + 100% { + transform: translateX(0); + } + } + } + + & > * { + display: inline-block; + white-space: nowrap; + } +} diff --git a/modules/amezing/sass/components/_not-found.scss b/modules/amezing/sass/components/_not-found.scss new file mode 100644 index 0000000..34b92bf --- /dev/null +++ b/modules/amezing/sass/components/_not-found.scss @@ -0,0 +1,11 @@ +picture#not-found { + display: block; + + img { + width: 8rem; + } +} + +img#not-found { + width: 8rem; +} diff --git a/modules/amezing/sass/components/_pre-container.scss b/modules/amezing/sass/components/_pre-container.scss new file mode 100644 index 0000000..d011d77 --- /dev/null +++ b/modules/amezing/sass/components/_pre-container.scss @@ -0,0 +1,141 @@ +@use "../citrine/abstracts/functions" as *; + +.pre-container { + position: relative; + margin: 1rem 0 1rem; + box-shadow: var(--shadow); + border-radius: var(--rounded-corner); + + &::after { + position: absolute; + inset: 0; + box-shadow: var(--edge-highlight); + border-radius: inherit; + pointer-events: none; + content: ""; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + transition: border-block-end-color var(--transition); + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0; + border-block-end: rpx(1) solid transparent; + background-image: linear-gradient( + to right, + transparent 50%, + var(--bg-muted-neutral-1) 75%, + transparent 100% + ); + background-size: 200%; + padding: 0.25rem; + + &:not(.z-l-code), + &:not(.z-d-code) { + background-color: var(--bg-muted-1); + color: var(--accent-color); + + button:hover { + background-color: var(--bg-muted-1); + } + + &.active { + border-block-end-color: var(--bg-muted-1); + + button { + background-color: var(--bg-muted-1); + } + } + } + + span { + margin-inline-start: 0.75rem; + font-weight: var(--font-weight-semi-bold); + } + + button { + position: relative; + transition: + box-shadow var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + cursor: pointer; + box-shadow: none; + border-radius: var(--rounded-corner-small); + background-color: transparent; + padding: 0.5rem; + color: inherit; + line-height: 0; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-neutral-1); + } + + &:active { + transform: var(--active); + } + + &:disabled { + cursor: not-allowed; + + &:active { + transform: none; + } + } + + .icon { + transition: + opacity var(--transition-longer), + transform var(--transition-bezier-longer); + font-size: 1rem; + + &.copy { + --icon: var(--icon-copy); + transform-origin: bottom left; + } + + &.check { + --icon: var(--icon-check-square-offset); + position: absolute; + transform: scale(0.5); + transform-origin: top right; + opacity: 0; + inset: 0.5rem; + } + } + } + + &.active { + animation: active-shimmer var(--transition-long); + border-block-end-color: var(--bg-muted-neutral-1); + + button { + opacity: 1; + + .icon.copy { + transform: scale(0.5); + opacity: 0; + } + + .icon.check { + transform: none; + opacity: 1; + } + } + + @keyframes active-shimmer { + to { + background-position-x: -200%; + } + } + } + } + + pre { + margin: 0; + box-shadow: none; + border-radius: 0 0 var(--rounded-corner) var(--rounded-corner); + } +} diff --git a/modules/amezing/sass/components/_search-fuse.scss b/modules/amezing/sass/components/_search-fuse.scss new file mode 100644 index 0000000..20bb5c2 --- /dev/null +++ b/modules/amezing/sass/components/_search-fuse.scss @@ -0,0 +1,73 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.search-fuse { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; + margin: 0 auto; + + &-input-wrap { + position: relative; + display: flex; + align-items: center; + } + + &-icon { + position: absolute; + left: 0.75rem; + color: var(--fg-muted-2); + pointer-events: none; + transition: + color var(--transition), + opacity var(--transition-bezier); + opacity: 0.7; + } + + &-input { + width: 100%; + padding: 0.75rem 1rem 0.75rem 2.5rem; + border-radius: 0.5rem; + border: 1px solid var(--bg-muted-2); + background-color: var(--bg-muted-1); + color: var(--fg-color); + font-size: var(--font-size-medium); + transition: + border-color var(--transition), + box-shadow var(--transition), + background-color var(--transition); + + &:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px var(--accent-bg-1), 0 0 0 4px var(--accent-color); + } + + &::placeholder { + color: var(--fg-muted-2); + } + } + + &-results { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .search-fuse-result { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--bg-muted-1); + text-decoration: none; + color: var(--fg-color); + transition: + background-color var(--transition), + padding-left var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + padding-left: 1rem; + } + } + } +} diff --git a/modules/amezing/sass/components/_search-modal.scss b/modules/amezing/sass/components/_search-modal.scss new file mode 100644 index 0000000..2174293 --- /dev/null +++ b/modules/amezing/sass/components/_search-modal.scss @@ -0,0 +1,128 @@ +#search-modal { + display: flex; + position: fixed; + flex-direction: column; + align-items: center; + visibility: hidden; + opacity: 0; + z-index: 9999; + transition: + visibility var(--transition), + opacity var(--transition); + inset: 0; + background-color: var(--shade-color); + padding-block-start: 15vh; + width: 100%; + height: 100vh; + + &.active { + visibility: visible; + opacity: 1; + + #search-modal-content { + transform: none; + } + } + + &-content { + display: flex; + flex-direction: column; + gap: 1rem; + transform: var(--hover); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + transition: + border-radius var(--transition), + padding var(--transition), + max-width var(--transition), + transform var(--transition-bezier-longer); + margin: -0.5rem; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 2.25rem 2.25rem 1.75rem 1.75rem; + background-color: var(--glass-bg-1); + padding: 1rem; + width: calc(100% - 2rem); + max-width: calc(var(--content-width) / 1.5); + overflow: hidden; + + &:has(#search-results:empty) { + border-radius: 1.75rem; + padding: 0.5rem; + max-width: calc(var(--content-width) / 2); + } + + & > div { + position: relative; + } + } + + #search-input { + flex-shrink: 0; + box-shadow: var(--edge-highlight); + border-radius: 9999px; + padding: 0 1rem; + width: 100%; + height: 2.5rem; + + &:placeholder-shown + div .icon { + transform: none; + opacity: var(--disabled-opacity); + pointer-events: all; + } + + & + div .icon { + position: absolute; + transform: translateX(0.5rem); + opacity: 0; + transition: + opacity var(--transition), + transform var(--transition-bezier); + cursor: help; + inset-block-start: 0.625rem; + inset-inline-end: 1rem; + pointer-events: none; + color: var(--fg-muted-2); + font-size: 1.25rem; + } + } + + #search-results { + display: none; + flex: 1; + flex-direction: column; + gap: 0.5rem; + margin: -1rem; + padding: 1rem; + max-height: 50vh; + overflow: auto; + scrollbar-width: thin; + + .item { + display: inline-flex; + flex-direction: column; + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + + a { + text-decoration: none; + + &::after { + content: " →"; + } + } + + span { + margin-block-start: 0.25rem; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding-block-start: 0.25rem; + color: var(--fg-muted-2); + + strong { + color: var(--fg-color); + } + } + } + } +} diff --git a/modules/amezing/sass/components/_skip-to-content.scss b/modules/amezing/sass/components/_skip-to-content.scss new file mode 100644 index 0000000..1e217af --- /dev/null +++ b/modules/amezing/sass/components/_skip-to-content.scss @@ -0,0 +1,23 @@ +#skip-to-content { + position: absolute; + transform: translateX(-50%) translateY(calc(-100% - 1rem)); + z-index: 9999; + transition: + backdrop-filter var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + inset-inline-start: 50vw; + border-radius: 9999px; + background-color: var(--glass-bg-1); + padding: 0.375rem 0.75rem; + width: max-content; + text-decoration: none; + + &:focus-visible, + &:focus { + -webkit-backdrop-filter: var(--blur); + transform: translateX(-50%) translateY(1rem); + backdrop-filter: var(--blur); + box-shadow: var(--edge-highlight), var(--shadow); + } +} diff --git a/modules/amezing/sass/components/_statements.scss b/modules/amezing/sass/components/_statements.scss new file mode 100644 index 0000000..f76e587 --- /dev/null +++ b/modules/amezing/sass/components/_statements.scss @@ -0,0 +1,61 @@ +@use "../citrine/abstracts/functions" as *; + +.statement-container { + margin: 1rem 0; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + padding: 1rem; + + :last-child { + margin-block-end: 0; + } + + & > :nth-child(2) { + margin-block-start: 0.5rem; + } + + .title { + font-weight: var(--font-weight-semi-bold); + + .icon { + vertical-align: rpx(-2); + margin-inline-end: 0.25rem; + } + } + + &.archive { + --accent-color: var(--purple-fg); + --accent-highlight: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--disabled-opacity) + ); + background-color: var(--purple-bg-1); + color: var(--purple-fg); + } + + &.disclaimer { + --accent-color: var(--red-fg); + --accent-highlight: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--disabled-opacity) + ); + background-color: var(--red-bg-1); + color: var(--red-fg); + } + + &.trigger { + --accent-color: var(--yellow-fg); + --accent-highlight: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--disabled-opacity) + ); + background-color: var(--yellow-bg-1); + color: var(--yellow-fg); + } +} diff --git a/modules/amezing/sass/components/_tag-chips.scss b/modules/amezing/sass/components/_tag-chips.scss new file mode 100644 index 0000000..df38adc --- /dev/null +++ b/modules/amezing/sass/components/_tag-chips.scss @@ -0,0 +1,59 @@ +@use "../citrine/abstracts/functions" as *; + +#tag-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding-inline-start: 0; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding-inline-start: 0; + } + + a { + display: flex; + transition: + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: 1rem; + background-color: var(--bg-muted-1); + padding: 0.25rem; + padding-inline-start: 0.75rem; + color: var(--fg-muted-1); + font-size: var(--font-size-small); + line-height: 1.5rem; + text-decoration: none; + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &::before { + content: "#"; + } + + .counter { + transition: inherit; + margin-inline-start: 0.375rem; + box-shadow: var(--edge-highlight); + border-radius: inherit; + background-color: inherit; + aspect-ratio: 1 / 1; + height: 1.5rem; + line-height: 1.5rem; + font-variant-numeric: tabular-nums; + text-align: center; + } + } +} diff --git a/modules/amezing/sass/components/_zola-anchor.scss b/modules/amezing/sass/components/_zola-anchor.scss new file mode 100644 index 0000000..f942600 --- /dev/null +++ b/modules/amezing/sass/components/_zola-anchor.scss @@ -0,0 +1,50 @@ +h1, +h2, +h3, +h4, +h5, +h6 { + &:hover .zola-anchor { + transform: none; + opacity: 1; + } + + &:has(.zola-anchor) { + position: relative; + } +} + +.zola-anchor { + position: absolute; + transform: translateX(0.25em); + opacity: 0; + transition: + color var(--transition), + opacity var(--transition), + transform var(--transition-bezier); + inset-inline-start: -1.25em; + padding-inline-end: 0.25em; + color: var(--fg-muted-1); + line-height: 1; + + @media only screen and (max-width: 720px) { + display: none; + } + + &:hover { + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &:focus-visible { + opacity: 1; + } +} + +:target .zola-anchor { + transform: none; + opacity: var(--disabled-opacity); +} diff --git a/modules/amezing/sass/components/_zola-error.scss b/modules/amezing/sass/components/_zola-error.scss new file mode 100644 index 0000000..433f6cc --- /dev/null +++ b/modules/amezing/sass/components/_zola-error.scss @@ -0,0 +1,55 @@ +// Based on https://github.com/welpo/tabi/blob/68c35b02f8c09e21ecff48f61bad6da3d96ce337/sass/parts/_zola-error.scss + +body > div:last-child:has(div:last-child[style]:not([class]):not([id])) { + z-index: 9999 !important; + background-color: var(--shade-color) !important; +} + +body > div:last-child > div:last-child[style]:not([class]):not([id]) { + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: calc(var(--rounded-corner) + 1rem) !important; + background-color: hsla( + var(--red-h), + calc(var(--red-s) - var(--red-s) * 0.7), + calc(var(--red-l) + (100% - var(--red-l)) * 0.9), + var(--dim-opacity) + ) !important; + padding: 1rem !important; + width: fit-content; + max-width: calc(100% - 2rem); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + filter: none !important; + + @media (prefers-color-scheme: dark) { + & { + background-color: hsla( + var(--red-h), + calc(var(--red-s) - var(--red-s) * 0.6), + calc(var(--red-l) - var(--red-l) * 0.8), + var(--dim-opacity) + ) !important; + } + } +} + +body + > div:last-child + > div:last-child[style]:not([class]):not([id]) + > p[style]:first-child { + margin-block-end: 1rem !important; + color: var(--red-fg) !important; + font-weight: var(--font-weight-semi-bold) !important; + font-size: var(--font-size-large) !important; +} + +body + > div:last-child + > div:last-child[style]:not([class]):not([id]) + > pre[style]:last-child { + border-radius: var(--rounded-corner) !important; + background-color: var(--red-bg-1) !important; + padding: 0.75rem 1rem !important; + color: var(--red-fg) !important; + font-weight: var(--font-weight-normal) !important; +} diff --git a/modules/amezing/sass/fonts.scss b/modules/amezing/sass/fonts.scss new file mode 100644 index 0000000..a148e87 --- /dev/null +++ b/modules/amezing/sass/fonts.scss @@ -0,0 +1,52 @@ +$commissioner-version: "1.012.1"; +$commit-mono-version: "1.143"; + +:root { + --font-sans-serif: "Commissioner"; + --font-monospace: "Commit Mono"; +} + +// Commissioner +@font-face { + font-style: normal; + font-weight: 100 900; + src: url("/fonts/commissioner.woff2?v=#{$commissioner-version}") + format("woff2"); + font-family: "Commissioner"; + font-display: swap; + // Fix vertical alignment, + // See https://github.com/kosbarts/Commissioner/issues/55 + ascent-override: 96.3%; + descent-override: 25%; +} + +@font-face { + font-style: italic; + font-weight: 100 900; + src: url("/fonts/commissioner.woff2?v=#{$commissioner-version}") + format("woff2"); + font-family: "Commissioner"; + font-display: swap; + font-variation-settings: "slnt" -12; + // Ditto + ascent-override: 96.3%; + descent-override: 25%; +} + +// Commit Mono +@font-face { + font-style: normal; + font-weight: 100 900; + src: url("/fonts/commit-mono.woff2?v=#{$commit-mono-version}") format("woff2"); + font-family: "Commit Mono"; + font-display: swap; +} + +@font-face { + font-style: italic; + font-weight: 100 900; + src: url("/fonts/commit-mono.woff2?v=#{$commit-mono-version}") format("woff2"); + font-family: "Commit Mono"; + font-display: swap; + font-variation-settings: "ital" 1; +} diff --git a/modules/amezing/sass/layout/_footer.scss b/modules/amezing/sass/layout/_footer.scss new file mode 100644 index 0000000..25a09fe --- /dev/null +++ b/modules/amezing/sass/layout/_footer.scss @@ -0,0 +1,140 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-footer { + --padding: 1rem; + display: flex; + grid-area: footer; + gap: 1rem; + margin-block: 3.25rem 1rem; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding: 1rem; + width: 100%; + min-width: 0; + max-width: calc(var(--content-width) + var(--padding) * 2); + + @media only screen and (max-width: 480px) { + flex-direction: column-reverse; + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + + p { + margin: 0; + } + + &:nth-of-type(1) { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + &:nth-of-type(2) { + justify-content: space-between; + align-items: flex-end; + text-align: end; + + @media only screen and (max-width: 480px) { + align-items: normal; + text-align: start; + } + } + } + + a.external::after { + margin-inline-start: 0.25rem; + } + + .link { + @include chip; + display: inline-block; + line-height: 1.5rem; + + &:hover { + background-color: var(--bg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + // IMO it's pretty cute + .webrings { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-block: 0; + padding-inline-start: 0; + list-style: none; + } + + .webring { + display: flex; + gap: 0.25rem; + margin-block: 0; + padding-inline-start: 0; + + .random, + .previous, + .next { + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + padding: 0.25rem; + font-size: 1rem; + } + } + + .timestamp { + color: var(--fg-muted-1); + font-size: var(--font-size-xx-small); + font-family: var(--font-monospace); + text-transform: uppercase; + } + + .socials { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin: 0; + padding-inline-start: 0; + list-style: none; + + @media only screen and (max-width: 480px) { + justify-content: normal; + } + + li { + margin-block: 0; + padding-inline-start: 0; + } + + a { + display: flex; + transition: + color var(--transition), + transform var(--transition-bezier); + height: fit-content; + color: var(--fg-muted-1); + + &:hover { + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + svg { + fill: currentColor; + width: 1.5rem; + height: 1.5rem; + } + } +} diff --git a/modules/amezing/sass/layout/_full-bleed.scss b/modules/amezing/sass/layout/_full-bleed.scss new file mode 100644 index 0000000..85e9fd3 --- /dev/null +++ b/modules/amezing/sass/layout/_full-bleed.scss @@ -0,0 +1,13 @@ +.full-bleed { + transform: translateX(-50%); + margin-inline-start: 50%; + width: calc(100vw - var(--sidebar-width)); + + :root[dir*="rtl"] & { + transform: translateX(50%); + } + + @media only screen and (max-width: 960px) { + width: calc(100vw - 3.5rem); + } +} diff --git a/modules/amezing/sass/layout/_grid.scss b/modules/amezing/sass/layout/_grid.scss new file mode 100644 index 0000000..6a8b9db --- /dev/null +++ b/modules/amezing/sass/layout/_grid.scss @@ -0,0 +1,16 @@ +body { + display: grid; + grid-template-rows: auto auto 1fr auto; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-areas: + "sidebar header" + "sidebar banner" + "sidebar main " + "sidebar footer"; + justify-items: center; + transition: grid-template-columns var(--transition); + + @media only screen and (max-width: 960px) { + grid-template-columns: 0 1fr; + } +} diff --git a/modules/amezing/sass/layout/_header.scss b/modules/amezing/sass/layout/_header.scss new file mode 100644 index 0000000..0ecab63 --- /dev/null +++ b/modules/amezing/sass/layout/_header.scss @@ -0,0 +1,347 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-header { + display: flex; + position: sticky; + grid-area: header; + // height: 3rem; + justify-content: space-between; + // flex-wrap: nowrap; + align-items: center; + gap: 0.25rem; + z-index: 2; + // height: 3.75rem; + inset-block-start: 0; + // padding: 0.5rem 0.75rem; + padding: 0.5rem; + width: 100%; + min-width: 0; + + &::before, + &::after { + position: absolute; + inset: 0; + @include easing-mask(bottom); + inset-block-end: -100%; + pointer-events: none; + content: ""; + } + + &::before { + z-index: -2; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + } + + &::after { + z-index: -1; + background-color: var(--bg-color); + } + + .title { + margin-inline: 0.75rem; + min-width: 0; + overflow: hidden; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + line-height: 2rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .buttons { + display: flex; + flex-wrap: nowrap; + gap: 0.25rem; + margin: 0; + // margin-inline-start: auto; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 1.125rem; + background-color: var(--glass-bg-1); + padding: 0.25rem; + list-style: none; + + li { + flex: 0; + margin-block: 0; + padding-inline-start: 0; + } + + & > li { + height: fit-content; + line-height: 0; + } + + button, + details { + box-shadow: none; + background-color: transparent; + line-height: inherit; + + .icon { + margin-inline-end: 0; + box-shadow: none; + background-color: transparent; + padding: 0; + } + } + + button, + summary { + border-radius: 50%; + padding: 0.375rem; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + details { + box-shadow: none; + border-radius: 0; + padding: 0; + } + + button { + font-size: var(--font-size-medium); + } + + summary { + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + } + + &::before { + display: none; + } + } + + details { + display: block; + position: relative; + margin-block-end: 0; + + &::details-content { + block-size: auto; + } + + &[open] { + &::details-content { + block-size: auto; + } + + summary { + color: var(--fg-muted-1); + + &:hover { + color: var(--fg-muted-2); + } + } + + @supports not (block-size: calc-size(auto, size)) { + summary ~ * { + animation: none; + } + } + } + } + } +} + +.buttons .dropdown { + display: flex; + position: absolute; + flex-direction: column; + gap: 0.25rem; + transform-origin: top right; + z-index: 1; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + animation: expand-up-right var(--transition-bezier); + inset-block-start: calc(100% + 0.75rem); + inset-inline-end: -0.25rem; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: calc(var(--rounded-corner) + 0.25rem); + background-color: var(--glass-bg-1); + padding: 0.25rem; + width: max-content; + min-width: 8rem; + list-style: none; + + :root[dir*="rtl"] & { + transform-origin: top left; + animation: expand-up-left var(--transition-bezier); + } + + li { + flex: 1; + } + + a { + box-shadow: none; + border-radius: var(--rounded-corner); + background-color: transparent; + padding: 0.375rem 0.75rem; + width: 100%; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1) !important; + } + } +} + +#site-sidebar-controls { + display: flex; + flex-shrink: 0; + gap: 0.25rem; + transition: + width var(--transition), + visibility var(--transition); + margin-block: 0; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 1.125rem; + background-color: var(--glass-bg-1); + padding: 0.25rem; + height: fit-content; + line-height: 0; + list-style: none; + + @media only screen and (max-width: 960px) { + width: 2.25rem; + + &:has(#expand-sidebar:checked) { + width: 6.25rem; + + #site-sidebar-modes { + transform: none; + visibility: visible; + opacity: 1; + } + } + } + + ul { + padding-inline-start: 0; + list-style: none; + } + + li { + display: inline-block; + margin-block: 0; + padding-inline-start: 0; + + label { + display: inline-block; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + cursor: pointer; + border-radius: 50%; + padding: 0.375rem; + height: fit-content; + color: var(--fg-muted-1); + line-height: 0; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + input[type="radio"] { + & + label { + position: relative; + z-index: 1; + + &::before { + @include gradient-fill; + position: absolute; + opacity: 0; + z-index: -1; + transition: + opacity var(--transition), + box-shadow var(--transition), + background-color var(--transition); + inset: 0; + border-radius: inherit; + content: ""; + } + } + + &:checked + label { + color: var(--contrast-color); + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &:hover::before { + box-shadow: var(--edge-highlight); + background-color: var(--accent-color); + } + + &::before { + opacity: 1; + } + } + } + + input[type="radio"], + input[type="checkbox"] { + &:focus-visible + label { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + } + + @supports not selector(:focus-visible) { + &:focus + label { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + } + } + } + } + + #expand-sidebar-li { + display: none; + + @media only screen and (max-width: 960px) { + display: inline-block; + } + } +} + +#site-sidebar-modes { + display: flex; + gap: 0.25rem; + transform-origin: left; + transition: + visibility var(--transition), + opacity var(--transition), + transform var(--transition); + + @media only screen and (max-width: 960px) { + transform: translateX(-2.25rem); + visibility: hidden; + opacity: 0; + } +} diff --git a/modules/amezing/sass/layout/_index.scss b/modules/amezing/sass/layout/_index.scss new file mode 100644 index 0000000..b6da0b7 --- /dev/null +++ b/modules/amezing/sass/layout/_index.scss @@ -0,0 +1,6 @@ +@use "footer"; +@use "full-bleed"; +@use "grid"; +@use "header"; +@use "main-content"; +@use "sidebar"; \ No newline at end of file diff --git a/modules/amezing/sass/layout/_main-content.scss b/modules/amezing/sass/layout/_main-content.scss new file mode 100644 index 0000000..1c7c3b7 --- /dev/null +++ b/modules/amezing/sass/layout/_main-content.scss @@ -0,0 +1,59 @@ +#main-content { + --padding: 1rem; + grid-area: main; + transform-origin: 50% 50vh; + animation: fade-in-zoom var(--transition-bezier-longer); + margin-block-start: 3.25rem; + padding-inline: 1rem; + width: 100%; + min-width: 0; + max-width: calc(var(--content-width) + var(--padding) * 2); + + @supports (transform-origin: 50% 50dvh) { + transform-origin: 50% 50dvh; + } + + & > :first-child { + margin-block-start: 0; + } + + & > :last-child { + margin-block-end: 0; + } + + @keyframes fade-in-zoom { + from { + transform: scale(0.95); + opacity: 0; + } + } +} + +// Don't play animation when jumping to section +:target ~ #main-content { + animation-name: none; +} + +// Loading animation when going up in the sidebar +#↑:target ~ #main-content, +#up:target ~ #main-content { + animation-name: slide-in-up-longer; +} + +// Loading animation when going down in the sidebar +#↓:target ~ #main-content, +#down:target ~ #main-content { + animation-name: slide-in-down-longer; +} + +// Loading animation when going to the previous article +#←:target ~ #main-content, +#left:target ~ #main-content { + animation-name: slide-in-left-longer; +} + +// Loading animation when going to the next article +#→:target ~ #main-content, +#right:target ~ #main-content { + animation-name: slide-in-right-longer; +} diff --git a/modules/amezing/sass/layout/_sidebar.scss b/modules/amezing/sass/layout/_sidebar.scss new file mode 100644 index 0000000..6f7655e --- /dev/null +++ b/modules/amezing/sass/layout/_sidebar.scss @@ -0,0 +1,775 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-sidebar { + display: flex; + position: sticky; + grid-area: sidebar; + flex-direction: column; + z-index: 3; + transition-duration: var(--transition); + transition-property: + visibility, transform, inset-block-start, box-shadow, border-inline-end, + border-start-end-radius, border-end-end-radius, height; + inset-block-start: 0; + inset-inline-start: 0; + border-inline-end: rpx(1) solid var(--bg-muted-2); + width: var(--sidebar-width); + height: 100vh; + + @supports (height: 100dvh) { + height: 100dvh; + } + + @media only screen and (max-width: 960px) { + transform: translateX(-100%); + visibility: hidden; + inset-block-start: 3.25rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-inline-end: 0 solid transparent; + border-start-end-radius: var(--rounded-corner); + border-end-end-radius: var(--rounded-corner); + height: calc(100vh - 3.75rem); + + :root[dir*="rtl"] & { + transform: translateX(100%); + } + + @supports (height: 100dvh) { + height: calc(100dvh - 3.75rem); + } + + #site-header:has(#expand-sidebar:checked) ~ & { + transform: none; + visibility: visible; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + background-color: var(--glass-bg-1); + } + } + + &-content { + display: flex; + position: relative; + flex: 1; + overflow: hidden; + } +} + +#site-sidebar-header { + display: flex; + align-items: center; + transform-origin: rpx(22) 50%; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + margin: 1rem; + margin-block-end: 0; + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 0.75rem; + min-width: 0; + color: var(--fg-muted-1); + text-decoration: none; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + + .icon { + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + img { + margin: 0; + } + + img, + .icon { + margin-inline-end: 0.5rem; + border-radius: 50%; + } + + img { + width: 2.25rem; + height: 2.25rem; + } + + .icon { + transition: background-color var(--transition); + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + } + + div { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + line-height: 1.25; + + span:nth-of-type(1) { + overflow: hidden; + font-size: var(--font-size-small); + text-overflow: ellipsis; + white-space: nowrap; + } + + span:nth-of-type(2) { + font-weight: var(--font-weight-normal); + font-size: var(--font-size-x-small); + } + } +} + +#sidebar-nav, +#sidebar-meta { + display: flex; + flex: 1; + flex-direction: column; + transition: + visibility var(--transition), + opacity var(--transition), + filter var(--transition), + transform var(--transition-bezier); + padding: 1rem; + width: var(--sidebar-width); + min-width: 100%; + overflow: hidden auto; + scrollbar-width: thin; +} + +#site-header:has(#site-sidebar-modes #mode-meta:checked) + #site-sidebar { + #sidebar-nav { + transform: perspective(var(--content-width)) rotateY(15deg); + opacity: var(--disabled-opacity); + filter: blur(0.25rem); + } + + #sidebar-meta { + transform: perspective(var(--content-width)); + visibility: visible; + opacity: 1; + filter: none; + } +} + +#sidebar-nav { + transform: perspective(var(--content-width)); + transform-origin: left; + + :root[dir*="rtl"] & { + transform-origin: right; + } + + a { + display: flex; + flex: 1; + align-items: center; + transform-origin: rpx(22) 50%; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 0.75rem; + overflow: hidden; + color: var(--fg-muted-1); + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + + .icon { + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + &[aria-current="page"] { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--accent-color); + + .icon { + @include gradient-fill; + text-shadow: none; + } + + &:hover { + background-color: var(--bg-muted-2); + + .icon { + @include gradient-fill(hover); + } + } + } + + &.new .icon { + position: relative; + + &::after { + position: absolute; + inset-block-start: rpx(1); + inset-inline-end: rpx(1); + border-radius: 50%; + background-color: var(--accent-color); + width: 0.75rem; + height: 0.75rem; + content: ""; + } + } + } + + .icon { + transition: + background-color var(--transition), + color var(--transition), + box-shadow var(--transition); + margin-inline-end: 0.5rem; + box-shadow: var(--edge-highlight); + border-radius: 50%; + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + + &.fallback { + width: 1.25rem; + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-medium); + line-height: 1.25rem; + font-family: var(--font-monospace); + text-align: center; + + &::before { + content: none; + } + } + } + + details { + flex: 1; + margin-block-end: 0; + box-shadow: none; + border-radius: 0; + background-color: transparent; + padding: 0; + + &[open] { + summary { + transform: none; + opacity: 1; + box-shadow: none; + background-color: transparent; + + &::before { + transform: none; + } + + @supports not (block-size: calc-size(auto, size)) { + & ~ * { + animation: none; + } + } + } + } + + summary { + display: flex; + position: relative; + align-items: center; + border-radius: 0; + padding: 0.75rem 0; + color: var(--fg-muted-1); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + line-height: var(--font-size-small); + white-space: nowrap; + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &:active { + transform: none; + } + + &::before { + position: absolute; + transform: rotate(-90deg); + transition: transform var(--transition-bezier); + margin-inline-end: 0; + inset-block: 0.5rem; + inset-inline-end: 0; + font-size: 1rem; + } + + .line { + flex: 1; + margin-inline: 0.5rem 1.5rem; + background-color: var(--bg-muted-2); + height: rpx(1); + } + } + + ul { + margin-block: 0; + } + } + + ul { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-block: auto; + padding-inline-start: 0; + list-style: none; + } + + li { + display: flex; + transition: width var(--transition); + margin-block: 0; + padding-inline-start: 0; + width: 100%; + } +} + +#sidebar-meta { + position: absolute; + transform: perspective(var(--content-width)) rotateY(-30deg); + transform-origin: left; + visibility: hidden; + opacity: 0; + filter: blur(0.25rem); + inset: 0; + + :root[dir*="rtl"] & { + transform-origin: right; + } + + &:has(> div:empty) { + .status-page { + display: flex; + } + + & > div { + display: none; + } + } + + & > div { + display: flex; + flex-direction: column; + gap: 1rem; + margin-block: auto; + } + + .card { + background-color: var(--glass-bg-1); + } + + .while-writing { + display: inherit; + position: relative; + flex-direction: inherit; + gap: inherit; + margin: calc(-0.25rem - rpx(1)); + margin-block-start: 1rem; + border: rpx(1) solid var(--bg-muted-2); + border-block-start: none; + border-end-start-radius: calc(var(--rounded-corner) + 0.25rem + #{rpx(1)}); + border-end-end-radius: calc(var(--rounded-corner) + 0.25rem + #{rpx(1)}); + padding: 0.25rem; + + .card-title { + display: flex; + position: absolute; + gap: 0.25rem; + inset-block-start: calc(var(--rounded-corner-small) * -1); + inset-inline: rpx(-1); + height: var(--rounded-corner-small); + + .line { + border: rpx(1) solid var(--bg-muted-2); + border-block-end: none; + } + + .line:nth-of-type(1) { + border-inline-end: none; + border-start-start-radius: var(--rounded-corner); + width: var(--rounded-corner); + } + + .line:nth-of-type(2) { + flex: 1; + border-inline-start: none; + border-start-end-radius: var(--rounded-corner); + } + + .title { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + line-height: 0; + } + } + } + + .weather { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 1.25rem; + line-height: 1; + + &.cloudy { + animation: perlin 12s linear infinite; + background-image: url("/weather/perlin.png"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/perlin-dark.png"); + } + } + + &.showers { + animation: rain 3s linear infinite; + background-image: url("/weather/rain.svg"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/rain-dark.svg"); + } + } + + &.snowing { + animation: snow 12s linear infinite; + background-image: url("/weather/snow.svg"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/snow-dark.svg"); + } + } + + div { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .title { + color: inherit; + font-size: var(--font-size-medium); + } + + .icon { + box-shadow: var(--edge-highlight); + border-radius: 50%; + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + } + + .condition { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + @keyframes rain { + to { + background-position: 256px 320px; + } + } + + @keyframes perlin { + to { + background-position: -256px -64px; + } + } + + @keyframes snow { + to { + background-position: 256px 512px; + } + } + } + + .music { + display: flex; + position: relative; + flex-direction: column; + gap: 0.5rem; + border-radius: 1rem; + + &::after { + position: absolute; + z-index: 1; + inset: 0; + box-shadow: var(--edge-highlight); + border-radius: inherit; + max-height: 5rem; + pointer-events: none; + content: ""; + } + + .carousel { + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + display: flex; + margin-block: 0; + box-shadow: var(--shadow); + border-radius: inherit; + background-color: var(--glass-bg-1); + padding-inline-start: 0; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; + list-style: none; + + li { + display: flex; + position: relative; + flex-shrink: 0; + gap: 1rem; + margin-block: 0; + outline: none; + padding: 0.5rem; + width: 100%; + overflow: hidden; + scroll-snap-align: start; + + &.has-cover { + animation: cover-alternate 60s ease-in-out infinite; + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: 2000%; + + @keyframes cover-alternate { + // X 100% + // Y ┌─────────────┐ + // │ 1,9 │ + // │ 8 2 │ + // │ 3,7 │ + // │ 6 4 │ + // │ 5 │ + // 100% └─────────────┘ + + from { + background-position: 50% 0%; + } + + 12.5% { + background-position: 100% 25%; + } + + 25% { + background-position: 50% 50%; + } + + 37.5% { + background-position: 100% 75%; + } + + 50% { + background-position: 50% 100%; + } + + 62.5% { + background-position: 0% 75%; + } + + 75% { + background-position: 50% 50%; + } + + 87.5% { + background-position: 0% 25%; + } + + to { + background-position: 50% 0%; + } + } + + .music-type { + color: var(--fg-muted-neutral-1); + } + + .music-artist { + color: var(--fg-muted-neutral-2); + } + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; + gap: 0.25rem; + margin-inline-end: 0.5rem; + margin-block: 0.25rem; + min-width: 0; + min-width: 0; + line-height: 1; + } + + a { + position: absolute; + z-index: 1; + inset: 0; + border-radius: inherit; + } + } + } + + .music-type { + color: var(--fg-muted-1); + font-size: var(--font-size-xx-small); + } + + .music-artist { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + .title { + color: var(--fg-color); + font-size: var(--font-size-medium); + } + + img { + margin: 0; + border-radius: var(--rounded-corner-small); + } + + &-paginator { + display: flex; + justify-content: center; + gap: 0.25rem; + margin-inline: 1rem; + margin-block: 0; + padding-inline-start: 0; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding-inline-start: 0; + height: 0.75rem; + } + + a { + transition: + width var(--transition), + height var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + margin-block: auto; + border-radius: 0.375rem; + background-color: var(--fg-muted-1); + width: 1.5rem; + height: 0.375rem; + + &:hover { + background-color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &.active { + background-color: var(--fg-color); + width: 0.75rem; + height: 0.75rem; + } + } + } + } + + .toc { + display: flex; + flex-direction: column; + max-height: calc(var(--content-width) / 3); + + @media only screen and (max-width: 720px) { + max-height: calc(var(--content-width) / 2); + } + + .title { + margin-block-end: 1rem; + } + + .overshoot { + flex: 1; + margin: -1rem; + padding: 1rem; + overflow: auto; + scrollbar-width: thin; + + ul { + margin-block: 0; + padding-inline-start: 1rem; + color: var(--fg-muted-1); + font-size: var(--font-size-small); + } + + li { + margin-block: 0; + padding-inline-start: 0; + + &::marker { + color: inherit; + } + } + + a { + color: inherit; + text-decoration: none; + } + } + } + + .status-page { + display: none; + margin-block: auto; + text-align: center; + + .title, + p { + color: var(--fg-muted-2); + } + + .icon { + margin-inline: auto; + color: var(--fg-muted-1); + font-size: 3rem; + } + + p { + margin-block: 0; + } + } +} diff --git a/modules/amezing/sass/mods/_compact-sidebar.scss b/modules/amezing/sass/mods/_compact-sidebar.scss new file mode 100644 index 0000000..3169af3 --- /dev/null +++ b/modules/amezing/sass/mods/_compact-sidebar.scss @@ -0,0 +1,44 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-sidebar-header { + transform-origin: rpx(18) 50%; + border-radius: var(--rounded-corner); + + img, + .icon { + border-radius: var(--rounded-corner-small); + } + + img { + width: 2rem; + height: 2rem; + } + + .icon { + padding: 0.375rem; + } + + div { + gap: 0; + line-height: 1.125; + } +} + +#sidebar-nav { + a { + transform-origin: rpx(18) 50%; + border-radius: var(--rounded-corner); + padding: 0.25rem; + + &.new .icon::after { + inset-block-start: rpx(-2); + inset-inline-end: rpx(-2); + } + } + + .icon { + border-radius: var(--rounded-corner-small); + padding: 0.375rem; + } +} diff --git a/modules/amezing/sass/mods/_flat.scss b/modules/amezing/sass/mods/_flat.scss new file mode 100644 index 0000000..92e04da --- /dev/null +++ b/modules/amezing/sass/mods/_flat.scss @@ -0,0 +1,27 @@ +@use "../citrine/abstracts/functions" as *; + +:root { + --color-opacity-1: 0.15; + --color-opacity-2: 0.25; + --edge-highlight: 0 0 0 transparent; + --shadow: 0 0 0 transparent; + --shadow-raised: 0 0 0 transparent; + --shadow-glass: 0 0 0 transparent; + --shadow-glow: 0 0 0 transparent; +} + +.buttons .suggested, +#site-nav a.active .icon { + box-shadow: none; + background-image: none; + background-color: var(--accent-color); + text-shadow: none; +} + +.buttons .suggested:hover { + opacity: var(--dim-opacity); +} + +.crt { + outline: rpx(1) solid var(--bg-muted-2); +} diff --git a/modules/amezing/sass/mods/_pill-tags.scss b/modules/amezing/sass/mods/_pill-tags.scss new file mode 100644 index 0000000..37985cf --- /dev/null +++ b/modules/amezing/sass/mods/_pill-tags.scss @@ -0,0 +1,27 @@ +@use "../citrine/abstracts/mixins" as *; + +.article-details .tags { + gap: 0.25rem; + margin-block-end: 0 !important; + + a { + @include chip; + display: flex; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + line-height: 1.5rem; + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &::before { + content: none; + } + } +} diff --git a/modules/amezing/sass/mods/_skeuomorphic.scss b/modules/amezing/sass/mods/_skeuomorphic.scss new file mode 100644 index 0000000..7b382ea --- /dev/null +++ b/modules/amezing/sass/mods/_skeuomorphic.scss @@ -0,0 +1,30 @@ +// UNFINISHED, AND LIKELY WILL NEVER BE +@use "../citrine/abstracts/functions" as *; + +#site-nav { + a, + label, + button, + summary { + outline: rpx(1) solid var(--bg-muted-2); + box-shadow: var(--edge-highlight), var(--shadow); + background-image: linear-gradient( + to bottom, + var(--bg-muted-2), + var(--bg-muted-1) 50%, + transparent 50%, + var(--bg-muted-2) + ); + + .icon { + background-image: conic-gradient( + var(--bg-muted-2), + var(--bg-muted-1) 33%, + transparent, + var(--bg-muted-2) 66%, + transparent, + var(--bg-muted-2) + ); + } + } +} diff --git a/modules/amezing/sass/style.scss b/modules/amezing/sass/style.scss new file mode 100644 index 0000000..2288edd --- /dev/null +++ b/modules/amezing/sass/style.scss @@ -0,0 +1,40 @@ +/* +* Ametrine v0.1.0-alpha (https://ametrine.daudix.one) +* Copyright 2025 David "Daudix" Lapshin and contributors +* Licensed under MIT (https://codeberg.org/daudix/ametrine/raw/branch/main/LICENSE.txt) +*/ + +@use "layout/footer"; +@use "layout/full-bleed"; +@use "layout/grid"; +@use "layout/header"; +@use "layout/main-content"; +@use "layout/sidebar"; + +@use "components/alerts"; +@use "components/article-details"; +@use "components/article-list"; +@use "components/article-nav"; +@use "components/audio"; +@use "components/banner"; +@use "components/comments"; +@use "components/emoji"; +@use "components/footnotes"; +@use "components/giallo"; +@use "components/icon"; +@use "components/iframe"; +@use "components/marquee"; +@use "components/not-found"; +@use "components/pre-container"; +@use "components/search-modal"; +@use "components/skip-to-content"; +@use "components/statements"; +@use "components/tag-chips"; +@use "components/zola-anchor"; +@use "components/zola-error"; + +// Mods +// @use "mods/compact-sidebar"; +// @use "mods/flat"; +// @use "mods/pill-tags"; +// @use "mods/skeuomorphic"; // UNFINISHED, AND LIKELY WILL NEVER BE diff --git a/modules/amezing/scripts/audio-button.js b/modules/amezing/scripts/audio-button.js new file mode 100644 index 0000000..e76d3fc --- /dev/null +++ b/modules/amezing/scripts/audio-button.js @@ -0,0 +1,9 @@ +const audioButtons = document.querySelectorAll(".audio"); + +audioButtons.forEach((button) => { + button.addEventListener("click", (event) => playAudio(button.dataset.audio)); +}); + +function playAudio(url) { + new Audio(url).play(); +} diff --git a/modules/amezing/scripts/closable.js b/modules/amezing/scripts/closable.js new file mode 100644 index 0000000..a28774c --- /dev/null +++ b/modules/amezing/scripts/closable.js @@ -0,0 +1,34 @@ +const closable = document.querySelectorAll("details.closable"); + +closable.forEach((detail) => { + detail.addEventListener("toggle", () => { + if (detail.open) setTargetDetail(detail); + }); +}); + +function setTargetDetail(targetDetail) { + closable.forEach((detail) => { + if (detail !== targetDetail) { + detail.open = false; + } + }); +} + +document.addEventListener("click", function (event) { + const isClickInsideDetail = [...closable].some((detail) => + detail.contains(event.target), + ); + + if (!isClickInsideDetail) { + closable.forEach((detail) => { + detail.open = false; + }); + } +}); + +const expandSidebar = document.getElementById("expand-sidebar"); +const mainContent = document.getElementById("main-content"); + +mainContent.addEventListener("click", () => { + if (expandSidebar.checked) expandSidebar.checked = false; +}); diff --git a/modules/amezing/scripts/deanchor.js b/modules/amezing/scripts/deanchor.js new file mode 100644 index 0000000..94fbee5 --- /dev/null +++ b/modules/amezing/scripts/deanchor.js @@ -0,0 +1,10 @@ +const main = document.querySelector("#main-content"); + +main.addEventListener("animationend", () => { + const hash = decodeURIComponent(location.hash); + if ( + ["#↑", "#↓", "#←", "#→", "#up", "#down", "#left", "#right"].includes(hash) + ) { + history.replaceState(null, "", " "); + } +}); diff --git a/modules/amezing/scripts/fuse.js b/modules/amezing/scripts/fuse.js new file mode 100644 index 0000000..4357f5a --- /dev/null +++ b/modules/amezing/scripts/fuse.js @@ -0,0 +1,9 @@ +/** + * Fuse.js v7.2.0 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2026 Kiro Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +var e,t;e=this,t=function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&void 0!==arguments[0]?arguments[0]:{},n=t.getFn,i=void 0===n?j.getFn:n,u=t.fieldNormWeight,o=void 0===u?j.fieldNormWeight:u;r(this,e),this.norm=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:3,n=new Map,r=Math.pow(10,t);return{get:function(t){var i=t.match(L).length;if(n.has(i))return n.get(i);var u=1/Math.pow(i,.5*e),o=parseFloat(Math.round(u*r)/r);return n.set(i,o),o},clear:function(){n.clear()}}}(o,3),this.getFn=i,this.isCreated=!1,this.setIndexRecords()}return u(e,[{key:"setSources",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.docs=e}},{key:"setIndexRecords",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.records=e}},{key:"setKeys",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.keys=t,this._keysMap={},t.forEach((function(t,n){e._keysMap[t.id]=n}))}},{key:"create",value:function(){var e=this;!this.isCreated&&this.docs.length&&(this.isCreated=!0,A(this.docs[0])?this.docs.forEach((function(t,n){e._addString(t,n)})):this.docs.forEach((function(t,n){e._addObject(t,n)})),this.norm.clear())}},{key:"add",value:function(e){var t=this.size();A(e)?this._addString(e,t):this._addObject(e,t)}},{key:"removeAt",value:function(e){this.records.splice(e,1);for(var t=e,n=this.size();t=0;t-=1)this.records.splice(e[t],1);for(var n=0,r=this.records.length;n2&&void 0!==arguments[2]?arguments[2]:{},r=n.getFn,i=void 0===r?j.getFn:r,u=n.fieldNormWeight,o=void 0===u?j.fieldNormWeight:u,c=new I({getFn:i,fieldNormWeight:o});return c.setKeys(e.map(_)),c.setSources(t),c.create(),c}var $=32;function R(e,t,n){var r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},i=r.location,u=void 0===i?j.location:i,o=r.distance,c=void 0===o?j.distance:o,s=r.threshold,a=void 0===s?j.threshold:s,h=r.findAllMatches,l=void 0===h?j.findAllMatches:h,f=r.minMatchCharLength,d=void 0===f?j.minMatchCharLength:f,v=r.includeMatches,g=void 0===v?j.includeMatches:v,y=r.ignoreLocation,p=void 0===y?j.ignoreLocation:y;if(t.length>$)throw new Error("Pattern length exceeds max of ".concat($,"."));for(var A,m=t.length,C=e.length,k=Math.max(0,Math.min(u,C)),F=a,b=k,M=function(e,t){var n=e/m;if(p)return n;var r=Math.abs(k-t);return c?n+r/c:r?1:n},E=d>1||g,D=E?Array(C):[];(A=e.indexOf(t,b))>-1;){var B=M(0,A);if(F=Math.min(B,F),b=A+m,E)for(var x=0;x=R;z-=1){var T=z-1,K=n[e[T]];if(E&&(D[T]=+!!K),W[z]=(W[z+1]<<1|1)&K,L&&(W[z]|=(_[z+1]|_[z])<<1|1|_[z+1]),W[z]&O&&(S=M(L,T))<=F){if(F=S,(b=T)<=k)break;R=Math.max(1,2*k-b)}}if(M(L+1,k)>F)break;_=W}var U={isMatch:b>=0,score:Math.max(.001,S)};if(E){var q=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:j.minMatchCharLength,n=[],r=-1,i=-1,u=0,o=e.length;u=t&&n.push([r,i]),r=-1)}return e[u-1]&&u-r>=t&&n.push([r,u-1]),n}(D,d);q.length?g&&(U.indices=q):U.isMatch=!1}return U}function P(e){for(var t={},n=0,r=e.length;n1&&void 0!==arguments[1]?arguments[1]:{},u=i.location,o=void 0===u?j.location:u,c=i.threshold,s=void 0===c?j.threshold:c,a=i.distance,h=void 0===a?j.distance:a,l=i.includeMatches,f=void 0===l?j.includeMatches:l,d=i.findAllMatches,v=void 0===d?j.findAllMatches:d,g=i.minMatchCharLength,y=void 0===g?j.minMatchCharLength:g,p=i.isCaseSensitive,A=void 0===p?j.isCaseSensitive:p,m=i.ignoreDiacritics,C=void 0===m?j.ignoreDiacritics:m,k=i.ignoreLocation,F=void 0===k?j.ignoreLocation:k;if(r(this,e),this.options={location:o,threshold:s,distance:h,includeMatches:f,findAllMatches:v,minMatchCharLength:y,isCaseSensitive:A,ignoreDiacritics:C,ignoreLocation:F},t=A?t:t.toLowerCase(),t=C?W(t):t,this.pattern=t,this.chunks=[],this.pattern.length){var b=function(e,t){n.chunks.push({pattern:e,alphabet:P(e),startIndex:t})},M=this.pattern.length;if(M>$){for(var E=0,D=M%$,B=M-D;E1&&void 0!==arguments[1]?arguments[1]:{},o=u.location,c=void 0===o?j.location:o,s=u.threshold,a=void 0===s?j.threshold:s,h=u.distance,l=void 0===h?j.distance:h,f=u.includeMatches,d=void 0===f?j.includeMatches:f,v=u.findAllMatches,g=void 0===v?j.findAllMatches:v,y=u.minMatchCharLength,p=void 0===y?j.minMatchCharLength:y,A=u.isCaseSensitive,m=void 0===A?j.isCaseSensitive:A,C=u.ignoreDiacritics,k=void 0===C?j.ignoreDiacritics:C,F=u.ignoreLocation,b=void 0===F?j.ignoreLocation:F;return r(this,n),(i=t.call(this,e))._bitapSearch=new z(e,{location:c,threshold:a,distance:l,includeMatches:d,findAllMatches:g,minMatchCharLength:p,isCaseSensitive:m,ignoreDiacritics:k,ignoreLocation:b}),i}return u(n,[{key:"search",value:function(e){return this._bitapSearch.searchIn(e)}}],[{key:"type",get:function(){return"fuzzy"}},{key:"multiRegex",get:function(){return/^"(.*)"$/}},{key:"singleRegex",get:function(){return/^(.*)$/}}]),n}(T),X=function(e){c(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return u(n,[{key:"search",value:function(e){for(var t,n=0,r=[],i=this.pattern.length;(t=e.indexOf(this.pattern,n))>-1;)n=t+i,r.push([t,n-1]);var u=!!r.length;return{isMatch:u,score:u?0:1,indices:r}}}],[{key:"type",get:function(){return"include"}},{key:"multiRegex",get:function(){return/^'"(.*)"$/}},{key:"singleRegex",get:function(){return/^'(.*)$/}}]),n}(T),Y=[U,X,J,Q,G,V,q,H],Z=Y.length,ee=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/,te=new Set([H.type,X.type]),ne=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=n.isCaseSensitive,u=void 0===i?j.isCaseSensitive:i,o=n.ignoreDiacritics,c=void 0===o?j.ignoreDiacritics:o,s=n.includeMatches,a=void 0===s?j.includeMatches:s,h=n.minMatchCharLength,l=void 0===h?j.minMatchCharLength:h,f=n.ignoreLocation,d=void 0===f?j.ignoreLocation:f,v=n.findAllMatches,g=void 0===v?j.findAllMatches:v,y=n.location,p=void 0===y?j.location:y,A=n.threshold,m=void 0===A?j.threshold:A,C=n.distance,k=void 0===C?j.distance:C;r(this,e),this.query=null,this.options={isCaseSensitive:u,ignoreDiacritics:c,includeMatches:a,minMatchCharLength:l,findAllMatches:g,ignoreLocation:d,location:p,threshold:m,distance:k},t=u?t:t.toLowerCase(),t=c?W(t):t,this.pattern=t,this.query=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.split("|").map((function(e){for(var n=e.trim().split(ee).filter((function(e){return e&&!!e.trim()})),r=[],i=0,u=n.length;i2&&void 0!==arguments[2]?arguments[2]:{}).auto,r=void 0===n||n;return he(e)||(e=le(e)),function e(n){var i=Object.keys(n),u=function(e){return!!e[se]}(n);if(!u&&i.length>1&&!he(n))return e(le(n));if(function(e){return!g(e)&&k(e)&&!he(e)}(n)){var o=u?n[se]:i[0],c=u?n[ae]:n[o];if(!A(c))throw new Error(function(e){return"Invalid value for key ".concat(e)}(o));var s={keyId:w(o),pattern:c};return r&&(s.searcher=ue(c,t)),s}var a={children:[],operator:i[0]};return i.forEach((function(t){var r=n[t];g(r)&&r.forEach((function(t){a.children.push(e(t))}))})),a}(e)}function de(e,t){var n=t.ignoreFieldNorm,r=void 0===n?j.ignoreFieldNorm:n,i=1;e.matches.forEach((function(e){var t=e.key,n=e.norm,u=e.score,o=t?t.weight:null;i*=Math.pow(0===u&&o?Number.EPSILON:u,(o||1)*(r?1:n))})),e.score=i}var ve=function(){function e(t){r(this,e),this.limit=t,this.heap=[]}return u(e,[{key:"size",get:function(){return this.heap.length}},{key:"shouldInsert",value:function(e){return this.size0;){var n=e-1>>1;if(t[e].score<=t[n].score)break;var r=t[e];t[e]=t[n],t[n]=r,e=n}}},{key:"_sinkDown",value:function(e){var t=this.heap,n=t.length,r=e;do{var i=2*(e=r)+1,u=2*e+2;if(it[r].score&&(r=i),ut[r].score&&(r=u),r!==e){var o=t[e];t[e]=t[r],t[r]=o}}while(r!==e)}}]),e}();function ge(e,t){var n=e.matches;t.matches=[],F(n)&&n.forEach((function(e){if(F(e.indices)&&e.indices.length){var n={indices:e.indices,value:e.value};e.key&&(n.key=e.key.src),e.idx>-1&&(n.refIndex=e.idx),t.matches.push(n)}}))}function ye(e,t){t.score=e.score}var pe=function(){function e(n){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},u=arguments.length>2?arguments[2]:void 0;r(this,e),this.options=t(t({},j),i),this.options.useExtendedSearch,this._keyStore=new x(this.options.keys),this.setCollection(n,u),this._lastQuery=null,this._lastSearcher=null}return u(e,[{key:"_getSearcher",value:function(e){if(this._lastQuery===e)return this._lastSearcher;var t=ue(e,this.options);return this._lastQuery=e,this._lastSearcher=t,t}},{key:"setCollection",value:function(e,t){if(this._docs=e,t&&!(t instanceof I))throw new Error("Incorrect 'index' type");this._myIndex=t||N(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}},{key:"add",value:function(e){F(e)&&(this._docs.push(e),this._myIndex.add(e))}},{key:"remove",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!1},t=[],n=[],r=0,i=this._docs.length;r=0;u-=1)this._docs.splice(n[u],1);this._myIndex.removeAll(n)}return t}},{key:"removeAt",value:function(e){this._docs.splice(e,1),this._myIndex.removeAt(e)}},{key:"getIndex",value:function(){return this._myIndex}},{key:"search",value:function(e){var t,n=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).limit,r=void 0===n?-1:n,i=this.options,u=i.includeMatches,o=i.includeScore,c=i.shouldSort,s=i.sortFn,a=i.ignoreFieldNorm;if(m(r)&&r>0&&A(e)){var h=new ve(r);A(this._docs[0])?this._searchStringList(e,{heap:h,ignoreFieldNorm:a}):this._searchObjectList(e,{heap:h,ignoreFieldNorm:a}),t=h.extractSorted(s)}else(function(e,t){var n=t.ignoreFieldNorm,r=void 0===n?j.ignoreFieldNorm:n;e.forEach((function(e){de(e,{ignoreFieldNorm:r})}))})(t=A(e)?A(this._docs[0])?this._searchStringList(e):this._searchObjectList(e):this._searchLogical(e),{ignoreFieldNorm:a}),c&&t.sort(s),m(r)&&r>-1&&(t=t.slice(0,r));return function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.includeMatches,i=void 0===r?j.includeMatches:r,u=n.includeScore,o=void 0===u?j.includeScore:u,c=[];return i&&c.push(ge),o&&c.push(ye),e.map((function(e){var n=e.idx,r={item:t[n],refIndex:n};return c.length&&c.forEach((function(t){t(e,r)})),r}))}(t,this._docs,{includeMatches:u,includeScore:o})}},{key:"_searchStringList",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.heap,r=t.ignoreFieldNorm,i=this._getSearcher(e),u=this._myIndex.records,o=n?null:[];return u.forEach((function(e){var t=e.v,u=e.i,c=e.n;if(F(t)){var s=i.searchIn(t),a=s.isMatch,h=s.score,l=s.indices;if(a){var f={item:t,idx:u,matches:[{score:h,value:t,norm:c,indices:l}]};n?(de(f,{ignoreFieldNorm:r}),n.shouldInsert(f.score)&&n.insert(f)):o.push(f)}}})),o}},{key:"_searchLogical",value:function(e){var t=this,n=fe(e,this.options),r=function e(n,r,i){if(!n.children){var u=n.keyId,o=n.searcher,c=t._findMatches({key:t._keyStore.get(u),value:t._myIndex.getValueForItemAtKeyId(r,u),searcher:o});return c&&c.length?[{idx:i,item:r,matches:c}]:[]}for(var s=[],a=0,h=n.children.length;a1&&void 0!==arguments[1]?arguments[1]:{},r=n.heap,i=n.ignoreFieldNorm,u=this._getSearcher(e),o=this._myIndex,c=o.keys,s=o.records,a=r?null:[];return s.forEach((function(e){var n=e.$,o=e.i;if(F(n)){var s=[];if(c.forEach((function(e,r){s.push.apply(s,f(t._findMatches({key:e,value:n[r],searcher:u})))})),s.length){var h={idx:o,item:n,matches:s};r?(de(h,{ignoreFieldNorm:i}),r.shouldInsert(h.score)&&r.insert(h)):a.push(h)}}})),a}},{key:"_findMatches",value:function(e){var t=e.key,n=e.value,r=e.searcher;if(!F(n))return[];var i=[];if(g(n))n.forEach((function(e){var n=e.v,u=e.i,o=e.n;if(F(n)){var c=r.searchIn(n),s=c.isMatch,a=c.score,h=c.indices;s&&i.push({score:a,key:t,value:n,idx:u,norm:o,indices:h})}}));else{var u=n.v,o=n.n,c=r.searchIn(u),s=c.isMatch,a=c.score,h=c.indices;s&&i.push({score:a,key:t,value:u,norm:o,indices:h})}return i}}]),e}();return pe.version="7.2.0",pe.createIndex=N,pe.parseIndex=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.getFn,r=void 0===n?j.getFn:n,i=t.fieldNormWeight,u=void 0===i?j.fieldNormWeight:i,o=e.keys,c=e.records,s=new I({getFn:r,fieldNormWeight:u});return s.setKeys(o),s.setIndexRecords(c),s},pe.config=j,ie(ne),pe.use=function(){for(var e=arguments.length,t=new Array(e),n=0;n { + const text = container.firstElementChild; + if (!text) return; + + const style = getComputedStyle(container); + const paddingLeft = parseFloat(style.paddingLeft); + const paddingRight = parseFloat(style.paddingRight); + + const containerInnerWidth = + container.clientWidth - paddingLeft - paddingRight; + + const textWidth = text.offsetWidth; + + text.style.setProperty("--text-width", `${textWidth}`); + text.style.setProperty("--container-width", `${containerInnerWidth}`); + + container.classList.toggle( + "overshoot-row", + textWidth > containerInnerWidth, + ); + }); +} + +window.addEventListener("load", updateMarquees); +window.addEventListener("resize", updateMarquees); diff --git a/modules/amezing/scripts/paginator.js b/modules/amezing/scripts/paginator.js new file mode 100644 index 0000000..334f85e --- /dev/null +++ b/modules/amezing/scripts/paginator.js @@ -0,0 +1,23 @@ +const carousel = document.querySelector(".music .carousel"); +const items = document.querySelectorAll(".music .carousel > li"); +const links = document.querySelectorAll(".music-paginator li a"); + +const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + links.forEach((a) => a.classList.remove("active")); + const active = document.querySelector( + `.music-paginator a[href="#${entry.target.id}"]`, + ); + active?.classList.add("active"); + } + }); + }, + { + root: carousel, + threshold: 0.6, + }, +); + +items.forEach((item) => observer.observe(item)); diff --git a/modules/amezing/scripts/search-fuse.js b/modules/amezing/scripts/search-fuse.js new file mode 100644 index 0000000..df56e7d --- /dev/null +++ b/modules/amezing/scripts/search-fuse.js @@ -0,0 +1,178 @@ +// Based on https://codeberg.org/daudix/duckquill/issues/101#issuecomment-2377169 +let searchSetup = false; +let fuse; + +async function initIndex() { + if (searchSetup) return; + + let data; + if (typeof window.searchIndex !== "undefined") { + data = window.searchIndex; + } else { + const response = await fetch( + `/search_index.${document.documentElement.lang}.json`, + ); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + data = await response.json(); + } + + const options = { + includeScore: false, + includeMatches: true, + ignoreLocation: true, + threshold: 0.15, + keys: [ + { name: "title", weight: 3 }, + { name: "description", weight: 2 }, + { name: "body", weight: 1 }, + ], + }; + + fuse = new Fuse(data, options); + searchSetup = true; + + console.log("Search index initialized successfully"); +} + +function debounce(actual_fn, wait) { + let timeoutId; + + return (...args) => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + actual_fn(...args); + }, wait); + }; +} + +function initSearch() { + const searchModal = document.getElementById("search-modal"); // Full-screen modal + const searchModalContent = document.getElementById("search-modal-content"); // Actual modal box + const searchInput = document.getElementById("search-input"); // Search input + const searchResults = document.getElementById("search-results"); // Search results + const searchButton = document.getElementById("search"); // Search button + const MAX_ITEMS = 10; + const MAX_RESULTS = 4; + + let currentTerm = ""; + + // Open search modal when clicking the search button + if (searchButton) { + searchButton.addEventListener("click", function () { + searchModal.classList.add("active"); + initIndex(); + searchModal.addEventListener( + "transitionend", + function handler() { + searchInput.focus(); + searchModal.removeEventListener("transitionend", handler); + }, + { once: true }, + ); + }); + } + + // Open search modal on "/" key press + window.addEventListener("keydown", (event) => { + if ( + event.key === "/" && + document.activeElement.tagName !== "INPUT" && + document.activeElement.tagName !== "TEXTAREA" + ) { + event.preventDefault(); + initIndex(); + searchModal.classList.add("active"); + searchModal.addEventListener( + "transitionend", + function handler() { + searchInput.focus(); + searchModal.removeEventListener("transitionend", handler); + }, + { once: true }, + ); + } + }); + + // Close search modal on Escape key + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + searchModal.classList.remove("active"); + } + }); + + // Close search modal when clicking outside search-modal-content + searchModal.addEventListener("click", function (e) { + if (!searchModalContent.contains(e.target)) { + searchModal.classList.remove("active"); + } + }); + + // Prevent clicks inside modal content from closing it + searchModalContent.addEventListener("click", function (e) { + e.stopPropagation(); // Stops event from reaching searchModal click handler + }); + + // Search input event + searchInput.addEventListener( + "keyup", + debounce(async function () { + var term = searchInput.value.trim(); + if (term === currentTerm) return; + + searchResults.style.display = term === "" ? "none" : "flex"; + searchResults.innerHTML = ""; // Clear previous results + currentTerm = term; + if (term === "") return; + + await initIndex(); + var results = fuse.search(term, { limit: MAX_ITEMS }); + if (results.length === 0) { + searchResults.style.display = "none"; + return; + } + + // Insert formatted search result items + for (const result of results) { + searchResults.innerHTML += makeTeaser(result, term); + } + }, 150), + ); + + function makeTeaser(result, searchVal) { + const TEASER_SIZE = 20; + let output = `
${result.item.title}`; + + for (const match of result.matches) { + if (match.key === "title") continue; + + const indices = match.indices.sort((a, b) => Math.abs(a[1] - a[0] - searchVal.length) - Math.abs(b[1] - b[0] - searchVal.length)).slice(0, MAX_RESULTS); + const value = match.value; + + for (const ind of indices) { + const start = Math.max(0, ind[0] - TEASER_SIZE); + const end = Math.min(value.length - 1, ind[1] + TEASER_SIZE); + output += "" + + value.substring(start, ind[0]) + + `${value.substring(ind[0], ind[1] + 1)}` + + value.substring(ind[1] + 1, end) + + ""; + } + + if (match.indices.length > MAX_RESULTS) { + const moreMatchesText = document.getElementById("more-matches-text").textContent; + output += `${moreMatchesText}`.replace("$MATCHES", `+${match.indices.length - MAX_RESULTS}`); + } + } + return output + "
"; + } +} + +if ( + document.readyState === "complete" || + (document.readyState !== "loading" && !document.documentElement.doScroll) +) { + initSearch(); +} else { + document.addEventListener("DOMContentLoaded", initSearch); +} diff --git a/modules/amezing/styles/ametrine.css b/modules/amezing/styles/ametrine.css new file mode 100644 index 0000000..2a20007 --- /dev/null +++ b/modules/amezing/styles/ametrine.css @@ -0,0 +1 @@ +:root{--font-sans-serif: "Commissioner";--font-monospace: "Commit Mono"}@font-face{font-style:normal;font-weight:100 900;src:url("/fonts/commissioner.woff2?v=1.012.1") format("woff2");font-family:"Commissioner";font-display:swap;ascent-override:96.3%;descent-override:25%}@font-face{font-style:italic;font-weight:100 900;src:url("/fonts/commissioner.woff2?v=1.012.1") format("woff2");font-family:"Commissioner";font-display:swap;font-variation-settings:"slnt" -12;ascent-override:96.3%;descent-override:25%}@font-face{font-style:normal;font-weight:100 900;src:url("/fonts/commit-mono.woff2?v=1.143") format("woff2");font-family:"Commit Mono";font-display:swap}@font-face{font-style:italic;font-weight:100 900;src:url("/fonts/commit-mono.woff2?v=1.143") format("woff2");font-family:"Commit Mono";font-display:swap;font-variation-settings:"ital" 1}:root{--bg-overlay: linear-gradient( rgba(255, 255, 255, var(--dim-opacity)), rgba(255, 255, 255, var(--dim-opacity)) );--fg-color: #29242a;--fg-contrast: #fcfcfa;--red-fg: hsl(342.0779, 72%, 58%);--red-h: 342.0779;--red-s: 72%;--red-l: 58%;--orange-fg: hsl(15.771428, 74%, 54%);--orange-h: 15.771428;--orange-s: 74%;--orange-l: 54%;--yellow-fg: hsl(34.639175, 91%, 42%);--yellow-h: 34.639175;--yellow-s: 91%;--yellow-l: 42%;--green-fg: hsl(153.7815, 61%, 38%);--green-h: 153.7815;--green-s: 61%;--green-l: 38%;--blue-fg: hsl(192, 71%, 38%);--blue-h: 192;--blue-s: 71%;--blue-l: 38%;--purple-fg: hsl(254.11766, 44%, 55%);--purple-h: 254.11766;--purple-s: 44%;--purple-l: 55%;--red-bg-1: hsla( var(--red-h), var(--red-s), var(--red-l), var(--color-opacity-1) );--red-bg-2: hsla( var(--red-h), var(--red-s), var(--red-l), var(--color-opacity-2) );--orange-bg-1: hsla( var(--orange-h), var(--orange-s), var(--orange-l), var(--color-opacity-1) );--orange-bg-2: hsla( var(--orange-h), var(--orange-s), var(--orange-l), var(--color-opacity-2) );--yellow-bg-1: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--color-opacity-1) );--yellow-bg-2: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--color-opacity-2) );--green-bg-1: hsla( var(--green-h), var(--green-s), var(--green-l), var(--color-opacity-1) );--green-bg-2: hsla( var(--green-h), var(--green-s), var(--green-l), var(--color-opacity-2) );--blue-bg-1: hsla( var(--blue-h), var(--blue-s), var(--blue-l), var(--color-opacity-1) );--blue-bg-2: hsla( var(--blue-h), var(--blue-s), var(--blue-l), var(--color-opacity-2) );--purple-bg-1: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--color-opacity-1) );--purple-bg-2: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--color-opacity-2) );--red-fg-muted-1: hsla( var(--red-h), var(--red-s), calc(var(--red-l) - var(--red-l) * 0.85), var(--fg-muted-1-opacity) );--red-fg-muted-2: hsla( var(--red-h), var(--red-s), calc(var(--red-l) - var(--red-l) * 0.85), var(--fg-muted-2-opacity) );--orange-fg-muted-1: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) - var(--orange-l) * 0.85), var(--fg-muted-1-opacity) );--orange-fg-muted-2: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) - var(--orange-l) * 0.85), var(--fg-muted-2-opacity) );--yellow-fg-muted-1: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) - var(--yellow-l) * 0.85), var(--fg-muted-1-opacity) );--yellow-fg-muted-2: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) - var(--yellow-l) * 0.85), var(--fg-muted-2-opacity) );--green-fg-muted-1: hsla( var(--green-h), var(--green-s), calc(var(--green-l) - var(--green-l) * 0.85), var(--fg-muted-1-opacity) );--green-fg-muted-2: hsla( var(--green-h), var(--green-s), calc(var(--green-l) - var(--green-l) * 0.85), var(--fg-muted-2-opacity) );--blue-fg-muted-1: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) - var(--blue-l) * 0.85), var(--fg-muted-1-opacity) );--blue-fg-muted-2: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) - var(--blue-l) * 0.85), var(--fg-muted-2-opacity) );--purple-fg-muted-1: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) - var(--purple-l) * 0.85), var(--fg-muted-1-opacity) );--purple-fg-muted-2: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) - var(--purple-l) * 0.85), var(--fg-muted-2-opacity) );--shade-color: rgba(0, 0, 0, 0.07);--bg-muted-neutral-1: rgba(0, 0, 0, var(--color-opacity-1));--bg-muted-neutral-2: rgba(0, 0, 0, var(--color-opacity-2));--fg-muted-neutral-1: rgba(0, 0, 0, var(--fg-muted-1-opacity));--fg-muted-neutral-2: rgba(0, 0, 0, var(--fg-muted-2-opacity));--scanlines-opacity: 0.4;--fg-muted-1-opacity: 0.5;--fg-muted-2-opacity: 0.6;--edge-highlight: inset 0 -0.0625rem 0 rgba(0, 0, 0, var(--color-opacity-1));--content-width: 45rem;--sidebar-width: clamp( calc(var(--content-width) / 3), 20vw, calc(var(--content-width) / 2) );--rounded-corner: 0.75rem;--rounded-corner-small: 0.5rem;--blur: blur(0.75rem) saturate(180%);--printed: contrast(2) brightness(0.75) grayscale(1);--drop-shadow: drop-shadow(0 0.08rem 0.1rem rgba(0, 0, 0, 0.35)) drop-shadow(0 0.4rem 0.7rem rgba(0, 0, 0, 0.18));--font-system-ui: system-ui, sans-serif;--font-ui-monospace: ui-monospace, monospace;--font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-size-xx-small: 0.625rem;--font-size-x-small: 0.75rem;--font-size-small: 0.875rem;--font-size-medium: 1rem;--font-size-large: 1.25rem;--font-size-x-large: 1.5rem;--font-size-xx-large: 2rem;--font-size-xxx-large: 3rem;--font-weight-thin: 100;--font-weight-extra-light: 200;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semi-bold: 600;--font-weight-bold: 700;--font-weight-extra-bold: 800;--font-weight-black: 900;--color-opacity-1: 0.1;--color-opacity-2: 0.2;--dim-opacity: 0.8;--disabled-opacity: 0.4;--shadow: 0 0 0 0.0625rem rgba(0, 0, 0, 0.03), 0 0.0625rem 0.1875rem 0.0625rem rgba(0, 0, 0, 0.07), 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.03);--shadow-inset: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.03), inset 0 0.0625rem 0.1875rem 0.0625rem rgba(0, 0, 0, 0.07), inset 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.03);--shadow-raised: 0 0 0 0.0625rem rgba(0, 0, 0, 0.06), 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.14), 0 0.25rem 0.75rem 0.25rem rgba(0, 0, 0, 0.06);--shadow-glass: 0 0.375rem 0.75rem -0.375rem rgba(0, 0, 0, 0.1);--hover: scale(1.1);--active: scale(0.9);--transition: 0.2s;--transition-longer: 0.4s;--transition-long: 0.8s;--transition-bezier: 0.35s cubic-bezier(0.17, 0.89, 0.32, 1.28);--transition-bezier-longer: 0.525s cubic-bezier(0.17, 0.89, 0.32, 1.28);--icon-arrow-square-out: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M228%2C104a12%2C12%2C0%2C0%2C1-24%2C0V69l-59.51%2C59.51a12%2C12%2C0%2C0%2C1-17-17L187%2C52H152a12%2C12%2C0%2C0%2C1%2C0-24h64a12%2C12%2C0%2C0%2C1%2C12%2C12Zm-44%2C24a12%2C12%2C0%2C0%2C0-12%2C12v64H52V84h64a12%2C12%2C0%2C0%2C0%2C0-24H48A20%2C20%2C0%2C0%2C0%2C28%2C80V208a20%2C20%2C0%2C0%2C0%2C20%2C20H176a20%2C20%2C0%2C0%2C0%2C20-20V140A12%2C12%2C0%2C0%2C0%2C184%2C128Z%22/%3E%3C/svg%3E);--icon-caret-down: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M216.49%2C104.49l-80%2C80a12%2C12%2C0%2C0%2C1-17%2C0l-80-80a12%2C12%2C0%2C0%2C1%2C17-17L128%2C159l71.51-71.52a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E);--icon-check: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M232.49%2C80.49l-128%2C128a12%2C12%2C0%2C0%2C1-17%2C0l-56-56a12%2C12%2C0%2C1%2C1%2C17-17L96%2C183%2C215.51%2C63.51a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E);--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem;scrollbar-color:var(--accent-color) rgba(0,0,0,0);accent-color:var(--accent-color)}@media(prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}@media(prefers-color-scheme: dark){:root{--bg-overlay: linear-gradient( rgba(0, 0, 0, var(--dim-opacity)), rgba(0, 0, 0, var(--dim-opacity)) );--fg-color: #fcfcfa;--fg-contrast: #29242a;--red-fg: hsl(345.18988, 100%, 69%);--red-h: 345.18988;--red-s: 100%;--red-l: 69%;--orange-fg: hsl(19.731544, 96%, 70%);--orange-h: 19.731544;--orange-s: 96%;--orange-l: 70%;--yellow-fg: hsl(44.70588, 100%, 70%);--yellow-h: 44.70588;--yellow-s: 100%;--yellow-l: 70%;--green-fg: hsl(90, 59%, 66%);--green-h: 90;--green-s: 59%;--green-l: 66%;--blue-fg: hsl(186.42857, 71%, 69%);--blue-h: 186.42857;--blue-s: 71%;--blue-l: 69%;--purple-fg: hsl(249.88234, 77%, 78%);--purple-h: 249.88234;--purple-s: 77%;--purple-l: 78%;--red-fg-muted-1: hsla( var(--red-h), var(--red-s), calc(var(--red-l) + (100% - var(--red-l)) * 0.85), var(--fg-muted-1-opacity) );--red-fg-muted-2: hsla( var(--red-h), var(--red-s), calc(var(--red-l) + (100% - var(--red-l)) * 0.85), var(--fg-muted-2-opacity) );--orange-fg-muted-1: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), var(--fg-muted-1-opacity) );--orange-fg-muted-2: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), var(--fg-muted-2-opacity) );--yellow-fg-muted-1: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), var(--fg-muted-1-opacity) );--yellow-fg-muted-2: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), var(--fg-muted-2-opacity) );--green-fg-muted-1: hsla( var(--green-h), var(--green-s), calc(var(--green-l) + (100% - var(--green-l)) * 0.85), var(--fg-muted-1-opacity) );--green-fg-muted-2: hsla( var(--green-h), var(--green-s), calc(var(--green-l) + (100% - var(--green-l)) * 0.85), var(--fg-muted-2-opacity) );--blue-fg-muted-1: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), var(--fg-muted-1-opacity) );--blue-fg-muted-2: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), var(--fg-muted-2-opacity) );--purple-fg-muted-1: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), var(--fg-muted-1-opacity) );--purple-fg-muted-2: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), var(--fg-muted-2-opacity) );--shade-color: rgba(0, 0, 0, 0.25);--bg-muted-neutral-1: rgba(255, 255, 255, var(--color-opacity-1));--bg-muted-neutral-2: rgba(255, 255, 255, var(--color-opacity-2));--fg-muted-neutral-1: rgba(255, 255, 255, var(--fg-muted-1-opacity));--fg-muted-neutral-2: rgba(255, 255, 255, var(--fg-muted-2-opacity));--scanlines-opacity: 1;--fg-muted-1-opacity: 0.6;--fg-muted-2-opacity: 0.7;--edge-highlight: inset 0 0.0625rem 0 rgba(255, 255, 255, var(--color-opacity-1));--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) );color-scheme:dark}}*,:after,:before{vertical-align:baseline;box-sizing:border-box;margin:0;border:none;background:none;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{appearance:none}body{-webkit-text-size-adjust:none;text-size-adjust:none;width:100%;min-height:100vh;line-height:1.5}a,button,summary,input[type=button],input[type=checkbox],input[type=color],input[type=file],input[type=image],input[type=radio],input[type=range],input[type=reset],input[type=submit],label,select{cursor:pointer}article,aside,audio,canvas,details,figcaption,figure,footer,header,main,nav,rt,ruby,section,summary{display:block}bdi,mark,meter,output,progress,time{display:inline-block}[hidden],datalist,dialog:not([open]),rp{display:none !important}embed,iframe,img,object,picture,svg,video{display:block;max-width:100%;height:auto}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:none}strong{font-weight:700}em{font-style:italic}code,kbd,pre,samp{font-family:monospace}sub{vertical-align:sub}sup{vertical-align:super}small{font-size:small}table{border-collapse:collapse}@keyframes slide-in-up{from{transform:translateY(-1rem);opacity:0}}@keyframes slide-in-left{from{transform:translateX(1rem);opacity:0}}@keyframes slide-in-right{from{transform:translateX(-1rem);opacity:0}}@keyframes slide-in-up-longer{from{transform:translateY(-2rem);opacity:0}}@keyframes slide-in-down-longer{from{transform:translateY(2rem);opacity:0}}@keyframes slide-in-left-longer{from{transform:translateX(-2rem);opacity:0}}@keyframes slide-in-right-longer{from{transform:translateX(2rem);opacity:0}}@keyframes slide-out-down-longer{to{transform:translateY(-2rem);opacity:0}}@keyframes expand-up-left{from{transform:scale(0.5) translate(-1rem, -1rem);opacity:0}}@keyframes expand-up-right{from{transform:scale(0.5) translate(1rem, -1rem);opacity:0}}@keyframes expand-down-left{from{transform:scale(0.5) translate(-1rem, 1rem);opacity:0}}@keyframes expand-down-right{from{transform:scale(0.5) translate(1rem, 1rem);opacity:0}}:target:not(#main-content){scroll-margin-block-start:6.5rem}:focus-visible,:target:not(#main-content){animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}@keyframes focus-in{from{outline:.5rem solid rgba(0,0,0,0);outline-offset:.25rem}}::selection{background-color:var(--accent-color);color:var(--contrast-color)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:var(--bg-color);color:var(--fg-color);font-family:var(--font-sans-serif),var(--font-system-ui),var(--font-emoji);overflow-wrap:break-word}@supports(min-height: 100dvh){body{min-height:100dvh}}@media(prefers-reduced-motion){*,*::before,*::after{animation-duration:0s !important;transition-duration:0s !important}}@-moz-document url-prefix(){@media only screen and (max-width: 960px){.firefox-unblur,.firefox-unblur::before,.firefox-unblur::after{backdrop-filter:none !important}}}body>main,body>nav,body>footer{--padding: 1rem;margin-inline:auto;padding-inline:1rem;max-width:calc(var(--content-width) + var(--padding)*2)}body>footer{margin-block-start:4rem}body>nav{margin-block-end:4rem;border-block-end:0.0625rem solid var(--bg-muted-2)}body>nav ul{display:flex;gap:.25rem;margin-block:.625rem;padding-inline-start:0;list-style:none}@media only screen and (max-width: 480px){body>nav ul{flex-direction:column}}body>nav li{margin-block:0;padding-inline-start:0}p,ul,ol,dl,blockquote,pre{margin-block:1rem}ul ul,ul ol,ol ul,ol ol{margin-block:0}p{text-wrap:pretty;overflow-wrap:break-word}:disabled,[aria-disabled=true]{opacity:var(--disabled-opacity);cursor:not-allowed}h1,h2,h3,h4,h5,h6{transition-duration:var(--transition);transition-property:text-shadow,font-weight;margin-block:2rem 1rem;color:var(--accent-color);font-weight:var(--font-weight-bold);line-height:1;font-variation-settings:"FLAR" 100;letter-spacing:-0.05em;text-decoration:underline;text-decoration-color:var(--bg-muted-1);text-decoration-thickness:.125em;text-wrap:balance;overflow-wrap:break-word}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{animation:none !important;outline:none !important;font-weight:var(--font-weight-light);text-shadow:var(--text-shadow-glow)}h1{font-size:var(--font-size-xxx-large)}h2{font-size:var(--font-size-xx-large)}h3{font-size:var(--font-size-x-large)}h4{font-size:var(--font-size-large)}h5{font-size:var(--font-size-medium)}h6{font-size:var(--font-size-small)}@media only screen and (max-width: 480px){h1{font-size:var(--font-size-xx-large)}h2{font-size:var(--font-size-x-large)}h3{font-size:var(--font-size-large)}h4{font-size:var(--font-size-medium)}h5{font-size:var(--font-size-small)}h6{font-size:var(--font-size-x-small)}}small{color:var(--fg-muted-2);font-size:var(--font-size-small)}abbr[title]{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:0.0625rem}figcaption{color:var(--fg-muted-2);font-size:var(--font-size-small);text-align:center}blockquote{margin:1rem 0;border-inline-start:.25rem solid var(--accent-color);border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:.75rem;color:var(--fg-muted-2)}pre,code,kbd,samp{font-family:var(--font-monospace),var(--font-ui-monospace)}pre{margin:1rem 0;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem;overflow-x:auto;scrollbar-width:thin;line-height:1.25}code:not(pre code),mark,samp,q{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none}code:not(pre code){background-color:var(--red-bg-1);padding-block:0.1875rem;color:var(--red-fg);font-size:var(--font-size-small)}mark{color:var(--accent-color)}del{color:var(--red-fg);text-decoration:line-through;text-decoration-thickness:0.0625rem}ins{color:var(--green-fg);text-decoration:underline;text-decoration-thickness:0.0625rem}samp{color:var(--fg-muted-2);font-size:var(--font-size-small)}q{color:var(--fg-muted-2);font-style:italic}u{text-decoration:underline;text-decoration-style:wavy;text-decoration-color:var(--red-fg);text-decoration-thickness:0.0625rem}progress{appearance:none;box-shadow:var(--edge-highlight);border:none;border-radius:9999px;background-color:var(--bg-muted-1);padding:.25rem;width:100%;height:1rem;color:var(--accent-color)}progress:indeterminate{background-color:var(--bg-muted-1)}progress:indeterminate::-moz-progress-bar{background-color:rgba(0,0,0,0)}progress::-webkit-progress-bar{background-color:rgba(0,0,0,0)}progress::-moz-progress-bar{border-radius:9999px;background-color:var(--accent-color)}progress::-webkit-progress-value{border-radius:9999px;background-color:var(--accent-color)}kbd{display:inline-block;transition-duration:var(--transition);transition-property:transform,box-shadow,background-color;cursor:pointer;box-shadow:var(--edge-highlight),inset 0 -0.125rem 0 var(--bg-muted-2);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:0 .375rem;font-size:var(--font-size-small)}kbd:active{transform:translateY(0.125rem);box-shadow:inset 0 0.0625rem 0 var(--bg-muted-2);background-color:var(--bg-muted-2)}a{color:var(--accent-color);font-weight:var(--font-weight-semi-bold);text-decoration-color:var(--accent-highlight);text-decoration-thickness:0.0625rem}a:hover{text-decoration-style:wavy}hr{margin:2rem auto;border:.125rem solid var(--bg-muted-2);border-radius:9999px}dt{font-weight:var(--font-weight-semi-bold)}dd{margin-inline-start:1.5rem;margin-block-end:1rem}aside{float:inline-end;margin-inline-start:1rem;margin-block-end:1rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;width:calc(var(--content-width)/3)}@media only screen and (max-width: 720px){aside{float:none;margin-inline-start:0;width:100%}}aside :first-child{margin-block-start:0}aside :last-child{margin-block-end:0}details{display:flow-root;transition:padding-block-end var(--transition);margin-block-end:1rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem}details::details-content{display:block;opacity:0;transition-behavior:allow-discrete;transition-duration:var(--transition);transition-property:opacity,block-size,content-visibility;block-size:0;overflow:hidden}details[open]{padding-block-end:1rem}details[open]::details-content{opacity:1;block-size:auto;block-size:calc-size(auto,size)}details[open] summary{color:var(--accent-color)}details[open] summary::before{transform:scaleY(-1)}@supports not (block-size: calc-size(auto, size)){details[open] summary~*{animation:slide-in-up var(--transition-bezier)}}details summary{transition:var(--transition);color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);list-style:none}details summary::-webkit-details-marker{display:none}details summary::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-caret-down);display:inline-block;vertical-align:-0.125rem;transition:all var(--transition),transform var(--transition-bezier);margin-inline-end:.25rem}details>:last-child,details>:last-child img{margin-block-end:0}figure{margin-inline:0}ol,ul{padding-inline-start:1.5rem}li{margin-block:.25rem;padding-inline-start:.25rem}li::marker{color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}b,strong{font-weight:var(--font-weight-semi-bold)}.buttons{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.25rem;margin-block-start:2rem}.buttons.centered{justify-content:center}.buttons.start{justify-content:flex-start}.buttons.end{justify-content:flex-end}.buttons.big{gap:.5rem}.buttons a,a.button,button{display:inline-block;transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.5rem 1rem;color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-small);line-height:1.5rem;text-decoration:none}.buttons a:not(:disabled,[aria-disabled=true]):hover,a.button:not(:disabled,[aria-disabled=true]):hover,button:not(:disabled,[aria-disabled=true]):hover{background-color:var(--bg-muted-2);color:var(--fg-muted-2)}.buttons a:not(:disabled,[aria-disabled=true]):active,a.button:not(:disabled,[aria-disabled=true]):active,button:not(:disabled,[aria-disabled=true]):active{transform:var(--active)}.buttons a.suggested,a.button.suggested,button.suggested{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow)}.buttons a.suggested:not(:disabled,[aria-disabled=true]):hover,a.button.suggested:not(:disabled,[aria-disabled=true]):hover,button.suggested:not(:disabled,[aria-disabled=true]):hover{box-shadow:var(--edge-highlight);background-color:var(--accent-color);color:var(--contrast-color)}.buttons a.subtle,a.button.subtle,button.subtle{box-shadow:none;background-color:rgba(0,0,0,0)}.buttons a.subtle:not(:disabled,[aria-disabled=true]):hover,a.button.subtle:not(:disabled,[aria-disabled=true]):hover,button.subtle:not(:disabled,[aria-disabled=true]):hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}.buttons a[aria-busy=true],a.button[aria-busy=true],button[aria-busy=true]{animation:progress-shimmer var(--transition-long) ease-in-out alternate infinite;background-image:linear-gradient(to right, transparent 50%, var(--bg-muted-1) 75%, transparent 100%);background-size:200%}@keyframes progress-shimmer{to{background-position-x:-200%}}.buttons.big a,.buttons.big button,a.button.big,button.big{border-radius:9999px;padding:.625rem 1.25rem}.card{box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem}.crt,pre:has(code[data-crt],code[data-lang=""]){position:relative;animation:flicker .25s alternate infinite;margin-inline:auto;box-shadow:var(--shadow-glow);background-image:var(--crt-bg);background-color:rgba(0,0,0,0) !important;padding:0;width:fit-content;max-width:100%;overflow:hidden;color:var(--accent-color) !important;font-weight:var(--font-weight-medium);text-shadow:var(--text-shadow-glow)}@keyframes flicker{25%{opacity:.95}50%{opacity:.85}75%{opacity:1}to{opacity:.9}}.crt::before,.crt::after,pre:has(code[data-crt],code[data-lang=""])::before,pre:has(code[data-crt],code[data-lang=""])::after{position:absolute;inset:0;content:""}.crt::before,pre:has(code[data-crt],code[data-lang=""])::before{opacity:var(--scanlines-opacity);z-index:1;animation:scanlines .1s linear infinite;background-image:repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25) 0.125rem, transparent 0.125rem, transparent 0.25rem);background-position-y:-0.125rem;background-repeat:no-repeat;pointer-events:none}@keyframes scanlines{to{background-position-y:.125rem}}.crt::after,pre:has(code[data-crt],code[data-lang=""])::after{opacity:.05;animation:scanline 5s linear infinite;background-image:linear-gradient(to bottom, transparent, var(--accent-color) 16rem);background-position-y:-16rem;background-size:100% 16rem;pointer-events:none}@keyframes scanline{to{background-position-y:calc(100% + 16rem)}}.crt>code,pre:has(code[data-crt],code[data-lang=""])>code{display:block;border-radius:inherit;padding:.75rem 1rem;overflow-x:auto;scrollbar-width:thin}a.external:not(:has(img,svg,video,picture,figure))::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-arrow-square-out);display:inline-block;vertical-align:-0.125rem;opacity:var(--dim-opacity);margin-inline-start:.125rem;font-size:max(1rem,1em)}iframe{display:block;margin:1rem auto;box-shadow:var(--edge-highlight),var(--shadow);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);width:100%;max-width:100%}iframe:fullscreen{box-shadow:none;border-radius:0}iframe:-webkit-full-screen{box-shadow:none;border-radius:0}input{appearance:none;background-color:var(--bg-muted-1)}input:not([type]),input[type=text],input[type=search],input[type=password],input[type=submit]{box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);font-size:inherit;line-height:inherit;font-family:inherit}input:not([type]),input[type=text],input[type=search],input[type=password]{padding:.5rem .75rem;width:100%}input:not([type])::placeholder,input[type=text]::placeholder,input[type=search]::placeholder,input[type=password]::placeholder{opacity:1;color:var(--fg-muted-1)}input[type=submit]{transition:background-color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);padding:.375rem .75rem;color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-small)}input[type=submit]:enabled:hover{background-color:var(--bg-muted-2)}input[type=submit]:enabled:active{transform:var(--active)}input[type=radio],input[type=checkbox],input[type=color]{position:relative;transition:background-color var(--transition);border:.125rem solid var(--bg-muted-2);width:1rem;height:1rem}input[type=radio]:not(:checked):enabled:hover,input[type=checkbox]:not(:checked):enabled:hover,input[type=color]:not(:checked):enabled:hover{background-color:var(--bg-muted-2)}input[type=radio],input[type=checkbox]{vertical-align:-0.125em;margin-inline-end:.25rem}input[type=radio]::before,input[type=checkbox]::before{display:block;position:absolute;transform:scale(0.5);opacity:0;transition:opacity var(--transition),transform var(--transition-bezier);content:""}input[type=radio]:checked,input[type=checkbox]:checked{border:.15rem solid rgba(0,0,0,0);background-color:var(--accent-color)}input[type=radio]:checked::before,input[type=checkbox]:checked::before{transform:scale(1);opacity:1}input[type=radio]{border-radius:50%}input[type=radio]::before{inset-block-start:.125rem;inset-inline-start:.125rem;border-radius:50%;background-color:var(--contrast-color);width:.5rem;height:.5rem}input[type=checkbox]{border-radius:calc(var(--rounded-corner-small)/2)}input[type=checkbox]::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-check);transform-origin:bottom left;inset-block-start:-0.125rem;inset-inline-start:-0.125rem;color:var(--contrast-color);font-size:1rem}input[type=checkbox].switch{vertical-align:-0.375rem;box-shadow:var(--edge-highlight);border:none;border-radius:9999px;width:2.5rem;height:1.5rem}input[type=checkbox].switch.big{vertical-align:-0.625rem;width:3rem;height:2rem}input[type=checkbox].switch.big::before{width:1.5rem;height:1.5rem}input[type=checkbox].switch::before{transform:none;transform-origin:center;opacity:1;mask-image:none;inset-block-start:.25rem;inset-inline-start:.25rem;box-shadow:var(--shadow);border-radius:50%;background-color:#fff;width:1rem;height:1rem;content:""}input[type=checkbox].switch:checked{background-color:var(--accent-color)}input[type=checkbox].switch:checked::before{transform:translateX(1rem);background-color:var(--contrast-color)}:root[dir*=rtl] input[type=checkbox].switch:checked::before{transform:translateX(-1rem)}input[type=checkbox].switch:disabled::before{box-shadow:none}input[type=color]{vertical-align:-0.375em;box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner-small);padding:.25rem;width:3rem;height:2rem}input[type=color]::-moz-color-swatch{border:none;border-radius:calc(var(--rounded-corner-small) - .25rem)}input[type=color]::-webkit-color-swatch-wrapper{padding:0}input[type=color]::-webkit-color-swatch{border-radius:calc(var(--rounded-corner-small) - .25rem)}input[type=range]{appearance:none;box-shadow:var(--edge-highlight);border-radius:9999px;background:var(--bg-muted-1);padding:.5rem .25rem;width:100%;height:1rem}input[type=range]::-webkit-slider-runnable-track{cursor:pointer;border-radius:9999px;background:var(--accent-color);width:100%}input[type=range]::-moz-range-track{cursor:pointer;border-radius:9999px;background:var(--accent-color);width:100%;height:.5rem}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:ew-resize;margin-block:-0.5rem;box-shadow:var(--shadow);border:none;border-radius:.25rem;background-color:#fff;width:.5rem;height:1.5rem}input[type=range]::-moz-range-thumb{appearance:none;cursor:ew-resize;margin-block:-0.5rem;box-shadow:var(--shadow);border:none;border-radius:.25rem;background-color:#fff;width:.5rem;height:1.5rem}input[type=range].hollow::-webkit-slider-thumb{margin-block:0;outline:.25rem solid #fff;background-color:rgba(0,0,0,0);height:.5rem}input[type=range].hollow::-moz-range-thumb{margin-block:0;outline:.25rem solid #fff;background-color:rgba(0,0,0,0);height:.5rem}img,video{display:block;margin:1rem auto;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);max-width:100%;max-height:calc(100vh - 2rem)}img.full,img[src*="#full"],video.full,video[src*="#full"]{width:100%}img.start,img.end,img[src*="#start"],img[src*="#end"],video.start,video.end,video[src*="#start"],video[src*="#end"]{width:calc(var(--content-width)/3)}img.start,img[src*="#start"],video.start,video[src*="#start"]{float:inline-start;transform-origin:left;margin-inline-end:1rem}:root[dir*=rtl] img.start,:root[dir*=rtl] img[src*="#start"],:root[dir*=rtl] video.start,:root[dir*=rtl] video[src*="#start"]{transform-origin:right}img.end,img[src*="#end"],video.end,video[src*="#end"]{float:inline-end;transform-origin:right;margin-inline-start:1rem}:root[dir*=rtl] img.end,:root[dir*=rtl] img[src*="#end"],:root[dir*=rtl] video.end,:root[dir*=rtl] video[src*="#end"]{transform-origin:left}img.pixels,img[src*="#pixels"],video.pixels,video[src*="#pixels"]{image-rendering:pixelated}img.has-alpha,img[src*="#has-alpha"],video.has-alpha,video[src*="#has-alpha"]{box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0)}img.drop-shadow,img[src*="#drop-shadow"],video.drop-shadow,video[src*="#drop-shadow"]{filter:var(--drop-shadow)}img.spoiler,img[src*="#spoiler"],video.spoiler,video[src*="#spoiler"]{opacity:var(--dim-opacity);clip-path:inset(0 0 0 0 round var(--rounded-corner));filter:blur(1.5rem)}img.spoiler:hover,img.spoiler:active,img[src*="#spoiler"]:hover,img[src*="#spoiler"]:active,video.spoiler:hover,video.spoiler:active,video[src*="#spoiler"]:hover,video[src*="#spoiler"]:active{opacity:1;clip-path:inset(-0.75rem -0.75rem -0.75rem -0.75rem round var(--rounded-corner-small));filter:none}img.spoiler.solid,img.spoiler[src*="#solid"],img[src*="#spoiler"].solid,img[src*="#spoiler"][src*="#solid"],video.spoiler.solid,video.spoiler[src*="#solid"],video[src*="#spoiler"].solid,video[src*="#spoiler"][src*="#solid"]{clip-path:none;filter:brightness(0) contrast(0.5);box-shadow:none}img.spoiler.solid:hover,img.spoiler.solid:active,img.spoiler[src*="#solid"]:hover,img.spoiler[src*="#solid"]:active,img[src*="#spoiler"].solid:hover,img[src*="#spoiler"].solid:active,img[src*="#spoiler"][src*="#solid"]:hover,img[src*="#spoiler"][src*="#solid"]:active,video.spoiler.solid:hover,video.spoiler.solid:active,video.spoiler[src*="#solid"]:hover,video.spoiler[src*="#solid"]:active,video[src*="#spoiler"].solid:hover,video[src*="#spoiler"].solid:active,video[src*="#spoiler"][src*="#solid"]:hover,video[src*="#spoiler"][src*="#solid"]:active{filter:none}@media only screen and (max-width: 720px){img.start,img.end,img[src*="#start"],img[src*="#end"],video.start,video.end,video[src*="#start"],video[src*="#end"]{float:none;margin-inline-start:auto;margin-inline-end:auto;width:auto}}img{transition:box-shadow var(--transition),border-radius var(--transition),transform var(--transition-bezier)}img:not(.no-hover,[src*="#no-hover"],.emoji){cursor:zoom-in}img:not(.no-hover,[src*="#no-hover"],.emoji):hover{position:relative;transform:var(--hover);z-index:1;box-shadow:var(--edge-highlight),var(--shadow-raised);border-radius:var(--rounded-corner-small)}img:not(.no-hover,[src*="#no-hover"],.emoji).start,img:not(.no-hover,[src*="#no-hover"],.emoji).end,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"],img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]{transition:transform var(--transition-bezier-longer)}img:not(.no-hover,[src*="#no-hover"],.emoji).start:hover,img:not(.no-hover,[src*="#no-hover"],.emoji).end:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"]:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]:hover{transform:scale(2)}@media only screen and (max-width: 720px){img:not(.no-hover,[src*="#no-hover"],.emoji).start,img:not(.no-hover,[src*="#no-hover"],.emoji).end,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"],img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]{transform-origin:center;transition:transform var(--transition-bezier)}img:not(.no-hover,[src*="#no-hover"],.emoji).start:hover,img:not(.no-hover,[src*="#no-hover"],.emoji).end:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"]:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]:hover{transform:var(--hover)}}img:not(.no-hover,[src*="#no-hover"],.emoji).has-alpha:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#has-alpha"]:hover{box-shadow:none}a img:not(.no-hover,[src*="#no-hover"]){cursor:pointer}video:fullscreen{box-shadow:none;border-radius:0}video:-webkit-full-screen{box-shadow:none;border-radius:0}picture{display:inline-block}.overflow-auto{overflow:auto}.overflow-auto.overshoot{margin-block:-1rem;padding-block:1rem}.overflow-auto.overshoot-row{margin-inline:-1rem;padding-inline:1rem}.overshoot{--mask: linear-gradient( to bottom, transparent, black 1rem, black calc(100% - 1rem), transparent );-webkit-mask-image:var(--mask);mask-image:var(--mask)}.overshoot-row{--mask: linear-gradient( to right, transparent, black 1rem, black calc(100% - 1rem), transparent );-webkit-mask-image:var(--mask);mask-image:var(--mask)}#skip-to-content{position:absolute;transform:translateX(-50%) translateY(calc(-100% - 1rem));z-index:9999;transition:backdrop-filter var(--transition),box-shadow var(--transition),transform var(--transition-bezier);inset-inline-start:50vw;border-radius:9999px;background-color:var(--glass-bg-1);padding:.375rem .75rem;width:max-content;text-decoration:none}#skip-to-content:focus-visible,#skip-to-content:focus{-webkit-backdrop-filter:var(--blur);transform:translateX(-50%) translateY(1rem);backdrop-filter:var(--blur);box-shadow:var(--edge-highlight),var(--shadow)}span.spoiler{filter:blur(0.25rem);transition:filter var(--transition)}span.spoiler:focus,span.spoiler:hover{filter:none}span.spoiler.solid{filter:none;transition:background-color var(--transition),color var(--transition);border-radius:var(--rounded-corner-small);background-color:var(--fg-muted-1);color:rgba(0,0,0,0)}span.spoiler.solid:focus,span.spoiler.solid:hover{background-color:rgba(0,0,0,0);color:inherit}table{width:100%;overflow:hidden}thead{border-block-start:none;color:var(--fg-muted-2);font-weight:var(--font-weight-semi-bold)}tr:nth-child(even){background-color:var(--bg-muted-1)}tr th,tr td{padding:.5rem 1rem}tbody tr{border-block:0.0625rem solid var(--bg-muted-2)}tbody tr:last-child{border-block-end:none}.overflow-auto table{width:max-content;min-width:100%}textarea{appearance:none;box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;resize:none;font-size:inherit;line-height:inherit;font-family:inherit}textarea::placeholder{opacity:1;color:var(--fg-muted-1)}strong.title{color:var(--accent-color);font-size:var(--font-size-large);line-height:1}@media(pointer: coarse),(hover: none){.tooltip,abbr[title]{display:inline-flex;position:relative;justify-content:center}.tooltip::after,abbr[title]::after{position:absolute;top:calc(100% + .25rem);z-index:9999;backdrop-filter:var(--blur);transition:visibility var(--transition),opacity var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner-small);background-color:var(--glass-bg-1);padding:.125rem .375rem;width:max-content;content:attr(title);-webkit-backdrop-filter:var(--blur);transform:translateY(-0.5rem) scale(0.5);transform-origin:top;visibility:hidden;opacity:0;max-width:calc(var(--content-width)/3);color:var(--fg-muted-2);font-size:var(--font-size-small)}.tooltip:focus::after,.tooltip:hover::after,abbr[title]:focus::after,abbr[title]:hover::after{transform:none;visibility:visible;opacity:1}}.visually-hidden{position:absolute !important;clip-path:inset(50%) !important;margin:-1px !important;border-width:0 !important;padding:0 !important;width:1px !important;height:1px !important;overflow:hidden !important;white-space:nowrap !important}#site-footer{--padding: 1rem;display:flex;grid-area:footer;gap:1rem;margin-block:3.25rem 1rem;border-block-start:0.0625rem solid var(--bg-muted-2);padding:1rem;width:100%;min-width:0;max-width:calc(var(--content-width) + var(--padding)*2)}@media only screen and (max-width: 480px){#site-footer{flex-direction:column-reverse}}#site-footer>div{display:flex;flex:1;flex-direction:column;gap:1rem}#site-footer>div p{margin:0}#site-footer>div:nth-of-type(1){color:var(--fg-muted-2);font-size:var(--font-size-small)}#site-footer>div:nth-of-type(2){justify-content:space-between;align-items:flex-end;text-align:end}@media only screen and (max-width: 480px){#site-footer>div:nth-of-type(2){align-items:normal;text-align:start}}#site-footer a.external::after{margin-inline-start:.25rem}#site-footer .link{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;display:inline-block;line-height:1.5rem}#site-footer .link:hover{background-color:var(--bg-muted-2)}#site-footer .link:active{transform:var(--active)}#site-footer .webrings{display:flex;flex-direction:column;gap:.25rem;margin-block:0;padding-inline-start:0;list-style:none}#site-footer .webring{display:flex;gap:.25rem;margin-block:0;padding-inline-start:0}#site-footer .webring .random,#site-footer .webring .previous,#site-footer .webring .next{display:inline-flex;justify-content:center;align-items:center;border-radius:9999px;padding:.25rem;font-size:1rem}#site-footer .timestamp{color:var(--fg-muted-1);font-size:var(--font-size-xx-small);font-family:var(--font-monospace);text-transform:uppercase}#site-footer .socials{display:flex;justify-content:flex-end;gap:.75rem;margin:0;padding-inline-start:0;list-style:none}@media only screen and (max-width: 480px){#site-footer .socials{justify-content:normal}}#site-footer .socials li{margin-block:0;padding-inline-start:0}#site-footer .socials a{display:flex;transition:color var(--transition),transform var(--transition-bezier);height:fit-content;color:var(--fg-muted-1)}#site-footer .socials a:hover{color:var(--fg-muted-2)}#site-footer .socials a:active{transform:var(--active)}#site-footer .socials svg{fill:currentColor;width:1.5rem;height:1.5rem}.full-bleed{transform:translateX(-50%);margin-inline-start:50%;width:calc(100vw - var(--sidebar-width))}:root[dir*=rtl] .full-bleed{transform:translateX(50%)}@media only screen and (max-width: 960px){.full-bleed{width:calc(100vw - 3.5rem)}}body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:var(--sidebar-width) 1fr;grid-template-areas:"sidebar header" "sidebar banner" "sidebar main " "sidebar footer";justify-items:center;transition:grid-template-columns var(--transition)}@media only screen and (max-width: 960px){body{grid-template-columns:0 1fr}}#site-header{display:flex;position:sticky;grid-area:header;justify-content:space-between;align-items:center;gap:.25rem;z-index:2;inset-block-start:0;padding:.5rem;width:100%;min-width:0}#site-header::before,#site-header::after{position:absolute;inset:0;--mask-image: linear-gradient( to bottom, hsl(0, 0%, 0%) 0%, hsla(0, 0%, 0%, 0.987) 8.1%, hsla(0, 0%, 0%, 0.951) 15.5%, hsla(0, 0%, 0%, 0.896) 22.5%, hsla(0, 0%, 0%, 0.825) 29%, hsla(0, 0%, 0%, 0.741) 35.3%, hsla(0, 0%, 0%, 0.648) 41.2%, hsla(0, 0%, 0%, 0.55) 47.1%, hsla(0, 0%, 0%, 0.45) 52.9%, hsla(0, 0%, 0%, 0.352) 58.8%, hsla(0, 0%, 0%, 0.259) 64.7%, hsla(0, 0%, 0%, 0.175) 71%, hsla(0, 0%, 0%, 0.104) 77.5%, hsla(0, 0%, 0%, 0.049) 84.5%, hsla(0, 0%, 0%, 0.013) 91.9%, hsla(0, 0%, 0%, 0) 100% );mask-image:var(--mask-image);-webkit-mask-image:var(--mask-image);inset-block-end:-100%;pointer-events:none;content:""}#site-header::before{z-index:-2;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur)}#site-header::after{z-index:-1;background-color:var(--bg-color)}#site-header .title{margin-inline:.75rem;min-width:0;overflow:hidden;color:var(--fg-muted-2);font-size:var(--font-size-small);line-height:2rem;text-overflow:ellipsis;white-space:nowrap}#site-header .buttons{display:flex;flex-wrap:nowrap;gap:.25rem;margin:0;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:1.125rem;background-color:var(--glass-bg-1);padding:.25rem;list-style:none}#site-header .buttons li{flex:0;margin-block:0;padding-inline-start:0}#site-header .buttons>li{height:fit-content;line-height:0}#site-header .buttons button,#site-header .buttons details{box-shadow:none;background-color:rgba(0,0,0,0);line-height:inherit}#site-header .buttons button .icon,#site-header .buttons details .icon{margin-inline-end:0;box-shadow:none;background-color:rgba(0,0,0,0);padding:0}#site-header .buttons button,#site-header .buttons summary{border-radius:50%;padding:.375rem}#site-header .buttons button:hover,#site-header .buttons summary:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-header .buttons button:active,#site-header .buttons summary:active{transform:var(--active)}#site-header .buttons details{box-shadow:none;border-radius:0;padding:0}#site-header .buttons button{font-size:var(--font-size-medium)}#site-header .buttons summary:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1)}#site-header .buttons summary::before{display:none}#site-header .buttons details{display:block;position:relative;margin-block-end:0}#site-header .buttons details::details-content{block-size:auto}#site-header .buttons details[open]::details-content{block-size:auto}#site-header .buttons details[open] summary{color:var(--fg-muted-1)}#site-header .buttons details[open] summary:hover{color:var(--fg-muted-2)}@supports not (block-size: calc-size(auto, size)){#site-header .buttons details[open] summary~*{animation:none}}.buttons .dropdown{display:flex;position:absolute;flex-direction:column;gap:.25rem;transform-origin:top right;z-index:1;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);animation:expand-up-right var(--transition-bezier);inset-block-start:calc(100% + .75rem);inset-inline-end:-0.25rem;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:calc(var(--rounded-corner) + .25rem);background-color:var(--glass-bg-1);padding:.25rem;width:max-content;min-width:8rem;list-style:none}:root[dir*=rtl] .buttons .dropdown{transform-origin:top left;animation:expand-up-left var(--transition-bezier)}.buttons .dropdown li{flex:1}.buttons .dropdown a{box-shadow:none;border-radius:var(--rounded-corner);background-color:rgba(0,0,0,0);padding:.375rem .75rem;width:100%}.buttons .dropdown a:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1) !important}#site-sidebar-controls{display:flex;flex-shrink:0;gap:.25rem;transition:width var(--transition),visibility var(--transition);margin-block:0;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:1.125rem;background-color:var(--glass-bg-1);padding:.25rem;height:fit-content;line-height:0;list-style:none}@media only screen and (max-width: 960px){#site-sidebar-controls{width:2.25rem}#site-sidebar-controls:has(#expand-sidebar:checked){width:6.25rem}#site-sidebar-controls:has(#expand-sidebar:checked) #site-sidebar-modes{transform:none;visibility:visible;opacity:1}}#site-sidebar-controls ul{padding-inline-start:0;list-style:none}#site-sidebar-controls li{display:inline-block;margin-block:0;padding-inline-start:0}#site-sidebar-controls li label{display:inline-block;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);cursor:pointer;border-radius:50%;padding:.375rem;height:fit-content;color:var(--fg-muted-1);line-height:0}#site-sidebar-controls li label:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-sidebar-controls li label:active{transform:var(--active)}#site-sidebar-controls li input[type=radio]+label{position:relative;z-index:1}#site-sidebar-controls li input[type=radio]+label::before{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);position:absolute;opacity:0;z-index:-1;transition:opacity var(--transition),box-shadow var(--transition),background-color var(--transition);inset:0;border-radius:inherit;content:""}#site-sidebar-controls li input[type=radio]:checked+label{color:var(--contrast-color)}#site-sidebar-controls li input[type=radio]:checked+label:hover{box-shadow:none;background-color:rgba(0,0,0,0)}#site-sidebar-controls li input[type=radio]:checked+label:hover::before{box-shadow:var(--edge-highlight);background-color:var(--accent-color)}#site-sidebar-controls li input[type=radio]:checked+label::before{opacity:1}#site-sidebar-controls li input[type=radio]:focus-visible+label,#site-sidebar-controls li input[type=checkbox]:focus-visible+label{animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}@supports not selector(:focus-visible){#site-sidebar-controls li input[type=radio]:focus+label,#site-sidebar-controls li input[type=checkbox]:focus+label{animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}}#site-sidebar-controls #expand-sidebar-li{display:none}@media only screen and (max-width: 960px){#site-sidebar-controls #expand-sidebar-li{display:inline-block}}#site-sidebar-modes{display:flex;gap:.25rem;transform-origin:left;transition:visibility var(--transition),opacity var(--transition),transform var(--transition)}@media only screen and (max-width: 960px){#site-sidebar-modes{transform:translateX(-2.25rem);visibility:hidden;opacity:0}}#main-content{--padding: 1rem;grid-area:main;transform-origin:50% 50vh;animation:fade-in-zoom var(--transition-bezier-longer);margin-block-start:3.25rem;padding-inline:1rem;width:100%;min-width:0;max-width:calc(var(--content-width) + var(--padding)*2)}@supports(transform-origin: 50% 50dvh){#main-content{transform-origin:50% 50dvh}}#main-content>:first-child{margin-block-start:0}#main-content>:last-child{margin-block-end:0}@keyframes fade-in-zoom{from{transform:scale(0.95);opacity:0}}:target~#main-content{animation-name:none}#↑:target~#main-content,#up:target~#main-content{animation-name:slide-in-up-longer}#↓:target~#main-content,#down:target~#main-content{animation-name:slide-in-down-longer}#←:target~#main-content,#left:target~#main-content{animation-name:slide-in-left-longer}#→:target~#main-content,#right:target~#main-content{animation-name:slide-in-right-longer}#site-sidebar{display:flex;position:sticky;grid-area:sidebar;flex-direction:column;z-index:3;transition-duration:var(--transition);transition-property:visibility,transform,inset-block-start,box-shadow,border-inline-end,border-start-end-radius,border-end-end-radius,height;inset-block-start:0;inset-inline-start:0;border-inline-end:0.0625rem solid var(--bg-muted-2);width:var(--sidebar-width);height:100vh}@supports(height: 100dvh){#site-sidebar{height:100dvh}}@media only screen and (max-width: 960px){#site-sidebar{transform:translateX(-100%);visibility:hidden;inset-block-start:3.25rem;box-shadow:var(--edge-highlight),var(--shadow);border-inline-end:0 solid rgba(0,0,0,0);border-start-end-radius:var(--rounded-corner);border-end-end-radius:var(--rounded-corner);height:calc(100vh - 3.75rem)}:root[dir*=rtl] #site-sidebar{transform:translateX(100%)}@supports(height: 100dvh){#site-sidebar{height:calc(100dvh - 3.75rem)}}#site-header:has(#expand-sidebar:checked)~#site-sidebar{transform:none;visibility:visible;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);background-color:var(--glass-bg-1)}}#site-sidebar-content{display:flex;position:relative;flex:1;overflow:hidden}#site-sidebar-header{display:flex;align-items:center;transform-origin:1.375rem 50%;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);margin:1rem;margin-block-end:0;border-radius:1.375rem;padding:.25rem;padding-inline-end:.75rem;min-width:0;color:var(--fg-muted-1);text-decoration:none}#site-sidebar-header:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-sidebar-header:hover .icon{background-color:var(--bg-muted-2)}#site-sidebar-header:active{transform:var(--active)}#site-sidebar-header img{margin:0}#site-sidebar-header img,#site-sidebar-header .icon{margin-inline-end:.5rem;border-radius:50%}#site-sidebar-header img{width:2.25rem;height:2.25rem}#site-sidebar-header .icon{transition:background-color var(--transition);box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#site-sidebar-header div{display:flex;flex:1;flex-direction:column;gap:.125rem;min-width:0;line-height:1.25}#site-sidebar-header div span:nth-of-type(1){overflow:hidden;font-size:var(--font-size-small);text-overflow:ellipsis;white-space:nowrap}#site-sidebar-header div span:nth-of-type(2){font-weight:var(--font-weight-normal);font-size:var(--font-size-x-small)}#sidebar-nav,#sidebar-meta{display:flex;flex:1;flex-direction:column;transition:visibility var(--transition),opacity var(--transition),filter var(--transition),transform var(--transition-bezier);padding:1rem;width:var(--sidebar-width);min-width:100%;overflow:hidden auto;scrollbar-width:thin}#site-header:has(#site-sidebar-modes #mode-meta:checked)+#site-sidebar #sidebar-nav{transform:perspective(var(--content-width)) rotateY(15deg);opacity:var(--disabled-opacity);filter:blur(0.25rem)}#site-header:has(#site-sidebar-modes #mode-meta:checked)+#site-sidebar #sidebar-meta{transform:perspective(var(--content-width));visibility:visible;opacity:1;filter:none}#sidebar-nav{transform:perspective(var(--content-width));transform-origin:left}:root[dir*=rtl] #sidebar-nav{transform-origin:right}#sidebar-nav a{display:flex;flex:1;align-items:center;transform-origin:1.375rem 50%;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);border-radius:1.375rem;padding:.25rem;padding-inline-end:.75rem;overflow:hidden;color:var(--fg-muted-1);text-decoration:none;text-overflow:ellipsis;white-space:nowrap}#sidebar-nav a:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#sidebar-nav a:hover .icon{background-color:var(--bg-muted-2)}#sidebar-nav a:active{transform:var(--active)}#sidebar-nav a[aria-current=page]{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--accent-color)}#sidebar-nav a[aria-current=page] .icon{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);text-shadow:none}#sidebar-nav a[aria-current=page]:hover{background-color:var(--bg-muted-2)}#sidebar-nav a[aria-current=page]:hover .icon{box-shadow:var(--edge-highlight);background-color:var(--accent-color);color:var(--contrast-color)}#sidebar-nav a.new .icon{position:relative}#sidebar-nav a.new .icon::after{position:absolute;inset-block-start:0.0625rem;inset-inline-end:0.0625rem;border-radius:50%;background-color:var(--accent-color);width:.75rem;height:.75rem;content:""}#sidebar-nav .icon{transition:background-color var(--transition),color var(--transition),box-shadow var(--transition);margin-inline-end:.5rem;box-shadow:var(--edge-highlight);border-radius:50%;background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#sidebar-nav .icon.fallback{width:1.25rem;font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-medium);line-height:1.25rem;font-family:var(--font-monospace);text-align:center}#sidebar-nav .icon.fallback::before{content:none}#sidebar-nav details{flex:1;margin-block-end:0;box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0);padding:0}#sidebar-nav details[open] summary{transform:none;opacity:1;box-shadow:none;background-color:rgba(0,0,0,0)}#sidebar-nav details[open] summary::before{transform:none}@supports not (block-size: calc-size(auto, size)){#sidebar-nav details[open] summary~*{animation:none}}#sidebar-nav details summary{display:flex;position:relative;align-items:center;border-radius:0;padding:.75rem 0;color:var(--fg-muted-1);font-weight:var(--font-weight-normal);font-size:var(--font-size-small);line-height:var(--font-size-small);white-space:nowrap}#sidebar-nav details summary:hover{box-shadow:none;background-color:rgba(0,0,0,0)}#sidebar-nav details summary:active{transform:none}#sidebar-nav details summary::before{position:absolute;transform:rotate(-90deg);transition:transform var(--transition-bezier);margin-inline-end:0;inset-block:.5rem;inset-inline-end:0;font-size:1rem}#sidebar-nav details summary .line{flex:1;margin-inline:.5rem 1.5rem;background-color:var(--bg-muted-2);height:0.0625rem}#sidebar-nav details ul{margin-block:0}#sidebar-nav ul{display:flex;flex-direction:column;gap:.25rem;margin-block:auto;padding-inline-start:0;list-style:none}#sidebar-nav li{display:flex;transition:width var(--transition);margin-block:0;padding-inline-start:0;width:100%}#sidebar-meta{position:absolute;transform:perspective(var(--content-width)) rotateY(-30deg);transform-origin:left;visibility:hidden;opacity:0;filter:blur(0.25rem);inset:0}:root[dir*=rtl] #sidebar-meta{transform-origin:right}#sidebar-meta:has(>div:empty) .status-page{display:flex}#sidebar-meta:has(>div:empty)>div{display:none}#sidebar-meta>div{display:flex;flex-direction:column;gap:1rem;margin-block:auto}#sidebar-meta .card{background-color:var(--glass-bg-1)}#sidebar-meta .while-writing{display:inherit;position:relative;flex-direction:inherit;gap:inherit;margin:calc(-0.25rem - 0.0625rem);margin-block-start:1rem;border:0.0625rem solid var(--bg-muted-2);border-block-start:none;border-end-start-radius:calc(var(--rounded-corner) + .25rem + 0.0625rem);border-end-end-radius:calc(var(--rounded-corner) + .25rem + 0.0625rem);padding:.25rem}#sidebar-meta .while-writing .card-title{display:flex;position:absolute;gap:.25rem;inset-block-start:calc(var(--rounded-corner-small)*-1);inset-inline:-0.0625rem;height:var(--rounded-corner-small)}#sidebar-meta .while-writing .card-title .line{border:0.0625rem solid var(--bg-muted-2);border-block-end:none}#sidebar-meta .while-writing .card-title .line:nth-of-type(1){border-inline-end:none;border-start-start-radius:var(--rounded-corner);width:var(--rounded-corner)}#sidebar-meta .while-writing .card-title .line:nth-of-type(2){flex:1;border-inline-start:none;border-start-end-radius:var(--rounded-corner)}#sidebar-meta .while-writing .card-title .title{color:var(--fg-muted-2);font-size:var(--font-size-small);line-height:0}#sidebar-meta .weather{display:flex;justify-content:space-between;align-items:center;gap:1rem;border-radius:1.375rem;padding:.25rem;padding-inline-end:1.25rem;line-height:1}#sidebar-meta .weather.cloudy{animation:perlin 12s linear infinite;background-image:url("/weather/perlin.png")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.cloudy{background-image:url("/weather/perlin-dark.png")}}#sidebar-meta .weather.showers{animation:rain 3s linear infinite;background-image:url("/weather/rain.svg")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.showers{background-image:url("/weather/rain-dark.svg")}}#sidebar-meta .weather.snowing{animation:snow 12s linear infinite;background-image:url("/weather/snow.svg")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.snowing{background-image:url("/weather/snow-dark.svg")}}#sidebar-meta .weather div{display:flex;align-items:center;gap:.5rem}#sidebar-meta .weather .title{color:inherit;font-size:var(--font-size-medium)}#sidebar-meta .weather .icon{box-shadow:var(--edge-highlight);border-radius:50%;background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#sidebar-meta .weather .condition{color:var(--fg-muted-2);font-size:var(--font-size-small)}@keyframes rain{to{background-position:256px 320px}}@keyframes perlin{to{background-position:-256px -64px}}@keyframes snow{to{background-position:256px 512px}}#sidebar-meta .music{display:flex;position:relative;flex-direction:column;gap:.5rem;border-radius:1rem}#sidebar-meta .music::after{position:absolute;z-index:1;inset:0;box-shadow:var(--edge-highlight);border-radius:inherit;max-height:5rem;pointer-events:none;content:""}#sidebar-meta .music .carousel{scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;display:flex;margin-block:0;box-shadow:var(--shadow);border-radius:inherit;background-color:var(--glass-bg-1);padding-inline-start:0;overflow-x:auto;scroll-behavior:smooth;scrollbar-width:none;list-style:none}#sidebar-meta .music .carousel li{display:flex;position:relative;flex-shrink:0;gap:1rem;margin-block:0;outline:none;padding:.5rem;width:100%;overflow:hidden;scroll-snap-align:start}#sidebar-meta .music .carousel li.has-cover{animation:cover-alternate 60s ease-in-out infinite;background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:2000%}@keyframes cover-alternate{from{background-position:50% 0%}12.5%{background-position:100% 25%}25%{background-position:50% 50%}37.5%{background-position:100% 75%}50%{background-position:50% 100%}62.5%{background-position:0% 75%}75%{background-position:50% 50%}87.5%{background-position:0% 25%}to{background-position:50% 0%}}#sidebar-meta .music .carousel li.has-cover .music-type{color:var(--fg-muted-neutral-1)}#sidebar-meta .music .carousel li.has-cover .music-artist{color:var(--fg-muted-neutral-2)}#sidebar-meta .music .carousel li>div{display:flex;flex:1;flex-direction:column;justify-content:space-between;gap:.25rem;margin-inline-end:.5rem;margin-block:.25rem;min-width:0;min-width:0;line-height:1}#sidebar-meta .music .carousel li a{position:absolute;z-index:1;inset:0;border-radius:inherit}#sidebar-meta .music .music-type{color:var(--fg-muted-1);font-size:var(--font-size-xx-small)}#sidebar-meta .music .music-artist{color:var(--fg-muted-2);font-size:var(--font-size-small)}#sidebar-meta .music .title{color:var(--fg-color);font-size:var(--font-size-medium)}#sidebar-meta .music img{margin:0;border-radius:var(--rounded-corner-small)}#sidebar-meta .music-paginator{display:flex;justify-content:center;gap:.25rem;margin-inline:1rem;margin-block:0;padding-inline-start:0;list-style:none}#sidebar-meta .music-paginator li{display:flex;margin-block:0;padding-inline-start:0;height:.75rem}#sidebar-meta .music-paginator a{transition:width var(--transition),height var(--transition),background-color var(--transition),transform var(--transition-bezier);margin-block:auto;border-radius:.375rem;background-color:var(--fg-muted-1);width:1.5rem;height:.375rem}#sidebar-meta .music-paginator a:hover{background-color:var(--fg-muted-2)}#sidebar-meta .music-paginator a:active{transform:var(--active)}#sidebar-meta .music-paginator a.active{background-color:var(--fg-color);width:.75rem;height:.75rem}#sidebar-meta .toc{display:flex;flex-direction:column;max-height:calc(var(--content-width)/3)}@media only screen and (max-width: 720px){#sidebar-meta .toc{max-height:calc(var(--content-width)/2)}}#sidebar-meta .toc .title{margin-block-end:1rem}#sidebar-meta .toc .overshoot{flex:1;margin:-1rem;padding:1rem;overflow:auto;scrollbar-width:thin}#sidebar-meta .toc .overshoot ul{margin-block:0;padding-inline-start:1rem;color:var(--fg-muted-1);font-size:var(--font-size-small)}#sidebar-meta .toc .overshoot li{margin-block:0;padding-inline-start:0}#sidebar-meta .toc .overshoot li::marker{color:inherit}#sidebar-meta .toc .overshoot a{color:inherit;text-decoration:none}#sidebar-meta .status-page{display:none;margin-block:auto;text-align:center}#sidebar-meta .status-page .title,#sidebar-meta .status-page p{color:var(--fg-muted-2)}#sidebar-meta .status-page .icon{margin-inline:auto;color:var(--fg-muted-1);font-size:3rem}#sidebar-meta .status-page p{margin-block:0}[class^=markdown-alert]{display:flow-root;position:relative}[class^=markdown-alert]::before{margin-inline-start:1.5rem;content:var(--alert-title);color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}[class^=markdown-alert]::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--alert-icon);position:absolute;margin-inline-end:.25rem;inset-block-start:0.0625rem;inset-inline-start:.75rem;color:var(--accent-color);font-size:1.25rem}[class^=markdown-alert]>:first-child{margin-block-start:.25rem}[class^=markdown-alert]>:last-child{margin-block-end:0}.markdown-alert-note,.markdown-alert-tip,.markdown-alert-important,.markdown-alert-warning,.markdown-alert-caution,.markdown-alert-red,.markdown-alert-orange,.markdown-alert-yellow,.markdown-alert-green,.markdown-alert-blue,.markdown-alert-purple{--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem}@media(prefers-color-scheme: dark){.markdown-alert-note,.markdown-alert-tip,.markdown-alert-important,.markdown-alert-warning,.markdown-alert-caution,.markdown-alert-red,.markdown-alert-orange,.markdown-alert-yellow,.markdown-alert-green,.markdown-alert-blue,.markdown-alert-purple{--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) )}}.markdown-alert-note::before{content:var(--alert-note-title)}.markdown-alert-note::after{--icon: var(--icon-info)}.markdown-alert-tip::before{content:var(--alert-tip-title)}.markdown-alert-tip::after{--icon: var(--icon-lightbulb)}.markdown-alert-important::before{content:var(--alert-important-title)}.markdown-alert-important::after{--icon: var(--icon-warning-diamond)}.markdown-alert-warning::before{content:var(--alert-warning-title)}.markdown-alert-warning::after{--icon: var(--icon-warning)}.markdown-alert-caution::before{content:var(--alert-caution-title)}.markdown-alert-caution::after{--icon: var(--icon-warning-octagon)}.markdown-alert-caution,.markdown-alert-red{--accent-h: var(--red-h);--accent-s: var(--red-s);--accent-l: var(--red-l);--accent-color: var(--red-fg)}.markdown-alert-orange{--accent-h: var(--orange-h);--accent-s: var(--orange-s);--accent-l: var(--orange-l);--accent-color: var(--orange-fg)}.markdown-alert-warning,.markdown-alert-yellow{--accent-h: var(--yellow-h);--accent-s: var(--yellow-s);--accent-l: var(--yellow-l);--accent-color: var(--yellow-fg)}.markdown-alert-tip,.markdown-alert-green{--accent-h: var(--green-h);--accent-s: var(--green-s);--accent-l: var(--green-l);--accent-color: var(--green-fg)}.markdown-alert-note,.markdown-alert-blue{--accent-h: var(--blue-h);--accent-s: var(--blue-s);--accent-l: var(--blue-l);--accent-color: var(--blue-fg)}.markdown-alert-important,.markdown-alert-purple{--accent-h: var(--purple-h);--accent-s: var(--purple-s);--accent-l: var(--purple-l);--accent-color: var(--purple-fg)}.article-details{display:flex;flex-direction:column;gap:.75rem;margin-block-end:2rem;color:var(--fg-muted-2);font-size:var(--font-size-small)}.article-details .items{display:flex;flex-wrap:wrap;align-items:flex-start;gap:.25rem .5rem}.article-details .item{display:inline-flex;align-items:center;gap:.375rem;border-radius:var(--rounded-corner-small)}.article-details a{color:inherit;font-weight:var(--font-weight-normal);font-size:var(--font-size-small);text-decoration-color:rgba(0,0,0,0)}.article-details a:hover{text-decoration-style:solid;text-decoration-color:var(--fg-muted-1)}.article-details .tooltip{cursor:help}.article-details .icon{box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:.375rem;color:var(--accent-color);font-size:1.25rem}.article-details .tags{display:flex;flex-wrap:wrap;gap:0 .5rem;margin:0;border-block-start:0.0625rem solid var(--bg-muted-2);padding-inline-start:0;padding-block-start:.5rem;width:100%;list-style:none}.article-details .tags li{display:flex;margin-block:0;padding:0}.article-details .tags a::before{content:"#"}.article-list{display:grid;grid-template-columns:repeat(auto-fill, minmax(calc(var(--content-width) / 2.5), 1fr));gap:1rem;margin-block-start:2rem}.article-list.categories article{position:relative}.article-list.categories article:hover .icon:last-child{transform:translateY(-50%) rotate(-10deg) scale(1.5)}.article-list.categories article .icon:last-child{position:absolute;transform:translateY(-50%);z-index:-1;transition:transform var(--transition-bezier);inset-block-start:50%;inset-inline-end:-3rem;color:var(--bg-muted-1);font-size:12rem}.article-list article{display:flex;position:relative;flex-direction:column;justify-content:space-between;box-shadow:var(--edge-highlight),var(--shadow);border-radius:1.25rem;background-color:var(--bg-muted-1);padding:0;overflow:hidden}.article-list article.draft{opacity:var(--disabled-opacity);filter:grayscale(1);transition-duration:var(--transition);transition-property:opacity,filter}.article-list article.draft:hover{opacity:1;filter:none}.article-list article.has-banner.has-accent-color{background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:cover}.article-list article.has-accent-color{--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem}@media(prefers-color-scheme: dark){.article-list article.has-accent-color{--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) )}}.article-list article>div{display:flex;flex:1;flex-direction:column;margin:1rem}.article-list article>div>a{text-decoration:none}.article-list article h3{margin:0;border-inline-start:.25rem solid;border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:.5rem;color:var(--accent-color);font-weight:var(--font-weight-semi-bold);text-decoration:none}.article-list article h3:hover::after{opacity:1}.article-list article h3::after{opacity:0;transition:opacity var(--transition);content:" →"}.article-list article .description p{margin-block-end:.75rem}.article-list article .open-post{--mask-image: linear-gradient( to bottom, hsl(0, 0%, 0%) 0%, hsla(0, 0%, 0%, 0.987) 8.1%, hsla(0, 0%, 0%, 0.951) 15.5%, hsla(0, 0%, 0%, 0.896) 22.5%, hsla(0, 0%, 0%, 0.825) 29%, hsla(0, 0%, 0%, 0.741) 35.3%, hsla(0, 0%, 0%, 0.648) 41.2%, hsla(0, 0%, 0%, 0.55) 47.1%, hsla(0, 0%, 0%, 0.45) 52.9%, hsla(0, 0%, 0%, 0.352) 58.8%, hsla(0, 0%, 0%, 0.259) 64.7%, hsla(0, 0%, 0%, 0.175) 71%, hsla(0, 0%, 0%, 0.104) 77.5%, hsla(0, 0%, 0%, 0.049) 84.5%, hsla(0, 0%, 0%, 0.013) 91.9%, hsla(0, 0%, 0%, 0) 100% );mask-image:var(--mask-image);-webkit-mask-image:var(--mask-image);display:flex;position:relative;justify-content:center;align-items:center;margin-block-end:-2rem;box-shadow:var(--edge-highlight);border-radius:0;aspect-ratio:2/1;overflow:hidden;text-decoration:none}.article-list article .open-post:hover::before{background-color:var(--shade-color)}.article-list article .open-post:hover img{transform:var(--hover);filter:blur(0.25rem)}.article-list article .open-post:hover .icon{transform:none;opacity:1}.article-list article .open-post:active img{transform:none}.article-list article .open-post::before{position:absolute;z-index:1;transition:background-color var(--transition);inset:0;border-radius:inherit;content:""}.article-list article .open-post img{position:absolute;transition-duration:var(--transition);transition-property:transform,filter;margin:0;inset:0;width:100%;height:100%;object-fit:cover}.article-list article .open-post .icon{transform:scale(1.5);opacity:0;z-index:1;transition:transform var(--transition-bezier),opacity var(--transition);color:#fff;font-size:1.25rem}.article-list article .article-details{flex:1;justify-content:flex-end;margin-block-end:0}.article-list article .article-details .tags{margin-block-start:auto;margin-block-end:-0.25rem}.paginator{display:flex;justify-content:center;align-items:center;gap:.25rem;margin-block-start:4rem}.paginator:has(a.paginator-item.previous:active) .paginator-counter{border-start-start-radius:1rem;border-end-start-radius:1rem}.paginator:has(a.paginator-item.next:active) .paginator-counter{border-start-end-radius:1rem;border-end-end-radius:1rem}.paginator-item{display:inline-block;transition:border-radius var(--transition),border-start-end-radius var(--transition),border-end-end-radius var(--transition),border-start-start-radius var(--transition),border-end-start-radius var(--transition),background-color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:1rem;background-color:var(--bg-muted-1);padding:.5rem;color:var(--accent-color);line-height:0;text-decoration:none}.paginator-item:not([aria-disabled=true]):hover{background-color:var(--bg-muted-2)}.paginator-item:not([aria-disabled=true]):active{transform:var(--active);border-radius:1rem}.paginator-item-previous{border-start-end-radius:var(--rounded-corner-small);border-end-end-radius:var(--rounded-corner-small)}.paginator-item-next{border-start-start-radius:var(--rounded-corner-small);border-end-start-radius:var(--rounded-corner-small)}.paginator-counter{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);display:inline-block;transition:var(--transition-bezier);transition-property:border-start-start-radius,border-end-start-radius,border-start-end-radius,border-end-end-radius;border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:.25rem .5rem;font-weight:var(--font-weight-semi-bold);font-variant-numeric:tabular-nums}.article-nav{display:flex;gap:.25rem}@media only screen and (max-width: 720px){.article-nav{flex-direction:column}}.article-nav-item{flex:1;transition:box-shadow var(--transition),background-color var(--transition),transform var(--transition-bezier);border-radius:var(--rounded-corner);padding:.75rem 1rem;min-width:0;text-decoration:none}.article-nav-item:hover{box-shadow:var(--edge-highlight),var(--shadow);background-color:var(--bg-muted-1)}.article-nav-item:hover .article-title{color:var(--accent-color)}.article-nav-item:active{transform:var(--active)}.article-nav-item.prev{text-align:start}.article-nav-item.prev .nav-arrow::before{content:"← "}.article-nav-item.next{text-align:end}.article-nav-item.next .nav-arrow::after{content:" →"}.article-nav .nav-arrow{margin-block-end:.5rem;color:var(--fg-muted-2);font-weight:var(--font-weight-normal);font-size:var(--font-size-small)}.article-nav .article-title{display:block;transition:color var(--transition);max-width:100%;overflow:hidden;color:var(--fg-muted-1);text-overflow:ellipsis;white-space:nowrap}.audio{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;padding-block:0;color:var(--accent-color);font-size:var(--font-size-medium)}.audio:hover{color:var(--accent-color) !important}.audio:hover .icon{animation:speaker var(--transition-long) ease-out infinite}@keyframes speaker{from{--icon: var(--icon-speaker-none)}50%{--icon: var(--icon-speaker-low)}to{--icon: var(--icon-speaker-high)}}.audio:active{transform:var(--active)}.audio .icon{vertical-align:-0.1875rem;margin-inline-start:.25rem}body.has-banner{background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:cover;background-attachment:fixed}#banner{--mask: linear-gradient(black calc(100% - 2rem), transparent);-webkit-mask-image:var(--mask);display:flex;grid-area:banner;justify-content:center;mask-image:var(--mask);margin-block-end:-2.5rem;padding-block:3.25rem 2.5rem;width:100%}#banner div{position:relative;margin-inline:1rem;aspect-ratio:2/1;width:100%;max-width:var(--content-width)}#banner img{position:absolute;margin:0;inset:0;border-radius:var(--rounded-corner) var(--rounded-corner) 0 0 !important;aspect-ratio:2/1;width:100%;object-fit:cover}#banner img:nth-of-type(1){opacity:var(--dim-opacity);z-index:-1;filter:blur(1.5rem);animation:banner-glow-load calc(.525s + var(--transition-long));inset-block-start:1rem}@keyframes banner-glow-load{from,52.5%{opacity:0}}#banner img:nth-of-type(2){transform-origin:bottom;animation:banner-load calc(.525s + var(--transition-longer))}@keyframes banner-load{from,52.5%{transform:translateY(2rem) scale(0.95);opacity:0}}#banner+#main-content{margin-block-start:0}#banner+#main-content article>h1:first-of-type,#banner+#main-content h1:first-of-type{margin-inline:-1rem;margin-block:0;border-radius:var(--rounded-corner) var(--rounded-corner) 0 0;padding:1rem;width:unset;-webkit-backdrop-filter:blur(1rem);backdrop-filter:blur(1rem);animation:banner-heading calc(.525s + var(--transition-longer));box-shadow:inset 0 0.0625rem 0 var(--accent-highlight);background-image:linear-gradient(to bottom, var(--bg-muted-1), transparent 50%);background-size:auto 200%;text-decoration:none}@keyframes banner-heading{from,52.5%{box-shadow:none;background-position-y:100%}}#comments #qrcode{float:inline-end;cursor:help;box-sizing:content-box;margin-inline-start:1rem;margin-block-start:3rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:#fff;padding:.75rem;width:7.25rem;height:7.25rem}#comments #qrcode svg{pointer-events:none}@media only screen and (max-width: 720px){#comments #qrcode{display:none}}#comments #comments-wrapper{display:flex;flex-direction:column;gap:2rem;margin-block-start:2rem}#comments #comments-wrapper #comments-status{color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-x-large);text-align:center}#comments #comments-wrapper .comment{display:grid;grid-template-columns:auto 1fr;grid-template-areas:"avatar header " "...... post " "...... media " "...... card " "...... interactions";column-gap:1rem;justify-items:start;animation:slide-in-right var(--transition-bezier)}:root[dir*=rtl] #comments #comments-wrapper .comment{animation:slide-in-left var(--transition-bezier)}@media only screen and (max-width: 720px){#comments #comments-wrapper .comment{grid-template-areas:"avatar header " "post post " "media media " "card card " "interactions interactions";align-items:start}}#comments #comments-wrapper .comment.comment-reply{position:relative;border-inline-start:.25rem solid var(--bg-muted-2);border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:1rem}#comments #comments-wrapper .comment.comment-reply:has(+.comment-reply){border-end-start-radius:0}#comments #comments-wrapper .comment.comment-reply+.comment-reply{margin-block-start:-2rem;border-start-start-radius:0;padding-block-start:2rem}#comments #comments-wrapper .comment .avatar-link{grid-area:avatar;width:4rem;height:4rem}@media only screen and (max-width: 480px){#comments #comments-wrapper .comment .avatar-link{width:3rem;height:3rem}}#comments #comments-wrapper .comment .avatar-link .avatar{transition:transform var(--transition-bezier);margin:0;background-size:cover;width:100%;height:100%}#comments #comments-wrapper .comment .avatar-link .avatar:hover{transform:rotate(10deg) var(--hover);border-radius:var(--rounded-corner)}#comments #comments-wrapper .comment .avatar-link .avatar:active{transform:var(--active)}#comments #comments-wrapper .comment .mention{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;display:inline-block}#comments #comments-wrapper .comment .mention:hover{background-color:var(--bg-muted-2)}#comments #comments-wrapper .comment .mention:active{transform:var(--active)}#comments #comments-wrapper .comment .invisible{display:none}#comments #comments-wrapper .comment .ellipsis::after{content:"…"}#comments #comments-wrapper .comment header{display:flex;grid-area:header;flex-direction:column;width:100%;min-width:0;color:var(--fg-muted-1);line-height:1}#comments #comments-wrapper .comment header .display,#comments #comments-wrapper .comment header .instance{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#comments #comments-wrapper .comment header .display{color:var(--fg-color);line-height:1.5rem;text-decoration:none}#comments #comments-wrapper .comment header .display .emoji{cursor:unset}#comments #comments-wrapper .comment header .display .emoji:hover{transform:none}#comments #comments-wrapper .comment header .instance{margin-block-start:.25rem;font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-x-small)}#comments #comments-wrapper .comment header .instance.op{cursor:help}#comments #comments-wrapper .comment header .instance.op::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-seal-check);display:inline-block;vertical-align:text-top;margin-inline-end:.25rem;font-size:1rem}#comments #comments-wrapper .comment header .timestamp{margin-block-start:.25rem}#comments #comments-wrapper .comment header .timestamp a{color:inherit;font-size:var(--font-size-x-small);text-decoration-color:rgba(0,0,0,0)}#comments #comments-wrapper .comment header .timestamp a:hover{text-decoration-style:solid;text-decoration-color:inherit}#comments #comments-wrapper .comment header .timestamp a::after{vertical-align:top}#comments #comments-wrapper .comment details{border-radius:var(--rounded-corner-small);background-image:linear-gradient(to right, transparent, transparent 0.5rem, var(--bg-muted-1) 0.5rem, var(--bg-muted-1) calc(100% - 0.5rem), transparent calc(100% - 0.5rem), transparent),linear-gradient(to right, transparent, transparent 0.5rem, var(--bg-color) 0.5rem, var(--bg-color) calc(100% - 0.5rem), transparent calc(100% - 0.5rem), transparent),repeating-linear-gradient(45deg, var(--contrast-color), var(--contrast-color) 0.25rem, var(--accent-color) 0.25rem, var(--accent-color) 0.5rem);width:100%}#comments #comments-wrapper .comment details summary{border-radius:0;background-image:none}#comments #comments-wrapper .comment main{grid-area:post;justify-self:stretch;margin-block-start:1rem;padding:0}#comments #comments-wrapper .comment main :first-child{margin-block-start:0}#comments #comments-wrapper .comment main :last-child{margin-block-end:0}#comments #comments-wrapper .comment .attachments{display:grid;grid-template-columns:repeat(auto-fit, minmax(16rem, 1fr));grid-area:media;gap:.5rem;margin-block-start:1rem}#comments #comments-wrapper .comment .attachments img,#comments #comments-wrapper .comment .attachments video{margin:0}#comments #comments-wrapper .comment .meta-card{grid-area:card;transition:transform var(--transition-bezier);margin-block-start:1rem;width:min(var(--content-width)/2,100%);font-weight:var(--font-weight-normal);text-decoration:none}#comments #comments-wrapper .comment .meta-card:hover img{transform:var(--hover);box-shadow:var(--edge-highlight),var(--shadow-raised);border-radius:var(--rounded-corner-small)}#comments #comments-wrapper .comment .meta-card:hover figcaption{border-radius:var(--rounded-corner);background-color:var(--bg-muted-2)}#comments #comments-wrapper .comment .meta-card:active{transform:var(--active)}#comments #comments-wrapper .comment .meta-card figure{display:flex;flex-direction:column;gap:.25rem;margin:0}#comments #comments-wrapper .comment .meta-card figure img{margin:0;border-radius:var(--rounded-corner) var(--rounded-corner) var(--rounded-corner-small) var(--rounded-corner-small);aspect-ratio:16/9;object-fit:cover}#comments #comments-wrapper .comment .meta-card figure img+figcaption{border-radius:var(--rounded-corner-small) var(--rounded-corner-small) var(--rounded-corner) var(--rounded-corner)}#comments #comments-wrapper .comment .meta-card figure figcaption{display:flex;flex-direction:column;gap:.25rem;transition-duration:var(--transition);transition-property:border-radius,background-color;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;color:var(--fg-color);font-size:var(--font-size-medium);text-align:start}#comments #comments-wrapper .comment .meta-card figure figcaption p{margin:0;color:var(--fg-muted-2);font-size:var(--font-size-small)}#comments #comments-wrapper .comment footer{display:flex;grid-area:interactions;align-items:center;gap:.25rem;margin-block-start:1rem;width:100%;min-width:0}@media only screen and (max-width: 480px){#comments #comments-wrapper .comment footer{flex-wrap:wrap}}#comments #comments-wrapper .comment footer .boosts,#comments #comments-wrapper .comment footer .faves,#comments #comments-wrapper .comment footer .reaction{padding:.375rem .75rem;font-variant-numeric:tabular-nums;white-space:nowrap}#comments #comments-wrapper .comment footer .boosts,#comments #comments-wrapper .comment footer .faves{transition:box-shadow var(--transition),border-radius var(--transition),transform var(--transition-bezier);border-radius:1.125rem;text-decoration:none}#comments #comments-wrapper .comment footer .boosts .icon,#comments #comments-wrapper .comment footer .faves .icon{vertical-align:-0.125rem;transition:transform var(--transition-bezier);margin-inline-end:.25rem}#comments #comments-wrapper .comment footer .boosts:hover,#comments #comments-wrapper .comment footer .faves:hover{box-shadow:var(--edge-highlight);text-decoration:none}#comments #comments-wrapper .comment footer .boosts:active,#comments #comments-wrapper .comment footer .faves:active{transform:var(--active);border-radius:1.125rem}#comments #comments-wrapper .comment footer .boosts{border-start-end-radius:var(--rounded-corner-small);border-end-end-radius:var(--rounded-corner-small);padding-inline-end:.625rem;color:var(--purple-fg)}#comments #comments-wrapper .comment footer .boosts .icon{--icon: var(--icon-repeat)}#comments #comments-wrapper .comment footer .boosts:hover{background-color:var(--purple-bg-1)}#comments #comments-wrapper .comment footer .boosts:hover .icon{transform:rotate(180deg)}#comments #comments-wrapper .comment footer .faves{border-start-start-radius:var(--rounded-corner-small);border-end-start-radius:var(--rounded-corner-small);padding-inline-start:.625rem;color:var(--yellow-fg)}#comments #comments-wrapper .comment footer .faves .icon{--icon: var(--icon-star)}#comments #comments-wrapper .comment footer .faves:hover{background-color:var(--yellow-bg-1)}#comments #comments-wrapper .comment footer .faves:hover .icon{transform:rotate(72deg)}#comments #comments-wrapper .comment footer .reactions{display:flex;gap:.25rem;margin:-0.5rem -1rem;padding:.5rem 1rem;min-width:0;overflow-x:auto;scrollbar-width:none;color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}#comments #comments-wrapper .comment footer .reactions .reaction{display:flex;flex-shrink:0;align-items:center;gap:.25rem;cursor:zoom-in;border:0.0625rem solid var(--bg-muted-2);border-radius:1.1875rem}#comments #comments-wrapper .comment footer .reactions .reaction:hover .emoji{transform:scale(2)}#comments #comments-wrapper .comment footer .reactions .reaction .emoji{cursor:unset}#comments #comments-wrapper .comment footer .reactions .reaction span{font-variant-numeric:tabular-nums}.emoji{display:inline-block;vertical-align:bottom;transition:transform var(--transition-bezier);cursor:zoom-in;margin:0;box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0);width:1.5rem;height:1.5rem}.emoji:hover{transform:scale(2)}.emoji.big{width:3rem;height:3rem}.footnotes-list p{margin-block:0}.footnote-reference{display:inline-flex;vertical-align:baseline;transition:background-color var(--transition),outline-offset calc(var(--transition-long)*3),transform var(--transition-bezier);margin-inline-start:.25rem;outline-offset:calc(var(--content-width)/2) !important;border-radius:.6875em;background-color:var(--fg-muted-1);line-height:1.375em}.footnote-reference:hover{background-color:var(--accent-color)}.footnote-reference:hover a::after{mask-position:center 1.125rem}.footnote-reference:active{transform:var(--active)}.footnote-reference:target{animation:none !important;outline-offset:-0.1875rem !important;background-color:var(--accent-color)}.footnote-reference a{flex:1;border-radius:inherit;padding-inline:0.5625rem 0.375rem;color:var(--bg-color);font-size:var(--font-size-small);font-variant-numeric:tabular-nums;text-align:center;text-decoration:none}.footnote-reference a::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-caret-down);display:inline-block;vertical-align:-0.4375rem;mask-position:center;mask-size:1em;transition:mask-position var(--transition);margin-inline-start:.25rem;height:1.375rem}.giallo:has(.giallo-ln){padding-inline:0}.giallo-l{display:inline-block;width:100%}.giallo-ln{display:inline-block;margin-right:1ch;min-width:3ch;user-select:none;text-align:right}.icon{font-style:normal;font-variant:normal;font-weight:normal;line-height:1;user-select:none;text-transform:none;-webkit-user-select:none;display:inline-block;box-sizing:content-box}.icon::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:""}iframe.vimeo-embed,iframe.youtube-embed{aspect-ratio:16/9}.marquee{margin-inline:-1rem;padding-inline:1rem;overflow:hidden}.marquee.overshoot-row>*{animation:marquee-scroll calc((var(--text-width) + var(--container-width))/40*1s) linear infinite}@keyframes marquee-scroll{0%,10%{transform:translateX(0)}45%,55%{transform:translateX(calc((var(--container-width) - var(--text-width)) * 1px))}90%,100%{transform:translateX(0)}}.marquee>*{display:inline-block;white-space:nowrap}picture#not-found{display:block}picture#not-found img{width:8rem}img#not-found{width:8rem}.pre-container{position:relative;margin:1rem 0 1rem;box-shadow:var(--shadow);border-radius:var(--rounded-corner)}.pre-container::after{position:absolute;inset:0;box-shadow:var(--edge-highlight);border-radius:inherit;pointer-events:none;content:""}.pre-container .header{display:flex;justify-content:space-between;align-items:center;transition:border-block-end-color var(--transition);border-radius:var(--rounded-corner) var(--rounded-corner) 0 0;border-block-end:0.0625rem solid rgba(0,0,0,0);background-image:linear-gradient(to right, transparent 50%, var(--bg-muted-neutral-1) 75%, transparent 100%);background-size:200%;padding:.25rem}.pre-container .header:not(.z-l-code),.pre-container .header:not(.z-d-code){background-color:var(--bg-muted-1);color:var(--accent-color)}.pre-container .header:not(.z-l-code) button:hover,.pre-container .header:not(.z-d-code) button:hover{background-color:var(--bg-muted-1)}.pre-container .header:not(.z-l-code).active,.pre-container .header:not(.z-d-code).active{border-block-end-color:var(--bg-muted-1)}.pre-container .header:not(.z-l-code).active button,.pre-container .header:not(.z-d-code).active button{background-color:var(--bg-muted-1)}.pre-container .header span{margin-inline-start:.75rem;font-weight:var(--font-weight-semi-bold)}.pre-container .header button{position:relative;transition:box-shadow var(--transition),background-color var(--transition),transform var(--transition-bezier);cursor:pointer;box-shadow:none;border-radius:var(--rounded-corner-small);background-color:rgba(0,0,0,0);padding:.5rem;color:inherit;line-height:0}.pre-container .header button:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-neutral-1)}.pre-container .header button:active{transform:var(--active)}.pre-container .header button:disabled{cursor:not-allowed}.pre-container .header button:disabled:active{transform:none}.pre-container .header button .icon{transition:opacity var(--transition-longer),transform var(--transition-bezier-longer);font-size:1rem}.pre-container .header button .icon.copy{--icon: var(--icon-copy);transform-origin:bottom left}.pre-container .header button .icon.check{--icon: var(--icon-check-square-offset);position:absolute;transform:scale(0.5);transform-origin:top right;opacity:0;inset:.5rem}.pre-container .header.active{animation:active-shimmer var(--transition-long);border-block-end-color:var(--bg-muted-neutral-1)}.pre-container .header.active button{opacity:1}.pre-container .header.active button .icon.copy{transform:scale(0.5);opacity:0}.pre-container .header.active button .icon.check{transform:none;opacity:1}@keyframes active-shimmer{to{background-position-x:-200%}}.pre-container pre{margin:0;box-shadow:none;border-radius:0 0 var(--rounded-corner) var(--rounded-corner)}#search-modal{display:flex;position:fixed;flex-direction:column;align-items:center;visibility:hidden;opacity:0;z-index:9999;transition:visibility var(--transition),opacity var(--transition);inset:0;background-color:var(--shade-color);padding-block-start:15vh;width:100%;height:100vh}#search-modal.active{visibility:visible;opacity:1}#search-modal.active #search-modal-content{transform:none}#search-modal-content{display:flex;flex-direction:column;gap:1rem;transform:var(--hover);backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);transition:border-radius var(--transition),padding var(--transition),max-width var(--transition),transform var(--transition-bezier-longer);margin:-0.5rem;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:2.25rem 2.25rem 1.75rem 1.75rem;background-color:var(--glass-bg-1);padding:1rem;width:calc(100% - 2rem);max-width:calc(var(--content-width)/1.5);overflow:hidden}#search-modal-content:has(#search-results:empty){border-radius:1.75rem;padding:.5rem;max-width:calc(var(--content-width)/2)}#search-modal-content>div{position:relative}#search-modal #search-input{flex-shrink:0;box-shadow:var(--edge-highlight);border-radius:9999px;padding:0 1rem;width:100%;height:2.5rem}#search-modal #search-input:placeholder-shown+div .icon{transform:none;opacity:var(--disabled-opacity);pointer-events:all}#search-modal #search-input+div .icon{position:absolute;transform:translateX(0.5rem);opacity:0;transition:opacity var(--transition),transform var(--transition-bezier);cursor:help;inset-block-start:.625rem;inset-inline-end:1rem;pointer-events:none;color:var(--fg-muted-2);font-size:1.25rem}#search-modal #search-results{display:none;flex:1;flex-direction:column;gap:.5rem;margin:-1rem;padding:1rem;max-height:50vh;overflow:auto;scrollbar-width:thin}#search-modal #search-results .item{display:inline-flex;flex-direction:column;box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem}#search-modal #search-results .item a{text-decoration:none}#search-modal #search-results .item a::after{content:" →"}#search-modal #search-results .item span{margin-block-start:.25rem;border-block-start:rpx(1) solid var(--bg-muted-2);padding-block-start:.25rem;color:var(--fg-muted-2)}#search-modal #search-results .item span strong{color:var(--fg-color)}#skip-to-content{position:absolute;transform:translateX(-50%) translateY(calc(-100% - 1rem));z-index:9999;transition:backdrop-filter var(--transition),box-shadow var(--transition),transform var(--transition-bezier);inset-inline-start:50vw;border-radius:9999px;background-color:var(--glass-bg-1);padding:.375rem .75rem;width:max-content;text-decoration:none}#skip-to-content:focus-visible,#skip-to-content:focus{-webkit-backdrop-filter:var(--blur);transform:translateX(-50%) translateY(1rem);backdrop-filter:var(--blur);box-shadow:var(--edge-highlight),var(--shadow)}.statement-container{margin:1rem 0;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);padding:1rem}.statement-container :last-child{margin-block-end:0}.statement-container>:nth-child(2){margin-block-start:.5rem}.statement-container .title{font-weight:var(--font-weight-semi-bold)}.statement-container .title .icon{vertical-align:-0.125rem;margin-inline-end:.25rem}.statement-container.archive{--accent-color: var(--purple-fg);--accent-highlight: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--disabled-opacity) );background-color:var(--purple-bg-1);color:var(--purple-fg)}.statement-container.disclaimer{--accent-color: var(--red-fg);--accent-highlight: hsla( var(--red-h), var(--red-s), var(--red-l), var(--disabled-opacity) );background-color:var(--red-bg-1);color:var(--red-fg)}.statement-container.trigger{--accent-color: var(--yellow-fg);--accent-highlight: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--disabled-opacity) );background-color:var(--yellow-bg-1);color:var(--yellow-fg)}#tag-chips{display:flex;flex-wrap:wrap;gap:.25rem;margin:0;padding-inline-start:0;list-style:none}#tag-chips li{display:flex;margin-block:0;padding-inline-start:0}#tag-chips a{display:flex;transition:background-color var(--transition),color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:1rem;background-color:var(--bg-muted-1);padding:.25rem;padding-inline-start:.75rem;color:var(--fg-muted-1);font-size:var(--font-size-small);line-height:1.5rem;text-decoration:none}#tag-chips a:hover{background-color:var(--bg-muted-2);color:var(--fg-muted-2)}#tag-chips a:active{transform:var(--active)}#tag-chips a::before{content:"#"}#tag-chips a .counter{transition:inherit;margin-inline-start:.375rem;box-shadow:var(--edge-highlight);border-radius:inherit;background-color:inherit;aspect-ratio:1/1;height:1.5rem;line-height:1.5rem;font-variant-numeric:tabular-nums;text-align:center}h1:hover .zola-anchor,h2:hover .zola-anchor,h3:hover .zola-anchor,h4:hover .zola-anchor,h5:hover .zola-anchor,h6:hover .zola-anchor{transform:none;opacity:1}h1:has(.zola-anchor),h2:has(.zola-anchor),h3:has(.zola-anchor),h4:has(.zola-anchor),h5:has(.zola-anchor),h6:has(.zola-anchor){position:relative}.zola-anchor{position:absolute;transform:translateX(0.25em);opacity:0;transition:color var(--transition),opacity var(--transition),transform var(--transition-bezier);inset-inline-start:-1.25em;padding-inline-end:.25em;color:var(--fg-muted-1);line-height:1}@media only screen and (max-width: 720px){.zola-anchor{display:none}}.zola-anchor:hover{color:var(--fg-muted-2)}.zola-anchor:active{transform:var(--active)}.zola-anchor:focus-visible{opacity:1}:target .zola-anchor{transform:none;opacity:var(--disabled-opacity)}body>div:last-child:has(div:last-child[style]:not([class]):not([id])){z-index:9999 !important;background-color:var(--shade-color) !important}body>div:last-child>div:last-child[style]:not([class]):not([id]){box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:calc(var(--rounded-corner) + 1rem) !important;background-color:hsla(var(--red-h), calc(var(--red-s) - var(--red-s) * 0.7), calc(var(--red-l) + (100% - var(--red-l)) * 0.9), var(--dim-opacity)) !important;padding:1rem !important;width:fit-content;max-width:calc(100% - 2rem);-webkit-backdrop-filter:var(--blur);backdrop-filter:var(--blur);filter:none !important}@media(prefers-color-scheme: dark){body>div:last-child>div:last-child[style]:not([class]):not([id]){background-color:hsla(var(--red-h), calc(var(--red-s) - var(--red-s) * 0.6), calc(var(--red-l) - var(--red-l) * 0.8), var(--dim-opacity)) !important}}body>div:last-child>div:last-child[style]:not([class]):not([id])>p[style]:first-child{margin-block-end:1rem !important;color:var(--red-fg) !important;font-weight:var(--font-weight-semi-bold) !important;font-size:var(--font-size-large) !important}body>div:last-child>div:last-child[style]:not([class]):not([id])>pre[style]:last-child{border-radius:var(--rounded-corner) !important;background-color:var(--red-bg-1) !important;padding:.75rem 1rem !important;color:var(--red-fg) !important;font-weight:var(--font-weight-normal) !important} diff --git a/modules/amezing/styles/vars.css b/modules/amezing/styles/vars.css new file mode 100644 index 0000000..82e9b14 --- /dev/null +++ b/modules/amezing/styles/vars.css @@ -0,0 +1,20 @@ +:root { + --alert-note-title: "Note"; + --alert-tip-title: "Tip"; + --alert-important-title: "Important"; + --alert-warning-title: "Warning"; + --alert-caution-title: "Caution"; + + --font-sans-serif: "Commissioner"; + --font-system-ui: ui-sans-serif, system-ui, sans-serif; + --font-emoji: "Noto Color Emoji"; + --font-monospace: "Commit Mono"; + + --icon-info: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/info.svg"); + --icon-lightbulb: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/lightbulb.svg"); + --icon-warning-diamond: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning-diamond.svg"); + --icon-warning: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning.svg"); + --icon-warning-octagon: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning-octagon.svg"); + --icon-copy: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/copy.svg"); + --icon-check-square-offset: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/check-square-offset.svg"); +} diff --git a/modules/analysis/README.md b/modules/analysis/README.md new file mode 100644 index 0000000..95336bf --- /dev/null +++ b/modules/analysis/README.md @@ -0,0 +1 @@ +# Static rendered analysis \ No newline at end of file diff --git a/modules/analysis/index.ts b/modules/analysis/index.ts new file mode 100644 index 0000000..2207c76 --- /dev/null +++ b/modules/analysis/index.ts @@ -0,0 +1,56 @@ +import { chromium } from "playwright"; +import type { Integration } from "muddle/contracts"; +import { inspectDocument } from "./inspect.ts"; + +export interface AuditFinding { + rule: string; + selector: string; + severity: "error" | "warning" | "review"; + message: string; + evidence: Record; +} +export interface AuditReport { + viewport: { width: number; height: number }; + findings: AuditFinding[]; + score: number; + checked: number; + method: "static-rendered-heuristic"; +} +export interface AuditOptions { + viewports?: { width: number; height: number }[]; + ignore?: string[]; + failBelow?: number; + failOnError?: boolean; + includeDisclosures?: boolean; +} + +export async function analyzeHtml(html: string, kind: "accessible" | "overflow", options: AuditOptions = {}): Promise { + const browser = await chromium.launch(); + try { + const reports: AuditReport[] = []; + for (const viewport of options.viewports ?? [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { + const context = await browser.newContext({ viewport, javaScriptEnabled: false }); + try { + const page = await context.newPage(); + await page.route("**/*", (route) => route.abort()); + await page.setContent(html, { waitUntil: "load" }); + const result = await page.evaluate(inspectDocument, { kind, ignore: options.ignore ?? [], disclosures: options.includeDisclosures ?? false }); + const findings = result.findings as AuditFinding[]; + const score = Math.max(0, Math.round(100 - findings.reduce((sum, finding) => sum + (finding.severity === "error" ? 10 : finding.severity === "warning" ? 4 : 2), 0))); + const report: AuditReport = { viewport, findings, score, checked: result.checked, method: "static-rendered-heuristic" }; + reports.push(report); + if (options.failBelow !== undefined && score < options.failBelow) throw new Error(`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`); + if (options.failOnError && findings.some((finding) => finding.severity === "error")) throw new Error(`${kind} audit found errors at ${viewport.width}px`); + } finally { await context.close(); } + } + return reports; + } finally { await browser.close(); } +} + +export function accessibleIntegration(options: AuditOptions = {}): Integration { + return { name: "accessible", analyze: (page) => analyzeHtml(page.html, "accessible", options) }; +} + +export function overflowIntegration(options: AuditOptions = {}): Integration { + return { name: "overflow", analyze: (page) => analyzeHtml(page.html, "overflow", options) }; +} diff --git a/modules/analysis/inspect.ts b/modules/analysis/inspect.ts new file mode 100644 index 0000000..d159e94 --- /dev/null +++ b/modules/analysis/inspect.ts @@ -0,0 +1,75 @@ +/** Runs inside the browser against static DOM and resolved CSS, without executing page scripts. */ +export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean }) { + const { document, getComputedStyle, innerWidth } = globalThis as any; + const findings: { rule: string; selector: string; severity: "error" | "warning" | "review"; message: string; evidence: Record }[] = []; + const elements = [...document.querySelectorAll("body *")]; + if (options.disclosures) for (const element of elements) if (element.tagName === "DETAILS") element.open = true; + const selector = (node: any): string => { + if (node.id) return `[id=${JSON.stringify(node.id)}]`; + const parts: string[] = []; + while (node && node.tagName !== "BODY") { + const index = [...node.parentElement.children].indexOf(node) + 1; + parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`); + node = node.parentElement; + } + return `body > ${parts.join(" > ")}`; + }; + const add = (node: any, rule: string, message: string, evidence: Record = {}, severity: "error" | "warning" | "review" = "error") => findings.push({ selector: selector(node), rule, message, evidence, severity }); + const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + const color = (value: string): number[] => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; }; + const composite = (front: number[], back: number[]): number[] => front.slice(0, 3).map((channel, index) => channel * front[3]! / 255 + back[index]! * (1 - front[3]! / 255)).concat(255); + const luminance = (rgb: number[]): number => rgb.slice(0, 3).map((value) => { const c = value / 255; return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0); + let checked = 0; + for (const node of elements) { + if (options.ignore.some((ignore) => node.closest(ignore))) continue; + const style = getComputedStyle(node); + const box = node.getBoundingClientRect(); + if (!box.width || !box.height || style.display === "none" || style.visibility !== "visible" || node.closest("[hidden], [inert]")) continue; + checked++; + if (options.kind === "overflow") { + const clippedX = ["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; + const clippedY = ["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1; + const truncated = style.textOverflow === "ellipsis" || (style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0); + if ((clippedX || clippedY) && !truncated) add(node, "clipping", "Content exceeds a clipping boundary", { overflowX: style.overflowX, overflowY: style.overflowY, clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight }); + let managed = false; + for (let parent = node.parentElement; parent; parent = parent.parentElement) if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(parent).overflowX)) { managed = true; break; } + if (!managed && (box.right > innerWidth + 1 || box.left < -1)) add(node, "viewport-overflow", "Element extends outside the viewport without a containing scroll region", { left: box.left, right: box.right, viewportWidth: innerWidth }); + continue; + } + const label = node.getAttribute("aria-label") || (node.getAttribute("aria-labelledby") || "").split(/\s+/).map((id: string) => document.getElementById(id)?.textContent || "").join("").trim() || [...(node.labels || [])].map((label: any) => label.textContent).join("").trim(); + for (const id of (node.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean)) if (!document.getElementById(id)) add(node, "aria-reference", "aria-labelledby references a missing element", { id }); + if (["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) && !node.disabled && (box.width < 24 || box.height < 24)) add(node, "target-size", "Interactive target is smaller than 24 CSS pixels; spacing exceptions require review", { width: box.width, height: box.height }, "review"); + if (node.tagName === "IMG" && !node.hasAttribute("alt")) add(node, "image-alt", "Image lacks an alt attribute"); + if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !["hidden", "submit", "button", "reset"].includes(node.type) && !label) add(node, "control-name", "Form control lacks a programmatic label"); + if (["A", "BUTTON"].includes(node.tagName) && !label && !node.textContent.trim() && !node.querySelector("img[alt]")) add(node, "control-name", "Interactive element lacks an accessible name"); + const ownText = [...node.childNodes].some((child: any) => child.nodeType === 3 && child.textContent.trim()); + if (!ownText) continue; + const chain: any[] = []; let opacity = 1; + for (let parent = node; parent; parent = parent.parentElement) { chain.unshift(parent); opacity *= Number(getComputedStyle(parent).opacity); } + let background = [255, 255, 255, 255]; + const effects: Record[] = []; + for (const parent of chain) { + const css = getComputedStyle(parent); + background = composite(color(css.backgroundColor), background); + if (css.filter !== "none" || css.backdropFilter !== "none" || css.mixBlendMode !== "normal" || css.backgroundImage !== "none" || css.clipPath !== "none" || css.maskImage !== "none") effects.push({ selector: selector(parent), filter: css.filter, backdropFilter: css.backdropFilter, blend: css.mixBlendMode, backgroundImage: css.backgroundImage, clipPath: css.clipPath, maskImage: css.maskImage }); + } + const foreground = color(style.color); foreground[3] = foreground[3]! * opacity; + const text = composite(foreground, background); + const l1 = luminance(text), l2 = luminance(background); + const contrast = (Math.max(l1, l2) + .05) / (Math.min(l1, l2) + .05); + const threshold = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700) ? 3 : 4.5; + if (effects.length) add(node, "visibility-effects", "Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive", { effects, opacity, nominalContrast: contrast }, "review"); + if (contrast < threshold) add(node, "text-contrast", "Estimated text contrast falls below the configured text threshold", { contrast, threshold, opacity, estimate: effects.length > 0 }, effects.length ? "review" : "error"); + if (opacity < .5) add(node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning"); + } + if (options.kind === "accessible") { + const ids = new Set(); + for (const node of document.querySelectorAll("[id]")) { if (ids.has(node.id)) add(node, "duplicate-id", "Duplicate identifiers make references ambiguous", { id: node.id }); ids.add(node.id); } + let previous = 0; + for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) { const level = Number(heading.tagName.slice(1)); if (previous && level > previous + 1) add(heading, "heading-order", "Heading hierarchy skips a level", { previous, level }, "warning"); previous = level; } + if (!document.documentElement.lang) findings.push({ selector: "html", rule: "document-language", severity: "error", message: "Document language is missing", evidence: {} }); + if (!document.title.trim()) findings.push({ selector: "head", rule: "document-title", severity: "error", message: "Document title is missing", evidence: {} }); + } + return { findings, checked }; +} diff --git a/modules/analysis/package.json b/modules/analysis/package.json new file mode 100644 index 0000000..8062ef9 --- /dev/null +++ b/modules/analysis/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/analysis", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "peerDependencies": { "muddle": "*", "playwright": "^1.0.0" } +} diff --git a/modules/astro/index.ts b/modules/astro/index.ts new file mode 100644 index 0000000..1f2665f --- /dev/null +++ b/modules/astro/index.ts @@ -0,0 +1,80 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { transform } from "@astrojs/compiler"; +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import ts from "typescript"; +import type { Integration, IntegrationContext } from "muddle/contracts"; + +export interface AstroIntegrationOptions { dir?: string; partials?: boolean } + +function routeFor(root: string, file: string): string { + const path = relative(root, file).replace(/\\/g, "/").replace(/\.astro$/, ""); + return `/${path.replace(/index$/, "").replace(/\/+/g, "/") || ""}` || "/"; +} + +export function astroIntegration(options: AstroIntegrationOptions = {}): Integration { + return { + name: "astro", + async setup(context: IntegrationContext) { + context.loader("astro", { name: "astro", extensions: [".astro"], async load(file) { return { data: {}, content: await renderAstro(file.source, undefined, file.path) }; } }); + const dir = resolve(context.config.root, options.dir ?? "src/pages"); + if (!existsSync(dir)) return; + for (const file of await astroFiles(dir)) { + const route = routeFor(dir, file); + context.route("GET", route, async (request) => new Response(await renderAstro(await readFile(file, "utf8"), request, file), { headers: { "content-type": "text/html; charset=utf-8" } })); + } + }, + }; +} + +async function astroFiles(dir: string): Promise { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await astroFiles(path))); + else if (entry.name.endsWith(".astro")) files.push(path); + } + return files; +} + +export async function renderAstro(source: string, request = new Request("http://localhost/"), filename = resolve("inline.astro")): Promise { + const component = await compileAstro(source, filename); + const container = await AstroContainer.create(); + return container.renderToString(component, { request }); +} + +async function compileAstro(source: string, filename: string, ancestors: string[] = []): Promise { + if (ancestors.includes(filename)) throw new Error(`Circular Astro import ${filename}`); + const result = await transform(source, { filename, internalURL: "astro/runtime/server/index.js" }); + let code = result.code.replace(/,\s*createMetadata as \$\$createMetadata/, "").replace(/export const \$\$metadata = [^\n]+\n/g, ""); + for (const match of [...code.matchAll(/from\s+["']([^"']+)["']/g)]) { + const specifier = match[1]!; + let url: string; + if (specifier.endsWith(".astro")) { + const file = resolve(filename, "..", specifier); + const child = await compileAstro(await readFile(file, "utf8"), file, [...ancestors, filename]); + const key = `__muddle_astro_${crypto.randomUUID().replaceAll("-", "")}`; + (globalThis as Record)[key] = child; + url = `data:text/javascript;base64,${Buffer.from(`export default globalThis[${JSON.stringify(key)}]`).toString("base64")}`; + } else url = specifier.startsWith(".") ? pathToFileURL(resolve(filename, "..", specifier)).href : import.meta.resolve(specifier); + code = code.replaceAll(`"${specifier}"`, JSON.stringify(url)).replaceAll(`'${specifier}'`, JSON.stringify(url)); + } + code = ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText; + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; +} + +/** Import into TemplateContext with `.import("Card", astroComponent("./Card.astro"))`. */ +export function astroComponent(file: string) { + return { + async render(props: Record, context: { params: Record; locals: Record }) { + const path = resolve(file); + const component = await compileAstro(await readFile(path, "utf8"), path); + const container = await AstroContainer.create(); + const slots: Record = {}; + for (const [key, value] of Object.entries(props)) if (typeof value === "function") slots[key === "content" ? "default" : key] = await value(); + return container.renderToString(component, { props, params: context.params, locals: context.locals, slots }); + }, + }; +} diff --git a/modules/astro/package.json b/modules/astro/package.json new file mode 100644 index 0000000..8c3e392 --- /dev/null +++ b/modules/astro/package.json @@ -0,0 +1,14 @@ +{ + "name": "@muddle/astro", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "@astrojs/compiler": "2", + "astro": "5", + "typescript": "5.9.3" + } +} diff --git a/modules/database/index.ts b/modules/database/index.ts new file mode 100644 index 0000000..84f7fd1 --- /dev/null +++ b/modules/database/index.ts @@ -0,0 +1,2 @@ +export * from "./src/index.ts"; +export { DatabaseSessionStore } from "./src/sessions.ts"; diff --git a/modules/database/package.json b/modules/database/package.json new file mode 100644 index 0000000..1ea59a8 --- /dev/null +++ b/modules/database/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/database", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "pg": "^8.23.1" + } +} diff --git a/modules/database/src/adapters/d1.ts b/modules/database/src/adapters/d1.ts new file mode 100644 index 0000000..5ac0006 --- /dev/null +++ b/modules/database/src/adapters/d1.ts @@ -0,0 +1,213 @@ +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, type SqlDialect } from "./driver.ts"; + +export interface D1Options { + /** Account the database belongs to. */ + accountId: string; + /** Identifier of the database. */ + databaseId: string; + /** Api token with the `D1 Database:Edit` permission. */ + apiToken: string; + /** Endpoint of the api, useful for tests. */ + endpoint?: string; +} + +const D1_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "INTEGER", + real: "REAL", + bool: "INTEGER", + blob: "BLOB", + json: "TEXT", + uuid: "TEXT", + timestamp: "TEXT", + date: "TEXT", +}; + +export const d1Dialect: SqlDialect = { + name: "sqlite", + columnType: (type) => D1_TYPES[type], + placeholder: () => "?", + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") return column.default ? "1" : "0"; + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +interface D1Response { + success: boolean; + result?: { + results?: Record[]; + success?: boolean; + meta?: { changes?: number }; + }[]; + errors?: { message: string }[]; +} + +export class D1Driver implements SqlDriver { + readonly dialect = "sqlite" as const; + readonly supportsReturning = true; + + constructor(private readonly options: D1Options) {} + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(_index: number): string { + return "?"; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const response = await this.send(sql, params); + const statement = response.result?.[0]; + return (statement?.results ?? []) as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const response = await this.send(sql, params); + const statement = response.result?.[0]; + return { changes: statement?.meta?.changes ?? 0 }; + } + + /** + * Runs `fn` inside a transaction. + * + * D1 has no interactive transactions, the statements are collected and + * sent as a single batch, which is atomic. + */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const buffered = new D1Buffer(this.options); + const result = await fn(buffered); + await buffered.flush(); + return result; + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute(buildCreateTable(d1Dialect, table, columns, options)); + } + + /** Closes the driver, D1 has no local resources. */ + async close(): Promise {} + + private async send( + sql: string, + params: readonly unknown[], + ): Promise { + const endpoint = + this.options.endpoint ?? + `https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/query`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + authorization: `Bearer ${this.options.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + sql, + params: params.map((value) => normalize(value)), + }), + }); + + const payload = (await response.json()) as D1Response; + if (!response.ok || payload.success === false) { + const message = payload.errors?.[0]?.message ?? response.statusText; + throw new Error(`D1 request failed: ${message}`); + } + return payload; + } +} + +/** Driver that collects the statements of a transaction. */ +class D1Buffer implements SqlDriver { + readonly dialect = "sqlite" as const; + readonly supportsReturning = true; + + private readonly statements: { sql: string; params: unknown[] }[] = []; + private result: unknown; + + constructor(private readonly options: D1Options) {} + + placeholder(_index: number): string { + return "?"; + } + + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + this.statements.push({ sql, params: [...params] }); + this.result = []; + return [] as T[]; + } + + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + this.statements.push({ sql, params: [...params] }); + return { changes: 0 }; + } + + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + return fn(this); + } + + async close(): Promise {} + + async flush(): Promise { + if (this.statements.length === 0) return; + const endpoint = + this.options.endpoint ?? + `https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/batch`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + authorization: `Bearer ${this.options.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + statements: this.statements.map((statement) => ({ + sql: statement.sql, + params: statement.params.map((value) => normalize(value)), + })), + }), + }); + + if (!response.ok) { + throw new Error(`D1 batch failed: ${response.statusText}`); + } + } +} + +function normalize(value: unknown): unknown { + if (value === undefined || value === null) return null; + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "object" && !(value instanceof Uint8Array)) { + return JSON.stringify(value); + } + return value; +} + +/** Creates a Cloudflare D1 driver. */ +export function createD1Driver(options: D1Options): D1Driver { + return new D1Driver(options); +} diff --git a/modules/database/src/adapters/driver.ts b/modules/database/src/adapters/driver.ts new file mode 100644 index 0000000..9ba2967 --- /dev/null +++ b/modules/database/src/adapters/driver.ts @@ -0,0 +1,165 @@ +import type { + ColumnMapping, + FieldType, + TableOptions, +} from "muddle/contracts/typegen/orm/index.ts"; + +/** Anything able to produce a parameter placeholder. */ +export interface PlaceholderSource { + placeholder(index: number): string; +} + +export interface SqlDialect { + readonly name: "sqlite" | "postgres"; + /** SQL type of a field. */ + columnType(type: FieldType): string; + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning: boolean; + /** Default value expression of a column, if any. */ + defaultExpression(column: ColumnMapping): string | undefined; +} + +/** Quotes an SQL identifier. */ +export function quote(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +/** Registers a parameter and returns its placeholder. */ +export function bind( + dialect: PlaceholderSource, + params: unknown[], + value: unknown, +): string { + params.push(value); + return dialect.placeholder(params.length); +} + +export interface Condition { + column: string; + operator: string; + value?: unknown; +} + +/** Builds a `WHERE` clause from the condition groups of the query builder. */ +export function buildWhere( + dialect: PlaceholderSource, + groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[], + params: unknown[], +): string { + if (groups.length === 0) return ""; + const parts = groups.map((group) => { + const conditions = group.conditions.map((condition) => + buildCondition(dialect, condition, params), + ); + if (conditions.length === 1) return conditions[0] as string; + return `(${conditions.join(` ${group.conjunction} `)})`; + }); + return ` WHERE ${parts.join(" AND ")}`; +} + +/** Builds a single condition, e.g. `"age" > $1`. */ +export function buildCondition( + dialect: PlaceholderSource, + condition: Condition, + params: unknown[], +): string { + const column = quote(condition.column); + + if ( + condition.operator === "is null" || + condition.operator === "is not null" + ) { + return `${column} ${condition.operator.toUpperCase()}`; + } + + if (condition.operator === "in" || condition.operator === "not in") { + const values = Array.isArray(condition.value) + ? condition.value + : [condition.value]; + if (values.length === 0) { + return condition.operator === "in" ? "1 = 0" : "1 = 1"; + } + const placeholders = values + .map((value) => bind(dialect, params, value)) + .join(", "); + return `${column} ${condition.operator.toUpperCase()} (${placeholders})`; + } + + return `${column} ${condition.operator} ${bind(dialect, params, condition.value)}`; +} + +/** Builds the `CREATE TABLE` statement of a table. */ +export function buildCreateTable( + dialect: SqlDialect, + table: string, + columns: ColumnMapping[], + options: TableOptions, +): string { + const definitions: string[] = []; + + for (const column of columns) { + const parts = [quote(column.column), dialect.columnType(column.type)]; + if (column.primary) parts.push("PRIMARY KEY"); + if (column.unique) parts.push("UNIQUE"); + if (!column.nullable && !column.primary) parts.push("NOT NULL"); + const defaultExpression = dialect.defaultExpression(column); + if (defaultExpression) parts.push(`DEFAULT ${defaultExpression}`); + if (column.references) { + const target = quote(column.references.table); + const targetColumn = column.references.column + ? `${target}(${quote(column.references.column)})` + : undefined; + parts.push( + `REFERENCES ${targetColumn ?? target}${column.references.onDelete ? ` ON DELETE ${column.references.onDelete}` : ""}${ + column.references.onUpdate + ? ` ON UPDATE ${column.references.onUpdate}` + : "" + }`, + ); + } + definitions.push(parts.join(" ")); + } + + if (options.timestamps) { + definitions.push( + `${quote("created_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`, + ); + definitions.push( + `${quote("updated_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`, + ); + } + + const primaryKey = options.primaryKey + ? Array.isArray(options.primaryKey) + ? options.primaryKey + : [options.primaryKey] + : undefined; + + if (primaryKey && primaryKey.length > 0) { + definitions.push(`PRIMARY KEY (${primaryKey.map(quote).join(", ")})`); + } + + const ifNotExists = options.ifNotExists === false ? "" : "IF NOT EXISTS "; + const suffix = options.suffix ? ` ${options.suffix}` : ""; + + return `CREATE TABLE ${ifNotExists}${quote(table)} (\n ${definitions.join(",\n ")}\n)${suffix}`; +} + +/** Normalises a value before it is handed to the driver. */ +export function normalizeValue(value: unknown): unknown { + if (value === undefined) return null; + if (value instanceof Date) return value; + if (typeof value === "boolean") return value; + if (typeof value === "bigint") return value; + if ( + value !== null && + typeof value === "object" && + !Buffer.isBuffer(value) && + !(value instanceof Uint8Array) + ) { + return JSON.stringify(value); + } + return value; +} diff --git a/modules/database/src/adapters/pg.ts b/modules/database/src/adapters/pg.ts new file mode 100644 index 0000000..3e97c4d --- /dev/null +++ b/modules/database/src/adapters/pg.ts @@ -0,0 +1,118 @@ +import { Pool, type PoolClient, type PoolConfig } from "pg"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, type SqlDialect } from "./driver.ts"; + +export type PostgresOptions = PoolConfig | string; + +const POSTGRES_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "BIGINT", + real: "DOUBLE PRECISION", + bool: "BOOLEAN", + blob: "BYTEA", + json: "JSONB", + uuid: "UUID", + timestamp: "TIMESTAMPTZ", + date: "DATE", +}; + +export const postgresDialect: SqlDialect = { + name: "postgres", + columnType: (type) => POSTGRES_TYPES[type], + placeholder: (index) => `$${index}`, + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") + return column.default ? "TRUE" : "FALSE"; + if (typeof column.default === "object") + return JSON.stringify(column.default); + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +export class PostgresDriver implements SqlDriver { + readonly dialect = "postgres" as const; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning = true; + private readonly pool: Pool | undefined; + private readonly client?: PoolClient; + + constructor(options: PostgresOptions, client?: PoolClient) { + this.pool = client + ? undefined + : new Pool( + typeof options === "string" ? { connectionString: options } : options, + ); + this.client = client; + } + + private get runner(): Pool | PoolClient { + return this.client ?? (this.pool as Pool); + } + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string { + return `$${index}`; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const result = await this.runner.query(sql, [...params] as unknown[]); + return result.rows as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const result = await this.runner.query(sql, [...params] as unknown[]); + return { changes: result.rowCount ?? 0 }; + } + + /** Runs `fn` inside a transaction on a dedicated connection. */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const client = this.client ?? (await (this.pool as Pool).connect()); + const ownsClient = !this.client; + const driver = new PostgresDriver({}, client); + try { + await client.query("BEGIN"); + const result = await fn(driver); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + if (ownsClient) client.release(); + } + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(postgresDialect, table, columns, options), + ); + } + + /** Closes the pool. */ + async close(): Promise { + if (this.pool) await this.pool.end(); + } +} + +/** Creates a PostgreSQL driver. */ +export function createPostgresDriver(options: PostgresOptions): PostgresDriver { + return new PostgresDriver(options); +} diff --git a/modules/database/src/adapters/sqlite.ts b/modules/database/src/adapters/sqlite.ts new file mode 100644 index 0000000..b78681e --- /dev/null +++ b/modules/database/src/adapters/sqlite.ts @@ -0,0 +1,130 @@ +import { Database as SqliteDatabase, type SQLQueryBindings } from "bun:sqlite"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, normalizeValue, type SqlDialect } from "./driver.ts"; + +export interface SqliteOptions { + /** Path of the database file, `:memory:` for an in-memory database. */ + path?: string; + /** Opens the database in read only mode. */ + readonly?: boolean; + /** `false` disables the automatic creation of the database file. */ + create?: boolean; +} + +const SQLITE_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "INTEGER", + real: "REAL", + bool: "INTEGER", + blob: "BLOB", + json: "TEXT", + uuid: "TEXT", + timestamp: "TEXT", + date: "TEXT", +}; + +export const sqliteDialect: SqlDialect = { + name: "sqlite", + columnType: (type) => SQLITE_TYPES[type], + placeholder: () => "?", + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") return column.default ? "1" : "0"; + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +/** Converts a value into a representation SQLite understands. */ +function toSqliteValue(value: unknown): unknown { + const normalized = normalizeValue(value); + if (normalized === null || normalized === undefined) return null; + if (typeof normalized === "boolean") return normalized ? 1 : 0; + if (normalized instanceof Date) return normalized.toISOString(); + return normalized; +} + +export class SqliteDriver implements SqlDriver { + readonly dialect = "sqlite" as const; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning = true; + private readonly database: SqliteDatabase; + private depth = 0; + + constructor(options: SqliteOptions = {}) { + this.database = new SqliteDatabase(options.path ?? ":memory:", { + readonly: options.readonly ?? false, + create: options.create ?? true, + }); + } + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(_index: number): string { + return "?"; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const statement = this.database.query(sql); + return statement.all( + ...(params.map(toSqliteValue) as SQLQueryBindings[]), + ) as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const statement = this.database.query(sql); + const info = statement.run( + ...(params.map(toSqliteValue) as SQLQueryBindings[]), + ); + return { changes: info.changes }; + } + + /** Runs `fn` inside a transaction, nested calls use savepoints. */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const nested = this.depth > 0; + const savepoint = `muddle_sp_${this.depth}`; + await this.execute(nested ? `SAVEPOINT ${savepoint}` : "BEGIN"); + this.depth += 1; + try { + const result = await fn(this); + this.depth -= 1; + await this.execute(nested ? `RELEASE ${savepoint}` : "COMMIT"); + return result; + } catch (error) { + this.depth -= 1; + await this.execute(nested ? `ROLLBACK TO ${savepoint}` : "ROLLBACK"); + throw error; + } + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(sqliteDialect, table, columns, options), + ); + } + + /** Closes the database. */ + async close(): Promise { + this.database.close(); + } +} + +/** Creates a SQLite driver. */ +export function createSqliteDriver(options: SqliteOptions = {}): SqliteDriver { + return new SqliteDriver(options); +} diff --git a/modules/database/src/database.ts b/modules/database/src/database.ts new file mode 100644 index 0000000..ff889e8 --- /dev/null +++ b/modules/database/src/database.ts @@ -0,0 +1,55 @@ +import type { + ColumnMapping, + SqlDriver, + TableOptions, +} from "muddle/contracts/typegen/orm/index.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; +import { Repository } from "./repository.ts"; + +/** + * Database. + * + * Owns the driver, keeps track of the registered models and creates the + * tables of those models. + */ +export class Database { + private readonly models = new Map>(); + + constructor(readonly driver: SqlDriver) {} + + /** Registers a model and returns its repository. */ + model(model: TableClass): Repository { + const metadata = getTableMetadata(model as unknown as object); + this.models.set(metadata.name, model as unknown as TableClass); + return new Repository(this.driver, model); + } + + /** Creates the tables of every registered model. */ + async migrate(): Promise { + for (const model of this.models.values()) { + const metadata = getTableMetadata(model as unknown as object); + const columns = [...metadata.fields.values()]; + const driver = this.driver as SqlDriver & { + createTable?: ( + table: string, + columns: ColumnMapping[], + options: TableOptions, + ) => Promise; + }; + if (driver.createTable) { + await driver.createTable(metadata.name, columns, metadata.options); + } + } + } + + /** Runs `fn` inside a transaction. */ + async transaction(fn: (database: Database) => Promise): Promise { + return this.driver.transaction(async (driver) => fn(new Database(driver))); + } + + /** Closes the underlying driver. */ + async close(): Promise { + await this.driver.close(); + } +} diff --git a/modules/database/src/decorators/Field.ts b/modules/database/src/decorators/Field.ts new file mode 100644 index 0000000..bbf8272 --- /dev/null +++ b/modules/database/src/decorators/Field.ts @@ -0,0 +1,40 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; +import type { FieldOptions } from "muddle/contracts/typegen/orm/field.ts"; +import type { FieldDescriptor } from "muddle/contracts/typegen/orm/field.ts"; + +export const FIELD_METADATA_KEY = "muddle:orm:fields"; + +/** Converts a property name into the default column name. */ +export function toColumnName(property: string): string { + return property + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +/** Declares the column backing a property. */ +export function Field( + options: FieldOptions, +): PropertyDecorator { + return (target, propertyKey) => { + const constructor = target.constructor as object; + const fields = getFields(constructor); + const property = String(propertyKey); + fields.set(property, { + property, + column: options.column ?? toColumnName(property), + options, + }); + }; +} + +/** Reads the fields declared on a model class. */ +export function getFields(target: object): Map { + const existing = getMetadata(FIELD_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const fields = new Map(); + defineMetadata(FIELD_METADATA_KEY, fields, target); + return fields; +} diff --git a/modules/database/src/decorators/Hydrate.ts b/modules/database/src/decorators/Hydrate.ts new file mode 100644 index 0000000..76e0b95 --- /dev/null +++ b/modules/database/src/decorators/Hydrate.ts @@ -0,0 +1,42 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; + +export const HYDRATE_METADATA_KEY = "muddle:orm:hydrated"; + +export interface HydrateOptions { + /** Computes the value from the raw database row. */ + decode?: (row: Record) => Value; + /** Column the value is derived from, handed to `decode` as second argument. */ + column?: string; +} + +export interface HydrateDescriptor { + property: string; + options: HydrateOptions; +} + +/** Declares a hydrated (computed) property. */ +export function Hydrate( + options: HydrateOptions = {}, +): PropertyDecorator { + return (target, propertyKey) => { + const constructor = target.constructor as object; + const hydrated = getHydratedProperties(constructor); + hydrated.set(String(propertyKey), { + property: String(propertyKey), + options: options as HydrateOptions, + }); + }; +} + +/** Reads the hydrated properties declared on a model class. */ +export function getHydratedProperties( + target: object, +): Map { + const existing = getMetadata(HYDRATE_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const hydrated = new Map(); + defineMetadata(HYDRATE_METADATA_KEY, hydrated, target); + return hydrated; +} diff --git a/modules/database/src/decorators/Table.ts b/modules/database/src/decorators/Table.ts new file mode 100644 index 0000000..ff19ebb --- /dev/null +++ b/modules/database/src/decorators/Table.ts @@ -0,0 +1,85 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; +import type { ColumnMapping } from "muddle/contracts/typegen/orm/field.ts"; +import type { TableOptions } from "muddle/contracts/typegen/orm/table.ts"; +import { getFields } from "./Field.ts"; +import { getHydratedProperties } from "./Hydrate.ts"; + +export const TABLE_METADATA_KEY = "muddle:orm:table"; + +export interface TableMetadata { + /** Table name. */ + name: string; + options: TableOptions; + /** Persisted fields, in declaration order. */ + fields: Map; + /** Raw field descriptors, including the encode/decode transformations. */ + descriptors: Map< + string, + { property: string; column: string; options: unknown } + >; + /** Computed properties, hydrated but never persisted. */ + hydrated: Map; +} + +/** Declares the table backing a model class. */ +export function Table( + name: string, + options: TableOptions = {}, +): ClassDecorator { + return (target) => { + const constructor = target as unknown as object; + const descriptors = getFields(constructor); + const hydrated = getHydratedProperties(constructor); + + const fields = new Map(); + for (const [property, descriptor] of descriptors) { + const fieldOptions = descriptor.options as { + type: ColumnMapping["type"]; + primary?: boolean; + unique?: boolean; + nullable?: boolean; + default?: unknown; + references?: ColumnMapping["references"]; + }; + fields.set(property, { + property, + column: descriptor.column, + type: fieldOptions.type, + nullable: fieldOptions.nullable === true, + primary: fieldOptions.primary === true, + unique: fieldOptions.unique === true, + default: fieldOptions.default, + references: fieldOptions.references, + }); + } + + const metadata: TableMetadata = { + name, + options, + fields, + descriptors: descriptors as TableMetadata["descriptors"], + hydrated: hydrated as TableMetadata["hydrated"], + }; + + defineMetadata(TABLE_METADATA_KEY, metadata, constructor); + return target; + }; +} + +/** Reads the table metadata of a model class. */ +export function getTableMetadata(target: object): TableMetadata { + const metadata = getMetadata(TABLE_METADATA_KEY, target) as + | TableMetadata + | undefined; + if (!metadata) { + throw new Error( + `${(target as { name?: string }).name ?? "model"} is not decorated with @Table()`, + ); + } + return metadata; +} + +/** `true` when the class is a table model. */ +export function isTable(target: object): boolean { + return getMetadata(TABLE_METADATA_KEY, target) !== undefined; +} diff --git a/modules/database/src/hydration.ts b/modules/database/src/hydration.ts new file mode 100644 index 0000000..8fac546 --- /dev/null +++ b/modules/database/src/hydration.ts @@ -0,0 +1,81 @@ +import { getTableMetadata } from "./decorators/Table.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; + +function createInstance(model: TableClass): T { + try { + return new (model as unknown as new () => T)(); + } catch { + return Object.create(model.prototype) as T; + } +} + +/** Hydrates a raw database row into a model instance. */ +export function hydrateRow( + model: TableClass, + row: Record, +): T { + const metadata = getTableMetadata(model as unknown as object); + const instance = createInstance(model); + const target = instance as unknown as Record; + + for (const [property, descriptor] of metadata.descriptors) { + const raw = row[descriptor.column]; + const options = descriptor.options as { + decode?: (value: unknown) => unknown; + }; + target[property] = options.decode ? options.decode(raw) : raw; + } + + for (const [property, descriptor] of metadata.hydrated) { + const options = descriptor.options as { + decode?: (row: Record) => unknown; + column?: string; + }; + let value: unknown; + if (options.decode) { + value = options.decode(row); + } else { + const getter = Object.getOwnPropertyDescriptor( + model.prototype, + property, + )?.get; + value = getter ? getter.call(instance) : undefined; + } + Object.defineProperty(target, property, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + + return instance; +} + +/** Encodes a model value into the values written to the database. */ +export function encodeRow( + model: TableClass, + data: Partial | Record, +): Record { + const metadata = getTableMetadata(model as unknown as object); + const encoded: Record = {}; + + for (const [key, value] of Object.entries(data as Record)) { + const descriptor = metadata.descriptors.get(key); + if (!descriptor) continue; + const options = descriptor.options as { + encode?: (value: unknown) => unknown; + }; + encoded[descriptor.column] = options.encode ? options.encode(value) : value; + } + + return encoded; +} + +/** Hydrates a list of raw rows. */ +export function hydrateRows( + model: TableClass, + rows: Record[], +): T[] { + return rows.map((row) => hydrateRow(model, row)); +} diff --git a/modules/database/src/index.ts b/modules/database/src/index.ts new file mode 100644 index 0000000..08fd40e --- /dev/null +++ b/modules/database/src/index.ts @@ -0,0 +1,68 @@ +import type { PoolConfig } from "pg"; +import { createD1Driver, D1Driver } from "./adapters/d1.ts"; +import { createPostgresDriver, PostgresDriver } from "./adapters/pg.ts"; +import { createSqliteDriver } from "./adapters/sqlite.ts"; +import { Database } from "./database.ts"; + +export * from "./adapters/d1.ts"; +export * from "./adapters/driver.ts"; +export * from "./adapters/pg.ts"; +export * from "./adapters/sqlite.ts"; +export * from "./database.ts"; +export * from "./decorators/Field.ts"; +export * from "./decorators/Hydrate.ts"; +export * from "./decorators/Table.ts"; +export * from "./hydration.ts"; +export * from "./repository.ts"; +export * from "./migrations.ts"; + +export interface SqliteDatabaseOptions { + dialect: "sqlite"; + /** Path of the database file, `:memory:` for an in-memory database. */ + path?: string; + readonly?: boolean; +} + +export interface PostgresDatabaseOptions { + dialect: "postgres"; + /** Connection string, e.g. `postgres://user:pass@localhost:5432/db`. */ + url?: string; + /** Connection options, used when no url is given. */ + connection?: PoolConfig; +} + +export interface D1DatabaseOptions { + dialect: "d1"; + /** Account the database belongs to. */ + accountId: string; + /** Identifier of the database. */ + databaseId: string; + /** Api token of the account. */ + apiToken: string; + /** Endpoint of the api, useful for tests. */ + endpoint?: string; +} + +/** Creates a database backed by SQLite or PostgreSQL. */ +export function createDatabase( + options: SqliteDatabaseOptions | PostgresDatabaseOptions | D1DatabaseOptions, +): Database { + if (options.dialect === "sqlite") { + return new Database( + createSqliteDriver({ path: options.path, readonly: options.readonly }), + ); + } + if (options.dialect === "d1") { + const driver: D1Driver = createD1Driver({ + accountId: options.accountId, + databaseId: options.databaseId, + apiToken: options.apiToken, + endpoint: options.endpoint, + }); + return new Database(driver); + } + const driver: PostgresDriver = options.url + ? createPostgresDriver(options.url) + : createPostgresDriver(options.connection ?? {}); + return new Database(driver); +} diff --git a/modules/database/src/migrations.ts b/modules/database/src/migrations.ts new file mode 100644 index 0000000..0666974 --- /dev/null +++ b/modules/database/src/migrations.ts @@ -0,0 +1,43 @@ +import type { TableClass } from "muddle/contracts/typegen/orm/index.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; + +export interface SchemaColumn { name: string; type: string; nullable: boolean } +export interface SchemaTable { name: string; columns: SchemaColumn[] } +export interface Migration { up: string[]; down: string[] } + +export function schemaOf(tables: TableClass[]): SchemaTable[] { + return tables.map((table) => { + const metadata = getTableMetadata(table); + if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`); + return { name: metadata.name, columns: [...metadata.fields.values()].map((field) => ({ name: field.column, type: field.type, nullable: field.nullable })) }; + }); +} + +export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migration { + const oldTables = new Map(previous.map((table) => [table.name, table])); + const nextTables = new Map(next.map((table) => [table.name, table])); + const up: string[] = []; + const down: string[] = []; + for (const table of next) { + const old = oldTables.get(table.name); + if (!old) { + up.push(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); + down.unshift(`DROP TABLE ${table.name};`); + continue; + } + const oldColumns = new Map(old.columns.map((column) => [column.name, column])); + for (const column of table.columns) if (!oldColumns.has(column.name)) { + up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`); + down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`); + } + } + for (const table of previous) if (!nextTables.has(table.name)) { + up.push(`DROP TABLE ${table.name};`); + down.unshift(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); + } + return { up, down }; +} + +function columnSql(column: SchemaColumn): string { + return `${column.name} ${column.type.toUpperCase()}${column.nullable ? "" : " NOT NULL"}`; +} diff --git a/modules/database/src/repository.ts b/modules/database/src/repository.ts new file mode 100644 index 0000000..621c14d --- /dev/null +++ b/modules/database/src/repository.ts @@ -0,0 +1,281 @@ +import type { + ColumnName, + OrderDirection, + Repository as RepositoryContract, + WhereCondition, + WhereInput, + WhereOperator, +} from "muddle/contracts/typegen/orm/index.ts"; +import { WHERE_OPERATORS } from "muddle/contracts/typegen/orm/index.ts"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; +import { encodeRow, hydrateRow, hydrateRows } from "./hydration.ts"; +import { buildWhere, quote, type Condition } from "./adapters/driver.ts"; + +interface QueryState { + groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[]; + order: { column: string; direction: OrderDirection }[]; + limit?: number; + offset?: number; + select?: string[]; +} + +const EMPTY_STATE: QueryState = { groups: [], order: [] }; + +function isOperator(value: unknown): value is WhereOperator { + return ( + typeof value === "string" && + (WHERE_OPERATORS as readonly string[]).includes(value) + ); +} + +export class Repository implements RepositoryContract { + constructor( + private readonly driver: SqlDriver, + private readonly model: TableClass, + private readonly state: QueryState = EMPTY_STATE, + ) {} + + private get table(): string { + return getTableMetadata(this.model as unknown as object).name; + } + + private clone(state: Partial): this { + return new Repository(this.driver, this.model, { + ...this.state, + ...state, + }) as unknown as this; + } + + /** Restricts the selected columns. */ + select(...columns: ColumnName[]): this { + return this.clone({ + select: columns.length > 0 ? [...columns] : undefined, + }); + } + + /** Adds a condition. */ + where(conditions: WhereCondition[]): this; + where(column: ColumnName, value: unknown): this; + where(column: ColumnName, operator: WhereOperator, value?: unknown): this; + where( + columnOrConditions: ColumnName | WhereCondition[], + operatorOrValue?: WhereOperator | unknown, + value?: unknown, + ): this { + if (Array.isArray(columnOrConditions)) { + return this.and(columnOrConditions); + } + const column = String(columnOrConditions); + if (isOperator(operatorOrValue)) { + return this.clone({ + groups: [ + ...this.state.groups, + { + conditions: [{ column, operator: operatorOrValue, value }], + conjunction: "AND", + }, + ], + }); + } + return this.clone({ + groups: [ + ...this.state.groups, + { + conditions: [{ column, operator: "=", value: operatorOrValue }], + conjunction: "AND", + }, + ], + }); + } + + /** Adds a group of conditions combined with `AND`. */ + and(conditions: WhereCondition[]): this { + return this.clone({ + groups: [ + ...this.state.groups, + { conditions: [...conditions], conjunction: "AND" }, + ], + }); + } + + /** Adds a group of conditions combined with `OR`. */ + or(conditions: WhereCondition[]): this { + return this.clone({ + groups: [ + ...this.state.groups, + { conditions: [...conditions], conjunction: "OR" }, + ], + }); + } + + /** Orders the result set. */ + orderBy(column: ColumnName, direction: OrderDirection = "asc"): this { + return this.clone({ + order: [...this.state.order, { column: String(column), direction }], + }); + } + + /** Limits the result set. */ + limit(count: number): this { + return this.clone({ limit: count }); + } + + /** Skips rows. */ + offset(count: number): this { + return this.clone({ offset: count }); + } + + /** + * Runs a raw statement and returns the rows. + * + * The rows are not hydrated, the method is meant for the statements the + * repository does not cover. + */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + return this.driver.query(sql, params); + } + + /** Executes the query and hydrates every row. */ + async all(): Promise { + const params: unknown[] = []; + const columns = this.state.select + ? this.state.select + .map((column) => quote(this.columnOf(column))) + .join(", ") + : "*"; + const where = buildWhere(this.driver, this.state.groups, params); + const order = this.buildOrder(); + const limit = this.buildLimit(params); + const sql = `SELECT ${columns} FROM ${quote(this.table)}${where}${order}${limit}`; + const rows = await this.driver.query>(sql, params); + return hydrateRows(this.model, rows); + } + + /** Alias of {@link all}. */ + async find(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.all(); + } + + /** Executes the query and hydrates the first row. */ + async first(): Promise { + const rows = await this.limit(1).all(); + return rows[0] ?? null; + } + + /** Alias of {@link first}. */ + async findOne(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.first(); + } + + /** Counts the rows matching the current conditions. */ + async count(): Promise { + const params: unknown[] = []; + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `SELECT COUNT(*) AS ${quote("count")} FROM ${quote(this.table)}${where}`; + const rows = await this.driver.query<{ count: string | number }>( + sql, + params, + ); + const value = rows[0]?.count ?? 0; + return Number(value); + } + + /** Inserts a row, encoding every value before the commit. */ + async create(data: Partial): Promise { + const encoded = encodeRow(this.model, data); + const columns = Object.keys(encoded); + if (columns.length === 0) { + throw new Error(`Cannot insert an empty row into ${this.table}`); + } + const params: unknown[] = []; + const placeholders = columns.map((column) => { + params.push(encoded[column]); + return this.driver.placeholder(params.length); + }); + const returning = this.driver.supportsReturning ? " RETURNING *" : ""; + const sql = `INSERT INTO ${quote(this.table)} (${columns.map(quote).join(", ")}) VALUES (${placeholders.join(", ")})${returning}`; + const rows = await this.driver.query>(sql, params); + const row = rows[0]; + if (!row) { + throw new Error(`Insert into ${this.table} did not return a row`); + } + return hydrateRow(this.model, row); + } + + /** Updates the matching rows, encoding every value before the commit. */ + async update(data: Partial): Promise { + const encoded = encodeRow(this.model, data); + const columns = Object.keys(encoded); + if (columns.length === 0) return 0; + const params: unknown[] = []; + const assignments = columns.map((column) => { + params.push(encoded[column]); + return `${quote(column)} = ${this.driver.placeholder(params.length)}`; + }); + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `UPDATE ${quote(this.table)} SET ${assignments.join(", ")}${where}`; + const result = await this.driver.execute(sql, params); + return result.changes; + } + + /** Deletes the matching rows. */ + async delete(): Promise { + const params: unknown[] = []; + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `DELETE FROM ${quote(this.table)}${where}`; + const result = await this.driver.execute(sql, params); + return result.changes; + } + + private get dialect(): SqlDriver["dialect"] { + return this.driver.dialect; + } + + private applyWhereInput(where: WhereInput): this { + if (Array.isArray(where)) { + return this.and(where); + } + const conditions: WhereCondition[] = Object.entries(where).map( + ([column, value]) => ({ + column, + operator: "=", + value, + }), + ); + return this.and(conditions); + } + + private columnOf(column: string): string { + const metadata = getTableMetadata(this.model as unknown as object); + return metadata.fields.get(column)?.column ?? column; + } + + private buildOrder(): string { + if (this.state.order.length === 0) return ""; + const parts = this.state.order.map( + (entry) => + `${quote(this.columnOf(entry.column))} ${entry.direction.toUpperCase()}`, + ); + return ` ORDER BY ${parts.join(", ")}`; + } + + private buildLimit(params: unknown[]): string { + let sql = ""; + if (this.state.limit !== undefined) { + params.push(this.state.limit); + sql += ` LIMIT ${this.driver.placeholder(params.length)}`; + } + if (this.state.offset !== undefined) { + params.push(this.state.offset); + sql += ` OFFSET ${this.driver.placeholder(params.length)}`; + } + return sql; + } +} diff --git a/modules/database/src/sessions.ts b/modules/database/src/sessions.ts new file mode 100644 index 0000000..f4e8898 --- /dev/null +++ b/modules/database/src/sessions.ts @@ -0,0 +1,35 @@ +import type { SessionRecord, SessionStore } from "muddle/sessions"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; + +/** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */ +export class DatabaseSessionStore implements SessionStore { + private constructor(private readonly driver: SqlDriver, private readonly table: string) {} + + static async create(driver: SqlDriver, table = "muddle_sessions"): Promise { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error("Invalid session table name"); + await driver.execute(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`); + return new DatabaseSessionStore(driver, table); + } + + async get(key: string): Promise { + const rows = await this.driver.query<{ value: string; expires: number }>(`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); + const row = rows[0]; + if (!row || Number(row.expires) <= Date.now()) { await this.delete(key); return undefined; } + return { value: JSON.parse(row.value), expires: Number(row.expires) }; + } + + async set(key: string, record: SessionRecord): Promise { + const args = [key, JSON.stringify(record.value), record.expires]; + await this.driver.execute(`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`, args); + } + + async delete(key: string): Promise { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); } + + async consume(key: string): Promise { + const rows = await this.driver.query<{ value: string; expires: number }>(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`, [key]); + const row = rows[0]; + return row && Number(row.expires) > Date.now() ? { value: JSON.parse(row.value), expires: Number(row.expires) } : undefined; + } + + async prune(): Promise { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); } +} diff --git a/modules/latex/index.ts b/modules/latex/index.ts new file mode 100644 index 0000000..39dfa2d --- /dev/null +++ b/modules/latex/index.ts @@ -0,0 +1,39 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Integration, IntegrationContext } from "muddle/contracts"; +import { renderMarkdown } from "muddle/markdown"; +import katex from "katex"; + +export function renderLatex(source: string, displayMode = false): string { + return katex.renderToString(source, { displayMode, throwOnError: false, trust: false }); +} + +export function renderMathMarkdown(source: string): string { + const formulas: string[] = []; + const body = source.replace(/\$\$([\s\S]+?)\$\$|(? { + formulas.push(renderLatex(display ?? inline, display !== undefined)); + return `MUDDLEMATH${formulas.length - 1}END`; + }); + return renderMarkdown(body).replace(/MUDDLEMATH(\d+)END/g, (_match, index) => formulas[Number(index)] ?? ""); +} + +export interface LatexIntegrationOptions { route?: string } + +export function latexIntegration(options: LatexIntegrationOptions = {}): Integration { + const route = options.route ?? "/_muddle/katex.css"; + return { + name: "latex", + async setup(context: IntegrationContext) { + const base = dirname(fileURLToPath(import.meta.resolve("katex/package.json"))); + const css = await readFile(join(base, "dist", "katex.min.css"), "utf8"); + context.loader("latex-markdown", { + name: "latex-markdown", + extensions: [".latex.md", ".latex.markdown"], + load(file) { return { data: {}, content: renderMathMarkdown(file.source) }; }, + }); + context.route("GET", route, () => new Response(css, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=31536000, immutable" } })); + }, + }; +} + diff --git a/modules/latex/package.json b/modules/latex/package.json new file mode 100644 index 0000000..0f3ef7f --- /dev/null +++ b/modules/latex/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/latex", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "katex": "^0.19.0" + } +} diff --git a/modules/mdx/index.ts b/modules/mdx/index.ts new file mode 100644 index 0000000..c0f760d --- /dev/null +++ b/modules/mdx/index.ts @@ -0,0 +1,14 @@ +import { evaluate } from "@mdx-js/mdx"; +import * as runtime from "react/jsx-runtime"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Integration } from "muddle/contracts"; + +export async function renderMdx(source: string): Promise { + const module = await evaluate(source, { ...runtime }); + return renderToStaticMarkup(createElement(module.default)); +} + +export function mdxIntegration(): Integration { + return { name: "mdx", setup(context) { context.loader("mdx", { name: "mdx", extensions: [".mdx"], async load(file) { return { data: {}, content: await renderMdx(file.source) }; } }); } }; +} diff --git a/modules/mdx/package.json b/modules/mdx/package.json new file mode 100644 index 0000000..e53c043 --- /dev/null +++ b/modules/mdx/package.json @@ -0,0 +1,18 @@ +{ + "name": "@muddle/mdx", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "@mdx-js/mdx": "^3.1.1", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0" + } +} diff --git a/modules/optimize/README.md b/modules/optimize/README.md new file mode 100644 index 0000000..da43184 --- /dev/null +++ b/modules/optimize/README.md @@ -0,0 +1 @@ +# Build optimization \ No newline at end of file diff --git a/modules/optimize/index.ts b/modules/optimize/index.ts new file mode 100644 index 0000000..6e42db8 --- /dev/null +++ b/modules/optimize/index.ts @@ -0,0 +1,88 @@ +import type { Integration, IntegrationContext } from "muddle/contracts"; + +import * as terser from "terser"; +import * as svgo from "svgo"; +import * as csso from "csso"; +import * as htmlMinifier from "html-minifier-terser"; + +export function minifyCss(source: string): string { + return csso.minify(source).css; +} + +export async function minifyHtml(source: string): Promise { + return await htmlMinifier.minify(source, { + collapseWhitespace: true, + removeComments: true, + removeRedundantAttributes: true, + removeEmptyAttributes: true, + removeOptionalTags: true, + }); +} + + +export async function minifyJavaScript(source: string): Promise { + return (await terser.minify(source, { format: { comments: false } })).code ?? source; +} + +export function optimizeSvg(source: string): string { + return svgo.optimize(source, { multipass: true }).data; +} + +export interface ImageOptimizer { + optimize(source: Uint8Array, type: string): Promise | Uint8Array; +} + +export function imageOptimization(optimizer: ImageOptimizer): ImageOptimizer { + return optimizer; +} + +function loaderIntegration(name: string, extension: string, transform: (source: string) => string | Promise): Integration { + return { + name, + async transform(asset) { + if (!asset.path.endsWith(extension)) return asset; + const source = typeof asset.content === "string" ? asset.content : new TextDecoder().decode(asset.content); + return { ...asset, content: await transform(source) }; + }, + setup(context: IntegrationContext) { + context.loader(name, { name, extensions: [extension], async load(file) { return { data: {}, content: await transform(file.source) }; } }); + }, + }; +} + +export function terserIntegration(): Integration { + return loaderIntegration("terser", ".js", minifyJavaScript); +} + +export function cssMinifyIntegration(): Integration { + return loaderIntegration("css-minify", ".css", minifyCss); +} + +export function htmlMinifyIntegration(): Integration { + return loaderIntegration("html-minify", ".html", minifyHtml); +} + +export function svgoIntegration(): Integration { + return loaderIntegration("svgo", ".svg", optimizeSvg); +} + +export function imageOptimizationIntegration(optimizer: ImageOptimizer): Integration { + return { + name: "image-optimization", + async transform(asset) { + if (!/\.(png|jpe?g|webp|avif)$/i.test(asset.path)) return asset; + const input = typeof asset.content === "string" ? new TextEncoder().encode(asset.content) : asset.content; + return { ...asset, content: await optimizer.optimize(input, asset.contentType) }; + }, + setup(context: IntegrationContext) { + context.loader("image", { + name: "image", + extensions: [".png", ".jpg", ".jpeg", ".webp", ".avif"], + async load(file) { + const optimized = await optimizer.optimize(new TextEncoder().encode(file.source), "image/*"); + return { data: { optimized }, content: file.source }; + }, + }); + }, + }; +} diff --git a/modules/optimize/package.json b/modules/optimize/package.json new file mode 100644 index 0000000..175d648 --- /dev/null +++ b/modules/optimize/package.json @@ -0,0 +1,17 @@ +{ + "name": "@muddle/optimize", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "dependencies": { + "terser": "^5.51.2", + "svgo": "^4.1.0", + "csso": "^5.0.5", + "html-minifier-terser": "^7.2.0", + "@types/csso": "^5.0.5", + "@types/html-minifier-terser": "^7.0.2" + }, + "peerDependencies": { + "muddle": "*" + } +} diff --git a/modules/prettier-plugin/index.ts b/modules/prettier-plugin/index.ts new file mode 100644 index 0000000..bafeebd --- /dev/null +++ b/modules/prettier-plugin/index.ts @@ -0,0 +1,21 @@ +import type { Plugin, Parser } from "prettier"; +import { formatSource, type FormatLanguage } from "muddle/formatter"; + +interface Document { formatted: string } +const parser = (language: FormatLanguage): Parser => ({ + astFormat: "muddle-document", + async parse(source, options) { return { formatted: await formatSource(source, language, options) }; }, + locStart: () => 0, + locEnd: (node) => node.formatted.length, +}); + +const plugin: Plugin = { + languages: [ + { name: "SPUG", parsers: ["spug"], extensions: [".spug", ".stpug", ".stug"], vscodeLanguageIds: ["spug"] }, + { name: "STCSS", parsers: ["stcss"], extensions: [".stcss"], vscodeLanguageIds: ["stcss"] }, + ], + parsers: { spug: parser("spug"), stcss: parser("stcss") }, + printers: { "muddle-document": { print: (path) => path.node.formatted } }, +}; +export default plugin; +export const { languages, parsers, printers } = plugin; diff --git a/modules/prettier-plugin/package.json b/modules/prettier-plugin/package.json new file mode 100644 index 0000000..fc124ef --- /dev/null +++ b/modules/prettier-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/prettier-plugin", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "peerDependencies": { "muddle": "*", "prettier": "^3.0.0" } +} diff --git a/modules/shadcn/index.ts b/modules/shadcn/index.ts new file mode 100644 index 0000000..37ef6f5 --- /dev/null +++ b/modules/shadcn/index.ts @@ -0,0 +1 @@ +export { shadcnIntegration, type ShadcnIntegrationOptions } from "../../integrations/shadcn.ts"; diff --git a/modules/shadcn/package.json b/modules/shadcn/package.json new file mode 100644 index 0000000..06e46fe --- /dev/null +++ b/modules/shadcn/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/shadcn", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { "muddle": "*" } +} diff --git a/modules/tailwind/index.ts b/modules/tailwind/index.ts new file mode 100644 index 0000000..0b5eaeb --- /dev/null +++ b/modules/tailwind/index.ts @@ -0,0 +1,62 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compile } from "tailwindcss"; +import type { Integration, IntegrationContext } from "muddle/contracts"; + +export interface TailwindOptions { + route?: string; + css?: string; + content?: string[]; +} + +const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".spug", ".mdx"]); + +export async function tailwindCandidates(root: string, paths?: string[]): Promise { + const candidates = new Set(); + const visit = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { await visit(path); continue; } + if (!SCANNED_EXTENSIONS.has(extname(entry.name))) continue; + if (paths && !paths.some((value) => path.includes(value))) continue; + const source = await readFile(path, "utf8"); + for (const match of source.matchAll(/(?:class|className)\s*=\s*["'`]([^"'`]+)["'`]/g)) { + for (const value of (match[1] ?? "").split(/\s+/)) if (value) candidates.add(value); + } + } + }; + await visit(root); + return [...candidates]; +} + +export async function compileTailwind(css: string, candidates: string[] = []): Promise { + const tailwindCss = join(dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))), "index.css"); + const compiler = await compile(css, { + base: process.cwd(), + loadStylesheet: async (id) => { + if (!["tailwindcss", "tailwindcss/theme.css", "tailwindcss/utilities.css"].includes(id)) throw new Error(`Unsupported Tailwind stylesheet "${id}"`); + const path = id === "tailwindcss" ? tailwindCss : join(dirname(tailwindCss), id.split("/")[1]!); + return { path, base: process.cwd(), content: await readFile(path, "utf8") }; + }, + }); + return compiler.build(candidates); +} + +export function tailwindIntegration(options: TailwindOptions = {}): Integration { + const route = options.route ?? "/_muddle/tailwind.css"; + const source = options.css ?? '@import "tailwindcss";'; + return { + name: "tailwind", + async setup(context: IntegrationContext) { + const build = () => tailwindCandidates(context.config.root, options.content).then((candidates) => compileTailwind(source, candidates)); + context.loader("tailwind", { + name: "tailwind", + extensions: [".tailwind.css"], + async load(file) { return { data: {}, content: await compileTailwind(file.source, await tailwindCandidates(context.config.root, options.content)) }; }, + }); + context.route("GET", route, async () => new Response(await build(), { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=60" } })); + }, + }; +} diff --git a/modules/tailwind/package.json b/modules/tailwind/package.json new file mode 100644 index 0000000..b3bb253 --- /dev/null +++ b/modules/tailwind/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/tailwind", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "tailwindcss": "^4.3.3" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0c69e8 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "muddle", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "typecheck": "bun check", + "typegen": "bun run contracts/typegen/generator.ts samples/hello-world", + "dev": "bun run samples/hello-world/index.ts", + "react": "bun run samples/react/index.ts", + "react:build": "bun run samples/react/index.ts --prerender", + "build": "bun run samples/hello-world/index.ts --prerender", + "test": "bun test ./tests", + "lint": "bunx eslint . --ignore-pattern node_modules --ignore-pattern ametrine --ignore-pattern .opencode --ignore-pattern dist --ignore-pattern .git --ignore-pattern .cache --ignore-pattern .next --ignore-pattern .turbo --ignore-pattern .vercel --ignore-pattern .vscode --ignore-pattern .idea", + "format": "bun run cli.ts fmt .", + "muddle": "bun run cli.ts" + }, + "bin": { + "muddle": "./cli.ts" + }, + "dependencies": { + "@eslint/js": "^10.0.1", + "lightningcss": "^1.33.0", + "prettier": "^3.9.10", + "sass": "^1.106.1", + "typescript-eslint": "^8.72.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.15.5", + "playwright": "^1.64.1", + "@muddle/astro": "workspace:*", + "@muddle/database": "workspace:*", + "@muddle/latex": "workspace:*", + "@muddle/mdx": "workspace:*", + "@muddle/tailwind": "workspace:*", + "@muddle/prettier-plugin": "workspace:*", + "@muddle/optimize": "workspace:*", + "@muddle/amezing": "workspace:*", + "@muddle/analysis": "workspace:*" + }, + "peerDependencies": { + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2" + }, + "workspaces": [ + "modules/*" + ], + "exports": { + ".": "./index.ts", + "./contracts": "./contracts/index.ts", + "./contracts/*": "./contracts/*", + "./metadata": "./core/metadata.ts", + "./markdown": "./views/markdown.ts", + "./views": "./views/api.ts", + "./views/integrations": "./views/integrations.ts", + "./stcss": "./views/css/index.ts", + "./sessions": "./core/sessions.ts", + "./formatter": "./core/format-source.ts" + } +} diff --git a/samples/hello-world/.gitignore b/samples/hello-world/.gitignore new file mode 100644 index 0000000..33fd522 --- /dev/null +++ b/samples/hello-world/.gitignore @@ -0,0 +1,3 @@ +dist/ +dist.previous/ +dist.staging/ \ No newline at end of file diff --git a/samples/hello-world/_muddle/contracts/index.ts b/samples/hello-world/_muddle/contracts/index.ts new file mode 100644 index 0000000..249433b --- /dev/null +++ b/samples/hello-world/_muddle/contracts/index.ts @@ -0,0 +1,3 @@ +export * from "./views.ts"; +export * from "./orm/tables.ts"; +export * from "./minify.ts"; diff --git a/samples/hello-world/_muddle/contracts/minify.ts b/samples/hello-world/_muddle/contracts/minify.ts new file mode 100644 index 0000000..1390627 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/minify.ts @@ -0,0 +1,2 @@ +export type Simplify = { [K in keyof T]: T[K] } & {}; +export type Minify = Simplify; diff --git a/samples/hello-world/_muddle/contracts/orm/tables.ts b/samples/hello-world/_muddle/contracts/orm/tables.ts new file mode 100644 index 0000000..0edbd23 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/orm/tables.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + + + +export interface TableRows { + +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; diff --git a/samples/hello-world/_muddle/contracts/views.ts b/samples/hello-world/_muddle/contracts/views.ts new file mode 100644 index 0000000..898dae0 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/views.ts @@ -0,0 +1,16 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { + "modules/app/views/index": { title: string; nav: {label:string,href:string}[]; posts: {id:string,title:string,excerpt:string}[] }; + "modules/app/views/layout": { title: string; nav: {label:string,href:string}[]; currentPath?: string }; + "modules/app/views/post": { title: string; nav: {label:string,href:string}[]; post: {id:string,title:string,excerpt:string}|null }; +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; diff --git a/samples/hello-world/config.ts b/samples/hello-world/config.ts new file mode 100644 index 0000000..a619be6 --- /dev/null +++ b/samples/hello-world/config.ts @@ -0,0 +1,4 @@ +import { resolveConfig } from "@muddle/core/config.ts"; +import config from "./ct.config.ts"; + +export const resolved = resolveConfig(config); diff --git a/samples/hello-world/ct.config.ts b/samples/hello-world/ct.config.ts new file mode 100644 index 0000000..2cc3615 --- /dev/null +++ b/samples/hello-world/ct.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations.ts"; +import { accessibleIntegration, overflowIntegration } from "@muddle/analysis"; + +export default defineConfig({ + root: import.meta.dir, + + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + + integrations: [ + stcssIntegration({ dir: "styles" }), + accessibleIntegration(), + overflowIntegration(), + ], + + prerender: { + routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], + outDir: "dist", + }, + +}); diff --git a/samples/hello-world/index.ts b/samples/hello-world/index.ts new file mode 100644 index 0000000..5e81e8a --- /dev/null +++ b/samples/hello-world/index.ts @@ -0,0 +1,46 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun.ts"; +import { prerender } from "@muddle/adapters/static.ts"; +import { App } from "@muddle/core/app.ts"; +import { createViews } from "@muddle/views/api.ts"; +import { amezingIntegration } from "@muddle/amezing"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, + componentsDir: resolved.components, +}); + +const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: [...resolved.integrations, amezingIntegration()], +}); + +export async function build(options: { progress?: (event: { current: number; total: number; route: string }) => void } = {}): Promise { + if (!resolved.prerender) return; + const results = await prerender(app, { + routes: resolved.prerender.routes, + outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"), + progress: options.progress + }); + for (const result of results) { + console.log(`muddle: prerendered ${result.route} -> ${result.file}`); + } +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) { + await build(); + } else { + const server = serve(app, { + port: Number(process.env.PORT ?? 3067), + }); + console.log(`muddle: listening on ${server.url}`); + } +} + +export { app }; diff --git a/samples/hello-world/modules/app/app.controller.ts b/samples/hello-world/modules/app/app.controller.ts new file mode 100644 index 0000000..32e8cdf --- /dev/null +++ b/samples/hello-world/modules/app/app.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Param, + Route, + View, + ViewRoot, +} from "@muddle/decorators"; +import type { ViewPropsOf } from "../../_muddle/contracts/views.ts"; + +const NAV = [ + { label: "Home", href: "/" }, +]; + +const POSTS = [ + { id: "test1", title: "Test1", excerpt: "Test1" }, + { id: "test2", title: "Test2", excerpt: "Test2" }, + { id: "test3", title: "Test3", excerpt: "Test3" }, +]; + +@Controller("/") +@ViewRoot("./views") +export class AppController { + @View("index.spug") + index(): ViewPropsOf<"modules/app/views/index"> { + return { + title: "Blog", + nav: NAV, + posts: POSTS, + }; + } + + @Route("/posts/:id") + @View("post.spug") + show(@Param("id") id: string): ViewPropsOf<"modules/app/views/post"> { + const post = POSTS.find((entry) => entry.id === id) ?? null; + if (!post) { + return { + title: "Not found", + nav: NAV, + post: null, + }; + } + return { + title: post.title, + nav: NAV, + post: { + id: post.id, + title: post.title, + excerpt: post.excerpt, + }, + }; + } +} diff --git a/samples/hello-world/modules/app/app.module.ts b/samples/hello-world/modules/app/app.module.ts new file mode 100644 index 0000000..4c5eadc --- /dev/null +++ b/samples/hello-world/modules/app/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@muddle/decorators"; +import { AppController } from "./app.controller.ts"; +import { AssetController } from "./asset.controller.ts"; + +@Module({ + controllers: [AppController, AssetController], + providers: [], +}) +export class AppModule {} diff --git a/samples/hello-world/modules/app/asset.controller.ts b/samples/hello-world/modules/app/asset.controller.ts new file mode 100644 index 0000000..cff0878 --- /dev/null +++ b/samples/hello-world/modules/app/asset.controller.ts @@ -0,0 +1,5 @@ +import { Controller, Root } from "@muddle/decorators"; + +@Controller("/assets") +@Root("./public") +export class AssetController {} diff --git a/samples/hello-world/modules/app/public/robots.txt b/samples/hello-world/modules/app/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/samples/hello-world/modules/app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/samples/hello-world/modules/app/views/index.spug b/samples/hello-world/modules/app/views/index.spug new file mode 100644 index 0000000..6af0c48 --- /dev/null +++ b/samples/hello-world/modules/app/views/index.spug @@ -0,0 +1,26 @@ +extends ./layout.spug + +props + title: string + nav: { label: string, href: string }[] + posts: { id: string, title: string, excerpt: string }[] + +block content + section.primary-section@hero() + h1= title + p Test blog + section@posts() + div.card + div.card-title + div.line + span.title Posts + div.line + component amezing-article-list + pages: posts.map((post) => ({ title: post.title, href: `/posts/${post.id}`, description: post.excerpt })) + +$$$ + assertExists("h1") + assertExists(".article-list") + assertAttribute("main", "id", "main-content") + assertAttribute("section.primary-section", "class", "primary-section") +notes diff --git a/samples/hello-world/modules/app/views/layout.spug b/samples/hello-world/modules/app/views/layout.spug new file mode 100644 index 0000000..3263997 --- /dev/null +++ b/samples/hello-world/modules/app/views/layout.spug @@ -0,0 +1,13 @@ +props + title: string + nav: { label: string, href: string }[] + currentPath?: string + +component amezing-base + title: title + siteTitle: title + description: 'A tiny blog built with muddle' + nav: nav.map((entry) => ({ name: entry.label, href: entry.href })) + copyright: '© 2026 muddle' + render content + block content diff --git a/samples/hello-world/modules/app/views/post.spug b/samples/hello-world/modules/app/views/post.spug new file mode 100644 index 0000000..5976681 --- /dev/null +++ b/samples/hello-world/modules/app/views/post.spug @@ -0,0 +1,31 @@ +extends ./layout.spug + +props + title: string + nav: { label: string, href: string }[] + post: { id: string, title: string, excerpt: string } | null + +block content + if post + article@post() + div.card + div.card-title + div.line + span.title= post.title + div.line + component amezing-article + title: post.title + date: 'October 22, 2026' + body: post.excerpt + component amezing-article-nav + previousHref: '/' + previousTitle: 'Back to posts' + else + section.primary-section + h1 Not found + p The post does not exist. + +$$$ + assertExists("article .card-title") + assertText("article h1", "Test1") +notes diff --git a/samples/hello-world/repositories/posts.repository.ts b/samples/hello-world/repositories/posts.repository.ts new file mode 100644 index 0000000..e1107af --- /dev/null +++ b/samples/hello-world/repositories/posts.repository.ts @@ -0,0 +1,3 @@ +import { Repository } from "@muddle/database"; + +export class PostsRepository extends Repository> {} diff --git a/samples/hello-world/styles/app.stcss b/samples/hello-world/styles/app.stcss new file mode 100644 index 0000000..442b783 --- /dev/null +++ b/samples/hello-world/styles/app.stcss @@ -0,0 +1,28 @@ +main#main-content { + max-width: 72rem; + margin: 0 auto; + padding: 0 1.25rem 3rem; +} + +section.primary-section { + max-width: 48rem; + padding: 4rem 0 2rem; + + h1 { + margin: 0.75rem 0 0; + } + + p { + max-width: 36rem; + } + + @predict mobile max-width max-height transform; +} + +section .card { + border-radius: concentric 0.75rem; +} + +.hero-blob { + background-image: freeform-gradient(#7c3aed 10% 50%, #f00 70% 40%); +} diff --git a/samples/hello-world/tsconfig.json b/samples/hello-world/tsconfig.json new file mode 100644 index 0000000..3416094 --- /dev/null +++ b/samples/hello-world/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/database": ["../../modules/database/index.ts"], + "@muddle/*": ["../../*"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/samples/react/.gitignore b/samples/react/.gitignore new file mode 100644 index 0000000..785efe4 --- /dev/null +++ b/samples/react/.gitignore @@ -0,0 +1,3 @@ +dist/ +dist.staging/ +dist.previous/ \ No newline at end of file diff --git a/samples/react/README.md b/samples/react/README.md new file mode 100644 index 0000000..810bd2e --- /dev/null +++ b/samples/react/README.md @@ -0,0 +1 @@ +# reactor \ No newline at end of file diff --git a/samples/react/_muddle/contracts/index.ts b/samples/react/_muddle/contracts/index.ts new file mode 100644 index 0000000..249433b --- /dev/null +++ b/samples/react/_muddle/contracts/index.ts @@ -0,0 +1,3 @@ +export * from "./views.ts"; +export * from "./orm/tables.ts"; +export * from "./minify.ts"; diff --git a/samples/react/_muddle/contracts/minify.ts b/samples/react/_muddle/contracts/minify.ts new file mode 100644 index 0000000..1390627 --- /dev/null +++ b/samples/react/_muddle/contracts/minify.ts @@ -0,0 +1,2 @@ +export type Simplify = { [K in keyof T]: T[K] } & {}; +export type Minify = Simplify; diff --git a/samples/react/_muddle/contracts/orm/tables.ts b/samples/react/_muddle/contracts/orm/tables.ts new file mode 100644 index 0000000..0edbd23 --- /dev/null +++ b/samples/react/_muddle/contracts/orm/tables.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + + + +export interface TableRows { + +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; diff --git a/samples/react/_muddle/contracts/views.ts b/samples/react/_muddle/contracts/views.ts new file mode 100644 index 0000000..6f4f60c --- /dev/null +++ b/samples/react/_muddle/contracts/views.ts @@ -0,0 +1,17 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { + "modules/app/views/feature": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; label: string; prose: string|null; features: {label:string,href:string}[]; current?: string }; + "modules/app/views/index": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; intro: {id:string,label:string,note:string,tint:string}[]; features: {label:string,href:string}[] }; + "modules/app/views/layout": Record; + "modules/app/views/navbar": { nav: {id:string,label:string,href:string,active?:boolean,detail:string}[]; albums: {id:string,name:string,date:string,cover:string,tracks:{title:string,duration:string}[]}[] }; +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; diff --git a/samples/react/config.ts b/samples/react/config.ts new file mode 100644 index 0000000..a619be6 --- /dev/null +++ b/samples/react/config.ts @@ -0,0 +1,4 @@ +import { resolveConfig } from "@muddle/core/config.ts"; +import config from "./ct.config.ts"; + +export const resolved = resolveConfig(config); diff --git a/samples/react/ct.config.ts b/samples/react/ct.config.ts new file mode 100644 index 0000000..8b76c91 --- /dev/null +++ b/samples/react/ct.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations.ts"; + +export default defineConfig({ + root: import.meta.dir, + + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + + integrations: [stcssIntegration({ dir: "styles" })], + + prerender: { + routes: ["/", "/features/interpolate", "/features/refer", "/features/predict", "/features/route"], + outDir: "dist", + }, +}); diff --git a/samples/react/index.ts b/samples/react/index.ts new file mode 100644 index 0000000..b84b8ec --- /dev/null +++ b/samples/react/index.ts @@ -0,0 +1,51 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun.ts"; +import { prerender } from "@muddle/adapters/static.ts"; +import { App } from "@muddle/core/app.ts"; +import { createViews } from "@muddle/views/api.ts"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, +}); + +const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: resolved.integrations, +}); + +export async function build(): Promise { + if (!resolved.prerender) return; + const results = await prerender(app, { + routes: resolved.prerender.routes, + outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"), + predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] }, + progress: (progress) => { + console.log( + `[...] prerendering ${progress.route} (${progress.current}/${progress.total})`, + ); + }, + }); + for (const result of results) { + console.log( + `[OK ] prerendered ${result.route} @ ${result.file} (${result.status})`, + ); + } +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) { + await build(); + } else { + const server = serve(app, { + port: Number(process.env.PORT ?? 3069), + }); + console.log(`muddle: listening on ${server.url}`); + } +} + +export { app }; diff --git a/samples/react/modules/app/albumStyle.ts b/samples/react/modules/app/albumStyle.ts new file mode 100644 index 0000000..788e27e --- /dev/null +++ b/samples/react/modules/app/albumStyle.ts @@ -0,0 +1,27 @@ +/** + * Server-generated stylesheet for the discography navbar entries. Each album + * gets a `$refer` rule that moves the ribbon under its navbar item plus a + * cross-fade rule that reveals its pane. + */ +import { compileCss } from "../../../../views/css/index.ts"; +import type { Album } from "./musicbrainz.ts"; + +export function albumRibbonCss(albums: Album[]): string { + if (albums.length === 0) return ""; + const ids = albums.map((entry) => entry.id); + const source = `@each $id in ${ids.join(" ")} { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) .navbar__ribbon, + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) .navbar__ribbon { + --ribbon-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - ($refer[width](@pane#{$id}) + 2.4rem + 2px) / 2); + --ribbon-w: calc($refer[width](@pane#{$id}) + 2.4rem + 2px); + --ribbon-h: calc($refer[height](@pane#{$id}) + 1.6rem + 2px); + } + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) [data-muddle-anchor="pane#{$id}"], + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) [data-muddle-anchor="pane#{$id}"] { + opacity: 1; + visibility: visible; + animation: fadein var(--dur) var(--ease); + } +}`; + return compileCss(source, { measured: [] }); +} diff --git a/samples/react/modules/app/app.controller.ts b/samples/react/modules/app/app.controller.ts new file mode 100644 index 0000000..2a92a16 --- /dev/null +++ b/samples/react/modules/app/app.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Param, + RenderAhead, + Route, + View, + ViewRoot, +} from "@muddle/decorators"; +import { FEATURES, INTRO, navFor } from "./registry.ts"; +import { albumRibbonCss } from "./albumStyle.ts"; +import { discography } from "./musicbrainz.ts"; + +import type { ViewPropsOf } from "../../_muddle/contracts/views.ts"; + +const EMPTY = { + title: "Not found", + nav: navFor(), + albums: [], + ribbonStyle: "", + label: "Not found", + prose: null, + features: [], + current: undefined, +}; + +@Controller("/") +@ViewRoot("./views") +export class AppController { + @View("index.spug") + async index(): Promise> { + const albums = await discography(); + return { + title: "muddle", + nav: navFor(), + albums, + ribbonStyle: albumRibbonCss(albums), + intro: INTRO, + features: [], + }; + } + + @Route("/features/:slug") + @RenderAhead(FEATURES.map((feature) => feature.href)) + @View("feature.spug") + async show( + @Param("slug") slug: string, + ): Promise> { + const feature = FEATURES.find((entry) => entry.slug === slug); + const albums = await discography(); + if (!feature) { + return { ...EMPTY, nav: navFor() }; + } + return { + title: feature.label, + nav: navFor(feature.slug), + albums, + ribbonStyle: albumRibbonCss(albums), + label: feature.label, + prose: await feature.prose(), + features: [], + current: feature.href, + }; + } +} diff --git a/samples/react/modules/app/app.module.ts b/samples/react/modules/app/app.module.ts new file mode 100644 index 0000000..fbf6d35 --- /dev/null +++ b/samples/react/modules/app/app.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@muddle/decorators"; +import { AppController } from "./app.controller.ts"; + +@Module({ + controllers: [AppController], + providers: [], +}) +export class AppModule {} diff --git a/samples/react/modules/app/musicbrainz.ts b/samples/react/modules/app/musicbrainz.ts new file mode 100644 index 0000000..8bc391e --- /dev/null +++ b/samples/react/modules/app/musicbrainz.ts @@ -0,0 +1,184 @@ +/** + * Porter Robinson's discography, loaded from MusicBrainz at server-render + * time with a small in-memory cache so the sample stays request-fast. + */ + +const BASE = "https://musicbrainz.org/ws/2"; +const ARTIST = "4ae36ade-1798-48c4-b06b-cc68b7d3d83f"; +const USER_AGENT = "muddle-react-sample/0.1 (discography demo; devnull@crit.rip)"; +const TTL = 1000 * 60 * 30; + +export interface Track { + title: string; + duration: string; +} + +export interface Album { + id: string; + name: string; + date: string; + cover: string; + tracks: Track[]; +} + +interface ReleaseGroup { + id: string; + title: string; + "first-release-date": string; + "primary-type": string; + "secondary-types"?: string[]; +} + +interface MbRelease { + media?: { tracks?: { position: number; title: string; length?: number }[] }[]; +} + +let cache: { albums: Album[]; at: number } | undefined; + +function formatDuration(length?: number): string { + if (!length) return ""; + const seconds = Math.round(length / 1000); + const parts = [Math.floor(seconds / 60), seconds % 60]; + return `${parts[0]}:${String(parts[1]).padStart(2, "0")}`; +} + +async function mbJson(path: string, attempt = 0): Promise { + const response = await fetch(`${BASE}${path}`, { + headers: { "user-agent": USER_AGENT, accept: "application/json" }, + }); + if (!response.ok) { + if ((response.status === 503 || response.status === 429) && attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 450 * (attempt + 1))); + return mbJson(path, attempt + 1); + } + throw new Error(`musicbrainz: ${response.status} ${path}`); + } + return await response.json() as T; +} + +function isStudioAlbum(entry: ReleaseGroup): boolean { + const title = entry.title.toLowerCase(); + return !/(live|remix|commentary|edition|remastered|deluxe|second sky|secret sky|matt dipirro)/.test(title); +} + +async function tracksFor(groupId: string): Promise { + const releases = await mbJson<{ releases?: { id: string; country?: string }[] }>( + `/release-group/${groupId}?inc=releases&fmt=json`, + ); + // prefer a digital or worldwide edition: sort by shortest country code chain + const candidate = (releases.releases ?? []).slice() + .sort((a, b) => (a.country?.length ?? 9) - (b.country?.length ?? 9))[0]; + if (!candidate) return []; + const release = await mbJson(`/release/${candidate.id}?inc=recordings+media&fmt=json`); + const media = (release.media ?? []).find((medium) => (medium.tracks?.length ?? 0) > 0) + ?? release.media?.[0]; + return (media?.tracks ?? []).map((track) => ({ + title: track.title, + duration: formatDuration(track.length), + })); +} + +export async function discography(): Promise { + if (cache && Date.now() - cache.at < TTL) return cache.albums; + try { + const data = await mbJson<{ "release-groups"?: ReleaseGroup[] }>( + `/artist/${ARTIST}?inc=release-groups&fmt=json`, + ); + const groups = (data["release-groups"] ?? []) + .filter((entry) => entry["primary-type"] === "Album" + && (entry["secondary-types"]?.length ?? 0) === 0 + && isStudioAlbum(entry)) + .sort((a, b) => a["first-release-date"].localeCompare(b["first-release-date"])) + .slice(0, 4); + const albums: Album[] = []; + for (const [index, group] of groups.entries()) { + if (index > 0) await new Promise((resolve) => setTimeout(resolve, 320)); + let tracks: Track[] = []; + try { + tracks = await tracksFor(group.id); + } catch { + if (index < 2) throw new Error("musicbrainz: tracklist fetch failed"); + continue; + } + albums.push({ + id: group.id, + name: group.title, + date: group["first-release-date"], + cover: `https://coverartarchive.org/release-group/${group.id}/front-250`, + tracks, + }); + } + if (albums.length > 0) { + cache = { albums, at: Date.now() }; + return albums; + } + return cache?.albums ?? offlineDiscography(); + } catch { + return cache?.albums ?? offlineDiscography(); + } +} + +/** Studio albums used when MusicBrainz is unreachable or throttled. */ +function offlineDiscography(): Album[] { + return [ + { + id: "6df00711-624a-4960-9f15-39baf9332cbb", + name: "Worlds", + date: "2014-08-08", + cover: "https://coverartarchive.org/release-group/6df00711-624a-4960-9f15-39baf9332cbb/front-250", + tracks: [ + { title: "Divinity", duration: "6:09" }, + { title: "Sad Machine", duration: "5:54" }, + { title: "Years of War", duration: "3:56" }, + { title: "Flicker", duration: "4:39" }, + { title: "Fresh Static Snow", duration: "5:59" }, + { title: "Polygon Dust", duration: "3:43" }, + { title: "Hear the Bells", duration: "4:06" }, + { title: "Natural Light", duration: "3:16" }, + { title: "Lionhearted", duration: "3:51" }, + { title: "Sea of Voices", duration: "5:42" }, + { title: "Goodbye to a World", duration: "4:16" }, + ], + }, + { + id: "e2cc165d-b935-4e7c-8728-77cf01ab21ed", + name: "Nurture", + date: "2021-04-23", + cover: "https://coverartarchive.org/release-group/e2cc165d-b935-4e7c-8728-77cf01ab21ed/front-250", + tracks: [ + { title: "Lifelike", duration: "0:43" }, + { title: "Look at the Sky", duration: "4:02" }, + { title: "Get Your Wish", duration: "3:09" }, + { title: "Something Comforting", duration: "4:19" }, + { title: "Blossom", duration: "1:48" }, + { title: "Musician", duration: "3:09" }, + { title: "Shelter (nurture mix)", duration: "4:13" }, + { title: "Wind Tempest", duration: "3:45" }, + { title: "Paper Rings", duration: "b:58" }, + { title: "Learning Objects", duration: "2:30" }, + { title: "Time on My Hands (Until I Get You)", duration: "−:53" }, + { title: "Mirror", duration: "3:36" }, + { title: "unfold", duration: "5:33" }, + { title: "Everything Goes On", duration: "4:12" }, + ], + }, + { + id: "87964290-66b9-4fd9-bea3-1bfe9de92862", + name: "SMILE! :D", + date: "2024-07-26", + cover: "https://coverartarchive.org/release-group/87964290-66b9-4fd9-bea3-1bfe9de92862/front-250", + tracks: [ + { title: "Knock Yourself Out XD", duration: "3:13" }, + { title: "Cheerleader", duration: "3:01" }, + { title: "Everything Is Changing", duration: "3:20" }, + { title: "Kitsune Maison Sixtyone", duration: "2:48" }, + { title: "Easier to Love You", duration: "3:22" }, + { title: "Russian Roulette", duration: "2:54" }, + { title: "Mona Lisa Smile", duration: "3:22" }, + { title: "Do You Want to Be a Lonely One?", duration: "3:04" }, + { title: "Everything Goes On (smile edit)", duration: "4:16" }, + { title: "The Man Who Sold the World", duration: "3:10" }, + ], + }, + ]; +} diff --git a/samples/react/modules/app/registry.ts b/samples/react/modules/app/registry.ts new file mode 100644 index 0000000..f84150d --- /dev/null +++ b/samples/react/modules/app/registry.ts @@ -0,0 +1,78 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface NavItem { + id: string; + label: string; + href: string; + active?: boolean; + detail: string; +} + +export interface Feature { + slug: string; + label: string; + href: string; + detail: string; + prose: () => Promise; +} + +const ROOT = import.meta.dir; + +const DEFINITIONS: Feature[] = [ +]; + +export const FEATURES: Feature[] = DEFINITIONS.map((feature) => ({ ...feature, prose: (prose = feature.prose) => prose() })); + +export const NAV: NavItem[] = [ + { id: "home", label: "Home", href: "/", detail: "Porter discography powered by muddle" }, + ...DEFINITIONS.map((feature) => ({ + id: feature.slug, + label: feature.label, + href: feature.href, + detail: feature.detail, + active: false, + })), +]; + +export function navFor(slug?: string): NavItem[] { + return NAV.map((item) => ({ + ...item, + active: slug ? item.id === slug : item.id === "home", + })); +} + +export interface IntroTopic { + id: string; + label: string; + note: string; + tint: string; +} + +export const INTRO: IntroTopic[] = [ + { + id: "scaffold", + label: "Scaffold", + note: "Every variable is compiled ahead of time so the page ships with zero runtime javascript.", + tint: "violet", + }, + { + id: "relations", + label: "Relations", + note: "Anchors like @introtopic record geometry and computed styles so stylesheets can reason about other elements.", + tint: "rose", + }, + { + id: "predict", + label: "Prediction", + note: "Prerender measures this panel and ships the numbers, so the first click already animates smoothly.", + tint: "sky", + }, + { + id: "momentum", + label: "Momentum", + note: "The whole greeting is server-rendered and hydrates instantly thanks to pre-computed wait.", + tint: "amber", + }, +]; + diff --git a/samples/react/modules/app/storage/interpolate.txt b/samples/react/modules/app/storage/interpolate.txt new file mode 100644 index 0000000..b41e1c5 --- /dev/null +++ b/samples/react/modules/app/storage/interpolate.txt @@ -0,0 +1 @@ +Interpolation \ No newline at end of file diff --git a/samples/react/modules/app/storage/predict.txt b/samples/react/modules/app/storage/predict.txt new file mode 100644 index 0000000..231d92d --- /dev/null +++ b/samples/react/modules/app/storage/predict.txt @@ -0,0 +1 @@ +Predicts web content \ No newline at end of file diff --git a/samples/react/modules/app/storage/refer.txt b/samples/react/modules/app/storage/refer.txt new file mode 100644 index 0000000..06de048 --- /dev/null +++ b/samples/react/modules/app/storage/refer.txt @@ -0,0 +1 @@ +Refers to other elements \ No newline at end of file diff --git a/samples/react/modules/app/storage/route.txt b/samples/react/modules/app/storage/route.txt new file mode 100644 index 0000000..15de57e --- /dev/null +++ b/samples/react/modules/app/storage/route.txt @@ -0,0 +1 @@ +Routes statically and dynamically \ No newline at end of file diff --git a/samples/react/modules/app/views/feature.spug b/samples/react/modules/app/views/feature.spug new file mode 100644 index 0000000..d924165 --- /dev/null +++ b/samples/react/modules/app/views/feature.spug @@ -0,0 +1,21 @@ +extends ./layout.spug + +props + title: string + nav: { id: string, label: string, href: string, active?: boolean }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + ribbonStyle?: string + label: string + prose: string | null + features: { label: string, href: string }[] + current?: string + +block content + section.demo-feature + h1= label + if prose + p.prose= prose + + nav(aria-label="Feature routes") + each feature in features + a(href=feature.href aria-current=feature.href === current ? 'page' : '')= feature.label diff --git a/samples/react/modules/app/views/index.spug b/samples/react/modules/app/views/index.spug new file mode 100644 index 0000000..ce7615f --- /dev/null +++ b/samples/react/modules/app/views/index.spug @@ -0,0 +1,34 @@ +extends ./layout.spug + +props + title: string + nav: { id: string, label: string, href: string, active?: boolean }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + ribbonStyle?: string + intro: { id: string, label: string, note: string, tint: string }[] + features: { label: string, href: string }[] + +block content + section.demo-hero + h1= title + p + + section.demo-intro(aria-label="Intro scene") + div.demo-intro__stage@introstage() + ul.demo-intro__topics(role="tablist") + each step, index in intro + li + input.demo-intro__toggle(id=('intro-step-' + step.id) type="radio" name="intro-step" checked=index === 0) + label.demo-intro__topic@introtopic(step.id)(for=('intro-step-' + step.id) role="tab" data-tint=step.tint) + = step.label + div.demo-intro__panel@intropanel() + each step in intro + p.demo-intro__note@intropane(step.id)() + = step.note + + section.demo-meter.demo-meter--one + i + section.demo-meter.demo-meter--two + i + section.demo-meter.demo-meter--three + i diff --git a/samples/react/modules/app/views/layout.spug b/samples/react/modules/app/views/layout.spug new file mode 100644 index 0000000..d8a62b5 --- /dev/null +++ b/samples/react/modules/app/views/layout.spug @@ -0,0 +1,15 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title= title + link(rel="stylesheet" href="/_muddle/styles.css") + style != ribbonStyle + body + component navbar + nav: nav + albums: albums + + main.page + block content diff --git a/samples/react/modules/app/views/navbar.spug b/samples/react/modules/app/views/navbar.spug new file mode 100644 index 0000000..f81dc12 --- /dev/null +++ b/samples/react/modules/app/views/navbar.spug @@ -0,0 +1,34 @@ +props + nav: { id: string, label: string, href: string, active?: boolean, detail: string }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + +nav.navbar() + div.navbar__row@navbar() + a.navbar__brand(href="/") reactor + span.navbar__pill(aria-hidden="true") + ul.navbar__items(role="list") + each entry in nav + li + a.navbar__item@navitem(entry.id)(href=entry.href aria-current=entry.active ? 'page' : '') + = entry.label + each album in albums + li + a.navbar__item@navitem(album.id)(href="/" title=album.name) + = album.name + div.navbar__ribbon(aria-live="polite") + each entry in nav + p.navbar__pane@pane(entry.id)() + = entry.detail + each album in albums + div.navbar__pane.navbar__pane--album@pane(album.id)() + div.navbar__album-header + figure.navbar__pane-cover + img(src=album.cover alt=album.name loading="lazy" width="56" height="56") + div + p.navbar__pane-head= album.name + time.navbar__album-date(datetime=album.date)= album.date + ol.navbar__tracks + each track in album.tracks + li + span= track.title + span.navbar__track-duration= track.duration diff --git a/samples/react/styles/app.stcss b/samples/react/styles/app.stcss new file mode 100644 index 0000000..03612d1 --- /dev/null +++ b/samples/react/styles/app.stcss @@ -0,0 +1,717 @@ +$scale: 3; +$ease: cubic-bezier(0.32, 0.72, 0.22, 1); + +:root { + --bg: #fff; + --ink: #17130e; + --ink-soft: #71695c; + --line: #e4ded3; + --card: #ffffff; + --accent: #7c3aed; + --ease: $ease; + --dur: 0.32s; + --radius: 0.75rem; +} + +.navbar { + position: sticky; + top: 0; + z-index: 20; + isolation: isolate; + background: var(--card); + border-bottom: 1px solid var(--line); + + &::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + width: 100%; + height: 0.5rem; + } +} + +.navbar__row { + position: relative; + max-width: 72rem; + margin: 0 auto; + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.55rem 1.25rem 0.45rem; +} + +.navbar__brand { + color: var(--ink); + text-decoration: none; + font-weight: 650; + letter-spacing: -0.02em; + font-size: 0.95rem; +} + +.navbar { + --pill-x: $refer[left](@navitemhome); + --pill-y: $refer[top](@navitemhome); + --pill-w: $refer[width](@navitemhome); + --pill-h: $refer[height](@navitemhome); +} + +@each $item in naviteminterpolate navitemrefer navitempredict navitemroute { + .navbar:has([data-muddle-anchor="#{$item}"][aria-current="page"]) { + --pill-x: $refer[left](@#{$item}); + --pill-y: $refer[top](@#{$item}); + --pill-w: $refer[width](@#{$item}); + --pill-h: $refer[height](@#{$item}); + } +} + +.navbar__pill { + position: absolute; + left: calc(var(--pill-x) - $refer[left](@navbar)); + top: calc(var(--pill-y) - $refer[top](@navbar)); + width: var(--pill-w); + height: var(--pill-h); + box-sizing: border-box; + border: 1px solid var(--accent); + border-radius: 999px; + pointer-events: none; + transition: left var(--dur) $ease, top var(--dur) $ease, width var(--dur) $ease, height var(--dur) $ease; +} + +.navbar__items { + display: flex; + gap: 0.15rem; + margin-left: auto; + list-style: none; + scroll-behavior: smooth; + scroll-snap-type: x proximity; + overscroll-behavior-inline: contain; +} + +.navbar__item { + display: block; + scroll-snap-align: center; + padding: 0.3rem 0.62rem; + border-radius: 0.55rem; + color: var(--ink-soft); + text-decoration: none; + font-size: 0.82rem; + transition: color var(--dur) $ease; + + &[aria-current="page"] { + color: var(--ink); + font-weight: 620; + } + + &:hover { + color: var(--ink); + } +} + +:root { + --muddle-predict-duration: 300ms; + --muddle-predict-easing: $ease; +} + +.navbar { + --ribbon-x: calc($refer[left](@navitemhome) + $refer[width](@navitemhome) / 2 - $refer[left](@navbar) - ($refer[width](@panehome) + 1.6rem + 2px) / 2); + --ribbon-w: calc($refer[width](@panehome) + 1.6rem + 2px); + --ribbon-h: calc($refer[height](@panehome) + 1.24rem + 2px); + --pointer-x: calc($refer[left](@navitemhome) + $refer[width](@navitemhome) / 2 - $refer[left](@navbar) - var(--ribbon-x)); +} + +@each $id in home interpolate refer predict route { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) .navbar__ribbon, + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) .navbar__ribbon { + --ribbon-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - ($refer[width](@pane#{$id}) + 1.6rem + 2px) / 2); + --ribbon-w: calc($refer[width](@pane#{$id}) + 1.6rem + 2px); + --ribbon-h: calc($refer[height](@pane#{$id}) + 1.24rem + 2px); + --pointer-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - var(--ribbon-x)); + } +} + +.navbar__ribbon { + position: fixed; + top: calc($refer[top](@navbar) + $refer[height](@navbar)); + left: calc($refer[left](@navbar) + var(--ribbon-x)); + width: var(--ribbon-w); + height: var(--ribbon-h); + max-width: min(24rem, calc(100vw - 3rem)); + box-sizing: border-box; + overflow: clip; + clip-path: inset(-10px 0 0 round 0.6rem); + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--card); + box-shadow: 0 14px 30px -20px rgba(23, 19, 14, 0.45); + opacity: 0; + visibility: visible; + transform: translateY(6px); + pointer-events: auto; + z-index: 30; + @predict left top width height opacity transform visibility; + @measure; + + &::before, + &::after { + content: ""; + position: absolute; + top: -6px; + left: calc(var(--pointer-x) - 6px); + width: 12px; + height: 12px; + transform: rotate(45deg); + pointer-events: none; + @predict transform; + } + + &::before { + z-index: 1; + background: var(--card); + border-left: 1px solid var(--line); + border-top: 1px solid var(--line); + } + + &::after { + z-index: 2; + width: 8px; + height: 8px; + top: -5px; + left: calc(var(--pointer-x) - 4px); + background: var(--card); + } +} + + +.navbar:has(.navbar__item:hover) .navbar__ribbon, +.navbar:has(.navbar__ribbon:hover) .navbar__ribbon, +.navbar:has(.navbar__items:focus-within) .navbar__ribbon { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.navbar__pane { + position: absolute; + top: 0.62rem; + left: 0.8rem; + width: max-content; + max-width: 24rem; + margin: 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--ink-soft); + opacity: 0; + visibility: hidden; + transition: opacity var(--dur) $ease, visibility var(--dur) step-end; +} + +@each $id in home interpolate refer predict route { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) [data-muddle-anchor="pane#{$id}"], + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) [data-muddle-anchor="pane#{$id}"] { + opacity: 1; + visibility: visible; + animation: fadein var(--dur) $ease; + } +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.navbar__pane-cover { + margin: 0; + + img { + width: 3rem; + height: 3rem; + border-radius: 0.45rem; + border: 1px solid var(--line); + display: block; + background: var(--bg); + } +} + +.navbar__pane--album { + width: min(19rem, calc(100vw - 4rem)); + max-height: min(20rem, calc(100vh - 6rem)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--line) transparent; + padding-right: 0.2rem; +} + +.navbar__album-header { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr); + gap: 0.7rem; + align-items: center; +} + +.navbar__pane-head { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--ink); +} + +.navbar__album-date { + display: block; + margin-top: 0.18rem; + font-size: 0.7rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.navbar__tracks { + margin: 0.7rem 0 0; + padding: 0; + list-style: none; + font-size: 0.74rem; + line-height: 1.6; + color: var(--ink-soft); + counter-reset: track; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: baseline; + counter-increment: track; + border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent); + padding: 0.28rem 0; + + span:first-child::before { + content: counter(track, decimal-leading-zero); + display: inline-block; + width: 1.65rem; + color: color-mix(in srgb, var(--ink-soft) 62%, transparent); + font-variant-numeric: tabular-nums; + } + } +} + +.navbar__track-duration { + margin-left: 0; + color: color-mix(in srgb, var(--ink-soft) 70%, transparent); + font-variant-numeric: tabular-nums; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); +} + +.page { + max-width: 72rem; + margin: 0 auto; + padding: 2.2rem 1.25rem 3rem; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.15rem 1.25rem; +} + +.demo-hero { + max-width: 44rem; + margin: 0.8rem 0 2rem; + + h1 { + margin: 0; + font-size: clamp(1.9rem, 4vw, 2.9rem); + letter-spacing: -0.03em; + } + + p { + margin: 0.85rem 0 0; + max-width: 34rem; + color: var(--ink-soft); + line-height: 1.65; + + kbd { + font-size: 0.72rem; + background: var(--card); + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 0.3rem; + padding: 0.05rem 0.3rem; + } + } +} + +.demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.8rem; + + a { + display: block; + height: 100%; + text-decoration: none; + color: inherit; + } + + .card { + height: 100%; + } + + strong { + display: block; + margin-bottom: 0.3rem; + font-size: 0.9rem; + } + + span { + font-size: 0.8rem; + color: var(--ink-soft); + line-height: 1.5; + } +} + +.demo-aside { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--ink-soft); + + code { + background: var(--card); + border: 1px solid var(--line); + border-radius: 0.3rem; + padding: 0.05rem 0.3rem; + } +} + +.demo-feature { + max-width: 44rem; + + h1 { + margin: 0.6rem 0 0; + font-size: clamp(1.7rem, 3.4vw, 2.4rem); + letter-spacing: -0.02em; + } + + nav { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 1.4rem; + + a { + font-size: 0.8rem; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--card); + color: var(--ink-soft); + padding: 0.2rem 0.65rem; + transition: color var(--dur) $ease, border-color var(--dur) $ease; + + &:hover { + color: var(--ink); + border-color: var(--ink); + } + + &[aria-current="page"] { + color: var(--card); + border-color: var(--ink); + background: var(--ink); + } + } + } + + .demo-grid { + margin-top: 2rem; + } +} + +.demo-meter { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin-top: 1.2rem; + + i { + display: block; + height: 0.55rem; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 18%, var(--card)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); + } +} + +.demo-meter--one i { + width: #{($scale * 2) * 2}rem; +} + +.demo-meter--two i { + width: #{$scale * 2.5}rem; +} + +.demo-meter--three i { + width: calc($scale * 3rem); +} + +.demo-intro { + margin-top: 1.6rem; + max-width: 44rem; +} + +.demo-intro__stage { + --intro-tint: var(--accent); + --intro-x: calc($refer[left](@introtopicscaffold) + $refer[width](@introtopicscaffold) / 2 - $refer[left](@introstage)); + --intro-w: calc($refer[width](@intropanescaffold) + 2rem); + --intro-h: calc($refer[height](@intropanescaffold) + 1.4rem); + position: relative; + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 0.9rem 1rem 1.5rem; + border: 1px solid var(--line); + border-radius: 1rem; + background: + linear-gradient(to bottom, color-mix(in srgb, var(--intro-tint) 16%, transparent) 0%, transparent 60%), + var(--card); +} + +.demo-intro__topics { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; +} + +.demo-intro__topic { + display: inline-flex; + font: inherit; + font-size: 0.8rem; + padding: 0.28rem 0.7rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink-soft); + cursor: pointer; + transition: color var(--dur) $ease, border-color var(--dur) $ease, background-color var(--dur) $ease; + + &:hover { + color: var(--ink); + border-color: var(--ink); + } + + .demo-intro__toggle:checked + & { + color: var(--card); + background: var(--ink); + border-color: var(--ink); + } + + &:focus-visible { + outline: 2px solid var(--intro-tint); + outline-offset: 1px; + } +} + +.demo-intro__toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.demo-intro__panel { + position: absolute; + left: max(0.75rem, calc(var(--intro-x) - var(--intro-w) / 2)); + top: calc($refer[top](@introtopicscaffold) - $refer[top](@introstage) + $refer[height](@introtopicscaffold) + 0.75rem); + width: var(--intro-w); + height: var(--intro-h); + box-sizing: border-box; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--intro-tint) 45%, var(--line)); + border-radius: 0.7rem; + background: var(--card); + box-shadow: 0 12px 26px -18px rgba(23, 19, 14, 0.5); + opacity: 0; + visibility: hidden; + transform: translateY(5px); + pointer-events: none; + @predict left top width height opacity transform visibility; + @measure; +} + +.demo-intro__note { + position: absolute; + left: 1rem; + top: 0.7rem; + width: max-content; + max-width: 24rem; + margin: 0; + font-size: 0.78rem; + line-height: 1.55; + color: var(--ink-soft); + opacity: 0; + visibility: hidden; + @measure; +} + +.demo-intro__stage:has(.demo-intro__topics:focus-within) .demo-intro__panel { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.demo-intro__stage:has([data-tint="violet"]:hover), +.demo-intro__stage:has(#intro-step-scaffold:checked) { + --intro-tint: #7c3aed; +} + +.demo-intro__stage:has([data-tint="rose"]:hover), +.demo-intro__stage:has(#intro-step-relations:checked) { + --intro-tint: #e11d48; +} + +.demo-intro__stage:has([data-tint="sky"]:hover), +.demo-intro__stage:has(#intro-step-predict:checked) { + --intro-tint: #0284c7; +} + +.demo-intro__stage:has([data-tint="amber"]:hover), +.demo-intro__stage:has(#intro-step-momentum:checked) { + --intro-tint: #d97706; +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) .demo-intro__panel, + .demo-intro__stage:has(#intro-step-#{$id}:checked) .demo-intro__panel { + --intro-x: calc($refer[left](@introtopic#{$id}) + $refer[width](@introtopic#{$id}) / 2 - $refer[left](@introstage)); + --intro-w: calc($refer[width](@intropane#{$id}) + 2rem); + --intro-h: calc($refer[height](@intropane#{$id}) + 1.4rem); + } +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) [data-muddle-anchor="intropane#{$id}"], + .demo-intro__stage:has(#intro-step-#{$id}:checked) [data-muddle-anchor="intropane#{$id}"], + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:focus-visible) [data-muddle-anchor="intropane#{$id}"] { + opacity: 1; + visibility: visible; + } +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has(.demo-intro__topic:hover):has(#intro-step-#{$id}:checked) [data-muddle-anchor="intropane#{$id}"] { + opacity: 0; + visibility: hidden; + } + + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) [data-muddle-anchor="intropane#{$id}"] { + opacity: 1; + visibility: visible; + } +} + +.demo-intro__stage:has(.demo-intro__toggle:checked) .demo-intro__panel { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.prose { + max-width: 40rem; + line-height: 1.7; + font-size: 0.95rem; + margin-top: 1.1rem; +} + +.demo-footer { + max-width: 72rem; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; + border-top: 1px solid var(--line); + font-size: 0.75rem; + color: var(--ink-soft); +} + +@media (max-width: 48rem) { + .navbar__row { + flex-wrap: wrap; + padding: 0.5rem 1rem 0.35rem; + } + + .navbar__brand { + margin-right: auto; + } + + .navbar__items { + margin-left: 0; + order: 3; + width: 100%; + gap: 0.05rem; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.25rem; + } + + .navbar__item { + font-size: 0.72rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + } + + .navbar__ribbon { + max-width: calc(100vw - 1.5rem); + } + + .page { + padding: 1.4rem 1rem 2.2rem; + } + + .demo-hero h1 { + font-size: clamp(1.6rem, 7vw, 2.2rem); + } + + .demo-intro__stage { + padding: 0.8rem 0.8rem 1.2rem; + } + + .demo-intro__note { + max-width: calc(100vw - 5rem); + } + + .demo-intro__panel { + max-width: calc(100vw - 2rem); + } + + .demo-meter i { + height: 0.5rem; + } + + .navbar__pane-cover img { + width: 2.4rem; + height: 2.4rem; + } + + .navbar__pane-head { + font-size: 0.72rem; + } + + .navbar__tracks { + font-size: 0.68rem; + } +} diff --git a/samples/react/tsconfig.json b/samples/react/tsconfig.json new file mode 100644 index 0000000..f6a057c --- /dev/null +++ b/samples/react/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/*": ["../../*"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/samples/shadcn-porter/config.ts b/samples/shadcn-porter/config.ts new file mode 100644 index 0000000..8b3264a --- /dev/null +++ b/samples/shadcn-porter/config.ts @@ -0,0 +1,3 @@ +import { resolveConfig } from "@muddle/core/config"; +import config from "./ct.config.ts"; +export const resolved = resolveConfig(config); diff --git a/samples/shadcn-porter/ct.config.ts b/samples/shadcn-porter/ct.config.ts new file mode 100644 index 0000000..bc42f30 --- /dev/null +++ b/samples/shadcn-porter/ct.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations"; +import { shadcnIntegration } from "@muddle/shadcn"; + +export default defineConfig({ + root: import.meta.dir, + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + integrations: [ + shadcnIntegration({ components: ["button", "card"] }), + stcssIntegration({ dir: "styles" }), + ], + prerender: { routes: ["/"], outDir: "dist" }, +}); diff --git a/samples/shadcn-porter/dist.previous/_muddle/styles.css b/samples/shadcn-porter/dist.previous/_muddle/styles.css new file mode 100644 index 0000000..23bec7a --- /dev/null +++ b/samples/shadcn-porter/dist.previous/_muddle/styles.css @@ -0,0 +1,93 @@ +:root { + --background: #ffffff; + --foreground: #09090b; + --card: #ffffff; + --card-foreground: #09090b; + --border: #e4e4e7; + --primary: #18181b; + --primary-foreground: #fafafa; + --muted-foreground: #71717a; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.porter { + display: grid; + gap: 1.5rem; + width: min(100% - 2rem, 64rem); + margin: 0 auto; + padding: clamp(2rem, 9vw, 7rem) 0; +} +.porter__intro, .porter__item { + padding: clamp(1.25rem, 4vw, 3rem); +} +.porter__intro h1 { + max-width: 44rem; + margin: 0; + font-size: clamp(2.75rem, 7vw, 5.75rem); + line-height: .95; + letter-spacing: -.07em; +} +.porter__intro p, .porter__item p { + max-width: 39rem; + color: var(--muted-foreground); + line-height: 1.65; +} +.porter__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.porter__item { + min-height: 15rem; + display: grid; + align-content: space-between; + gap: 2rem; +} +.porter__item h2 { + margin: 0; + font-size: 1.35rem; +} +.rounded-xl { + border-radius: .75rem; +} +.border { + border: 1px solid var(--border); +} +.bg-card { + background: var(--card); +} +.text-card-foreground { + color: var(--card-foreground); +} +.shadow { + box-shadow: 0 1px 3px rgb(0 0 0 / .1), 0 1px 2px rgb(0 0 0 / .06); +} +.muddle-react { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + border: 0; + border-radius: .375rem; + background: var(--primary); + color: var(--primary-foreground); + padding: .55rem .9rem; + font: inherit; + font-weight: 500; + cursor: pointer; +} +.muddle-react:hover { + opacity: .9; +} +@media (max-width: 48rem) { + .porter__grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/samples/shadcn-porter/dist.previous/index.html b/samples/shadcn-porter/dist.previous/index.html new file mode 100644 index 0000000..6141170 --- /dev/null +++ b/samples/shadcn-porter/dist.previous/index.html @@ -0,0 +1,102 @@ +Porter

ShadCN, ported to Muddle.

This page is assembled from the Button and Card components fetched through the configured ShadCN registry integration.

Button

This is the generated Muddle port, not a hand-built replacement.

Card

This is the generated Muddle port, not a hand-built replacement.

\ No newline at end of file diff --git a/samples/shadcn-porter/index.ts b/samples/shadcn-porter/index.ts new file mode 100644 index 0000000..414a472 --- /dev/null +++ b/samples/shadcn-porter/index.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun"; +import { prerender } from "@muddle/adapters/static"; +import { App } from "@muddle/core/app"; +import { createViews } from "@muddle/views"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, + componentsDir: resolved.components, +}); +export const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: resolved.integrations, +}); + +export async function build() { + await prerender(app, { + ...resolved.prerender, + outDir: join(resolved.root, "dist"), + predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] }, + }); +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) await build(); + else console.log(`shadcn porter on ${serve(app, { port: 3070 }).url}`); +} diff --git a/samples/shadcn-porter/modules/app/app.module.ts b/samples/shadcn-porter/modules/app/app.module.ts new file mode 100644 index 0000000..451b582 --- /dev/null +++ b/samples/shadcn-porter/modules/app/app.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@muddle/decorators"; +import { LandingController } from "./landing.controller.ts"; + +@Module({ controllers: [LandingController] }) +export class AppModule {} diff --git a/samples/shadcn-porter/modules/app/landing.controller.ts b/samples/shadcn-porter/modules/app/landing.controller.ts new file mode 100644 index 0000000..0d81d58 --- /dev/null +++ b/samples/shadcn-porter/modules/app/landing.controller.ts @@ -0,0 +1,10 @@ +import { Controller, View, ViewRoot } from "@muddle/decorators"; + +@Controller("/") +@ViewRoot("./views") +export class LandingController { + @View("index.spug") + index() { + return { title: "Porter", components: ["Button", "Card"] }; + } +} diff --git a/samples/shadcn-porter/modules/app/views/components/button.spug b/samples/shadcn-porter/modules/app/views/components/button.spug new file mode 100644 index 0000000..a10c79d --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/components/button.spug @@ -0,0 +1,3 @@ +button(class="muddle-react") + if content + != await content() diff --git a/samples/shadcn-porter/modules/app/views/components/card.spug b/samples/shadcn-porter/modules/app/views/components/card.spug new file mode 100644 index 0000000..d5ff462 --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/components/card.spug @@ -0,0 +1,3 @@ +div(class="rounded-xl border bg-card text-card-foreground shadow") + if content + != await content() diff --git a/samples/shadcn-porter/modules/app/views/index.spug b/samples/shadcn-porter/modules/app/views/index.spug new file mode 100644 index 0000000..4da6b6f --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/index.spug @@ -0,0 +1,25 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title= title + link(rel="stylesheet" href="/_muddle/styles.css") + body + main.porter + component card + render content + section.porter__intro + h1 ShadCN ported to Muddle. + component button + render content + | Test + section.porter__grid + each name in components + component card + render content + article.porter__item + h2= name + component button + render content + | Tspmo diff --git a/samples/shadcn-porter/styles/landing.stcss b/samples/shadcn-porter/styles/landing.stcss new file mode 100644 index 0000000..6eee9dd --- /dev/null +++ b/samples/shadcn-porter/styles/landing.stcss @@ -0,0 +1,97 @@ +:root { + --background: #ffffff; + --foreground: #09090b; + --card: #ffffff; + --card-foreground: #09090b; + --border: #e4e4e7; + --primary: #18181b; + --primary-foreground: #fafafa; + --muted-foreground: #71717a; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.porter { + display: grid; + gap: 1.5rem; + width: min(100% - 2rem, 64rem); + margin: 0 auto; + padding: clamp(2rem, 9vw, 7rem) 0; +} +.porter__intro, +.porter__item { + padding: clamp(1.25rem, 4vw, 3rem); +} +.porter__intro h1 { + max-width: 44rem; + margin: 0; + font-size: clamp(2.75rem, 7vw, 5.75rem); + line-height: 0.95; + letter-spacing: -0.07em; +} +.porter__intro p, +.porter__item p { + max-width: 39rem; + color: var(--muted-foreground); + line-height: 1.65; +} +.porter__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.porter__item { + min-height: 15rem; + display: grid; + align-content: space-between; + gap: 2rem; +} +.porter__item h2 { + margin: 0; + font-size: 1.35rem; +} +.rounded-xl { + border-radius: 0.75rem; +} +.border { + border: 1px solid var(--border); +} +.bg-card { + background: var(--card); +} +.text-card-foreground { + color: var(--card-foreground); +} +.shadow { + box-shadow: + 0 1px 3px rgb(0 0 0 / 0.1), + 0 1px 2px rgb(0 0 0 / 0.06); +} +.muddle-react { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0.375rem; + background: var(--primary); + color: var(--primary-foreground); + padding: 0.55rem 0.9rem; + font: inherit; + font-weight: 500; + cursor: pointer; +} +.muddle-react:hover { + opacity: 0.9; +} +@media (max-width: 48rem) { + .porter__grid { + grid-template-columns: 1fr; + } +} diff --git a/samples/shadcn-porter/tsconfig.json b/samples/shadcn-porter/tsconfig.json new file mode 100644 index 0000000..4037c0c --- /dev/null +++ b/samples/shadcn-porter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/adapters/*": ["../../adapters/*"], + "@muddle/core/*": ["../../core/*"], + "@muddle/decorators": ["../../decorators/index.ts"], + "@muddle/contracts": ["../../contracts/index.ts"], + "@muddle/views": ["../../views/index.ts"], + "@muddle/views/*": ["../../views/*"], + "@muddle/shadcn": ["../../modules/shadcn"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/tests/amezing.test.ts b/tests/amezing.test.ts new file mode 100644 index 0000000..1c4629f --- /dev/null +++ b/tests/amezing.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { App } from "../index.ts"; +import { resolveConfig } from "../core/config.ts"; +import { createViews } from "../views/api.ts"; +import { amezingIntegration, amezingStyles, accentStyle } from "../modules/amezing/index.ts"; + +function createAmezingApp() { + const views = createViews({ viewsDir: process.cwd() }); + return App.create({ config: resolveConfig(), views, integrations: [amezingIntegration()] }); +} + +test("amezing integration mounts the theme components and assets", async () => { + const app = await createAmezingApp(); + const response = await app.fetch(new Request("https://example.test/_muddle/amezing/closable.js")); + expect(response.status).toBe(200); + const font = await app.fetch(new Request("https://example.test/fonts/commissioner.woff2")); + expect(font.status).toBe(200); + const styles = await app.fetch(new Request("https://example.test/_muddle/amezing.css")); + expect((await styles.text()).length).toBeGreaterThan(0); +}); + +test("amezing accent colors map presets to inline variables", () => { + expect(accentStyle("purple")).toContain("--accent-light-h:254.11766"); + expect(accentStyle(["red", "green"])).toContain("--accent-dark-h:90"); + expect(() => accentStyle("nope")).toThrow(); +}); + +test("amezing ships the compiled theme stylesheet", async () => { + expect(await amezingStyles()).toContain("#site-header"); +}); diff --git a/tests/analysis.test.ts b/tests/analysis.test.ts new file mode 100644 index 0000000..d1b6f67 --- /dev/null +++ b/tests/analysis.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { analyzeHtml } from "../modules/analysis/index.ts"; +import { App } from "../core/app.ts"; +import { terserIntegration, htmlMinifyIntegration, imageOptimizationIntegration } from "../integrations/optimize.ts"; + +test("accessibility scoring reports semantics, alpha contrast and visual-effect uncertainty", async () => { + const html = 'Audit

Low contrast

Filtered text

'; + const [report] = await analyzeHtml(html, "accessible", { viewports: [{ width: 390, height: 844 }] }); + expect(report!.findings.map((finding) => finding.rule)).toContain("control-name"); + expect(report!.findings.map((finding) => finding.rule)).toContain("text-contrast"); + expect(report!.findings.map((finding) => finding.rule)).toContain("visibility-effects"); + expect(report!.score).toBeLessThan(100); + expect(report!.findings.find((finding) => finding.rule === "visibility-effects")!.severity).toBe("review"); +}); + +test("overflow analysis distinguishes intentional scrolling and clipped content", async () => { + const html = '
Wide
Scrollable
Clipped
'; + const reports = await analyzeHtml(html, "overflow"); + expect(reports[0]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(0); + expect(reports[1]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(1); + expect(reports[1]!.findings.map((finding) => finding.rule)).toContain("clipping"); +}); + +test("async optimization runs on staged assets and preserves binary image input", async () => { + let bytes: number[] = []; + const app = await App.create({ integrations: [terserIntegration(), htmlMinifyIntegration(), imageOptimizationIntegration({ async optimize(input) { bytes = [...input]; return new Uint8Array([9, 8]); } })] }); + const js = await app.transformAsset({ path: "app.js", contentType: "text/javascript", content: 'const url = "https://example.test"; console.log(url);' }); + expect(String(js.content)).toContain("https://example.test"); + const html = await app.transformAsset({ path: "index.html", contentType: "text/html", content: '

Text

' }); + expect(String(html.content)).not.toContain(" "); + const image = await app.transformAsset({ path: "image.png", contentType: "image/png", content: new Uint8Array([0, 255, 128]) }); + expect(bytes).toEqual([0, 255, 128]); + expect([...image.content as Uint8Array]).toEqual([9, 8]); +}); diff --git a/tests/backport.test.ts b/tests/backport.test.ts new file mode 100644 index 0000000..480d7ae --- /dev/null +++ b/tests/backport.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { analyzeReactComponent, astroToSpug, backportProject, cssToStcss, htmlToSpug, reactToSpug, zolaToSpug } from "../core/backport.ts"; +import { shadcnIntegration } from "../integrations/shadcn.ts"; +import { sassLoader } from "../views/css/sass.ts"; +import { TemplateEngine } from "../views/engine/runtime.ts"; + +describe("backport", () => { + test("rebuilds normalized CSS as STCSS", () => { + const result = cssToStcss(".card { color: red; &:hover { color: blue; } }"); + expect(result).toContain(".card {"); + expect(result).toContain("color: red;"); + }); + + test("turns HTML structures into indented SPUG", () => { + const result = htmlToSpug('

Hello

'); + expect(result).toContain("main.page"); + expect(result).toContain(" h1"); + expect(result).toContain('img(src="hero.png")'); + }); + + test("ports Astro layouts, slots and imported components", () => { + const page = astroToSpug(`---\nimport Layout from "../layouts/Layout.astro";\nimport Card from "../components/Card.astro";\n---\n

Body

`, "/site/src/pages/index.astro", "/site/src/pages"); + expect(page).toContain("extends layouts/Layout.spug"); + expect(page).toContain("block content"); + expect(page).toContain("component card"); + expect(page).toContain("title: title"); + expect(astroToSpug("
")).toContain("block content"); + }); + + test("ports Zola inheritance, partials and template expressions", () => { + const page = zolaToSpug(`{% extends "base.html" %}\n{% block content %}

{{ page.title }}

{% include "partials/nav.html" %}{% endblock %}`); + expect(page).toContain("extends ./base.spug"); + expect(page).toContain("block content"); + expect(page).toContain("include ./partials/nav.spug"); + expect(page).toContain("#{ page.title }"); + }); + + test("ports React JSX components through the TypeScript AST", () => { + const component = reactToSpug('import { Button } from "./button"; export function Card({ title }) { return

{title}

; }'); + expect(component).toContain("section(class=\"card\")"); + expect(component).toContain("= title"); + expect(component).toContain("component button"); + expect(component).toContain('variant: "outline"'); + }); + + test("analyzes hooks and lowers static state into a javascript block", () => { + const source = 'function Toggle() { const [open] = useState(false); useEffect(() => {}, []); return ; }'; + expect(analyzeReactComponent(source).hooks).toEqual(["useEffect", "useState"]); + expect(reactToSpug(source)).toContain("javascript!"); + expect(reactToSpug(source)).toContain("const open = false;"); + }); + + test("creates a Muddle project from an Astro folder", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-backport-")); + await Bun.write(join(root, "astro.config.mjs"), "export default {}"); + await mkdir(join(root, "src/pages"), { recursive: true }); + await mkdir(join(root, "src/styles"), { recursive: true }); + await mkdir(join(root, "src/content/posts"), { recursive: true }); + await mkdir(join(root, "src/layouts"), { recursive: true }); + await mkdir(join(root, "src/components"), { recursive: true }); + await Bun.write(join(root, "src/pages/index.astro"), "

Hi

"); + await Bun.write(join(root, "src/pages/about.astro"), "---\nimport Shell from '../layouts/Shell.astro';\n---\n

About

"); + await Bun.write(join(root, "src/layouts/Shell.astro"), ""); + await Bun.write(join(root, "src/components/Card.astro"), "
"); + await Bun.write(join(root, "src/styles/site.css"), ".page { color: red; }"); + await Bun.write(join(root, "src/content/posts/hello.md"), "---\ntitle: Hello\n---\n\nBody"); + const destination = join(root, "muddle"); + const result = await backportProject(root, destination); + expect(result.source).toBe("astro"); + expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("main"); + expect(await readFile(join(destination, "styles/site.stcss"), "utf8")).toContain(".page"); + expect(await readFile(join(destination, "content/posts/hello.md"), "utf8")).toContain("title: Hello"); + expect(await readFile(join(destination, "ct.config.ts"), "utf8")).toContain('name: "posts"'); + const engine = new TemplateEngine({ viewsDir: join(destination, "modules/app/views") }); + expect(await engine.render("index.spug")).toContain("

Hi

"); + expect(await engine.render("about.spug")).toContain("

About

"); + }); + + test("carries Zola configuration and partial templates into the Muddle project", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-zola-")); + await mkdir(join(root, "templates/partials"), { recursive: true }); + await mkdir(join(root, "content/posts"), { recursive: true }); + await Bun.write(join(root, "config.toml"), 'title = "Zola notes"\nbase_url = "https://notes.example"'); + await Bun.write(join(root, "templates/index.html"), '{% extends "base.html" %}{% block content %}

{{ config.title }}

{% endblock %}'); + await Bun.write(join(root, "templates/partials/nav.html"), ""); + await Bun.write(join(root, "content/posts/first.md"), "---\ntitle: First\n---\n\nPost"); + const destination = join(root, "muddle"); + await backportProject(root, destination); + expect(await readFile(join(destination, "modules/app/site.config.ts"), "utf8")).toContain("Zola notes"); + expect(await readFile(join(destination, "modules/app/views/partials/nav.spug"), "utf8")).toContain("nav"); + expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("extends ./base.spug"); + }); + + test("compiles Sass before handing CSS to STCSS", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-sass-")); + const path = join(root, "site.scss"); + await writeFile(path, "$ink: #123456; .card { color: $ink; }"); + const loaded = await sassLoader().load({ path, relativePath: "styles/site.scss", source: await readFile(path, "utf8") }); + expect(loaded.content).toContain("color: #123456;"); + }); + + test("pulls declarative ShadCN registry components into Spug and STCSS", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-shadcn-")); + const integration = shadcnIntegration({ + components: ["button"], + fetch: async () => new Response(JSON.stringify({ files: [ + { path: "button.tsx", content: "export function Button() { return ; }" }, + { path: "button.css", content: ".btn { color: red; }" }, + ] })), + }); + await integration.setup?.({ config: { root } as any, route() {}, loader() {} }); + expect(await readFile(join(root, "modules/app/views/components/button.spug"), "utf8")).toContain("button"); + expect(await readFile(join(root, "styles/shadcn/button.stcss"), "utf8")).toContain(".btn"); + }); +}); diff --git a/tests/components.test.ts b/tests/components.test.ts new file mode 100644 index 0000000..537c4f2 --- /dev/null +++ b/tests/components.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { app } from "../samples/hello-world/index.ts"; + +/** + * The minimal blog deliberately has no application server components. + */ +describe("server components", () => { + test("renders the static blog shell", async () => { + const html = await ( + await app.fetch(new Request("http://localhost/")) + ).text(); + expect(html).toContain("Test1"); + }); + + test("does not ship a relation bridge for anchors", async () => { + const html = await ( + await app.fetch(new Request("http://localhost/")) + ).text(); + expect(html).not.toContain("window.__muddleRelations"); + }); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..3d7b7f9 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generate, renderProps } from "../contracts/typegen/generator.ts"; +import { Container } from "../core/container.ts"; +import { Module } from "../decorators/Module.ts"; +import { Service } from "../decorators/Service.ts"; +import { Controller } from "../decorators/Controller.ts"; +import { Route } from "../decorators/Route.ts"; +import { validate, toFormFields } from "../core/form.ts"; + +/** + * Tests of the container, of the modules and of the type generator. + */ + +describe("container", () => { + test("resolves a dependency by type", () => { + @Service() + class UserService { + find() { + return "ada"; + } + } + + @Controller("/") + class UserController { + constructor(private readonly users: UserService) {} + + find() { + return this.users.find(); + } + } + + const container = new Container(); + container.registerClass(UserService); + container.registerClass(UserController); + + const controller = container.resolve(UserController); + expect(controller.find()).toBe("ada"); + }); + + test("awaits an asynchronous provider", async () => { + @Service() + class AsyncService { + async load() { + return "loaded"; + } + } + + const container = new Container(); + container.registerFactory( + AsyncService, + async () => new AsyncService(), + ); + + const service = await container.resolveAsync(AsyncService); + expect(await service.load()).toBe("loaded"); + }); + + test("reports an unknown dependency", () => { + const container = new Container(); + expect(() => container.resolve("missing")).toThrow("Cannot resolve"); + }); +}); + +describe("modules", () => { + test("collects the controllers of a module", () => { + @Controller("/") + class HomeController { + @Route("/") + index() { + return "home"; + } + } + + @Module({ controllers: [HomeController] }) + class AppModule {} + + const container = new Container(); + container.registerClass(HomeController); + + expect(AppModule).toBeDefined(); + }); +}); + +describe("forms", () => { + test("reads the fields of a form", () => { + const fields = toFormFields({ title: "Hello", tags: ["a", "b"] }); + expect(fields.get("title")).toBe("Hello"); + expect(fields.has("tags")).toBe(true); + expect(fields.all().tags).toEqual(["a", "b"]); + }); + + test("validates the fields", () => { + const fields = toFormFields({ title: "Hello", age: "42" }); + const result = validate<{ title: string; age: number }>(fields, { + title: { required: true, maxLength: 10 }, + age: { type: "number", min: 18 }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.age).toBe(42); + } + }); + + test("reports the invalid fields", () => { + const fields = toFormFields({ title: "", age: "abc" }); + const result = validate(fields, { + title: { required: true }, + age: { type: "number" }, + }); + expect(result.ok).toBe(false); + expect(result.errors.title).toBe("title is required"); + expect(result.errors.age).toBe("age must be a number"); + }); +}); + +describe("typegen", () => { + test("renders the props of a view", () => { + expect(renderProps([])).toBe("Record"); + expect( + renderProps([ + { name: "title", type: "string", optional: false }, + { name: "body", type: "string", optional: true }, + ]), + ).toBe("{ title: string; body?: string }"); + }); + + test("generates the contracts of the templates", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-typegen-")); + await writeFile( + join(root, "index.spug"), + "props\n title: string\n count?: number\n\np= title", + "utf8", + ); + const result = await generate({ + root, + out: join(root, "out"), + viewRoots: ["."], + }); + expect(result.views).toHaveLength(1); + expect(result.views[0]?.key).toBe("index"); + expect(result.views[0]?.props.map((prop) => prop.name)).toEqual([ + "title", + "count", + ]); + + await rm(root, { recursive: true, force: true }); + }); +}); diff --git a/tests/css.test.ts b/tests/css.test.ts new file mode 100644 index 0000000..8dae563 --- /dev/null +++ b/tests/css.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; +import { compileCss } from "../views/css/index.ts"; +import { evaluate } from "../views/css/evaluate.ts"; +import { defineHooks } from "../views/hooks.ts"; + +/** + * Tests of the structured css. + */ + +describe("stcss", () => { + test("flattens the nested rules", () => { + const css = compileCss(` +.card { + color: red; + + &__title { + font-weight: 700; + } +} +`); + expect(css).toContain(".card {"); + expect(css).toContain(".card__title {"); + expect(css).toContain("font-weight: 700;"); + }); + + test("accepts Pug-like indentation, colonless declarations and blocks", () => { + const css = compileCss(` +$ink #101828 +.card + color $ink + padding 1rem + &__title + font-weight 700 + &:hover + color #000 +`); + expect(css).toContain(".card {"); + expect(css).toContain("color: #101828;"); + expect(css).toContain("padding: 1rem;"); + expect(css).toContain(".card__title {"); + expect(css).toContain(".card:hover {"); + }); + + test("accepts Pug-like at-rule blocks", () => { + const css = compileCss(` +.panel + width: 30rem + @media (max-width: 48rem) + width 100% +`); + expect(css).toContain(".panel {"); + expect(css).toContain("@media (max-width: 48rem)"); + expect(css).toContain("width: 100%;"); + }); + + test("runs application-defined STCSS expansions from a contextual registry", () => { + const hooks = defineHooks((hook) => { + hook.stcss.value("double", ({ inner, resolve }) => `calc(${resolve(inner)} * 2)`); + hook.stcss.declaration(({ selector, declaration }) => declaration.property === "outline-token" + ? [`${selector}::before { content: "hook"; }`] + : undefined); + hook.stcss.atRule("paint", ({ selector, params }) => `${selector} { color: ${params}; }`); + }); + const css = compileCss(`.card { width: double(12px); outline-token: yes; @paint rebeccapurple; }`, { hooks }); + expect(css).toContain("width: calc(12px * 2);"); + expect(css).toContain('.card::before { content: "hook"; }'); + expect(css).toContain(".card { color: rebeccapurple; }"); + }); + + test("expands calculation hooks and centered position aliases", () => { + const css = compileCss(` +.dialog { + position: fixed-center; + width: 30rem; + margin-left: $offset($center(30rem), $pad); + padding: $pad(2); +} +.page-dialog { position: page-center; transform: scale(.96); } +`); + expect(css).toContain("position: fixed;"); + expect(css).toContain("left: 50%;"); + expect(css).toContain("top: 50%;"); + expect(css).toContain("transform: translate(-50%, -50%);"); + expect(css).toContain("margin-left: calc(calc(50% - (30rem / 2)) + var(--muddle-pad, 1rem));"); + expect(css).toContain("padding: calc(var(--muddle-pad, 1rem) * 2);"); + expect(css).toContain("position: absolute;"); + expect(css).toContain("transform: translate(-50%, -50%) scale(.96);"); + }); + + test("registers application calculation hooks", () => { + const hooks = defineHooks((hook) => { + hook.stcss.calculation("third", ({ args, resolve }) => `calc(${resolve(args ?? "100%")} / 3)`); + }); + expect(compileCss(".column { width: $third(90%); }", { hooks })).toContain("width: calc(90% / 3);"); + }); + + test("resolves the variables", () => { + const css = compileCss(` +$ink: #101828; + +.body { + color: $ink; +} +`); + expect(css).toContain("color: #101828;"); + }); + + test("interpolates an expression", () => { + const css = compileCss(` +$space: 1; + +.gutter { + padding: #{$space * 2}rem; +} +`); + expect(css).toContain("padding: 2rem;"); + }); + + test("repeats a block", () => { + const css = compileCss(` +@each $space in 1 2 3 { + .space-#{$space} { + margin: #{$space * 0.5}rem; + } +} +`); + expect(css).toContain(".space-1 {"); + expect(css).toContain(".space-2 {"); + expect(css).toContain(".space-3 {"); + expect(css).toContain("margin: 1.5rem;"); + }); + + test("counts a range", () => { + const css = compileCss(` +@for $i from 1 through 2 { + .col-#{$i} { + width: #{$i * 10}%; + } +} +`); + expect(css).toContain(".col-1 {"); + expect(css).toContain(".col-2 {"); + }); + + test("expands a mixin", () => { + const css = compileCss(` +@mixin rounded($size) { + border-radius: $size; +} + +.button { + @include rounded(4px); + color: blue; +} +`); + expect(css).toContain("border-radius: 4px;"); + expect(css).toContain("color: blue;"); + }); + + test("keeps the css functions untouched", () => { + const css = compileCss(` +.box { + border: 1px solid rgba(16, 24, 40, 0.12); + color: rgb(1, 2, 3); +} +`); + expect(css).toContain("rgba(16, 24, 40, 0.12)"); + expect(css).toContain("rgb(1, 2, 3)"); + }); + + test("darkens a colour", () => { + const css = compileCss(` +.button { + color: darken(#2b6cb0, 10); +} +`); + expect(css).toMatch(/color: #[0-9a-f]{6};/); + }); + + test("evaluates an expression", () => { + expect(evaluate("$a * 2", { scope: { $a: "21" } })).toBe("42"); + expect(evaluate("$a + 1", { scope: { $a: "1" } })).toBe("2"); + }); + + test("compiles anchor relations, loop ranges and back-propagated state", () => { + const css = compileCss(` +@each $n in [0..2] { + nav:with(@hello$n:active) li:nth-child(#{$n + 1}) { + left: calc($relate(d"@hello{$n + 1}") + $relate[width](d"@hello{$n + 1}")); + color: @hello$n.computed.backgroundColor; + } +} +nav li:when(nav @hello1:clicked) { opacity: 1; } +`); + expect(css).toContain('nav:has([data-muddle-anchor="hello0"]:active) li:nth-child(1)'); + expect(css).toContain("var(--muddle-relate-hello1-x)"); + expect(css).toContain("var(--muddle-relate-hello1-width)"); + expect(css).toContain('nav:has([data-muddle-anchor="hello1"]:active) li'); + }); + + test("resolves $refer as a relation value", () => { + const css = compileCss(`.tooltip { left: $refer[left](@trigger); width: $refer[width](@trigger); }`); + expect(css).toContain("var(--muddle-relate-trigger-x)"); + expect(css).toContain("var(--muddle-relate-trigger-width)"); + }); + + test("reflects dotted anchor groups without enumerating their members", () => { + const css = compileCss(`:reflect(@docs) { scroll-margin-top: 4rem; }`); + expect(css).toContain('[data-muddle-anchor^="docs."]'); + }); + + test("expands concentric border radii", () => { + const css = compileCss(` +.panel { + padding: 1rem; + border: 2px solid #333; + border-radius: concentric 0.75rem; +} +.panel-default { + border-radius: concentric; +} +.blind { + padding: 12px; + border: var(--bw, 1px) solid; + border-radius: concentric var(--outer, 12px); +} +`); + expect(css).toContain(".panel > * {\n\tborder-radius: calc((0.75rem) - (1rem) - (2px));"); + expect(css).toContain(".panel-default > * {\n\tborder-radius: inherit;"); + expect(css).toContain(".blind > * {\n\tborder-radius: calc((var(--outer, 12px)) - (12px) - (var(--bw, 1px)));"); + }); + + test("expands freeform gradients into radial gradient stacks", () => { + const css = compileCss(` +.paint { + background-image: freeform-gradient(hsl(270 50% 60%) 10% 50%, red 70% 40%, rgb(0 128 0) 40% 80% 30%); +} +`); + expect(css).toContain("radial-gradient(circle farthest-side at 10% 50%, hsl(270 50% 60%), transparent)"); + expect(css).toContain("radial-gradient(circle farthest-side at 70% 40%, red, transparent)"); + expect(css).toContain("radial-gradient(circle at 40% 80%, rgb(0 128 0), rgb(0 128 0) calc(30% * 0.97), transparent 30%)"); + expect(() => compileCss(".x { background-image: freeform-gradient(red 1% 2% 3% 4%); }")).toThrow(); + }); +}); + +describe("stcss @measure", () => { + test("records prediction targets and anchors them as relations", () => { + const measured: string[] = []; + const css = compileCss(` +.ribbon { + @measure; +} +@measure @trigger, .card, panel; +`, { measured }); + expect(css).toContain("/* muddle-predict:"); + expect(measured).toEqual([ + ".ribbon", + '[data-muddle-anchor="trigger"]', + ".card", + "panel", + ]); + }); +}); diff --git a/tests/d1.test.ts b/tests/d1.test.ts new file mode 100644 index 0000000..3b3c6c9 --- /dev/null +++ b/tests/d1.test.ts @@ -0,0 +1,66 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createD1Driver } from "../modules/database/src/adapters/d1.ts"; + +const server = Bun.serve({ + port: 0, + async fetch(request) { + const payload = (await request.json()) as { + sql?: string; + params?: unknown[]; + }; + + if (payload.sql?.startsWith("SELECT")) { + return Response.json({ + success: true, + result: [ + { + results: [ + { id: "1", title: "First", position: 1 }, + ], + success: true, + meta: { changes: 1 }, + }, + ], + }); + } + + return Response.json({ + success: true, + result: [{ results: [], success: true, meta: { changes: 1 } }], + }); + }, +}); + +afterAll(() => { + server.stop(true); +}); + +describe("d1", () => { + test("sends the statements to the api", async () => { + const driver = createD1Driver({ + accountId: "account", + databaseId: "database", + apiToken: "token", + endpoint: `http://localhost:${server.port}/query`, + }); + + const rows = await driver.query("SELECT * FROM notes"); + expect(rows).toHaveLength(1); + expect(rows[0]?.title).toBe("First"); + + const result = await driver.execute("INSERT INTO notes VALUES (1)"); + expect(result.changes).toBe(1); + + await driver.close(); + }); + + test("uses the placeholders of sqlite", () => { + const driver = createD1Driver({ + accountId: "account", + databaseId: "database", + apiToken: "token", + }); + expect(driver.placeholder(1)).toBe("?"); + expect(driver.dialect).toBe("sqlite"); + }); +}); diff --git a/tests/hello-world.test.ts b/tests/hello-world.test.ts new file mode 100644 index 0000000..c4506d0 --- /dev/null +++ b/tests/hello-world.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { app } from "../samples/hello-world/index.ts"; + +/** + * End to end test of the hello world sample: the routes are rendered by the + * application, without a http server in between. + */ + +async function get(path: string): Promise { + return app.fetch(new Request(`http://localhost${path}`)); +} + +describe("hello world sample", () => { + test("renders the home page", async () => { + const response = await get("/"); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(""); + expect(html).toContain("Blog"); + expect(html).toContain("Test1"); + expect(html).toContain("Test2"); + }); + + test("renders a single post", async () => { + const response = await get("/posts/test1"); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Test1"); + }); + + test("answers with 404 for unknown routes", async () => { + const response = await get("/nope"); + expect(response.status).toBe(404); + }); + +}); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..e4f74eb --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createViews } from "../views/api.ts"; +import { defineHooks } from "../views/hooks.ts"; + +test("Spug hook registry composes render expansions with route context", async () => { + const hooks = defineHooks((hook) => { + hook.spug("stamp", ({ html, path, locals }) => `${html}`); + }); + const html = await hooks.renderSpug({ html: "
hello
", path: "/views/index.spug", locals: { title: "Muddle" } }); + expect(html).toContain(''); +}); + +test("Views applies Spug hooks after template composition", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-hooks-")); + try { + await writeFile(join(root, "index.spug"), "main= title\n"); + const hooks = defineHooks((hook) => { + hook.spug("mark", ({ html }) => `${html}`); + }); + const views = createViews({ root, viewsDir: root, hooks }); + expect(await views.render("index.spug", { title: "Muddle" } as never)).toContain(""); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/integrations.test.ts b/tests/integrations.test.ts new file mode 100644 index 0000000..6b030f6 --- /dev/null +++ b/tests/integrations.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + checkboxMarkup, + mouseTrackerMarkup, + mouseTrackerCss, + reactCss, + reactIntegration, +} from "../integrations/react.ts"; + +/** + * Tests of the reactivity integration: the state utilities, the native + * checkbox hack and the mouse tracker of the parent hack. + */ + +describe("react integration", () => { + test("compiles the state utilities", () => { + const css = reactCss(); + expect(css).toContain(".state-open"); + expect(css).toContain(".state-closed"); + expect(css).toContain('[data-state="open"]'); + expect(css).toContain("--muddle-state: open;"); + }); + + test("exposes checkbox state without prescribing visual styles", () => { + const css = reactCss(); + expect(css).toContain('.peer:checked ~ [data-state="checked"]'); + expect(css).not.toContain("background:"); + expect(css).not.toContain("box-shadow:"); + }); + + test("scopes the parent hack of the mouse tracker", () => { + const css = mouseTrackerCss({ columns: 4, rows: 3, scope: "card" }); + const rules = css.match(/data-muddle-tracker="card"\]:has/g) ?? []; + expect(rules).toHaveLength(12); + expect(css).toContain("--muddle-mouse-x:"); + expect(css).toContain("nth-child(1)"); + expect(css).toContain("nth-child(12)"); + }); + + test("renders the markup of the mouse tracker", () => { + const markup = mouseTrackerMarkup({ columns: 2, rows: 2 }); + expect((markup.match(/mouse-tracker__cell/g) ?? [])).toHaveLength(4); + expect(markup).toContain('class="mouse-tracker"'); + expect(markup).toContain('data-muddle-capture="false"'); + }); + + test("renders the markup of a checkbox", () => { + const markup = checkboxMarkup({ + name: "published", + label: "Published", + checked: true, + }); + expect(markup).toContain('type="checkbox"'); + expect(markup).toContain('name="published"'); + expect(markup).toContain("checked"); + expect(markup).toContain("Published"); + }); + + test("serves the stylesheet", async () => { + const routes: { method: string; path: string }[] = []; + const integration = reactIntegration(); + await integration.setup?.({ + config: { + root: process.cwd(), + views: "views", + components: "views/components", + content: "content", + styles: "styles", + publicDir: "public", + viewRoots: ["views"], + loaders: {}, + collections: [], + integrations: [], + }, + route: (method, path) => routes.push({ method, path }), + loader: () => undefined, + }); + + expect(routes).toEqual([ + { method: "GET", path: "/_muddle/react.css" }, + ]); + }); +}); diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts new file mode 100644 index 0000000..2337bb9 --- /dev/null +++ b/tests/markdown.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ContentRegistry } from "../core/content.ts"; +import { resolveConfig } from "../core/config.ts"; +import { markdownLoader, parseFrontmatter, renderMarkdown } from "../views/markdown.ts"; + +/** + * Tests of the markdown loader and of the content collections. + */ + +describe("markdown", () => { + test("parses the frontmatter", () => { + const { frontmatter, body } = parseFrontmatter( + "---\ntitle: Hello\ndraft: false\ncount: 3\ntags:\n - a\n - b\n---\n\nBody", + ); + expect(frontmatter.title).toBe("Hello"); + expect(frontmatter.draft).toBe(false); + expect(frontmatter.count).toBe(3); + expect(frontmatter.tags).toEqual(["a", "b"]); + expect(body.trim()).toBe("Body"); + }); + + test("renders the inline markup", () => { + expect(renderMarkdown("**bold** and *italic*")).toContain("bold"); + expect(renderMarkdown("`code`")).toContain("code"); + expect(renderMarkdown("[link](/x)")).toContain('link'); + }); + + test("renders the blocks", () => { + const html = renderMarkdown( + "# Title\n\nA paragraph.\n\n- one\n- two\n\n> quoted\n\n```js\nconst a = 1;\n```", + ); + expect(html).toContain("

Title

"); + expect(html).toContain("

A paragraph.

"); + expect(html).toContain("
  • one
  • "); + expect(html).toContain("
    "); + expect(html).toContain("
    ");
    +	});
    +});
    +
    +describe("content collections", () => {
    +	test("loads the entries of a collection", async () => {
    +		const dir = await mkdtemp(join(tmpdir(), "muddle-content-"));
    +		await writeFile(
    +			join(dir, "first.md"),
    +			"---\ntitle: First\n---\n\nHello **world**",
    +			"utf8",
    +		);
    +		await writeFile(
    +			join(dir, "second.md"),
    +			"---\ntitle: Second\n---\n\nSecond note",
    +			"utf8",
    +		);
    +
    +		const registry = new ContentRegistry(
    +			resolveConfig({
    +				root: dir,
    +				collections: [
    +					{
    +						name: "notes",
    +						dir: ".",
    +						loader: markdownLoader(),
    +						schema: { required: ["title"] },
    +					},
    +				],
    +			}),
    +		);
    +		await registry.load();
    +
    +		const entries = registry.entries("notes");
    +		expect(entries).toHaveLength(2);
    +		expect(entries[0]?.id).toBe("first");
    +		expect(entries[0]?.frontmatter.title).toBe("First");
    +		expect(entries[0]?.body).toContain("world");
    +		expect(registry.get("notes", "second")?.frontmatter.title).toBe("Second");
    +
    +		await rm(dir, { recursive: true, force: true });
    +	});
    +
    +	test("rejects an invalid frontmatter", async () => {
    +		const dir = await mkdtemp(join(tmpdir(), "muddle-content-"));
    +		await writeFile(join(dir, "broken.md"), "---\ndraft: true\n---\n\nBody", "utf8");
    +
    +		const registry = new ContentRegistry(
    +			resolveConfig({
    +				root: dir,
    +				collections: [
    +					{
    +						name: "notes",
    +						dir: ".",
    +						loader: markdownLoader(),
    +						schema: { required: ["title"] },
    +					},
    +				],
    +			}),
    +		);
    +
    +		await expect(registry.load()).rejects.toThrow("title is required");
    +
    +		await rm(dir, { recursive: true, force: true });
    +	});
    +});
    diff --git a/tests/modules.test.ts b/tests/modules.test.ts
    new file mode 100644
    index 0000000..ad7a799
    --- /dev/null
    +++ b/tests/modules.test.ts
    @@ -0,0 +1,61 @@
    +import { expect, test } from "bun:test";
    +import { mkdtemp, writeFile, rm } from "node:fs/promises";
    +import { join } from "node:path";
    +import { tmpdir } from "node:os";
    +import { astroComponent } from "../modules/astro/index.ts";
    +import { TemplateContext } from "../core/context.ts";
    +import { createViews } from "../views/api.ts";
    +import { authFromRequest, authGuard, authIntegration } from "../integrations/auth.ts";
    +import { App } from "../core/app.ts";
    +import { resolveConfig } from "../core/config.ts";
    +import { renderMdx } from "../modules/mdx/index.ts";
    +import { compileCss } from "../views/css/index.ts";
    +
    +test("Astro imports compose with typed props and SPUG slots", async () => {
    +	const root = await mkdtemp(join(tmpdir(), "astro-compose-"));
    +	try {
    +		await writeFile(join(root, "Title.astro"), '---\nconst { title } = Astro.props;\n---\n

    {title}

    '); + await writeFile(join(root, "Card.astro"), '---\nimport Title from "./Title.astro";\nconst { title } = Astro.props;\n---\n
    <slot /></article>'); + await writeFile(join(root, "index.spug"), 'component Card\n title: "Composed"\n render content\n p Slot body'); + const context = new TemplateContext().import("Card", astroComponent(join(root, "Card.astro"))); + const views = createViews({ viewsDir: root, context }); + const html = await views.renderFile("index"); + expect(html).toContain("<h2>Composed</h2>"); + expect(html).toContain("<p>Slot body</p>"); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("form authentication issues an opaque session and rejects forged identity cookies", async () => { + const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ login: (email, password) => password === "secret" ? { id: email } : undefined })] }); + const response = await app.fetch(new Request("https://example.test/auth/login", { method: "POST", headers: { origin: "https://example.test" }, body: new URLSearchParams({ email: "ada", password: "secret" }) })); + expect(response.status).toBe(302); + const cookie = response.headers.get("set-cookie")!; + expect((await authFromRequest(new Request("https://example.test", { headers: { cookie } })))?.id).toBe("ada"); + expect(await authFromRequest(new Request("https://example.test", { headers: { cookie: 'muddle_auth=%7B%22id%22%3A%22admin%22%7D' } }))).toBeUndefined(); +}); + +test("OAuth callback validates browser-bound state and exchanges a PKCE code", async () => { + const original = globalThis.fetch; + let exchanged = false; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/token")) { + const body = init?.body as URLSearchParams; + expect(body.get("code_verifier")?.length).toBeGreaterThan(40); + exchanged = true; + return Response.json({ access_token: "test-token" }); + } + return Response.json({ sub: "oauth-user" }); + }) as typeof fetch; + try { + const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ providers: [{ name: "test", authorizationUrl: "https://provider.test/authorize", tokenUrl: "https://provider.test/token", userInfoUrl: "https://provider.test/user", clientId: "test", redirectUri: "https://app.test/auth/test/callback", identity: (profile) => ({ id: String(profile.sub) }) }] })] }); + const start = await app.fetch(new Request("https://app.test/auth/test")); + const authorization = new URL(start.headers.get("location")!); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + const callback = `https://app.test/auth/test/callback?code=test-code&state=${authorization.searchParams.get("state")}`; + expect((await app.fetch(new Request(callback))).status).toBe(400); + const response = await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } })); + expect(response.status).toBe(302); + expect(exchanged).toBe(true); + expect((await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } }))).status).toBe(400); + } finally { globalThis.fetch = original; } +}); diff --git a/tests/orm.test.ts b/tests/orm.test.ts new file mode 100644 index 0000000..e5d6895 --- /dev/null +++ b/tests/orm.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { createDatabase, Database } from "../modules/database/src/index.ts"; +import { Field } from "../modules/database/src/decorators/Field.ts"; +import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts"; +import { Table } from "../modules/database/src/decorators/Table.ts"; +import { encodeRow, hydrateRow } from "../modules/database/src/hydration.ts"; + +/** + * Tests of the ORM: the fluent repository, the hydration of the rows and the + * encoding of the values. + */ + +@Table("users") +class User { + @Field({ type: "text", primary: true }) + id: string; + + @Field({ + type: "text", + unique: true, + encode: (value: string) => value.trim().toLowerCase(), + }) + email: string; + + @Field({ + type: "text", + nullable: true, + encode: (value: string | null) => value?.trim() ?? null, + decode: (value: unknown) => (value === null ? null : String(value)), + }) + name: string | null; + + @Field({ type: "int", default: 0 }) + age: number; + + @Hydrate({ + decode: (row) => `${String(row.email).split("@")[1] ?? ""}`, + }) + domain: string; +} + +async function database(): Promise<Database> { + const db = createDatabase({ dialect: "sqlite", path: ":memory:" }); + db.model(User); + await db.migrate(); + return db; +} + +describe("orm", () => { + test("creates the table of a model", async () => { + const db = await database(); + const rows = await db + .model(User) + .query("SELECT name FROM sqlite_master WHERE type = 'table'"); + expect(rows).toHaveLength(1); + await db.close(); + }); + + test("inserts and reads a row", async () => { + const db = await database(); + const users = db.model(User); + + const created = await users.create({ + id: "1", + email: "ADA@Example.COM", + name: "Ada", + age: 36, + }); + expect(created.id).toBe("1"); + + const found = await users.where("id", "1").first(); + expect(found?.email).toBe("ada@example.com"); + expect(found?.name).toBe("Ada"); + await db.close(); + }); + + test("encodes the values before the commit", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: " Ada ", + age: 36, + }); + + const rows = await users.query( + "SELECT name, email FROM users WHERE id = '1'", + ); + expect(rows[0]?.name).toBe("Ada"); + expect(rows[0]?.email).toBe("ada@example.com"); + await db.close(); + }); + + test("hydrates the rows before they are read", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: null, + age: 36, + }); + + const found = await users.where("id", "1").first(); + expect(found?.domain).toBe("example.com"); + expect(found?.name).toBeNull(); + await db.close(); + }); + + test("queries with conditions, order and limit", async () => { + const db = await database(); + const users = db.model(User); + + for (const [index, name] of ["Ada", "Grace", "Alan"].entries()) { + await users.create({ + id: String(index), + email: `${name.toLowerCase()}@example.com`, + name, + age: 30 + index, + }); + } + + expect(await users.count()).toBe(3); + expect(await users.where("age", ">", 30).count()).toBe(2); + expect( + (await users.orderBy("age", "desc").limit(1).all())[0]?.name, + ).toBe("Alan"); + expect( + await users + .where([{ column: "name", operator: "=", value: "Ada" }]) + .count(), + ).toBe(1); + await db.close(); + }); + + test("updates and deletes rows", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: "Ada", + age: 36, + }); + + expect(await users.where("id", "1").update({ age: 37 })).toBe(1); + expect((await users.where("id", "1").first())?.age).toBe(37); + expect(await users.where("id", "1").delete()).toBe(1); + expect(await users.count()).toBe(0); + await db.close(); + }); + + test("runs a transaction", async () => { + const db = await database(); + const users = db.model(User); + + await db.transaction(async (tx) => { + await tx.model(User).create({ + id: "1", + email: "ada@example.com", + name: "Ada", + age: 36, + }); + }); + + expect(await users.count()).toBe(1); + await db.close(); + }); + + test("encodes and hydrates a row on its own", () => { + const encoded = encodeRow<User>(User as never, { + email: " A@B.COM ", + name: " x ", + }); + expect(encoded.email).toBe("a@b.com"); + expect(encoded.name).toBe("x"); + + const hydrated = hydrateRow<User>(User as never, { + id: "1", + email: "ada@example.com", + name: null, + age: 36, + }); + expect(hydrated.domain).toBe("example.com"); + expect(hydrated.name).toBeNull(); + }); +}); diff --git a/tests/postgres.test.ts b/tests/postgres.test.ts new file mode 100644 index 0000000..2b8524b --- /dev/null +++ b/tests/postgres.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { createDatabase, type Database } from "../modules/database/src/index.ts"; +import { Field } from "../modules/database/src/decorators/Field.ts"; +import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts"; +import { Table } from "../modules/database/src/decorators/Table.ts"; + +/** + * Tests of the postgres adapter. + * + * The tests need a database, the connection string is read from + * `DATABASE_URL`. Without it the suite is skipped. + */ + +const url = process.env.DATABASE_URL; + +@Table("muddle_users") +class User { + @Field({ type: "text", primary: true }) + id: string; + + @Field({ + type: "text", + unique: true, + encode: (value: string) => value.trim().toLowerCase(), + }) + email: string; + + @Field({ + type: "text", + nullable: true, + encode: (value: string | null) => value?.trim() ?? null, + }) + name: string | null; + + @Field({ type: "int", default: 0 }) + age: number; + + @Field({ type: "bool", default: false }) + active: boolean; + + @Hydrate({ decode: (row) => String(row.email).split("@")[1] ?? "" }) + domain: string; +} + +async function database(): Promise<Database> { + const db = createDatabase({ dialect: "postgres", url }); + db.model(User); + await db.migrate(); + return db; +} + +describe.skipIf(!url)("postgres", () => { + test("creates the table of a model", async () => { + const db = await database(); + const rows = await db.model(User).query( + "SELECT table_name FROM information_schema.tables WHERE table_name = 'muddle_users'", + ); + expect(rows).toHaveLength(1); + await db.close(); + }); + + test("inserts, reads, updates and deletes", async () => { + const db = await database(); + const users = db.model(User); + await users.where("id", "1").delete(); + + await users.create({ + id: "1", + email: " ADA@Example.COM ", + name: " Ada ", + age: 36, + active: true, + }); + + const found = await users.where("id", "1").first(); + expect(found?.email).toBe("ada@example.com"); + expect(found?.name).toBe("Ada"); + expect(found?.active).toBe(true); + expect(found?.domain).toBe("example.com"); + + expect(await users.where("id", "1").update({ age: 37 })).toBe(1); + expect((await users.where("id", "1").first())?.age).toBe(37); + + expect(await users.where("id", "1").delete()).toBe(1); + await db.close(); + }); + + test("counts and filters rows", async () => { + const db = await database(); + const users = db.model(User); + await users.where("id", "2").delete(); + + await users.create({ + id: "2", + email: "grace@example.com", + name: "Grace", + age: 45, + }); + + expect(await users.count()).toBeGreaterThan(0); + expect(await users.where("age", ">", 40).count()).toBeGreaterThan(0); + await users.where("id", "2").delete(); + await db.close(); + }); +}); diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts new file mode 100644 index 0000000..658304d --- /dev/null +++ b/tests/prediction.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { predictLayout } from "../adapters/prediction.ts"; + +test("prediction measures desktop and mobile geometry and restores closed disclosure", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 400px; height: 180px; } @media(max-width:48rem) { .panel { width: 200px; height: 240px; } }</style></head><body><details><summary>Menu</summary><div class="panel" data-muddle-anchor="panel">Content</div></details></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], strict: true }); + expect(result.predictions.map((prediction) => prediction.width)).toEqual([400, 200]); + expect(result.predictions.map((prediction) => prediction.height)).toEqual([180, 240]); + expect(result.html).not.toContain("<details open"); + expect(result.html).toContain('data-muddle-predicted="predict-0"'); + expect(result.html).toContain("--muddle-predicted-desktop-width: 400px"); + expect(result.html).toContain("--muddle-predicted-mobile-width: 200px"); + expect(result.html).toContain("--muddle-relate-panel-desktop-width: 400px"); + expect(result.html).toContain("--muddle-relate-panel-mobile-width: 200px"); + expect(result.html).toContain("@media (max-width: 48rem)"); +}); + +test("prediction resolves a target that is sized from an ahead-of-time relation", async () => { + const html = `<!doctype html><html><head><style> + .source { position: absolute; left: 100px; top: 20px; width: 240px; height: 32px; } + .target { position: absolute; left: var(--muddle-relate-source-x); top: 80px; width: var(--muddle-relate-source-width); height: 24px; } + @media (max-width: 48rem) { + .source { left: 24px; width: 160px; } + } + </style></head><body> + <div class="source" data-muddle-anchor="source"></div> + <div class="target"></div> + </body></html>`; + const result = await predictLayout(html, { selectors: [".target"], strict: true }); + const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); + expect(target.map((prediction) => prediction.width)).toEqual([240, 160]); + expect(target.map((prediction) => prediction.x)).toEqual([100, 24]); + expect(result.html).toContain("--muddle-predicted-desktop-width: 240px"); + expect(result.html).toContain("--muddle-predicted-mobile-width: 160px"); +}); + +test("prediction settles chained relations before writing the static page", async () => { + const html = `<!doctype html><html><head><style> + .source { position: absolute; left: 72px; top: 20px; width: 216px; height: 24px; } + .middle { position: absolute; left: var(--muddle-relate-source-x); top: 60px; width: var(--muddle-relate-source-width); height: 24px; } + .target { position: absolute; left: var(--muddle-relate-middle-x); top: 100px; width: var(--muddle-relate-middle-width); height: 24px; } + </style></head><body> + <div class="source" data-muddle-anchor="source"></div> + <div class="middle" data-muddle-anchor="middle"></div> + <div class="target"></div> + </body></html>`; + const result = await predictLayout(html, { selectors: [".target"], strict: true }); + const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); + expect(target.map((prediction) => prediction.width)).toEqual([216, 216]); + expect(target.map((prediction) => prediction.x)).toEqual([72, 72]); +}); + +test("prediction follows the page's widest mobile breakpoint", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 500px; height: 24px; } @media (max-width: 40rem) { .panel { width: 280px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], strict: true }); + expect(result.predictions.map((prediction) => prediction.width)).toEqual([500, 280]); + expect(result.html).toContain("@media (max-width: 40rem)"); +}); + +test("prediction emits each selected breakpointAcc relation layer", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 600px; height: 24px; } @media (max-width: 64rem) { .panel { width: 420px; } } @media (max-width: 48rem) { .panel { width: 300px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], breakpointAcc: ["LG", "MD"], strict: true }); + expect(result.html).toContain("--muddle-relate-panel-lg-width: 420px"); + expect(result.html).toContain("--muddle-relate-panel-md-width: 300px"); + expect(result.html).toContain("@media (max-width: 64rem)"); + expect(result.html).toContain("@media (max-width: 48rem)"); +}); + +test("prediction discovers dotted anchor groups and emits reflective metadata", async () => { + const html = '<!doctype html><html><head><style>.item { width: 200px; }</style></head><body><a class="item" data-muddle-anchor="docs.intro"></a><a class="item" data-muddle-anchor="docs.api"></a></body></html>'; + const result = await predictLayout(html, { strict: true }); + expect(result.groups).toEqual([{ name: "docs", anchors: ["docs.intro", "docs.api"] }]); + expect(result.html).toContain('data-muddle-group="docs"'); + expect(result.html).toContain('--muddle-reflect-docs-count: 2'); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts new file mode 100644 index 0000000..775c458 --- /dev/null +++ b/tests/router.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { App } from "../core/app.ts"; +import { Controller, Module, Root, ViewRoot } from "../decorators/index.ts"; +import { View } from "../decorators/View.ts"; +import { TemplateEngine } from "../views/engine/runtime.ts"; +import { Views } from "../views/api.ts"; + +/** + * Tests of the file based router: the relative views, the static roots of the + * controllers and the renderers that are applied inline. + */ + +const dir = await mkdtemp(join(tmpdir(), "muddle-router-")); + +await mkdir(join(dir, "views"), { recursive: true }); +await mkdir(join(dir, "public"), { recursive: true }); + +await writeFile( + join(dir, "views/index.spug"), + "props\n title: string\n\nh1= title", + "utf8", +); +await writeFile(join(dir, "views/about.spug"), "h2 About", "utf8"); +await writeFile( + join(dir, "views/card.spug"), + "props\n title: string\n content?: () => string | Promise<string>\n\narticle.card\n h3= title\n if content\n .card__content!= content()", + "utf8", +); +await writeFile(join(dir, "public/robots.txt"), "Allow: /", "utf8"); + +describe("file based router", () => { + test("resolves the views of the view root", async () => { + const engine = new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }); + const html = await engine.render("index.spug", { title: "Hello" }); + expect(html).toBe("<h1>Hello</h1>"); + }); + + test("derives the route from the view", async () => { + const html = await engine("about.spug"); + expect(html).toBe("<h2>About</h2>"); + + async function engine(path: string): Promise<string> { + return new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }).render(path); + } + }); + + test("renders a renderer that is applied inline", async () => { + const engine = new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }); + const html = await engine.render("card.spug", { + title: "Card", + content: async () => "<p>inline</p>", + }); + expect(html).toContain("<h3>Card</h3>"); + expect(html).toContain('<div class="card__content"><p>inline</p></div>'); + }); + + test("serves the static root of a controller", async () => { + @Controller("/assets") + @Root(join(dir, "public")) + class AssetController {} + + @Controller("/") + @ViewRoot(join(dir, "views")) + class PageController { + @View("index.spug") + index() { + return { title: "Hello" }; + } + } + + @Module({ controllers: [AssetController, PageController] }) + class AppModule {} + + const app = await App.create({ + modules: [AppModule], + views: new Views({ viewsDir: join(dir, "views"), root: dir }), + }); + + const page = await app.fetch(new Request("http://localhost/")); + expect(page.status).toBe(200); + expect(await page.text()).toBe("<h1>Hello</h1>"); + + const asset = await app.fetch(new Request("http://localhost/assets/robots.txt")); + expect(asset.status).toBe(200); + expect(await asset.text()).toBe("Allow: /"); + + const missing = await app.fetch( + new Request("http://localhost/assets/nope.txt"), + ); + expect(missing.status).toBe(404); + + await rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/sessions-format.test.ts b/tests/sessions-format.test.ts new file mode 100644 index 0000000..05baf20 --- /dev/null +++ b/tests/sessions-format.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { DatabaseSessionStore } from "../modules/database/src/sessions.ts"; +import { createSqliteDriver } from "../modules/database/src/adapters/sqlite.ts"; +import { App } from "../core/app.ts"; +import { authIntegration } from "../integrations/auth.ts"; +import { Controller } from "../decorators/Controller.ts"; +import { Route } from "../decorators/Route.ts"; +import { Auth } from "../decorators/Auth.ts"; +import { Module } from "../decorators/Module.ts"; +import { resolveConfig } from "../core/config.ts"; +import { formatSource } from "../core/format-source.ts"; +import plugin from "../modules/prettier-plugin/index.ts"; +import prettier from "prettier"; +import { compileCss } from "../views/css/index.ts"; + +test("database sessions authenticate across app instances, revoke and consume atomically", async () => { + const driver = createSqliteDriver({ path: ":memory:" }); + const store = await DatabaseSessionStore.create(driver); + @Controller("/") class PrivateController { + @Route("/private") @Auth() private() { return "protected"; } + } + @Module({ controllers: [PrivateController] }) class Root {} + const create = () => App.create({ config: resolveConfig(), modules: [Root], integrations: [authIntegration({ store, login: () => ({ id: "ada" }) })] }); + try { + const first = await create(); + const second = await create(); + const login = await first.fetch(new Request("https://app.test/auth/login", { method: "POST", headers: { origin: "https://app.test", "content-type": "application/json" }, body: JSON.stringify({ email: "ada", password: "secret" }) })); + const cookie = login.headers.get("set-cookie")!; + expect((await second.fetch(new Request("https://app.test/private", { headers: { cookie } }))).status).toBe(200); + await first.fetch(new Request("https://app.test/auth/logout", { method: "POST", headers: { origin: "https://app.test", cookie } })); + expect((await second.fetch(new Request("https://app.test/private", { headers: { cookie } }))).status).toBe(401); + await store.set("transaction", { value: { verifier: "once" }, expires: Date.now() + 10000 }); + expect((await Promise.all([store.consume("transaction"), store.consume("transaction")])).filter(Boolean)).toHaveLength(1); + await store.set("expired", { value: {}, expires: 1 }); + expect(await store.get("expired")).toBeUndefined(); + } finally { await driver.close(); } +}); + +test("Prettier delegates to muddle formatting and preserves compiled STCSS", async () => { + const source = '.card{padding:1rem;@predict mobile max-height; &:hover{opacity:0.8;}}'; + const formatted = await formatSource(source, "stcss"); + expect(await prettier.format(source, { parser: "stcss", plugins: [plugin] })).toBe(formatted); + expect(await formatSource(formatted, "stcss")).toBe(formatted); + expect(compileCss(formatted)).toBe(compileCss(source)); + const spug = 'javascript!\nconst title={text:"Hello"}\nend\ndiv\n p= title.text\n'; + const next = await formatSource(spug, "spug"); + expect(next).toContain('const title = { text: "Hello" };'); + expect(await formatSource(next, "spug")).toBe(next); + const raw = 'script.\n const text = "a b";\n\n console.log(text);\np End\n'; + expect(await formatSource(raw, "spug")).toBe(raw); +}); diff --git a/tests/stage.test.ts b/tests/stage.test.ts new file mode 100644 index 0000000..c3f8c25 --- /dev/null +++ b/tests/stage.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { compileTemplate } from "../views/engine/compiler.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { parse } from "../views/engine/parser.ts"; +import { stage } from "../views/stage.ts"; + +test("stage manager emits native scroll-driven scenes and portal transitions", () => { + const css = stage() + .scene("intro-in", { selector: ".intro", keyframes: { "0%": { opacity: 0, transform: "translateY(2rem)" }, "100%": { opacity: 1, transform: "translateY(0)" } }, range: "entry 0% cover 45%" }) + .portal("main", { old: { opacity: 1 }, new: { opacity: 0 }, duration: "180ms" }) + .css(); + expect(css).toContain("animation-timeline: view()"); + expect(css).toContain("animation-range: entry 0% cover 45%"); + expect(css).toContain("::view-transition-old(main)"); +}); + +test("portal blocks compile to named view-transition boundaries", () => { + const parsed = parse(lex("portal main\n h1 Hello")); + const compiled = compileTemplate(parsed.nodes, { path: "/tmp/portal.spug", dir: "/tmp" }); + expect(compiled.source).toContain("data-muddle-portal=\\\"main\\\""); + expect(compiled.source).toContain("view-transition-name:main"); +}); diff --git a/tests/templates-generator.test.ts b/tests/templates-generator.test.ts new file mode 100644 index 0000000..97ddef2 --- /dev/null +++ b/tests/templates-generator.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTemplate } from "../core/templates.ts"; + +test("creates the responsive documentation template", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-docs-")); + try { + const result = await createTemplate("docs", root); + expect(result.files.length).toBeGreaterThan(6); + const view = await readFile(join(root, "modules/app/views/index.spug"), "utf8"); + const styles = await readFile(join(root, "styles/app.stcss"), "utf8"); + expect(view).toContain("@docs.{section.id}"); + expect(styles).toContain(":reflect(@docs)"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("creates the minimal template", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-minimal-")); + try { + await createTemplate("minimal", root); + expect(await readFile(join(root, "index.ts"), "utf8")).toContain("export const app"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/templates.test.ts b/tests/templates.test.ts new file mode 100644 index 0000000..f96ed52 --- /dev/null +++ b/tests/templates.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "node:path"; +import { runTemplateTests } from "../views/testing.ts"; +import { createViews } from "../views/api.ts"; +import { parse } from "../views/engine/parser.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { compileTemplate } from "../views/engine/compiler.ts"; +import { registerAmezing } from "../modules/amezing/index.ts"; + +const root = resolve(import.meta.dir, ".."); +const sample = join(root, "samples/hello-world"); + +describe("template tests", () => { + test("SPUG anchors emit generated relation identities", () => { + const nodes = parse(lex("nav\n ul\n li@hello1() First\n li@row(item.id) Second")); + const firstAnchor = nodes.nodes[0]?.kind === "element" + ? nodes.nodes[0].children[0]?.kind === "element" + ? nodes.nodes[0].children[0].children[0] + : undefined + : undefined; + expect(firstAnchor).toMatchObject({ kind: "element", anchor: { name: "hello1" } }); + const compiled = compileTemplate(nodes.nodes, { path: "/tmp/relations.spug", dir: "/tmp" }); + expect(compiled.source).toContain('data-muddle-anchor'); + expect(compiled.source).toContain('"hello1"'); + expect(compiled.source).toContain('"row" + String(item.id)'); + expect(compiled.source).not.toContain("__muddleRelations"); + }); + + test("SPUG derives a dotted anchor leaf from composition scope", () => { + const nodes = parse(lex("a@docs.{section.id}(href='#' + section.id) Link")); + const compiled = compileTemplate(nodes.nodes, { path: "/tmp/reflective.spug", dir: "/tmp" }); + expect(compiled.source).toContain('"docs." + String(section.id)'); + }); + + test( + "the assertions of the templates pass", + async () => { + const views = createViews({ viewsDir: join(sample, "modules/app/views") }); + registerAmezing(views); + const run = await runTemplateTests({ + root: sample, + viewRoots: [join(sample, "modules/app/views")], + components: views.components, + locals: { + title: "muddle", + nav: [{ label: "Home", href: "/" }], + posts: [{ id: "test1", title: "Test1", excerpt: "Test1" }], + post: { id: "test1", title: "Test1", excerpt: "Test1" }, + }, + }); + + if (run.skipped) { + console.log(`muddle: template tests skipped, ${run.reason}`); + return; + } + + expect(run.tested.length).toBeGreaterThan(0); + for (const failure of run.failures) { + console.error(`muddle: ${failure.key}\n${failure.error.message}`); + } + expect(run.failures).toEqual([]); + }, + 30_000, + ); +}); diff --git a/tests/views.test.ts b/tests/views.test.ts new file mode 100644 index 0000000..59c34be --- /dev/null +++ b/tests/views.test.ts @@ -0,0 +1,195 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TemplateEngine } from "../views/engine/runtime.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { parse } from "../views/engine/parser.ts"; + +/** + * Tests of the pug like template engine. + */ + +const dir = await mkdtemp(join(tmpdir(), "muddle-views-")); + +async function template(name: string, source: string): Promise<string> { + await writeFile(join(dir, `${name}.spug`), source, "utf8"); + return name; +} + +const engine = new TemplateEngine({ viewsDir: dir, root: dir }); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("lexer", () => { + test("reads tags, classes and attributes", () => { + const tokens = lex("a.card#top(href='/x' target='_blank') Link"); + const tag = tokens[0]; + expect(tag?.name).toBe("a"); + expect(tag?.attributes?.map((entry) => entry.name)).toEqual([ + "class", + "id", + "href", + "target", + ]); + expect(tag?.text).toBe("Link"); + }); + + test("reads a buffered expression", () => { + const tokens = lex("p= title"); + expect(tokens[0]?.output).toBe("title"); + }); + + test("reads an unbuffered expression", () => { + const tokens = lex("p!= title"); + expect(tokens[0]?.output).toBe("title"); + expect(tokens[0]?.escaped).toBe(false); + }); + + test("reads the frontmatter", () => { + const tokens = lex("---\nconst a = 1\n---\np Hello"); + expect(tokens[0]?.type).toBe("frontmatter"); + expect(tokens[0]?.value).toContain("const a = 1"); + }); + + test("reads a props block", () => { + const parsed = parse(lex("props\n title: string\n body?: string\np Hi")); + const props = parsed.nodes.find((node) => node.kind === "props"); + expect(props?.kind).toBe("props"); + if (props?.kind === "props") { + expect(props.props.map((prop) => prop.name)).toEqual([ + "title", + "body", + ]); + expect(props.props[0]?.optional).toBe(false); + expect(props.props[1]?.optional).toBe(true); + expect(props.props[1]?.type).toBe("string"); + } + }); +}); + +describe("renderer", () => { + test("renders a tag with a class", async () => { + const name = await template("class", "div.card Hello"); + expect(await engine.render(name)).toBe('<div class="card">Hello</div>'); + }); + + test("escapes the text", async () => { + const name = await template("escape", "p= value"); + expect(await engine.render(name, { value: "<b>" })).toBe( + "<p><b></p>", + ); + }); + + test("renders unescaped output", async () => { + const name = await template("raw", "p!= value"); + expect(await engine.render(name, { value: "<b>x</b>" })).toBe( + "<p><b>x</b></p>", + ); + }); + + test("renders a condition", async () => { + const name = await template( + "condition", + "if value\n p yes\nelse\n p no", + ); + expect(await engine.render(name, { value: true })).toBe("<p>yes</p>"); + expect(await engine.render(name, { value: false })).toBe("<p>no</p>"); + }); + + test("renders a loop", async () => { + const name = await template( + "loop", + "ul\n each item in items\n li= item", + ); + expect(await engine.render(name, { items: ["a", "b"] })).toBe( + "<ul><li>a</li><li>b</li></ul>", + ); + }); + + test("renders an attribute expression", async () => { + const name = await template( + "attribute", + "a(href=link.href class=link.active ? 'on' : 'off')= link.label", + ); + expect( + await engine.render(name, { + link: { href: "/x", active: true, label: "Link" }, + }), + ).toBe('<a href="/x" class="on">Link</a>'); + }); + + test("merges the classes of the shorthand and of the attribute", async () => { + const name = await template( + "classes", + "div.card(class=extra) Text", + ); + expect(await engine.render(name, { extra: "wide" })).toBe( + '<div class="card wide">Text</div>', + ); + }); + + test("renders a mixin", async () => { + const name = await template( + "mixin", + "mixin Item({ text })\n li= text\nul\n +Item({ text: 'a' })", + ); + expect(await engine.render(name)).toBe("<ul><li>a</li></ul>"); + }); + + test("renders an included partial", async () => { + await template("partial", "footer Footer"); + const name = await template( + "include", + "div\n include ./partial.spug", + ); + expect(await engine.render(name)).toBe("<div><footer>Footer</footer></div>"); + }); + + test("renders a layout with a block", async () => { + await template( + "layout", + "html\n body\n block content\n footer Footer", + ); + const name = await template( + "page", + "extends ./layout.spug\nblock content\n p Page", + ); + expect(await engine.render(name)).toBe( + "<html><body><p>Page</p><footer>Footer</footer></body></html>", + ); + }); + + test("renders the frontmatter of a layout", async () => { + await template( + "front-layout", + "---\nconst greeting = 'Hello'\n---\np= greeting", + ); + expect(await engine.render("front-layout")).toBe("<p>Hello</p>"); + }); + + test("renders a component of the components directory", async () => { + const components = join(dir, "components"); + await mkdir(components, { recursive: true }); + await writeFile( + join(components, "card.spug"), + "article.card\n h3= title", + "utf8", + ); + const scoped = new TemplateEngine({ + viewsDir: dir, + componentsDir: components, + root: dir, + }); + expect( + await scoped.renderComponent("card", { title: "Card" }), + ).toBe('<article class="card"><h3>Card</h3></article>'); + }); + + test("keeps the html of the optional props empty", async () => { + const name = await template("optional", "p= missing"); + expect(await engine.render(name)).toBe("<p></p>"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ba61408 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,49 @@ +{ + "exclude": ["node_modules", "shadcn-svelte"], + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "paths": { + "muddle/formatter": ["./core/format-source.ts"], + "muddle/sessions": ["./core/sessions.ts"], + "muddle/contracts": ["./contracts/index.ts"], + "muddle/contracts/*": ["./contracts/*"], + "muddle/metadata": ["./core/metadata.ts"], + "muddle/markdown": ["./views/markdown.ts"], + "muddle/views": ["./views/api.ts"], + "muddle/stcss": ["./views/css/index.ts"], + "muddle": ["./index.ts"], + "@muddle/database": ["./modules/database/index.ts"], + "@muddle/shadcn": ["./modules/shadcn/index.ts"], + "@muddle/*": ["./*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + // Best practices + "strict": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/views/api.ts b/views/api.ts new file mode 100644 index 0000000..a9fcb3c --- /dev/null +++ b/views/api.ts @@ -0,0 +1,303 @@ +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import type { ViewDescriptor } from "../core/router.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts"; +import type { DeferredChunk } from "../contracts/typegen/http/response.ts"; +import { TemplateEngine, type RenderContext } from "./engine/runtime.ts"; +import { compileCss, styleTag } from "./css/index.ts"; +import type { TemplateContext } from "../core/context.ts"; +import type { + ComponentDefinition, + ComponentRegistry, + ServerComponent, +} from "./components.ts"; +import type { MuddleHooks } from "./hooks.ts"; + +/** + * Fluent, type generated view api. + * + * The props of a template are declared inside the template itself, the + * generator turns them into a contract and the builder only accepts the props + * of the selected template. + */ + +export interface ViewsOptions { + context?: TemplateContext; + /** Directory holding the templates. */ + viewsDir: string; + /** Root directory of the project, template paths are resolved against it. */ + root?: string; + /** Directory holding the components, defaults to `viewsDir/components`. */ + componentsDir?: string; + /** Server components registered on creation. */ + components?: ComponentRegistry; + globalLayouts?: string[]; + /** Contextual hooks shared by Spug rendering and STCSS component styles. */ + hooks?: MuddleHooks; +} + +export interface SpugComponentOptions { + template?: string; + style?: string | string[]; + deferred?: boolean; +} + +/** Options of a single render. */ +export interface RenderOptions { + /** Route parameters handed to the template. */ + params?: Record<string, string>; + /** Additional locals handed to the template. */ + locals?: Record<string, unknown>; +} + +/** Result of a render. */ +export interface RenderResult { + /** Html of the shell. */ + html: string; + /** Chunks rendered after the shell. */ + deferred: DeferredChunk[]; +} + +export class ViewBuilder<P extends ViewPath = ViewPath> { + private readonly state: { + props: Record<string, unknown>; + params: Record<string, string>; + locals: Record<string, unknown>; + }; + + constructor( + private readonly views: Views, + private readonly path: P, + props: Record<string, unknown> = {}, + params: Record<string, string> = {}, + locals: Record<string, unknown> = {}, + ) { + this.state = { + props: { ...props }, + params: { ...params }, + locals: { ...locals }, + }; + } + + /** Sets the props of the template. */ + with(props: ViewPropsOf<P>): this { + Object.assign(this.state.props, props); + return this; + } + + /** Sets the route parameters handed to the template. */ + params(params: Record<string, string>): this { + Object.assign(this.state.params, params); + return this; + } + + /** Sets additional locals handed to the template. */ + locals(locals: Record<string, unknown>): this { + Object.assign(this.state.locals, locals); + return this; + } + + /** Renders the template and awaits the deferred components. */ + async render(): Promise<string> { + const result = await this.renderResult(); + const deferred = await Promise.all( + result.deferred.map((chunk) => chunk.content), + ); + if (deferred.length === 0) return result.html; + const appended = deferred + .map( + (html, index) => + `<div data-muddle-deferred="${result.deferred[index]?.id ?? index}">${html}</div>`, + ) + .join(""); + return `${result.html}${appended}`; + } + + /** Renders the template and returns the deferred chunks separately. */ + async renderResult(): Promise<RenderResult> { + const context: RenderContext = { + locals: { ...this.state.locals, ...this.state.props }, + params: this.state.params, + deferred: [], + }; + const html = await this.views.engine.render( + this.path, + context.locals, + context, + ); + return { html, deferred: context.deferred }; + } + + /** + * Renders the template as a stream. + * + * The shell is sent first, the deferred components follow as soon as they + * are rendered. No javascript is required, the browser renders the chunks + * while they arrive. + */ + async renderStream(): Promise<ReadableStream<Uint8Array>> { + const { html, deferred } = await this.renderResult(); + const encoder = new TextEncoder(); + return new ReadableStream<Uint8Array>({ + async start(controller) { + controller.enqueue(encoder.encode(html)); + for (const chunk of deferred) { + const content = await chunk.content; + controller.enqueue( + encoder.encode( + `<div data-muddle-deferred="${chunk.id}">${content}</div>`, + ), + ); + } + controller.close(); + }, + }); + } +} + +export class Views { + readonly engine: TemplateEngine; + readonly components: ComponentRegistry; + private readonly hooks?: MuddleHooks; + + constructor(options: ViewsOptions) { + this.hooks = options.hooks; + this.components = options.components ?? new Map(); + for (const [name, component] of options.context?.imports ?? []) this.components.set(name, { name, component: component as ServerComponent, deferred: false }); + this.engine = new TemplateEngine({ + viewsDir: options.viewsDir, + componentsDir: options.componentsDir, + root: options.root, + components: this.components, + globalLayouts: options.globalLayouts, + hooks: options.hooks, + }); + } + + /** Registers a server component. */ + component( + name: string, + component: ServerComponent, + options: { deferred?: boolean } = {}, + ): this { + const definition: ComponentDefinition = { + name, + component, + deferred: options.deferred === true, + }; + this.components.set(name, definition); + return this; + } + + /** Registers a component rendered by a SPUG template and optional STCSS files. */ + spugComponent(name: string, options: SpugComponentOptions = {}): this { + const template = options.template ?? name; + const styles = Array.isArray(options.style) ? options.style : options.style ? [options.style] : []; + return this.component(name, { + render: async (props) => { + const html = await this.engine.renderComponent(template, props); + const css = await Promise.all(styles.map(async (style) => { + const path = resolve(this.componentsDir, style); + return existsSync(path) ? compileCss(await readFile(path, "utf8"), { hooks: this.hooks }) : ""; + })); + return `${css.filter(Boolean).map(styleTag).join("")}${html}`; + }, + }, { deferred: options.deferred }); + } + + /** Starts a fluent render of a template. */ + view<P extends ViewPath>(path: P): ViewBuilder<P> { + return new ViewBuilder<P>(this, path); + } + + /** Renders a template with the given props. */ + async render<P extends ViewPath>( + path: P, + props: ViewPropsOf<P>, + options: RenderOptions = {}, + ): Promise<string> { + const builder = this.view(path).with(props); + if (options.params) builder.params(options.params); + if (options.locals) builder.locals(options.locals); + return builder.render(); + } + + /** + * Renders a template by its path on disk. + * + * Used by the `@View()` decorator, therefore the path is resolved relative + * to the project. + */ + async renderFile( + path: string, + props: Record<string, unknown> = {}, + options: RenderOptions = {}, + ): Promise<string> { + const context: RenderContext = { + locals: { ...options.locals, ...props }, + params: options.params ?? {}, + deferred: [], + }; + const html = await this.engine.render(path, context.locals, context); + const deferred = await Promise.all( + context.deferred.map((chunk) => chunk.content), + ); + if (deferred.length === 0) return html; + const appended = deferred + .map( + (html, index) => + `<div data-muddle-deferred="${context.deferred[index]?.id ?? index}">${html}</div>`, + ) + .join(""); + return `${html}${appended}`; + } + + /** + * Renders a view descriptor of a route. + * + * The descriptor of a `@View()` decorator carries an absolute path, the + * descriptor of a controller carries the key of a view. + */ + async renderDescriptor( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise<string | RenderedView> { + const props = (descriptor.props ?? {}) as Record<string, unknown>; + if (isAbsolute(descriptor.template)) { + const html = await this.renderFile(descriptor.template, props, { + params: context.params, + locals: { ...context.locals }, + }); + return html; + } + const builder = this.view( + descriptor.template as Parameters<Views["view"]>[0], + ).with(props as never); + builder.params(context.params); + builder.locals(context.locals); + return builder.renderResult(); + } + + /** Directory of the templates. */ + get viewsDir(): string { + return this.engine.viewsDir; + } + + /** Directory of the components. */ + get componentsDir(): string { + return this.engine.componentsDir; + } +} + +/** Creates the views of a project. */ +export function createViews(options: ViewsOptions): Views { + return new Views(options); +} + +/** Resolves a template path relative to the views directory. */ +export function resolveTemplatePath(viewsDir: string, path: string): string { + return join(resolve(viewsDir), dirname(path), path); +} diff --git a/views/components.ts b/views/components.ts new file mode 100644 index 0000000..3cecd4f --- /dev/null +++ b/views/components.ts @@ -0,0 +1,79 @@ +import type { ComponentMetadata } from "../decorators/Component.ts"; + +/** + * Server components. + * + * A server component is a class that renders html on the server. It behaves + * like a template, but it is written in TypeScript, may inject services and + * may be asynchronous. + */ + +/** Context handed to a server component. */ +export interface ComponentContext { + /** + * Defers a chunk of html until the end of the response. + * + * The response is streamed, therefore the shell reaches the browser + * before the deferred content is rendered. + */ + defer(id: string, content: string | Promise<string>): void; + /** Locals of the current request. */ + locals: Record<string, unknown>; + /** Route parameters of the current request. */ + params: Record<string, string>; +} + +/** Declarative output from a decorated server component. */ +export interface SpugComponentRender { + /** Component template name or path, resolved as SPUG. */ + spug: string; + /** STCSS source or a stylesheet path relative to the component directory. */ + stcss?: string | string[]; + /** Props merged with the invocation props before rendering the SPUG template. */ + props?: Record<string, unknown>; +} + +/** Contract of a server component. */ +export interface ServerComponent { + /** Renders the component. */ + render( + props: Record<string, unknown>, + context: ComponentContext, + ): Promise<string | SpugComponentRender> | string | SpugComponentRender; +} + +export function isSpugComponentRender(value: unknown): value is SpugComponentRender { + return typeof value === "object" && value !== null && typeof (value as { spug?: unknown }).spug === "string"; +} + +/** A registered server component. */ +export interface ComponentDefinition { + /** Name used inside the templates. */ + name: string; + component: ServerComponent; + /** `true` when the component is rendered after the shell. */ + deferred: boolean; + description?: string; + params?: Record<string, string>; + /** Decorated components must return a SPUG and STCSS composition. */ + requiresSpug?: boolean; +} + +/** Registry of the server components of an application. */ +export type ComponentRegistry = Map<string, ComponentDefinition>; + +/** Builds a component definition from a decorated class. */ +export function defineComponent( + name: string, + component: ServerComponent, + metadata?: ComponentMetadata, +): ComponentDefinition { + return { + name, + component, + deferred: metadata?.deferred === true, + description: metadata?.description, + params: metadata?.params, + requiresSpug: metadata !== undefined, + }; +} diff --git a/views/css/compiler.ts b/views/css/compiler.ts new file mode 100644 index 0000000..0112243 --- /dev/null +++ b/views/css/compiler.ts @@ -0,0 +1,427 @@ +import { builtinFunctions, evaluate, type Scope } from "./evaluate.ts"; +import { + parseCss, + type CssAtRule, + type CssMixin, + type CssNode, +} from "./parser.ts"; +import { createHooks, type MuddleHooks } from "../hooks.ts"; + +export interface CompileOptions { + /** Variables added to the stylesheet, e.g. from the theme. */ + variables?: Record<string, string>; + /** Named viewport breakpoints used by `@responsive`. */ + breakpoints?: Record<string, string>; + /** Receives the `@measure` prediction targets of the stylesheet. */ + measured?: string[]; + /** Contextual STCSS expansions, including optional application-defined hooks. */ + hooks?: MuddleHooks; +} + +interface Context { + scope: Scope; + mixins: Map<string, CssMixin>; + breakpoints: Record<string, string>; + measured: string[]; + hooks: MuddleHooks; +} + +/** Compiles a stylesheet into plain css. */ +export function compileCss( + source: string, + options: CompileOptions = {}, +): string { + const scripted = source.replace(/(?:javascript|typescript)!\s*\n([\s\S]*?)\nend\s*/g, (_match, code: string) => { + for (const entry of code.matchAll(/(?:const|let)\s+([\w-]+)\s*=\s*([^;\n]+);?/g)) { + options.variables ??= {}; + options.variables[`$${entry[1]}`] = String(entry[2]).replace(/^['"]|['"]$/g, ""); + } + return ""; + }); + const stylesheet = parseCss(scripted); + const context: Context = { + scope: { ...(options.variables ?? {}) }, + mixins: new Map(stylesheet.mixins.map((mixin) => [mixin.name, mixin])), + breakpoints: options.breakpoints ?? {}, + measured: options.measured ?? [], + hooks: options.hooks ?? createHooks(), + }; + + for (const variable of stylesheet.variables) { + context.scope[variable.name] = resolveValue(variable.value, context); + } + + const compiled = compileNodes(stylesheet.rules, "", context, 0); + return context.measured.length > 0 + ? `/* muddle-predict: ${context.measured.join(" | ")} */\n${compiled}` + : compiled; +} + +function compileNodes( + nodes: CssNode[], + selector: string, + context: Context, + depth: number, +): string { + const output: string[] = []; + + for (const node of nodes) { + if (node.kind === "rule") { + const resolved = resolveSelector( + resolveValue(node.selector, context), + selector, + ); + const declarationInput = node.declarations.map((declaration) => ({ ...declaration })); + const expansions = context.hooks.expandStcssDeclarations({ + selector: resolved, + declarations: declarationInput, + depth, + resolve: (value) => resolveValue(value, context), + }); + const declarations = declarationInput.map((declaration) => + `${declaration.property}: ${resolveValue(declaration.value, context)};`, + ); + const nested = compileNodes(node.children, resolved, context, depth + 1); + + const indent = "\t".repeat(depth); + if (resolved.length === 0) { + for (const declaration of declarations) { + output.push(`${indent}${declaration}`); + } + if (expansions.length > 0) output.push(...expansions); + if (nested.length > 0) output.push(nested); + continue; + } + if (declarations.length > 0) { + output.push(`${indent}${resolved} {`); + for (const declaration of declarations) { + output.push(`${indent}\t${declaration}`); + } + output.push(`${indent}}`); + } + if (expansions.length > 0) { + if (declarations.length > 0) output.push(""); + output.push(...expansions); + } + if (nested.length > 0) { + if (declarations.length > 0 || expansions.length > 0) output.push(""); + output.push(nested); + } + continue; + } + + output.push(compileAtRule(node, selector, context, depth)); + } + + return output.filter((entry) => entry.length > 0).join("\n"); +} + +function compileAtRule( + node: CssAtRule, + selector: string, + context: Context, + depth: number, +): string { + const indent = "\t".repeat(depth); + const expanded = context.hooks.expandStcssAtRule({ + name: node.name, + params: node.params, + selector, + depth, + children: node.children, + resolve: (value) => resolveValue(value, context), + renderChildren: () => compileNodes(node.children, selector, context, depth + 1), + }); + if (expanded !== undefined) return expanded; + + switch (node.name) { + case "each": { + const match = /^\$([\w-]+)\s+in\s+(.+)$/.exec(node.params); + if (!match) return ""; + const name = `$${match[1] as string}`; + const items = splitValues(match[2] as string); + const output: string[] = []; + for (const item of items) { + const scope = { ...context.scope }; + scope[name] = resolveValue(item, context); + const nested = compileNodes( + node.children, + selector, + { ...context, scope }, + depth, + ); + if (nested.length > 0) output.push(nested); + } + return output.join("\n"); + } + case "for": { + const match = /^\$([\w-]+)\s+from\s+([\d.]+)\s+through\s+([\d.]+)$/.exec( + node.params, + ); + if (!match) return ""; + const name = `$${match[1] as string}`; + const from = Number(match[2]); + const to = Number(match[3]); + const output: string[] = []; + for (let value = from; value <= to; value += 1) { + const scope = { ...context.scope }; + scope[name] = String(value); + const nested = compileNodes( + node.children, + selector, + { ...context, scope }, + depth, + ); + if (nested.length > 0) output.push(nested); + } + return output.join("\n"); + } + case "include": { + const match = /^([\w-]+)\s*(?:\(([\s\S]*)\))?$/.exec(node.params.trim()); + if (!match) return ""; + const mixin = context.mixins.get(match[1] as string); + if (!mixin) { + throw new Error(`Unknown mixin "${match[1] as string}"`); + } + const arguments_ = splitList(match[2] ?? ""); + const scope = { ...context.scope }; + mixin.params.forEach((param, index) => { + const value = arguments_[index] ?? param.value; + if (value !== undefined) { + scope[param.name] = resolveValue(value, context); + } + }); + return compileNodes( + mixin.children, + selector, + { ...context, scope }, + depth, + ); + } + case "view-transition": { + const name = node.params.trim() || "none"; + return selector ? `${indent}${selector} {\n${indent}\tview-transition-name: ${name};\n${indent}}` : ""; + } + case "predict": { + const mobile = /\bmobile\b/.test(node.params); + const properties = node.params.replace(/\bmobile\b/g, "").trim() || "transform, max-width, max-height"; + const transitions = properties.split(/[\s,]+/).filter(Boolean).map((property) => `${property} var(--muddle-predict-duration, 280ms) var(--muddle-predict-easing, ease)`).join(", "); + const css = selector ? `${indent}${selector} {\n${indent}\ttransition: ${transitions};\n${indent}}` : ""; + return mobile ? `@media (max-width: 48rem) {\n${css}\n}` : css; + } + case "measure": { + const spec = node.params.trim(); + const candidates = spec.length > 0 ? splitList(spec) : [selector]; + for (const candidate of candidates) { + if (candidate.length === 0) continue; + const resolved = resolveValue(candidate, context); + const related = resolveRelationSelector(resolved).replace(/@([\w.-]+)/g, (_all, name: string) => `[data-muddle-anchor="${name}"]`); + context.measured.push(related); + } + return ""; + } + case "media": + case "supports": { + const nested = compileNodes(node.children, selector, context, depth + 1); + if (nested.length === 0) return ""; + return `${indent}@${node.name} ${node.params} {\n${nested}\n${indent}}`; + } + case "responsive": { + const width = context.breakpoints[node.params.trim()]; + if (!width) throw new Error(`Unknown responsive breakpoint "${node.params.trim()}"`); + const nested = compileNodes(node.children, selector, context, depth + 1); + return nested ? `${indent}@media (min-width: ${width}) {\n${nested}\n${indent}}` : ""; + } + default: + return ""; + } +} + + +/** Resolves the selector of a nested rule. */ +export function resolveSelector(selector: string, parent: string): string { + selector = resolveRelationSelector(selector); + const parts = selector + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + if (part.includes("&")) { + return part.replace(/&/g, parent); + } + return parent ? `${parent} ${part}` : part; + }); + return parts.join(", "); +} + +/** Resolves the variables, the interpolations and the functions of a value. */ +export function resolveValue(value: string, context: Context): string { + let result = value; + + result = result.replace(/#\{([^}]*)\}/g, (_match, expression: string) => { + return evaluate(expression, { scope: context.scope }); + }); + result = result.replace(/d(["'])(.*?)\1/g, (_match, quote: string, name: string) => { + const resolved = name.replace(/\{([^}]*)\}/g, (_entry: string, expression: string) => evaluate(expression, { scope: context.scope })); + return `d${quote}${resolved}${quote}`; + }); + + result = result.replace(/\$[\w-]+/g, (match) => { + const resolved = context.scope[match]; + return resolved ?? match; + }); + + result = replaceFunctions(result, context); + result = resolveRelationValue(result); + + return result.replace(/\s+/g, " ").trim(); +} + +function relationKey(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function relationState(name: string, state: string): string { + const anchor = `[data-muddle-anchor="${name}"]`; + if (state === "clicked") return `${anchor}:active`; + if (state === "active") return `${anchor}:active`; + if (state === "focus") return `${anchor}:focus-visible`; + return `${anchor}[data-muddle-${state}]`; +} + +/** Turns relation pseudo selectors into native relational selectors. */ +function resolveRelationSelector(selector: string): string { + let result = selector; + result = result.replace(/:reflect\(\s*@([\w.-]+)\s*\)/g, (_all, group: string) => + `[data-muddle-anchor^="${group}."]`, + ); + result = result.replace(/:with\(\s*@([\w.-]+):([\w-]+)\s*\)/g, (_all, name: string, state: string) => + `:has(${relationState(name, state)})`, + ); + result = result.replace(/([^,{]+?):when\(\s*([^\s()]+)\s+@([\w.-]+):([\w-]+)\s*\)/g, (_all, target: string, root: string, name: string, state: string) => { + const normalized = target.trim(); + const suffix = normalized.startsWith(root) + ? normalized.slice(root.length).trim() + : normalized; + return `${root}:has(${relationState(name, state)})${suffix ? ` ${suffix}` : ""}`; + }); + return result.replace(/@([\w.-]+):(active|clicked|focus)\b/g, (_all, name: string, state: string) => relationState(name, state)); +} + +/** Resolves precomputed anchor metrics and live computed-style mirrors. */ +function resolveRelationValue(value: string): string { + const self = value.replace(/\$self\[([a-zA-Z][\w-]*)\]/g, (_all, property: string) => { + return `var(--muddle-self-${relationKey(property)})`; + }); + + let result = self.replace(/\$(?:relate|refer)(?:\[([\w-]+)\])?\(\s*d?["']?@([^\s)"']+)["']?\s*\)/g, (_all, field: string | undefined, name: string) => { + const metric = ({ left: "x", top: "y" } as Record<string, string>)[field ?? ""] ?? field ?? "x"; + return `var(--muddle-relate-${relationKey(name)}-${metric})`; + }); + result = result.replace(/@([A-Za-z_][\w.-]*)\.computed\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => { + const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`; + }); + result = result.replace(/@([A-Za-z_][\w-]*)\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => { + const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`; + }); + return result; +} + +function replaceFunctions(value: string, context: Context): string { + let result = value; + let index = result.indexOf("("); + + while (index !== -1) { + const nameMatch = /(\$?[\w-]+)\s*$/.exec(result.slice(0, index)); + if (!nameMatch) { + index = result.indexOf("(", index + 1); + continue; + } + const name = nameMatch[1] as string; + const depth = matchParens(result, index); + if (depth === -1) break; + const inner = result.slice(index + 1, depth); + if (name.startsWith("$")) { + const expandedCalculation = context.hooks.expandStcssCalculation(name.slice(1), inner, (entry) => resolveValue(entry, context)); + if (expandedCalculation !== undefined) { + result = `${result.slice(0, index - name.length)}${expandedCalculation}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + continue; + } + } + const expanded = context.hooks.expandStcssValue(name, inner, (value) => resolveValue(value, context)); + if (expanded !== undefined) { + result = `${result.slice(0, index - name.length)}${expanded}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + continue; + } + if (!builtinFunctions[name]) { + // unknown functions are css functions, they stay untouched + index = result.indexOf("(", index + 1); + continue; + } + const evaluated = evaluate(`${name}(${inner})`, { scope: context.scope }); + result = `${result.slice(0, index - name.length)}${evaluated}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + } + + return context.hooks.expandStcssCalculationTokens(result, (entry) => resolveValue(entry, context)); +} + +function matchParens(source: string, start: number): number { + let depth = 0; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +/** + * Splits a list of values. + * + * The values are separated by commas or by spaces, e.g. `1 2 3` and + * `red, green, blue`. + */ +function splitValues(value: string): string[] { + const range = /^\[\s*(-?\d+)\s*\.\.\s*(-?\d+)\s*\]$/.exec(value.trim()); + if (range) { + const from = Number(range[1]); + const to = Number(range[2]); + const step = from <= to ? 1 : -1; + const values: string[] = []; + for (let current = from; step > 0 ? current <= to : current >= to; current += step) values.push(String(current)); + return values; + } + const items: string[] = []; + for (const part of splitList(value)) { + for (const entry of part.split(/\s+/)) { + if (entry.length > 0) items.push(entry); + } + } + return items; +} + +function splitList(value: string): string[] { + const items: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (char === "," && depth === 0) { + items.push(current.trim()); + current = ""; + continue; + } + current += char; + } + if (current.trim().length > 0) items.push(current.trim()); + return items; +} diff --git a/views/css/evaluate.ts b/views/css/evaluate.ts new file mode 100644 index 0000000..862fe5a --- /dev/null +++ b/views/css/evaluate.ts @@ -0,0 +1,318 @@ +/** + * Expression evaluation of the structured css. + * + * The evaluator understands numbers, strings, variables, arithmetic and a + * small set of colour functions. It is used by the interpolations + * (`#{$gap * 2}`) and by the functions of the stylesheets. + */ + +export type Scope = Record<string, string>; + +export interface EvaluateOptions { + /** Variables of the current scope. */ + scope?: Scope; + /** Names of the mixins that can be called. */ + functions?: Record<string, (...args: string[]) => string>; +} + +type Token = + | { type: "number"; value: number } + | { type: "string"; value: string } + | { type: "variable"; value: string } + | { type: "operator"; value: string } + | { type: "function"; value: string } + | { type: "punctuation"; value: string }; + +const OPERATORS = ["+", "-", "*", "/", "(", ")", ","]; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + + while (index < source.length) { + const char = source[index] as string; + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === "$") { + const match = /^\$[\w-]+/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid variable in "${source}"`); + tokens.push({ type: "variable", value: match[0] }); + index += match[0].length; + continue; + } + + if (char === "#") { + const match = /^#[\da-fA-F]{3,8}/.exec(source.slice(index)); + if (match) { + tokens.push({ type: "string", value: match[0] }); + index += match[0].length; + continue; + } + } + + if (/[\d.]/.test(char)) { + const match = /^[\d.]+/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid number in "${source}"`); + tokens.push({ type: "number", value: Number(match[0]) }); + index += match[0].length; + continue; + } + + if (/[a-zA-Z_]/.test(char)) { + const match = /^[a-zA-Z_][\w-]*/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid identifier in "${source}"`); + const word = match[0]; + let cursor = index + word.length; + while (cursor < source.length && /\s/.test(source[cursor] as string)) { + cursor += 1; + } + if (source[cursor] === "(") { + tokens.push({ type: "function", value: word }); + index = cursor; + continue; + } + tokens.push({ type: "string", value: word }); + index += word.length; + continue; + } + + if (char === '"' || char === "'") { + const end = source.indexOf(char, index + 1); + const value = + end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); + tokens.push({ type: "string", value }); + index = end === -1 ? source.length : end + 1; + continue; + } + + const operator = OPERATORS.find((candidate) => + source.startsWith(candidate, index), + ); + if (operator) { + tokens.push({ + type: + operator === "(" || operator === ")" || operator === "," + ? "punctuation" + : "operator", + value: operator, + }); + index += operator.length; + continue; + } + + throw new Error(`Unexpected character "${char}" in "${source}"`); + } + + return tokens; +} + +class Parser { + private index = 0; + + constructor( + private readonly tokens: Token[], + private readonly options: EvaluateOptions, + ) {} + + parse(): string { + const value = this.expression(); + if (this.index < this.tokens.length) { + throw new Error("Unexpected token in expression"); + } + return value; + } + + private peek(): Token | undefined { + return this.tokens[this.index]; + } + + private expression(): string { + let left = this.term(); + while (this.peek()?.type === "operator") { + const operator = (this.peek() as Token).value; + if (operator !== "+" && operator !== "-") break; + this.index += 1; + const right = this.term(); + left = operator === "+" ? add(left, right) : subtract(left, right); + } + return left; + } + + private term(): string { + let left = this.factor(); + while (this.peek()?.type === "operator") { + const operator = (this.peek() as Token).value; + if (operator !== "*" && operator !== "/") break; + this.index += 1; + const right = this.factor(); + left = operator === "*" ? multiply(left, right) : divide(left, right); + } + return left; + } + + private factor(): string { + const token = this.peek(); + if (!token) throw new Error("Unexpected end of expression"); + this.index += 1; + + if (token.type === "number") return String(token.value); + if (token.type === "string") { + if (token.value.startsWith("#")) return token.value; + return token.value; + } + if (token.type === "variable") { + const value = this.options.scope?.[token.value]; + if (value === undefined) { + throw new Error(`Unknown variable ${token.value}`); + } + return value; + } + if (token.type === "punctuation" && token.value === "(") { + const value = this.expression(); + const closing = this.peek(); + if (closing?.type === "punctuation" && closing.value === ")") { + this.index += 1; + } + return value; + } + if (token.type === "function") { + const args: string[] = []; + const opening = this.peek(); + if (opening?.type === "punctuation" && opening.value === "(") { + this.index += 1; + while (this.index < this.tokens.length) { + const current = this.peek() as Token; + if (current.type === "punctuation" && current.value === ")") { + this.index += 1; + break; + } + if (current.type === "punctuation" && current.value === ",") { + this.index += 1; + continue; + } + args.push(this.expression()); + } + } + const fn = this.options.functions?.[token.value]; + if (!fn) throw new Error(`Unknown function ${token.value}`); + return fn(...args); + } + + throw new Error(`Unexpected token ${token.value}`); + } +} + +function toNumber(value: string): number { + const numeric = Number(value); + return Number.isNaN(numeric) ? 0 : numeric; +} + +function add(left: string, right: string): string { + if (left.startsWith("#") || right.startsWith("#")) { + return `${left}${right}`; + } + const result = toNumber(left) + toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function subtract(left: string, right: string): string { + const result = toNumber(left) - toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function multiply(left: string, right: string): string { + const result = toNumber(left) * toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function divide(left: string, right: string): string { + const result = toNumber(left) / toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +/** Parses a colour into its channels. */ +export function parseColor(value: string): + | { + r: number; + g: number; + b: number; + } + | undefined { + const hex = value.replace("#", ""); + if (hex.length !== 3 && hex.length !== 6) return undefined; + const expanded = + hex.length === 3 + ? hex + .split("") + .map((char) => char + char) + .join("") + : hex; + return { + r: Number.parseInt(expanded.slice(0, 2), 16), + g: Number.parseInt(expanded.slice(2, 4), 16), + b: Number.parseInt(expanded.slice(4, 6), 16), + }; +} + +function toHex(value: number): string { + return Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, "0"); +} + +/** Darkens a colour by the given percentage. */ +export function darken(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const factor = 1 - amount / 100; + return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; +} + +/** Lightens a colour by the given percentage. */ +export function lighten(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const factor = 1 + amount / 100; + return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; +} + +/** Builds a rgba colour. */ +export function rgba(color: string, alpha: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`; +} + +/** The functions available inside a stylesheet. */ +export const builtinFunctions: Record<string, (...args: string[]) => string> = { + darken: (...args) => + String(args[0]).startsWith("#") + ? darken(String(args[0]), Number(args[1])) + : `darken(${args.join(", ")})`, + lighten: (...args) => + String(args[0]).startsWith("#") + ? lighten(String(args[0]), Number(args[1])) + : `lighten(${args.join(", ")})`, + rgba: (...args) => + String(args[0]).startsWith("#") + ? rgba(String(args[0]), Number(args[1])) + : `rgba(${args.join(", ")})`, +}; + +/** Evaluates an expression of a stylesheet. */ +export function evaluate( + source: string, + options: EvaluateOptions = {}, +): string { + const trimmed = source.trim(); + if (trimmed.length === 0) return ""; + const parser = new Parser(tokenize(trimmed), { + scope: options.scope ?? {}, + functions: { ...builtinFunctions, ...(options.functions ?? {}) }, + }); + return parser.parse(); +} diff --git a/views/css/index.ts b/views/css/index.ts new file mode 100644 index 0000000..b0dbc50 --- /dev/null +++ b/views/css/index.ts @@ -0,0 +1,43 @@ +import type { Loader, LoaderFile } from "../../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./compiler.ts"; + +/** + * Structured css. + * + * The stylesheets are written like pug: rules are nested, `&` refers to the + * parent selector, `#{$var}` interpolates an expression and `@each` repeats + * a block. The compiler flattens everything into plain css. + */ + +export { compileCss, type CompileOptions } from "./compiler.ts"; +export { evaluate, darken, lighten, rgba } from "./evaluate.ts"; +export { parseCss } from "./parser.ts"; +export { sassLoader, type SassLoaderOptions } from "./sass.ts"; + +export interface StcssLoaderOptions extends CompileOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** Loader for structured css files. */ +export function stcssLoader(options: StcssLoaderOptions = {}): Loader { + return { + name: "stcss", + extensions: [".stcss", ".css"], + load(file: LoaderFile) { + return { + data: {}, + content: compileCss(file.source, options), + }; + }, + }; +} + +/** Wraps compiled css into a style tag. */ +export function styleTag(css: string): string { + return `<style>${css}</style>`; +} + +export function viewTransition(name: string): string { + return `view-transition-name: ${name};`; +} diff --git a/views/css/parser.ts b/views/css/parser.ts new file mode 100644 index 0000000..06dfe29 --- /dev/null +++ b/views/css/parser.ts @@ -0,0 +1,381 @@ +export interface CssDeclaration { + property: string; + value: string; +} + +export interface CssRule { + kind: "rule"; + selector: string; + declarations: CssDeclaration[]; + children: CssNode[]; +} + +export interface CssAtRule { + kind: "at-rule"; + name: string; + params: string; + children: CssNode[]; +} + +/** A node of the stylesheet. */ +export type CssNode = CssRule | CssAtRule; + +export interface CssVariable { + name: string; + value: string; +} + +export interface CssMixin { + name: string; + params: { name: string; value?: string }[]; + children: CssNode[]; +} + +export interface CssStylesheet { + variables: CssVariable[]; + mixins: CssMixin[]; + rules: CssNode[]; +} + +const COMMENT = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g; + +const PUG_PROPERTIES = new Set([ + "accent-color", "align-content", "align-items", "align-self", "animation", "appearance", "aspect-ratio", + "backdrop-filter", "background", "background-color", "background-image", "background-size", "block-size", + "border", "border-radius", "border-color", "border-width", "bottom", "box-shadow", "box-sizing", + "color", "columns", "contain", "content", "cursor", "display", "filter", "flex", "flex-basis", + "flex-direction", "flex-grow", "flex-shrink", "flex-wrap", "float", "font", "font-family", "font-size", + "font-weight", "gap", "grid", "grid-area", "grid-column", "grid-row", "grid-template-columns", + "grid-template-rows", "height", "inline-size", "inset", "justify-content", "justify-items", "justify-self", + "left", "letter-spacing", "line-height", "list-style", "margin", "max-height", "max-width", "min-height", + "min-width", "object-fit", "opacity", "order", "outline", "overflow", "overflow-x", "overflow-y", + "padding", "perspective", "place-content", "place-items", "pointer-events", "position", "right", "rotate", + "scale", "scroll-behavior", "scroll-margin-top", "text-align", "text-decoration", "text-transform", "top", + "transform", "transform-origin", "transition", "translate", "user-select", "visibility", "white-space", "width", + "will-change", "word-break", "z-index", +]); + +function indentation(line: string): number { + let width = 0; + for (const char of line) { + if (char === " ") width += 1; + else if (char === "\t") width += 2; + else break; + } + return width; +} + +function pugDeclaration(line: string): string | undefined { + if (/^\$[\w-]+\s*:\s*.+$/.test(line)) return line; + const variable = /^(\$[\w-]+)\s+(.+)$/.exec(line); + if (variable) return `${variable[1]}: ${variable[2]}`; + if (/^(?:--[\w-]+|[a-zA-Z][\w-]*)\s*:\s+.+$/.test(line)) return line; + const match = /^(--[\w-]+|[a-zA-Z][\w-]*)\s+(.+)$/.exec(line); + if (match && PUG_PROPERTIES.has(match[1] as string)) return `${match[1]}: ${match[2]}`; + return undefined; +} + +function isPugStatement(line: string): boolean { + return /^@(include|measure|predict|concentric)\b/.test(line); +} + +/** Converts indentation-delimited STCSS into the brace form used by the core parser. */ +function normalizePugSyntax(source: string): string { + const withoutInterpolations = source.replace(/#\{[^}]*\}/g, ""); + if (/[{}]/.test(withoutInterpolations)) return source; + const lines = source.split("\n"); + const significant = lines.filter((line) => line.trim().length > 0); + if (!significant.some((line) => /^\s+/.test(line))) return source; + + const output: string[] = []; + const stack: number[] = []; + for (const raw of lines) { + const content = raw.trim(); + if (!content) continue; + const level = indentation(raw); + while (stack.length > 0 && level <= (stack[stack.length - 1] as number)) { + output.push("}"); + stack.pop(); + } + const declaration = pugDeclaration(content); + if (declaration) { + output.push(`${declaration};`); + continue; + } + if (isPugStatement(content)) { + output.push(`${content};`); + continue; + } + const block = content.endsWith(":") ? content.slice(0, -1).trim() : content; + output.push(`${block} {`); + stack.push(level); + } + while (stack.length > 0) { + output.push("}"); + stack.pop(); + } + return output.join("\n"); +} + +class StcssParser { + private index = 0; + + constructor(private readonly source: string) {} + + parse(): CssStylesheet { + const stylesheet: CssStylesheet = { + variables: [], + mixins: [], + rules: [], + }; + const nodes = this.parseNodes(stylesheet.variables); + + for (const node of nodes) { + if (node.kind === "at-rule" && node.name === "mixin") { + stylesheet.mixins.push({ + name: node.params.split(/[\s(]/)[0] ?? "", + params: parseMixinParams(node.params), + children: node.children, + }); + continue; + } + if (node.kind === "at-rule" && node.name === "each") { + // the variable of the loop is handled by the compiler + } + stylesheet.rules.push(node); + } + + return stylesheet; + } + + private parseNodes( + variables?: CssVariable[], + declarations?: CssDeclaration[], + ): CssNode[] { + const nodes: CssNode[] = []; + + while (this.index < this.source.length) { + this.skipWhitespace(); + if (this.index >= this.source.length) break; + if (this.source[this.index] === "}") { + this.index += 1; + break; + } + + const chunk = this.readUntil(["{", ";", "}"]); + if (chunk.trim().length === 0) { + this.index += 1; + continue; + } + + const delimiter = this.source[this.index]; + + if (delimiter === "{") { + this.index += 1; + const selector = chunk.trim(); + if (selector.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); + const name = match?.[1] ?? ""; + const params = (match?.[2] ?? "").trim(); + const body: CssDeclaration[] = []; + const children = this.parseNodes(undefined, body); + if (body.length > 0) { + children.unshift({ + kind: "rule", + selector: "", + declarations: body, + children: [], + }); + } + nodes.push({ kind: "at-rule", name, params, children }); + continue; + } + const rule: CssRule = { + kind: "rule", + selector, + declarations: [], + children: [], + }; + this.parseBody(rule); + nodes.push(rule); + continue; + } + + // declaration or variable + this.index += 1; + const declaration = chunk.trim(); + if (declaration.length === 0) continue; + if (declaration.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); + nodes.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children: [], + }); + continue; + } + if (!declaration.startsWith("$")) { + const separator = declaration.indexOf(":"); + if (separator !== -1 && declarations) { + declarations.push({ + property: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + if (declaration.startsWith("$")) { + const separator = declaration.indexOf(":"); + if (separator !== -1 && variables) { + variables.push({ + name: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + } + + return nodes; + } + + private parseBody(rule: CssRule): void { + while (this.index < this.source.length) { + this.skipWhitespace(); + if (this.index >= this.source.length) return; + if (this.source[this.index] === "}") { + this.index += 1; + return; + } + + const chunk = this.readUntil(["{", ";", "}"]); + const delimiter = this.source[this.index]; + + if (delimiter === "{") { + this.index += 1; + const selector = chunk.trim(); + if (selector.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); + const body: CssDeclaration[] = []; + const children = this.parseNodes(undefined, body); + if (body.length > 0) { + children.unshift({ + kind: "rule", + selector: "", + declarations: body, + children: [], + }); + } + rule.children.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children, + }); + continue; + } + const nested: CssRule = { + kind: "rule", + selector, + declarations: [], + children: [], + }; + this.parseBody(nested); + rule.children.push(nested); + continue; + } + + this.index += 1; + const declaration = chunk.trim(); + if (declaration.length === 0) continue; + if (declaration.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); + rule.children.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children: [], + }); + continue; + } + const separator = declaration.indexOf(":"); + if (separator === -1) continue; + rule.declarations.push({ + property: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + + private readUntil(delimiters: string[]): string { + const start = this.index; + let quote: string | undefined; + + while (this.index < this.source.length) { + const char = this.source[this.index] as string; + + if (quote) { + if (char === quote) quote = undefined; + this.index += 1; + continue; + } + if (char === '"' || char === "'") { + quote = char; + this.index += 1; + continue; + } + if (char === "#" && this.source[this.index + 1] === "{") { + // an interpolation, the braces belong to the expression + const end = this.source.indexOf("}", this.index); + this.index = end === -1 ? this.source.length : end + 1; + continue; + } + if (delimiters.includes(char)) break; + this.index += 1; + } + + return this.source.slice(start, this.index); + } + + private skipWhitespace(): void { + while (this.index < this.source.length) { + const char = this.source[this.index] as string; + if (/\s/.test(char)) { + this.index += 1; + continue; + } + if (char === "/" && this.source[this.index + 1] === "*") { + const end = this.source.indexOf("*/", this.index); + this.index = end === -1 ? this.source.length : end + 2; + continue; + } + break; + } + } +} + +/** Parses the parameters of a mixin, e.g. `$size: 1rem`. */ +export function parseMixinParams( + params: string, +): { name: string; value?: string }[] { + const open = params.indexOf("("); + const close = params.lastIndexOf(")"); + if (open === -1 || close === -1) return []; + const inner = params.slice(open + 1, close); + return inner + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const separator = entry.indexOf(":"); + if (separator === -1) return { name: entry }; + return { + name: entry.slice(0, separator).trim(), + value: entry.slice(separator + 1).trim(), + }; + }); +} + +/** Parses a stylesheet. */ +export function parseCss(source: string): CssStylesheet { + return new StcssParser(normalizePugSyntax(source.replace(COMMENT, ""))).parse(); +} diff --git a/views/css/sass.ts b/views/css/sass.ts new file mode 100644 index 0000000..05b20f2 --- /dev/null +++ b/views/css/sass.ts @@ -0,0 +1,29 @@ +import { compileStringAsync } from "sass"; +import { pathToFileURL } from "node:url"; +import type { Loader, LoaderFile } from "../../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./compiler.ts"; + +export interface SassLoaderOptions extends CompileOptions { + /** Directory scanned by the Sass integration, relative to the project root. */ + dir?: string; + /** Extra directories searched by Sass `@use` and `@import`. */ + loadPaths?: string[]; +} + +/** + * Compiles Sass to CSS and then runs it through STCSS. This lets a project + * move Sass files over incrementally while keeping one CSS output path. + */ +export function sassLoader(options: SassLoaderOptions = {}): Loader { + return { + name: "sass", + extensions: [".scss", ".sass"], + async load(file: LoaderFile) { + const result = await compileStringAsync(file.source, { + url: pathToFileURL(file.path), + loadPaths: options.loadPaths, + }); + return { data: {}, content: compileCss(result.css, options) }; + }, + }; +} diff --git a/views/engine/compiler.ts b/views/engine/compiler.ts new file mode 100644 index 0000000..6123adc --- /dev/null +++ b/views/engine/compiler.ts @@ -0,0 +1,707 @@ +import type { Node, PropDeclaration, RawAttribute } from "./types.ts"; + +export interface CompileOptions { + /** Absolute path of the template, used to resolve frontmatter imports. */ + path: string; + /** Directory of the template, used to resolve frontmatter imports. */ + dir: string; + /** Frontmatter of the template. */ + frontmatter?: string; +} + +export interface CompiledSource { + /** Source of the module creating the compiled template. */ + source: string; + /** Prop declarations found in the template. */ + props: PropDeclaration[]; + /** Names declared by the frontmatter. */ + frontmatterNames: string[]; +} + +const RESERVED = new Set([ + "await", + "async", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "of", + "package", + "private", + "protected", + "public", + "return", + "set", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "undefined", + "var", + "void", + "while", + "with", + "yield", +]); + +/** + * Identifiers used by an expression. + */ +export function collectIdentifiers(code: string): string[] { + const withoutStrings = code + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/'(?:[^'\\]|\\.)*'/g, "''") + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); + const names = new Set<string>(); + const pattern = /(?<![.\w$])([A-Za-z_$][\w$]*)/g; + let match = pattern.exec(withoutStrings); + while (match) { + const name = match[1] as string; + if (!RESERVED.has(name)) names.add(name); + match = pattern.exec(withoutStrings); + } + return [...names]; +} + +/** + * Names declared by the frontmatter. + */ +export function extractDeclarations(code: string): string[] { + const names: string[] = []; + const pattern = /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/gm; + let match = pattern.exec(code); + while (match) { + names.push(match[1] as string); + match = pattern.exec(code); + } + return names; +} + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +/** Escapes a string for use inside a JavaScript string literal. */ +function jsString(value: string): string { + return JSON.stringify(value); +} + +/** Escapes HTML entities of static text. */ +export function escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +/** Resolves an import specifier of the frontmatter. */ +function resolveSpecifier(specifier: string, dir: string): string { + if (!specifier.startsWith(".")) return specifier; + return `${dir}/${specifier}`.replace(/\/{2,}/g, "/"); +} + +/** + * Rewrites the `import` statements of the frontmatter into dynamic imports, + * because the frontmatter is compiled into a function body. + */ +export function transformFrontmatter(code: string, dir: string): string { + const lines = code.split("\n"); + const output: string[] = []; + + for (const line of lines) { + const named = /^\s*import\s+([\s\S]+?)\s+from\s+['"]([^'"]+)['"]\s*$/.exec( + line, + ); + if (named) { + const clause = (named[1] as string).trim(); + const specifier = resolveSpecifier(named[2] as string, dir); + const defaultMatch = /^([A-Za-z_$][\w$]*)\s*,\s*([\s\S]+)$/.exec(clause); + if (defaultMatch) { + const alias = defaultMatch[1] as string; + const rest = (defaultMatch[2] as string).replace(/^\{|\}$/g, "").trim(); + output.push(`const __module = await import(${jsString(specifier)});`); + output.push(`const ${alias} = __module.default;`); + output.push(`const { ${rest} } = __module;`); + continue; + } + if (/^\*\s*as\s+/.test(clause)) { + const alias = clause.replace(/^\*\s*as\s+/, "").trim(); + output.push(`const ${alias} = await import(${jsString(specifier)});`); + continue; + } + if (/^\{[\s\S]*\}$/.test(clause)) { + const names = clause.replace(/^\{|\}$/g, "").trim(); + output.push( + `const { ${names} } = await import(${jsString(specifier)});`, + ); + continue; + } + if (/^[A-Za-z_$][\w$]*$/.test(clause)) { + output.push( + `const ${clause} = (await import(${jsString(specifier)})).default;`, + ); + continue; + } + output.push(`await import(${jsString(specifier)});`); + continue; + } + + const sideEffect = /^\s*import\s+['"]([^'"]+)['"]\s*$/.exec(line); + if (sideEffect) { + output.push( + `await import(${jsString(resolveSpecifier(sideEffect[1] as string, dir))});`, + ); + continue; + } + + output.push(line); + } + + return output.join("\n"); +} + +/** Collects every mixin definition of a template. */ +export function collectMixinNodes( + nodes: Node[], +): Extract<Node, { kind: "mixin" }>[] { + const result: Extract<Node, { kind: "mixin" }>[] = []; + for (const node of nodes) { + if (node.kind === "mixin") { + result.push(node); + continue; + } + const children = childrenOf(node); + if (children) result.push(...collectMixinNodes(children)); + } + return result; +} + +/** Collects every named block of a template. */ +export function collectBlockNodes(nodes: Node[]): { + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; +}[] { + const result: { + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; + }[] = []; + for (const node of nodes) { + if (node.kind === "block") { + result.push({ + name: node.name, + mode: node.mode, + children: node.children, + }); + continue; + } + const children = childrenOf(node); + if (children) result.push(...collectBlockNodes(children)); + } + return result; +} + +class Compiler { + private readonly buffer: string[] = []; + + compile(nodes: Node[]): string { + this.emitBody(nodes); + return this.buffer.join("\n"); + } + + private emit(line: string): void { + this.buffer.push(line); + } + + private emitBody(nodes: Node[]): void { + for (const node of nodes) { + this.emitNode(node); + } + } + + private emitNode(node: Node): void { + switch (node.kind) { + case "element": + this.emitElement(node); + break; + case "text": + this.emitText(node.parts); + break; + case "rawtext": + this.emit(`__push(${jsString(escapeHtmlText(node.value))});`); + break; + case "code": + this.emit(node.code); + break; + case "out": + this.emit( + `__push(__helpers.render(await (${node.code}), ${node.escaped !== false}));`, + ); + break; + case "comment": + if (!node.silent) { + this.emit(`__push(${jsString(`<!--${node.value}-->`)});`); + } + break; + case "doctype": + this.emit(`__push(${jsString(`<!DOCTYPE ${node.value || "html"}>`)});`); + break; + case "if": + this.emitIf(node.branches); + break; + case "each": + this.emitEach(node); + break; + case "while": + this.emit(`while (${node.test}) {`); + this.emitBody(node.children); + this.emit("}"); + break; + case "case": + this.emitCase(node); + break; + case "mixin": + break; + case "mixin-call": + this.emit( + `__push(await __mixins[${jsString(node.name)}](__runtime, __ctx, ${node.args || ""}));`, + ); + break; + case "include": + this.emitBody(node.children); + break; + case "extends": + break; + case "block": + case "slot": + this.emitBlock(node.name, node.children); + break; + case "yield": + this.emitBlock(node.name, []); + break; + case "portal": + this.emit(`__push(${jsString(`<div data-muddle-portal="${node.name}" style="view-transition-name:${node.name}">`)});`); + this.emitBody(node.children); + this.emit(`__push("</div>");`); + break; + case "props": + case "test": + break; + case "component": + this.emitComponent(node.name, node.props, node.renders, false); + break; + case "island": + this.emitComponent(node.name, node.props, node.renders, true); + break; + } + } + + private emitElement(node: Extract<Node, { kind: "element" }>): void { + const anchor = node.anchor + ? node.anchor.expression + ? ` + __helpers.attr("data-muddle-anchor", ${jsString(node.anchor.name)} + String(${node.anchor.expression}))` + : ` + __helpers.attr("data-muddle-anchor", ${jsString(node.anchor.name)})` + : ""; + const attributes = this.emitAttributes(node.attributes) + anchor; + this.emit( + `__push(${jsString(`<${node.name}`)}${attributes} + ${jsString(">")});`, + ); + + if (VOID_ELEMENTS.has(node.name)) return; + + this.emitBody(node.children); + this.emit(`__push(${jsString(`</${node.name}>`)});`); + } + + /** + * Renders the attributes of a tag. + */ + private emitAttributes(attributes: RawAttribute[]): string { + const classes: RawAttribute[] = []; + const others: RawAttribute[] = []; + for (const attribute of attributes) { + if (attribute.name === "class") classes.push(attribute); + else others.push(attribute); + } + + let rendered = others + .map((attribute) => this.emitAttribute(attribute)) + .join(""); + + if (classes.length === 1) { + rendered += this.emitAttribute(classes[0] as RawAttribute); + return rendered; + } + if (classes.length > 1) { + const values = classes + .map((attribute) => + attribute.kind === "string" + ? jsString(attribute.value ?? "") + : `(${attribute.value ?? ""})`, + ) + .join(", "); + rendered += ` + __helpers.attr("class", __helpers.classes([${values}]))`; + } + return rendered; + } + + private emitAttribute(attribute: RawAttribute): string { + if (attribute.kind === "boolean") { + return ` + __helpers.attrBoolean(${jsString(attribute.name)})`; + } + if (attribute.kind === "string") { + return ` + __helpers.attr(${jsString(attribute.name)}, ${jsString(attribute.value ?? "")})`; + } + if (attribute.name === "style") { + return ` + __helpers.attr("style", __helpers.style(${attribute.value ?? ""}))`; + } + return ` + __helpers.attr(${jsString(attribute.name)}, (${attribute.value ?? ""}))`; + } + + private emitText( + parts: { + type: "literal" | "expression"; + value?: string; + code?: string; + escaped?: boolean; + }[], + ): void { + for (const part of parts) { + if (part.type === "literal") { + this.emit(`__push(${jsString(escapeHtmlText(part.value ?? ""))});`); + continue; + } + const code = part.code ?? ""; + this.emit( + `__push(__helpers.render(await (${code}), ${part.escaped !== false}));`, + ); + } + } + + private emitIf( + branches: { test: string | null; negate: boolean; children: Node[] }[], + ): void { + branches.forEach((branch, index) => { + if (index > 0) { + this.emit("}"); + } + if (branch.test === null) { + this.emit("else {"); + } else { + const test = branch.negate ? `!(${branch.test})` : branch.test; + this.emit(`${index === 0 ? "if" : "else if"} (${test}) {`); + } + this.emitBody(branch.children); + }); + this.emit("}"); + } + + private emitEach(node: Extract<Node, { kind: "each" }>): void { + this.emit("{"); + this.emit(`const __list = (${node.list});`); + this.emit("if (Array.isArray(__list)) {"); + this.emit("for (let __index = 0; __index < __list.length; __index++) {"); + this.emit(`const ${node.value} = __list[__index];`); + if (node.key) { + this.emit(`const ${node.key} = __index;`); + } + this.emitBody(node.children); + this.emit("}"); + this.emit("} else {"); + this.emit("for (const __key of Object.keys(__list ?? {})) {"); + this.emit(`const ${node.value} = __list[__key];`); + if (node.key) { + this.emit(`const ${node.key} = __key;`); + } + this.emitBody(node.children); + this.emit("}"); + this.emit("}"); + this.emit("}"); + } + + private emitCase(node: Extract<Node, { kind: "case" }>): void { + this.emit(`switch (${node.subject}) {`); + for (const entry of node.cases) { + if (entry.test === undefined) { + this.emit("default: {"); + } else { + this.emit(`case (${entry.test}): {`); + } + this.emitBody(entry.children); + this.emit("break;"); + this.emit("}"); + } + this.emit("}"); + } + + private emitBlock(name: string, children: Node[]): void { + this.emit( + `__push(await __runtime.getBlock(${jsString(name)}, async (__runtime, __ctx) => {`, + ); + this.emit("const __out = [];"); + this.emit("const __push = (__value) => { __out.push(__value); };"); + this.emit("with (__ctx) {"); + this.emitBody(children); + this.emit("}"); + this.emit('return __out.join("");'); + this.emit("}));"); + } + + private emitComponent( + name: string, + props: Record<string, string>, + renders: { name: string; children: Node[] }[] = [], + island = false, + ): void { + const entries = Object.entries(props).map( + ([key, value]) => `${jsString(key)}: (${value || "undefined"})`, + ); + + for (const render of renders) { + const renderer = new Compiler(); + const body = renderer.compile(render.children); + entries.push( + `${jsString(render.name)}: async () => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${body} } return __out.join(""); }`, + ); + } + + const method = island ? "renderIsland" : "renderComponent"; + this.emit( + `__push(await __runtime.${method}(${jsString(name)}, { ${entries.join(", ")} }));`, + ); + } +} + + +export function compileTemplate( + nodes: Node[], + options: CompileOptions, +): CompiledSource { + const bodyCompiler = new Compiler(); + const body = bodyCompiler.compile(nodes); + const uses = collectUses(nodes); + const tests = collectTests(nodes); + + const blockSource: string[] = []; + for (const block of collectBlockNodes(nodes)) { + const blockCompiler = new Compiler(); + const blockBody = blockCompiler.compile(block.children); + blockSource.push( + `${jsString(block.name)}: { mode: ${jsString(block.mode)}, fn: async (__runtime, __ctx) => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${blockBody} } return __out.join(""); } }`, + ); + } + + const mixinSource: string[] = []; + for (const mixin of collectMixinNodes(nodes)) { + const mixinCompiler = new Compiler(); + const mixinBody = mixinCompiler.compile(mixin.children); + const params = mixin.params.length > 0 ? mixin.params : ""; + mixinSource.push( + `${jsString(mixin.name)}: async (__runtime, __ctx, ${params}) => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${mixinBody} } return __out.join(""); }`, + ); + } + + const frontmatter = + options.frontmatter && options.frontmatter.trim().length > 0 + ? transformFrontmatter(options.frontmatter, options.dir) + : ""; + const frontmatterNames = + frontmatter.length > 0 ? extractDeclarations(frontmatter) : []; + const frontmatterAssignments = frontmatterNames + .map((name) => ` __values.${name} = ${name};`) + .join("\n"); + + const source = ` +const __blocks = { ${blockSource.join(", ")} }; +const __mixins = { ${mixinSource.join(", ")} }; +const __frontmatter = async (__runtime, __ctx) => { + const __values = {}; + const __helpers = __runtime.helpers; + const __muddle = __runtime.file; + with (__ctx) { +${frontmatter} +${frontmatterAssignments} + } + return __values; +}; +const __template = async (__runtime, __ctx) => { + const __out = []; + const __push = (__value) => { __out.push(__value); }; + const __helpers = __runtime.helpers; + const __muddle = __runtime.file; + with (__ctx) { + ${body} + } + return __out.join(""); +}; +return { template: __template, frontmatter: __frontmatter, blocks: __blocks, mixins: __mixins, uses: ${JSON.stringify(uses)}, tests: ${JSON.stringify(tests)} }; +`; + + return { source, props: [], frontmatterNames }; +} + +/** + * The assertions of a template. + */ +export function collectTests(nodes: Node[]): string[] { + const tests: string[] = []; + const visit = (nodes: Node[]): void => { + for (const node of nodes) { + if (node.kind === "test") { + tests.push(node.code); + continue; + } + const children = childrenOf(node); + if (children) visit(children); + } + }; + visit(nodes); + return tests; +} + +/** + * Every identifier used by a template. + */ +export function collectUses(nodes: Node[]): string[] { + const names = new Set<string>(); + const declared = new Set<string>(); + + const visit = (node: Node): void => { + if (node.kind === "mixin") { + for (const name of mixinParamNames(node.params)) declared.add(name); + } + switch (node.kind) { + case "out": + for (const name of collectIdentifiers(node.code)) names.add(name); + break; + case "code": + for (const name of collectIdentifiers(node.code)) names.add(name); + break; + case "element": + for (const attribute of node.attributes) { + if (attribute.kind !== "expression") continue; + for (const name of collectIdentifiers(attribute.value ?? "")) { + names.add(name); + } + } + break; + case "text": + for (const part of node.parts) { + if (part.type !== "expression") continue; + for (const name of collectIdentifiers(part.code ?? "")) { + names.add(name); + } + } + break; + case "if": + for (const branch of node.branches) { + if (branch.test) { + for (const name of collectIdentifiers(branch.test)) names.add(name); + } + } + break; + case "each": + for (const name of collectIdentifiers(node.list)) names.add(name); + break; + case "while": + for (const name of collectIdentifiers(node.test)) names.add(name); + break; + case "case": + for (const name of collectIdentifiers(node.subject)) names.add(name); + break; + case "mixin-call": + for (const name of collectIdentifiers(node.args)) names.add(name); + break; + case "component": + case "island": + for (const value of Object.values(node.props)) { + for (const name of collectIdentifiers(value)) names.add(name); + } + break; + } + + const children = childrenOf(node); + if (children) { + for (const child of children) visit(child); + } + }; + + for (const node of nodes) visit(node); + return [...names].filter((name) => !declared.has(name)); +} + +/** Names of the parameters of a mixin. */ +export function mixinParamNames(params: string): string[] { + const names: string[] = []; + const braced = /[{[(]([\s\S]*)[}\])]/.exec(params); + const inner = braced ? (braced[1] as string) : params; + for (const part of inner.split(",")) { + const cleaned = part.trim().replace(/^[.]{3}/, ""); + if (!cleaned) continue; + const name = /^[A-Za-z_$][\w$]*/.exec(cleaned); + if (name) names.push(name[0]); + } + return names; +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} diff --git a/views/engine/lexer.ts b/views/engine/lexer.ts new file mode 100644 index 0000000..2f80388 --- /dev/null +++ b/views/engine/lexer.ts @@ -0,0 +1,616 @@ +import type { RawAttribute, TextPart, Token } from "./types.ts"; + +const KEYWORDS = new Set([ + "if", + "unless", + "else", + "each", + "while", + "case", + "when", + "default", + "mixin", + "include", + "extends", + "block", + "append", + "prepend", + "slot", + "yield", + "props", + "component", + "island", + "render", + "portal", +]); + +const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9:_-]*/; + +/** Converts tabs to spaces so indentation can be compared by width. */ +function indentWidth(line: string): { indent: number; rest: string } { + let indent = 0; + let index = 0; + while (index < line.length) { + const char = line[index]; + if (char === " ") { + indent += 1; + } else if (char === "\t") { + indent += 2; + } else { + break; + } + index += 1; + } + return { indent, rest: line.slice(index) }; +} + +/** Counts the parentheses that are still open at the end of a line. */ +function parenBalance(line: string): number { + let balance = 0; + let quote: string | null = null; + for (let index = 0; index < line.length; index++) { + const char = line[index]; + if (quote) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "(") balance += 1; + if (char === ")") balance -= 1; + } + return balance; +} + +interface LogicalLine { + indent: number; + text: string; + line: number; +} + +/** Folds the source into logical lines, joining open attribute lists. */ +export function foldLines(source: string): LogicalLine[] { + const lines = source.split("\n"); + const result: LogicalLine[] = []; + let index = 0; + + while (index < lines.length) { + const raw = lines[index] as string; + const { indent, rest } = indentWidth(raw); + const lineNumber = index + 1; + + if (rest.trim().length === 0) { + index += 1; + continue; + } + + let text = rest; + while (parenBalance(text) > 0 && index + 1 < lines.length) { + index += 1; + text += `\n${(lines[index] as string).trim()}`; + } + + result.push({ indent, text, line: lineNumber }); + index += 1; + } + + return result; +} + +/** Extracts the Astro style frontmatter of a template. */ +export function extractFrontmatter(source: string): { + frontmatter: string; + body: string; +} { + const lines = source.split("\n"); + let start = 0; + while (start < lines.length && (lines[start] as string).trim().length === 0) + start += 1; + let frontmatter = ""; + if (start >= lines.length || (lines[start] as string).trim() !== "---") { + return extractScriptBlocks(source, frontmatter); + } + + const body: string[] = []; + let index = start + 1; + const frontmatterLines: string[] = []; + for (; index < lines.length; index++) { + const line = lines[index] as string; + if (line.trim() === "---") { + index += 1; + break; + } + frontmatterLines.push(line); + } + body.push(...lines.slice(index)); + frontmatter = frontmatterLines.join("\n"); + return extractScriptBlocks(body.join("\n"), frontmatter); +} + +/** Reads `javascript!` and `typescript!` blocks terminated by `end`. */ +export function extractScriptBlocks(source: string, frontmatter = ""): { frontmatter: string; body: string } { + const body: string[] = []; + const scripts: string[] = []; + const lines = source.split("\n"); + let index = 0; + while (index < lines.length) { + const directive = /^\s*(?:javascript|typescript)!\s*$/.test(lines[index] as string); + if (!directive) { body.push(lines[index] as string); index += 1; continue; } + index += 1; + while (index < lines.length && !/^\s*end\s*$/.test(lines[index] as string)) { scripts.push(lines[index] as string); index += 1; } + if (index < lines.length) index += 1; + } + return { frontmatter: [frontmatter, ...scripts].filter(Boolean).join("\n"), body: body.join("\n") }; +} + +/** Finds the index of the `}` matching the `{` at `start`. */ +export function findClosingBrace(text: string, start: number): number { + let depth = 0; + let quote: string | null = null; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (quote) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +/** Splits text into literal and interpolated parts. */ +export function parseTextParts(text: string): TextPart[] { + const parts: TextPart[] = []; + let literal = ""; + let index = 0; + + const flush = () => { + if (literal.length > 0) { + parts.push({ type: "literal", value: literal }); + literal = ""; + } + }; + + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + + if ((char === "#" || char === "!") && next === "{") { + const end = findClosingBrace(text, index + 1); + if (end !== -1) { + flush(); + parts.push({ + type: "expression", + code: text.slice(index + 2, end).trim(), + escaped: char === "#", + }); + index = end + 1; + continue; + } + } + + literal += char; + index += 1; + } + + flush(); + return parts; +} + +/** + * `true` when the text after the whitespace starts a new attribute. + */ +export function startsNewAttribute(source: string, index: number): boolean { + const rest = source.slice(index); + return /^\s+[A-Za-z_][\w-]*\s*(?:=|,|\)|$)/.test(rest); +} + +/** Parses the attribute list of a tag. */ +export function parseAttributes(source: string): RawAttribute[] { + const attributes: RawAttribute[] = []; + let index = 0; + + while (index < source.length) { + while ( + index < source.length && + (/\s/.test(source[index] as string) || source[index] === ",") + ) { + index += 1; + } + if (index >= source.length) break; + + const nameMatch = /^[^\s=,()]+/.exec(source.slice(index)); + if (!nameMatch) { + index += 1; + continue; + } + const name = nameMatch[0]; + index += name.length; + + while (index < source.length && /\s/.test(source[index] as string)) + index += 1; + + if (source[index] !== "=") { + attributes.push({ name, kind: "boolean" }); + continue; + } + + index += 1; + while (index < source.length && /\s/.test(source[index] as string)) + index += 1; + + const quote = source[index]; + if (quote === '"' || quote === "'") { + const end = source.indexOf(quote, index + 1); + const value = + end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); + attributes.push({ name, kind: "string", value }); + index = end === -1 ? source.length : end + 1; + continue; + } + + if (quote === "(") { + let depth = 0; + const start = index; + for (; index < source.length; index++) { + const char = source[index]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) { + index += 1; + break; + } + } + } + attributes.push({ + name, + kind: "expression", + value: source.slice(start + 1, index - 1).trim(), + }); + continue; + } + + const start = index; + let depth = 0; + while (index < source.length) { + const char = source[index] as string; + if (char === "(" || char === "[" || char === "{") depth += 1; + if (char === ")" || char === "]" || char === "}") depth -= 1; + if (depth === 0 && (char === "," || char === ")")) break; + if (depth === 0 && /\s/.test(char) && startsNewAttribute(source, index)) { + break; + } + index += 1; + } + attributes.push({ + name, + kind: "expression", + value: source.slice(start, index).trim(), + }); + } + + return attributes; +} + +/** Parses a tag line into its name, attributes and inline text. */ +export function parseTagLine(text: string): { + name: string; + attributes: RawAttribute[]; + text?: string; + output?: string; + escaped?: boolean; + blockText: boolean; + anchor?: { name: string; expression?: string }; +} { + let rest = text; + const nameMatch = TAG_NAME.exec(rest); + const name = nameMatch ? nameMatch[0] : "div"; + if (nameMatch) rest = rest.slice(name.length); + + const classes: string[] = []; + let id: string | undefined; + + while (rest.length > 0 && (rest[0] === "." || rest[0] === "#")) { + const marker = rest[0] as string; + const valueMatch = /^[^\s.#(=/!@]+/.exec(rest.slice(1)); + if (!valueMatch) break; + if (marker === ".") classes.push(valueMatch[0]); + else id = valueMatch[0]; + rest = rest.slice(1 + valueMatch[0].length); + } + + let anchor: { name: string; expression?: string } | undefined; + if (rest.startsWith("@")) { + const match = /^@([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)/.exec(rest); + if (match) { + let length = match[0].length; + let expression: string | undefined; + // `@docs.{section.id}` keeps the stable group prefix while deriving its + // leaf identity from the current composition scope. + if (rest.slice(length).startsWith(".{")) { + const end = rest.indexOf("}", length + 2); + if (end !== -1) { + expression = rest.slice(length + 2, end).trim() || undefined; + length = end + 1; + anchor = { name: `${match[1]}.`, ...(expression ? { expression } : {}) }; + } + } + if (rest[length] === "(") { + let depth = 0; + let end = length; + for (; end < rest.length; end += 1) { + if (rest[end] === "(") depth += 1; + if (rest[end] === ")") { + depth -= 1; + if (depth === 0) break; + } + } + if (depth === 0) { + expression = rest.slice(length + 1, end).trim() || undefined; + length = end + 1; + } + } + anchor ??= { name: match[1] as string, ...(expression ? { expression } : {}) }; + rest = rest.slice(length); + } + } + + const attributes: RawAttribute[] = []; + if (rest.startsWith("(")) { + let depth = 0; + let end = 0; + for (; end < rest.length; end++) { + const char = rest[end]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + attributes.push(...parseAttributes(rest.slice(1, end - 1))); + rest = rest.slice(end); + } + + if (id !== undefined) { + attributes.unshift({ name: "id", kind: "string", value: id }); + } + if (classes.length > 0) { + attributes.unshift({ + name: "class", + kind: "string", + value: classes.join(" "), + }); + } + + const inline = rest.trim(); + if (inline === ".") { + return { name, attributes, blockText: true, anchor }; + } + + if (inline.startsWith("!=")) { + return { + name, + attributes, + output: inline.slice(2).trim(), + escaped: false, + blockText: false, + anchor, + }; + } + + if (inline.startsWith("=")) { + return { + name, + attributes, + output: inline.slice(1).trim(), + escaped: true, + blockText: false, + anchor, + }; + } + + return { + name, + attributes, + text: inline.length > 0 ? inline : undefined, + blockText: false, + anchor, + }; +} + +/** Splits `each value, key in list` into its parts. */ +export function parseEach(value: string): { + value: string; + key?: string; + list: string; +} { + const match = /^([\w$]+)\s*(?:,\s*([\w$]+)\s*)?\s+(?:in|of)\s+(.+)$/.exec( + value.trim(), + ); + if (!match) { + throw new Error(`Invalid each expression: ${value}`); + } + return { + value: match[1] as string, + key: match[2], + list: (match[3] as string).trim(), + }; +} + +/** Turns a template into a flat list of tokens. */ +export function lex(source: string): Token[] { + const { frontmatter, body } = extractFrontmatter(source); + const tokens: Token[] = []; + + if (frontmatter.trim().length > 0) { + tokens.push({ + type: "frontmatter", + indent: 0, + line: 1, + value: frontmatter, + }); + } + + for (const line of foldLines(body)) { + const { text, indent, line: lineNumber } = line; + const trimmed = text.trim(); + + if (trimmed.startsWith("//-")) { + tokens.push({ + type: "comment", + indent, + line: lineNumber, + value: trimmed.slice(3).trim(), + silent: true, + }); + continue; + } + + if (trimmed.startsWith("//")) { + tokens.push({ + type: "comment", + indent, + line: lineNumber, + value: trimmed.slice(2).trim(), + silent: false, + }); + continue; + } + + if (trimmed.startsWith("!=")) { + tokens.push({ + type: "out", + indent, + line: lineNumber, + value: trimmed.slice(2).trim(), + escaped: false, + }); + continue; + } + + if (trimmed.startsWith("=")) { + tokens.push({ + type: "out", + indent, + line: lineNumber, + value: trimmed.slice(1).trim(), + escaped: true, + }); + continue; + } + + if (trimmed.startsWith("- ") || trimmed === "-") { + tokens.push({ + type: "code", + indent, + line: lineNumber, + value: trimmed === "-" ? "" : trimmed.slice(2).trim(), + }); + continue; + } + + if (trimmed.startsWith("|")) { + tokens.push({ + type: "text", + indent, + line: lineNumber, + value: trimmed.slice(1).trim(), + }); + continue; + } + + if (trimmed === "$$$") { + tokens.push({ + type: "test", + indent, + line: lineNumber, + value: trimmed, + }); + continue; + } + + if (/^doctype\b/i.test(trimmed)) { + tokens.push({ + type: "doctype", + indent, + line: lineNumber, + value: trimmed.slice("doctype".length).trim(), + }); + continue; + } + + const keywordMatch = /^([a-zA-Z]+)\b\s*(.*)$/.exec(trimmed); + if (keywordMatch) { + const keyword = keywordMatch[1] as string; + const keywordValue = (keywordMatch[2] ?? "").trim(); + if (KEYWORDS.has(keyword)) { + tokens.push({ + type: "keyword", + indent, + line: lineNumber, + value: trimmed, + keyword, + keywordValue, + }); + continue; + } + } + + if (trimmed.startsWith("+")) { + const callMatch = /^\+([a-zA-Z_$][\w$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec( + trimmed, + ); + if (callMatch) { + tokens.push({ + type: "keyword", + indent, + line: lineNumber, + value: trimmed, + keyword: "mixin-call", + keywordValue: callMatch[1] as string, + text: (callMatch[2] ?? "").trim(), + }); + continue; + } + } + + const tag = parseTagLine(text); + tokens.push({ + type: "tag", + indent, + line: lineNumber, + value: trimmed, + name: tag.name, + attributes: tag.attributes, + text: tag.text, + output: tag.output, + escaped: tag.escaped, + blockText: tag.blockText, + anchor: tag.anchor, + }); + } + + return tokens; +} diff --git a/views/engine/parser.ts b/views/engine/parser.ts new file mode 100644 index 0000000..be3d234 --- /dev/null +++ b/views/engine/parser.ts @@ -0,0 +1,485 @@ +import { parseEach, parseTextParts } from "./lexer.ts"; +import type { + CaseNode, + IfBranch, + Node, + PropDeclaration, + Token, +} from "./types.ts"; + +interface TokenNode { + token: Token; + children: TokenNode[]; +} + +/** Builds the indentation tree of the token list. */ +export function buildTree(tokens: Token[]): TokenNode[] { + const root: TokenNode[] = []; + const stack: { indent: number; nodes: TokenNode[] }[] = [ + { indent: -1, nodes: root }, + ]; + + for (const token of tokens) { + while ( + stack.length > 1 && + token.indent <= (stack[stack.length - 1] as { indent: number }).indent + ) { + stack.pop(); + } + const current = stack[stack.length - 1] as { + indent: number; + nodes: TokenNode[]; + }; + const node: TokenNode = { token, children: [] }; + current.nodes.push(node); + stack.push({ indent: token.indent, nodes: node.children }); + } + + return root; +} + +/** + * Splits a type into the type and the default value. + */ +export function splitDefault(value: string): { + type: string; + default?: string; +} { + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== "=") continue; + const next = value[index + 1]; + const previous = value[index - 1]; + if (next === "=" || next === ">") { + index += 1; + continue; + } + if (previous === "=" || previous === "!" || previous === "<" || previous === ">") { + continue; + } + return { + type: value.slice(0, index).trim(), + default: value.slice(index + 1).trim(), + }; + } + return { type: value.trim() }; +} + +function parseProps(children: TokenNode[]): PropDeclaration[] { + const props: PropDeclaration[] = []; + for (const child of children) { + const token = child.token; + if (token.type !== "tag" && token.type !== "text") continue; + const rawName = (token.name ?? token.value).replace(/:$/, "").trim(); + if (!rawName) continue; + let optional = rawName.endsWith("?"); + const name = optional ? rawName.slice(0, -1) : rawName; + let rest = (token.text ?? "").trim(); + if (rest.startsWith("?")) { + optional = true; + rest = rest.slice(1).trim(); + } + if (rest.startsWith(":")) { + rest = rest.slice(1).trim(); + } + + const split = splitDefault(rest); + const type = split.type; + const defaultValue = split.default; + + props.push({ + name, + type: type.length > 0 ? type : "unknown", + optional, + defaultValue, + }); + } + return props; +} + +function parseComponentProps(children: TokenNode[]): Record<string, string> { + const props: Record<string, string> = {}; + for (const child of children) { + const token = child.token; + if (token.type !== "tag" && token.type !== "text") continue; + const name = (token.name ?? token.value).replace(/:$/, "").trim(); + if (!name) continue; + props[name] = (token.text ?? "").trim(); + } + return props; +} + +function transform(tokenNodes: TokenNode[]): Node[] { + const nodes: Node[] = []; + let index = 0; + + while (index < tokenNodes.length) { + const entry = tokenNodes[index] as TokenNode; + const token = entry.token; + + switch (token.type) { + case "tag": { + let children = transform(entry.children); + if (token.output !== undefined) { + children = [ + { + kind: "out", + code: token.output, + escaped: token.escaped !== false, + }, + ...children, + ]; + } + if (token.text !== undefined && token.text.length > 0) { + children = [ + { kind: "text", parts: parseTextParts(token.text) }, + ...children, + ]; + } + if (token.blockText) { + const raw = entry.children + .map((child) => child.token.value.trim()) + .join("\n"); + children = [{ kind: "text", parts: parseTextParts(raw) }]; + } + nodes.push({ + kind: "element", + name: token.name ?? "div", + attributes: token.attributes ?? [], + children, + selfClosing: false, + anchor: token.anchor, + }); + index += 1; + break; + } + + case "text": { + nodes.push({ kind: "text", parts: parseTextParts(token.value) }); + index += 1; + break; + } + + case "code": { + nodes.push({ kind: "code", code: token.value }); + index += 1; + break; + } + + case "out": { + nodes.push({ + kind: "out", + code: token.value, + escaped: token.escaped !== false, + }); + index += 1; + break; + } + + case "comment": { + nodes.push({ + kind: "comment", + value: token.value ?? "", + silent: token.silent === true, + children: transform(entry.children), + }); + index += 1; + break; + } + + case "test": { + nodes.push({ + kind: "test", + code: entry.children + .map((child) => child.token.value) + .join("\n"), + }); + index += 1; + break; + } + + case "doctype": { + nodes.push({ kind: "doctype", value: token.value }); + index += 1; + break; + } + + case "keyword": { + const keyword = token.keyword ?? ""; + + if (keyword === "if" || keyword === "unless") { + const branches: IfBranch[] = [ + { + test: token.keywordValue ?? "", + negate: keyword === "unless", + children: transform(entry.children), + }, + ]; + + let cursor = index + 1; + while (cursor < tokenNodes.length) { + const next = (tokenNodes[cursor] as TokenNode).token; + if (next.keyword !== "else") break; + const value = (next.keywordValue ?? "").trim(); + if (value.startsWith("if")) { + branches.push({ + test: value.slice(2).trim(), + negate: false, + children: transform((tokenNodes[cursor] as TokenNode).children), + }); + } else { + branches.push({ + test: null, + negate: false, + children: transform((tokenNodes[cursor] as TokenNode).children), + }); + } + cursor += 1; + } + + nodes.push({ kind: "if", branches }); + index = cursor; + break; + } + + if (keyword === "each") { + const parsed = parseEach(token.keywordValue ?? ""); + nodes.push({ + kind: "each", + value: parsed.value, + key: parsed.key, + list: parsed.list, + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "while") { + nodes.push({ + kind: "while", + test: token.keywordValue ?? "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "case") { + const cases: { test?: string; children: Node[] }[] = []; + for (const child of entry.children) { + const childToken = child.token; + if (childToken.keyword === "when") { + cases.push({ + test: childToken.keywordValue ?? "", + children: transform(child.children), + }); + } else if (childToken.keyword === "default") { + cases.push({ children: transform(child.children) }); + } + } + const caseNode: CaseNode = { + kind: "case", + subject: token.keywordValue ?? "", + cases, + }; + nodes.push(caseNode); + index += 1; + break; + } + + if (keyword === "mixin") { + const definition = /^([\w$-]+)\s*\(([\s\S]*)\)\s*$/.exec( + (token.keywordValue ?? "").trim(), + ); + nodes.push({ + kind: "mixin", + name: definition + ? (definition[1] as string) + : (token.keywordValue ?? "").trim(), + params: definition ? (definition[2] as string).trim() : "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "mixin-call") { + nodes.push({ + kind: "mixin-call", + name: token.keywordValue ?? "", + args: token.text ?? "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "include") { + nodes.push({ + kind: "include", + path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), + children: [], + }); + index += 1; + break; + } + + if (keyword === "extends") { + nodes.push({ + kind: "extends", + path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), + }); + index += 1; + break; + } + + if ( + keyword === "block" || + keyword === "append" || + keyword === "prepend" + ) { + nodes.push({ + kind: "block", + name: (token.keywordValue ?? "default").trim(), + mode: + keyword === "append" + ? "append" + : keyword === "prepend" + ? "prepend" + : "replace", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "slot") { + nodes.push({ + kind: "slot", + name: (token.keywordValue ?? "default").trim(), + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "yield") { + nodes.push({ + kind: "yield", + name: (token.keywordValue ?? "default").trim(), + }); + index += 1; + break; + } + + if (keyword === "portal") { + nodes.push({ + kind: "portal", + name: (token.keywordValue ?? "default").trim().replace(/^['"]|['"]$/g, ""), + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "props") { + nodes.push({ kind: "props", props: parseProps(entry.children) }); + index += 1; + break; + } + + if (keyword === "component" || keyword === "island") { + const props: Record<string, string> = {}; + const renders: { name: string; children: Node[] }[] = []; + for (const child of entry.children) { + if (child.token.keyword === "render") { + renders.push({ + name: (child.token.keywordValue ?? "").trim(), + children: transform(child.children), + }); + continue; + } + const assignment = parseComponentProps([child]); + Object.assign(props, assignment); + } + nodes.push({ + kind: keyword, + name: (token.keywordValue ?? "").trim(), + props, + renders, + }); + index += 1; + break; + } + + index += 1; + break; + } + + default: + index += 1; + break; + } + } + + return nodes; +} + +export interface ParsedTemplate { + nodes: Node[]; + frontmatter: string; +} + +/** Parses a template into its AST. */ +export function parse(tokens: Token[]): ParsedTemplate { + const frontmatterToken = tokens.find((token) => token.type === "frontmatter"); + const rest = tokens.filter((token) => token.type !== "frontmatter"); + return { + nodes: transform(buildTree(rest)), + frontmatter: frontmatterToken?.value ?? "", + }; +} + +/** Collects every `props` block of a template. */ +export function collectProps(nodes: Node[]): PropDeclaration[] { + const props: PropDeclaration[] = []; + for (const node of nodes) { + if (node.kind === "props") { + props.push(...node.props); + } + } + return props; +} + +/** Collects every named block of a template. */ +export function collectBlocks( + nodes: Node[], +): Map<string, { mode: "replace" | "append" | "prepend"; children: Node[] }> { + const blocks = new Map< + string, + { mode: "replace" | "append" | "prepend"; children: Node[] } + >(); + for (const node of nodes) { + if (node.kind === "block") { + blocks.set(node.name, { mode: node.mode, children: node.children }); + } + } + return blocks; +} + +/** Collects every mixin definition of a template. */ +export function collectMixins( + nodes: Node[], +): { name: string; params: string; children: Node[] }[] { + const mixins: { name: string; params: string; children: Node[] }[] = []; + for (const node of nodes) { + if (node.kind === "mixin") { + mixins.push({ + name: node.name, + params: node.params, + children: node.children, + }); + } + } + return mixins; +} diff --git a/views/engine/runtime.ts b/views/engine/runtime.ts new file mode 100644 index 0000000..a42a346 --- /dev/null +++ b/views/engine/runtime.ts @@ -0,0 +1,562 @@ +import { existsSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { compileTemplate } from "./compiler.ts"; +import { lex } from "./lexer.ts"; +import { collectProps, parse } from "./parser.ts"; +import type { Node, PropDeclaration } from "./types.ts"; +import { isSpugComponentRender, type ComponentContext, type ComponentRegistry, type SpugComponentRender } from "../components.ts"; +import { compileCss, styleTag } from "../css/index.ts"; +import { isRaw, raw, styleValue } from "../raw.ts"; +import type { DeferredChunk } from "../../contracts/typegen/http/response.ts"; +import type { MuddleHooks } from "../hooks.ts"; + +/** + * Template runtime. + * + * Compiles templates, resolves includes and extends and provides the helpers + * used by the generated code. + */ + +export interface TemplateHelpers { + escapeHtml(value: string): string; + stringify(value: unknown): string; + /** Renders a value, raw values are never escaped. */ + render(value: unknown, escaped: boolean): string; + /** Transforms an object into the value of a style attribute. */ + style(value: unknown): string; + attr(name: string, value: unknown): string; + attrBoolean(name: string): string; + /** Merges the classes of a tag. */ + classes(values: unknown[]): string; +} + +export type CompiledFunction = ( + runtime: TemplateRuntime, + locals: Record<string, unknown>, +) => Promise<string>; + +export interface CompiledBlock { + mode: "replace" | "append" | "prepend"; + fn: CompiledFunction; +} + +export interface CompiledTemplate { + /** Absolute path of the template. */ + path: string; + template: CompiledFunction; + blocks: Map<string, CompiledBlock>; + mixins: Record<string, CompiledFunction>; + /** Props declared by the template. */ + props: PropDeclaration[]; + /** Frontmatter of the template. */ + frontmatter: string; + /** Frontmatter of the template, evaluated before the body. */ + frontmatterFn?: CompiledFunction; + /** Identifiers used by the template. */ + uses: string[]; + /** The assertions of the template, written in a `$$$` block. */ + tests: string[]; + /** Template this template extends, if any. */ + extends?: string; +} + +/** Context of a single render. */ +export interface RenderContext { + /** Locals of the render. */ + locals: Record<string, unknown>; + /** Route parameters of the render. */ + params: Record<string, string>; + /** Chunks rendered after the shell. */ + deferred: DeferredChunk[]; +} + +export interface TemplateRuntime { + helpers: TemplateHelpers; + /** File of the template, available in the frontmatter as `__muddle`. */ + file: { path: string; dir: string }; + getBlock(name: string, fallback: CompiledFunction): Promise<string>; + renderComponent( + name: string, + props: Record<string, unknown>, + ): Promise<string>; + renderIsland(name: string, props: Record<string, unknown>): Promise<string>; + /** Server components registered in the application. */ + components: ComponentRegistry; + /** Context of the current render. */ + context: RenderContext; +} + +export const helpers: TemplateHelpers = { + escapeHtml(value: string): string { + return String(value) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }, + stringify(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value instanceof Date) return value.toISOString(); + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); + }, + attr(name: string, value: unknown): string { + if (value === false || value === null || value === undefined) return ""; + if (value === true) return ` ${name}="${name}"`; + return ` ${name}="${helpers.escapeHtml(helpers.stringify(value))}"`; + }, + attrBoolean(name: string): string { + return ` ${name}="${name}"`; + }, + render(value: unknown, escaped: boolean): string { + if (isRaw(value)) return value.html; + const text = helpers.stringify(value); + return escaped ? helpers.escapeHtml(text) : text; + }, + style(value: unknown): string { + return styleValue(value); + }, + classes(values: unknown[]): string { + return values + .filter( + (value) => + value !== undefined && + value !== null && + value !== false && + value !== "", + ) + .map((value) => String(value)) + .join(" "); + }, +}; + +interface CompiledArtifact { + template: CompiledFunction; + frontmatter?: CompiledFunction; + blocks: Record<string, CompiledBlock>; + mixins: Record<string, CompiledFunction>; + /** Identifiers used by the template. */ + uses: string[]; + /** The assertions of the template, written in a `$$$` block. */ + tests: string[]; +} + +export interface TemplateEngineOptions { + /** Directory holding the templates. */ + viewsDir: string; + /** Root directory of the project, template paths are resolved against it. */ + root?: string; + /** Directory holding the components, defaults to `viewsDir/components`. */ + componentsDir?: string; + /** Server components available to the templates. */ + components?: ComponentRegistry; + /** Layout templates wrapped around every page render. */ + globalLayouts?: string[]; + /** Contextual render expansions applied after Spug composition. */ + hooks?: MuddleHooks; +} + +/** Merges the blocks of a child template into the blocks of its parent. */ +export function mergeBlocks( + parent: Map<string, CompiledBlock>, + child: Map<string, CompiledBlock>, +): Map<string, CompiledBlock> { + const merged = new Map(parent); + for (const [name, block] of child) { + const existing = merged.get(name); + if (!existing) { + merged.set(name, block); + continue; + } + if (block.mode === "append") { + const parentFn = existing.fn; + const childFn = block.fn; + merged.set(name, { + mode: "replace", + fn: async (runtime, locals) => + `${await parentFn(runtime, locals)}${await childFn(runtime, locals)}`, + }); + continue; + } + if (block.mode === "prepend") { + const parentFn = existing.fn; + const childFn = block.fn; + merged.set(name, { + mode: "replace", + fn: async (runtime, locals) => + `${await childFn(runtime, locals)}${await parentFn(runtime, locals)}`, + }); + continue; + } + merged.set(name, block); + } + return merged; +} + +export class TemplateEngine { + private readonly cache = new Map< + string, + { mtime: number; compiled: CompiledTemplate } + >(); + + constructor(private readonly options: TemplateEngineOptions) {} + + /** Directory holding the templates. */ + get viewsDir(): string { + return this.options.viewsDir; + } + + /** Directory holding the components. */ + get componentsDir(): string { + return ( + this.options.componentsDir ?? join(this.options.viewsDir, "components") + ); + } + + /** Server components available to the templates. */ + get components(): ComponentRegistry { + return this.options.components ?? new Map(); + } + + /** Resolves a template path relative to the views directory. */ + resolvePath(templatePath: string, from?: string): string { + const candidates: string[] = []; + if (isAbsolute(templatePath)) { + candidates.push(templatePath); + } else { + if (from) candidates.push(resolve(dirname(from), templatePath)); + candidates.push(resolve(this.options.viewsDir, templatePath)); + if (this.options.root) { + candidates.push(resolve(this.options.root, templatePath)); + } + } + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + if (existsSync(`${candidate}.spug`)) return `${candidate}.spug`; + } + throw new Error(`Template not found: ${templatePath}`); + } + + /** Loads and compiles a template, using the cache when possible. */ + async load(templatePath: string, from?: string): Promise<CompiledTemplate> { + const path = this.resolvePath(templatePath, from); + const mtime = statSync(path).mtimeMs; + const cached = this.cache.get(path); + if (cached && cached.mtime === mtime) return cached.compiled; + + const compiled = await this.compile(path); + this.cache.set(path, { mtime, compiled }); + return compiled; + } + + /** Compiles a template without touching the cache. */ + async compile(path: string): Promise<CompiledTemplate> { + const source = await readFile(path, "utf8"); + const tokens = lex(source); + const parsed = parse(tokens); + + const frontmatter = await this.linkIncludes( + parsed.nodes, + parsed.frontmatter, + path, + ); + const extendsPath = parsed.nodes.find((node) => node.kind === "extends"); + const { source: compiledSource } = compileTemplate(parsed.nodes, { + path, + dir: dirname(path), + frontmatter, + }); + + const factory = new Function(compiledSource) as () => CompiledArtifact; + const artifact = factory(); + + const blocks = new Map<string, CompiledBlock>( + Object.entries(artifact.blocks), + ); + const props = collectProps(parsed.nodes); + + return { + path, + template: artifact.template, + frontmatterFn: artifact.frontmatter, + uses: artifact.uses ?? [], + tests: artifact.tests ?? [], + blocks, + mixins: artifact.mixins, + props, + frontmatter, + extends: extendsPath?.kind === "extends" ? extendsPath.path : undefined, + }; + } + + /** Renders a template with the given locals. */ + async render( + templatePath: string, + initialLocals: Record<string, unknown> = {}, + context?: Partial<RenderContext>, + ): Promise<string> { + const loaded = await this.load(templatePath); + const locals: Record<string, unknown> = { ...initialLocals }; + const compiled = loaded; + this.fillLocals(compiled, locals); + const renderContext: RenderContext = { + locals, + params: context?.params ?? {}, + deferred: context?.deferred ?? [], + }; + + let html: string; + if (compiled.extends) { + const parent = await this.load(compiled.extends, compiled.path); + const runtime = this.createRuntime( + compiled, + locals, + compiled.blocks, + renderContext, + ); + await this.runFrontmatter(compiled, locals, runtime); + this.fillLocals(parent, locals); + const blocks = mergeBlocks(parent.blocks, compiled.blocks); + const parentRuntime = this.createRuntime( + parent, + locals, + blocks, + renderContext, + ); + await this.runFrontmatter(parent, locals, parentRuntime); + html = await parent.template(parentRuntime, locals); + } else { + const blocks = mergeBlocks(compiled.blocks, new Map()); + const runtime = this.createRuntime(compiled, locals, blocks, renderContext); + await this.runFrontmatter(compiled, locals, runtime); + html = await compiled.template(runtime, locals); + } + for (const layout of this.options.globalLayouts ?? []) { + html = await this.renderLayout(layout, { ...locals, content: raw(html) }, renderContext); + } + return this.options.hooks + ? this.options.hooks.renderSpug({ html, path: loaded.path, locals }) + : html; + } + + private async renderLayout(path: string, locals: Record<string, unknown>, context: RenderContext): Promise<string> { + const compiled = await this.load(path); + const runtime = this.createRuntime(compiled, locals, compiled.blocks, context); + await this.runFrontmatter(compiled, locals, runtime); + return compiled.template(runtime, locals); + } + + /** + * Fills the missing identifiers of a template with `undefined`. + */ + private fillLocals( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + ): void { + for (const name of compiled.uses ?? []) { + if (!(name in locals)) locals[name] = undefined; + } + } + + /** + * Evaluates the frontmatter of a template. + */ + private async runFrontmatter( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + runtime: TemplateRuntime, + ): Promise<void> { + if (!compiled.frontmatterFn) return; + const values = await compiled.frontmatterFn(runtime, locals); + Object.assign(locals, values); + } + + /** Renders a component by name. */ + async renderComponent( + name: string, + props: Record<string, unknown>, + context?: Partial<RenderContext>, + ): Promise<string> { + const path = this.resolveComponent(name); + const compiled = await this.load(path); + props = { ...props }; + this.fillLocals(compiled, props); + const runtime = this.createRuntime( + compiled, + props, + compiled.blocks, + context, + ); + await this.runFrontmatter(compiled, props, runtime); + return compiled.template(runtime, props); + } + + /** Renders a component and marks it for client side hydration. */ + async renderIsland( + name: string, + props: Record<string, unknown>, + context?: Partial<RenderContext>, + ): Promise<string> { + const html = await this.renderComponent(name, props, context); + const payload = JSON.stringify(props).replace(/</g, "\\u003c"); + return `${html}<script type="application/json" data-muddle-island="${name}">${payload}</script>`; + } + + private resolveComponent(name: string): string { + if (isAbsolute(name) && existsSync(name)) return name; + const candidates = [ + join(this.componentsDir, `${name}.spug`), + join(this.componentsDir, name), + join(this.viewsDir, `${name}.spug`), + join(this.viewsDir, name), + ]; + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`Component not found: ${name}`); + } + + private createRuntime( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + blocks: Map<string, CompiledBlock>, + context?: Partial<RenderContext>, + ): TemplateRuntime { + const renderContext: RenderContext = { + locals, + params: context?.params ?? {}, + deferred: context?.deferred ?? [], + }; + + const componentContext: ComponentContext = { + defer: (id, content) => { + renderContext.deferred.push({ + id, + content: + typeof content === "string" ? Promise.resolve(content) : content, + }); + }, + locals, + params: renderContext.params, + }; + + let deferredCount = 0; + + const runtime: TemplateRuntime = { + helpers, + file: { path: compiled.path, dir: dirname(compiled.path) }, + getBlock: async (name, fallback) => { + const block = blocks.get(name); + if (!block) return fallback(runtime, locals); + return block.fn(runtime, locals); + }, + renderComponent: async (name, props) => { + const definition = this.components.get(name); + if (!definition) { + return this.renderComponent(name, props, renderContext); + } + if (definition.deferred) { + deferredCount += 1; + const id = `${name}-${deferredCount}`; + componentContext.defer( + id, + Promise.resolve(definition.component.render(props, componentContext)) + .then((output) => this.renderComponentOutput(output, props, renderContext, definition.requiresSpug)), + ); + return `<div data-muddle-deferred="${id}"></div>`; + } + return this.renderComponentOutput(await definition.component.render(props, componentContext), props, renderContext, definition.requiresSpug); + }, + renderIsland: (name, props) => + this.renderIsland(name, props, renderContext), + components: this.components, + context: renderContext, + }; + + return runtime; + } + + private async renderComponentOutput( + output: string | SpugComponentRender, + props: Record<string, unknown>, + context: Partial<RenderContext>, + requiresSpug = false, + ): Promise<string> { + if (!isSpugComponentRender(output)) { + if (requiresSpug) throw new Error("@Component renderers must return { spug, stcss } rather than raw HTML"); + return output; + } + const html = await this.renderComponent(output.spug, { ...props, ...(output.props ?? {}) }, context); + const styles = Array.isArray(output.stcss) ? output.stcss : output.stcss ? [output.stcss] : []; + const css = await Promise.all(styles.map(async (style) => { + const path = resolve(this.componentsDir, style); + return existsSync(path) ? compileCss(await readFile(path, "utf8")) : compileCss(style); + })); + return `${css.filter(Boolean).map(styleTag).join("")}${html}`; + } + + /** Inlines the included files and collects their frontmatter. */ + private async linkIncludes( + nodes: Node[], + frontmatter: string, + from: string, + ): Promise<string> { + let collected = frontmatter; + + for (const node of nodes) { + const children = childrenOf(node); + if (!children) continue; + + if (node.kind === "include") { + const path = this.resolvePath(node.path, from); + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + node.children = parsed.nodes; + const nested = await this.linkIncludes( + parsed.nodes, + parsed.frontmatter, + path, + ); + collected = `${collected}\n${nested}`; + continue; + } + + collected = await this.linkIncludes(children, collected, from); + } + + return collected; + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} diff --git a/views/engine/types.ts b/views/engine/types.ts new file mode 100644 index 0000000..4b55a42 --- /dev/null +++ b/views/engine/types.ts @@ -0,0 +1,244 @@ +export type TokenType = + | "tag" + | "text" + | "code" + | "out" + | "comment" + | "doctype" + | "keyword" + | "frontmatter" + | "test" + | "rawtext"; + +export interface RawAttribute { + name: string; + value?: string; + kind: "string" | "expression" | "boolean"; +} + +/** A single line of a template, already split into its semantic parts. */ +export interface Token { + type: TokenType; + /** Indentation width, used to build the tree. */ + indent: number; + /** Line number in the source file, used for error messages. */ + line: number; + /** Raw text of the line. */ + value: string; + /** Tag name, only for `tag` tokens. */ + name?: string; + /** Attributes, only for `tag` tokens. */ + attributes?: RawAttribute[]; + /** Inline text following the tag, only for `tag` tokens. */ + text?: string; + /** Buffered expression of the tag, e.g. `p= title`. */ + output?: string; + /** Keyword name, only for `keyword` tokens. */ + keyword?: string; + /** Keyword payload (`each item in items`), only for `keyword` tokens. */ + keywordValue?: string; + /** Whether the tag is followed by a block of raw text (`p.`). */ + blockText?: boolean; + /** A generated relation identity declared with `tag@name()` in SPUG. */ + anchor?: { name: string; expression?: string }; + /** Whether a comment is silent (`//-`). */ + silent?: boolean; + /** Whether an output expression is escaped (`=` vs `!=`). */ + escaped?: boolean; +} + +export type TextPart = + | { type: "literal"; value: string } + | { type: "expression"; code: string; escaped: boolean }; + +export interface PropDeclaration { + name: string; + /** Type expression as written in the template. */ + type: string; + optional: boolean; + /** Default value expression, when declared with `=`. */ + defaultValue?: string; +} + +export interface ElementNode { + kind: "element"; + name: string; + attributes: RawAttribute[]; + children: Node[]; + selfClosing: boolean; + /** A generated relation identity declared with `tag@name()` in SPUG. */ + anchor?: { name: string; expression?: string }; +} + +export interface TextNode { + kind: "text"; + parts: TextPart[]; +} + +export interface RawTextNode { + kind: "rawtext"; + value: string; +} + +export interface CodeNode { + kind: "code"; + code: string; +} + +export interface OutNode { + kind: "out"; + code: string; + escaped: boolean; +} + +export interface CommentNode { + kind: "comment"; + value: string; + silent: boolean; + children: Node[]; +} + +export interface DoctypeNode { + kind: "doctype"; + value: string; +} + +export interface IfBranch { + /** `null` marks the `else` branch. */ + test: string | null; + negate: boolean; + children: Node[]; +} + +export interface IfNode { + kind: "if"; + branches: IfBranch[]; +} + +export interface EachNode { + kind: "each"; + value: string; + key?: string; + list: string; + children: Node[]; +} + +export interface WhileNode { + kind: "while"; + test: string; + children: Node[]; +} + +export interface CaseNode { + kind: "case"; + subject: string; + cases: { test?: string; children: Node[] }[]; +} + +export interface MixinDefNode { + kind: "mixin"; + name: string; + params: string; + children: Node[]; +} + +export interface MixinCallNode { + kind: "mixin-call"; + name: string; + args: string; + children: Node[]; +} + +export interface IncludeNode { + kind: "include"; + path: string; + /** Included file content, resolved by the linker. */ + children: Node[]; +} + +export interface ExtendsNode { + kind: "extends"; + path: string; +} + +export interface BlockNode { + kind: "block"; + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; +} + +export interface SlotNode { + kind: "slot"; + name: string; + children: Node[]; +} + +export interface YieldNode { + kind: "yield"; + name: string; +} + +export interface PropsNode { + kind: "props"; + props: PropDeclaration[]; +} + +export interface ComponentNode { + kind: "component" | "island"; + name: string; + /** Prop name to expression. */ + props: Record<string, string>; + /** Renderers that are applied inline by the component. */ + renders: { name: string; children: Node[] }[]; +} + +/** + * A block of assertions. + */ +export interface TestNode { + kind: "test"; + /** The assertions of the block. */ + code: string; +} + +/** + * A renderer that is applied inline. + */ +export interface RenderNode { + kind: "render"; + /** Name of the prop the renderer is handed to. */ + name: string; + children: Node[]; +} + +export interface PortalNode { + kind: "portal"; + name: string; + children: Node[]; +} + +export type Node = + | ElementNode + | TextNode + | RawTextNode + | CodeNode + | OutNode + | CommentNode + | DoctypeNode + | IfNode + | EachNode + | WhileNode + | CaseNode + | MixinDefNode + | MixinCallNode + | IncludeNode + | ExtendsNode + | BlockNode + | SlotNode + | YieldNode + | PropsNode + | ComponentNode + | TestNode + | RenderNode + | PortalNode; diff --git a/views/hooks.ts b/views/hooks.ts new file mode 100644 index 0000000..e8192c7 --- /dev/null +++ b/views/hooks.ts @@ -0,0 +1,246 @@ +import type { CssDeclaration, CssNode } from "./css/parser.ts"; + +export interface SpugHookContext { + html: string; + path: string; + locals: Record<string, unknown>; +} + +export type SpugExpansion = (context: SpugHookContext) => string | Promise<string>; + +export interface StcssValueHookContext { + name: string; + inner: string; + resolve(value: string): string; +} + +export type StcssValueExpansion = (context: StcssValueHookContext) => string | undefined; + +export interface StcssCalculationHookContext { + name: string; + args?: string; + resolve(value: string): string; +} + +export type StcssCalculationExpansion = (context: StcssCalculationHookContext) => string | undefined; + +export interface StcssDeclarationHookContext { + selector: string; + declaration: CssDeclaration; + declarations: CssDeclaration[]; + depth: number; + resolve(value: string): string; +} + +export type StcssDeclarationExpansion = (context: StcssDeclarationHookContext) => string[] | undefined; + +export interface StcssAtRuleHookContext { + name: string; + params: string; + selector: string; + depth: number; + children: CssNode[]; + resolve(value: string): string; + renderChildren(): string; +} + +export type StcssAtRuleExpansion = (context: StcssAtRuleHookContext) => string | undefined; + +/** + * A contextual registry for transformations performed while Spug renders or + * STCSS compiles. Registrations are scoped to an explicit registry instance, + * so applications can opt into a transformation without changing global state. + */ +export class MuddleHooks { + private readonly spugExpansions = new Map<string, SpugExpansion>(); + private readonly valueExpansions = new Map<string, StcssValueExpansion>(); + private readonly calculationExpansions = new Map<string, StcssCalculationExpansion>(); + private readonly declarationExpansions: StcssDeclarationExpansion[] = []; + private readonly atRuleExpansions = new Map<string, StcssAtRuleExpansion>(); + + spug(name: string, expansion: SpugExpansion): this { + this.spugExpansions.set(name, expansion); + return this; + } + + stcssValue(name: string, expansion: StcssValueExpansion): this { + this.valueExpansions.set(name, expansion); + return this; + } + + stcssCalculation(name: string, expansion: StcssCalculationExpansion): this { + this.calculationExpansions.set(name.replace(/^\$/, ""), expansion); + return this; + } + + stcssDeclaration(expansion: StcssDeclarationExpansion): this { + this.declarationExpansions.push(expansion); + return this; + } + + stcssAtRule(name: string, expansion: StcssAtRuleExpansion): this { + this.atRuleExpansions.set(name, expansion); + return this; + } + + async renderSpug(context: SpugHookContext): Promise<string> { + let html = context.html; + for (const expansion of this.spugExpansions.values()) { + html = await expansion({ ...context, html }); + } + return html; + } + + expandStcssValue(name: string, inner: string, resolve: (value: string) => string): string | undefined { + return this.valueExpansions.get(name)?.({ name, inner, resolve }); + } + + expandStcssCalculation(name: string, args: string | undefined, resolve: (value: string) => string): string | undefined { + return this.calculationExpansions.get(name.replace(/^\$/, ""))?.({ name, args, resolve }); + } + + expandStcssCalculationTokens(value: string, resolve: (value: string) => string): string { + return value.replace(/\$([A-Za-z][\w-]*)\b(?!\s*\()/g, (token, name: string) => + this.expandStcssCalculation(name, undefined, resolve) ?? token); + } + + expandStcssDeclarations(context: Omit<StcssDeclarationHookContext, "declaration">): string[] { + return context.declarations.flatMap((declaration) => this.declarationExpansions.flatMap((expansion) => + expansion({ ...context, declaration }) ?? [])); + } + + expandStcssAtRule(context: StcssAtRuleHookContext): string | undefined { + return this.atRuleExpansions.get(context.name)?.(context); + } +} + +export interface HookBuilder { + spug(name: string, expansion: SpugExpansion): void; + stcss: { + value(name: string, expansion: StcssValueExpansion): void; + calculation(name: string, expansion: StcssCalculationExpansion): void; + declaration(expansion: StcssDeclarationExpansion): void; + atRule(name: string, expansion: StcssAtRuleExpansion): void; + }; +} + +/** Creates an application-local hook registry through contextual builders. */ +export function defineHooks(build: (hooks: HookBuilder) => void, defaults = true): MuddleHooks { + const hooks = createHooks(defaults); + build({ + spug: (name, expansion) => { hooks.spug(name, expansion); }, + stcss: { + value: (name, expansion) => { hooks.stcssValue(name, expansion); }, + calculation: (name, expansion) => { hooks.stcssCalculation(name, expansion); }, + declaration: (expansion) => { hooks.stcssDeclaration(expansion); }, + atRule: (name, expansion) => { hooks.stcssAtRule(name, expansion); }, + }, + }); + return hooks; +} + +export function createHooks(defaults = true): MuddleHooks { + const hooks = new MuddleHooks(); + if (defaults) registerBuiltinStcssExpansions(hooks); + return hooks; +} + +function registerBuiltinStcssExpansions(hooks: MuddleHooks): void { + hooks.stcssCalculation("center", ({ args, resolve }) => { + const size = args?.trim(); + return size ? `calc(50% - (${resolve(size)} / 2))` : "50%"; + }); + hooks.stcssCalculation("offset", ({ args, resolve }) => { + const values = splitList(args ?? "").map((value) => resolve(value.trim())).filter(Boolean); + if (values.length === 0) return "0px"; + return values.length === 1 ? values[0] : `calc(${values.join(" + ")})`; + }); + hooks.stcssCalculation("pad", ({ args, resolve }) => { + const multiplier = args?.trim(); + return multiplier ? `calc(var(--muddle-pad, 1rem) * ${resolve(multiplier)})` : "var(--muddle-pad, 1rem)"; + }); + hooks.stcssValue("freeform-gradient", ({ inner, resolve }) => { + return splitList(resolve(inner)) + .filter((item) => item.trim().length > 0) + .map((item) => { + const tokens = gradientTokens(item.trim()); + if (tokens.length !== 3 && tokens.length !== 4) throw new Error(`Invalid freeform-gradient item "${item.trim()}"`); + const [color, x, y, radius] = tokens as [string, string, string, string?]; + return radius !== undefined + ? `radial-gradient(circle at ${x} ${y}, ${color}, ${color} calc(${radius} * 0.97), transparent ${radius})` + : `radial-gradient(circle farthest-side at ${x} ${y}, ${color}, transparent)`; + }) + .join(", "); + }); + hooks.stcssDeclaration(({ selector, declaration, declarations, depth, resolve }) => { + if (declaration.property !== "border-radius" || !resolve(declaration.value).startsWith("concentric") || !selector) return undefined; + const outer = resolve(declaration.value).slice("concentric".length).trim() || "var(--concentric-radius, 1rem)"; + declaration.value = outer; + const spacing = concentricSpacing(declarations, resolve); + const childRadius = spacing.length > 0 ? `calc(${[outer, ...spacing].map((token) => `(${token})`).join(" - ")})` : "inherit"; + const indent = "\t".repeat(depth); + return [`${indent}${selector} > * {\n${indent}\tborder-radius: ${childRadius};\n${indent}}`]; + }); + hooks.stcssDeclaration(({ declaration, declarations }) => { + if (declaration.property !== "position" || (declaration.value !== "fixed-center" && declaration.value !== "page-center")) return undefined; + declaration.value = declaration.value === "fixed-center" ? "fixed" : "absolute"; + const set = (property: string, value: string) => { + const current = declarations.find((entry) => entry.property === property); + if (current) current.value = value; + else declarations.push({ property, value }); + }; + set("left", "50%"); + set("top", "50%"); + const transform = declarations.find((entry) => entry.property === "transform"); + if (transform) transform.value = `translate(-50%, -50%) ${transform.value}`; + else declarations.push({ property: "transform", value: "translate(-50%, -50%)" }); + return undefined; + }); + hooks.stcssAtRule("concentric", ({ params, selector, depth, resolve }) => { + if (!selector) return ""; + const indent = "\t".repeat(depth); + const radius = resolve(params.trim() || "1rem"); + return `${indent}${selector} {\n${indent}\tborder-radius: ${radius};\n${indent}}\n${indent}${selector} > * {\n${indent}\tborder-radius: inherit;\n${indent}}`; + }); +} + +function concentricSpacing(declarations: CssDeclaration[], resolve: (value: string) => string): string[] { + const find = (property: string): string | undefined => declarations + .filter((declaration) => declaration.property === property || declaration.property === `${property}-top` || declaration.property === `${property}-left` || declaration.property === `${property}-block-start` || declaration.property === `${property}-inline-start`) + .flatMap((declaration) => gradientTokens(resolve(declaration.value).trim())) + .find((value) => value.length > 0 && value !== "solid" && value !== "none" && value !== "0"); + const spacing = [find("padding"), find("border-width") ?? find("border")].filter((value): value is string => value !== undefined); + return spacing; +} + +function gradientTokens(item: string): string[] { + const tokens: string[] = []; + let current = ""; + let depth = 0; + for (const char of item) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (/\s/.test(char) && depth === 0) { + if (current) tokens.push(current); + current = ""; + } else current += char; + } + if (current) tokens.push(current); + return tokens; +} + +function splitList(value: string): string[] { + const items: string[] = []; + let current = ""; + let depth = 0; + for (const char of value) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (char === "," && depth === 0) { + items.push(current); + current = ""; + } else current += char; + } + items.push(current); + return items; +} diff --git a/views/index.ts b/views/index.ts new file mode 100644 index 0000000..77d243e --- /dev/null +++ b/views/index.ts @@ -0,0 +1,18 @@ +export * from "./api.ts"; +export * from "./components.ts"; +export * from "./css/index.ts"; +export { + collectIdentifiers, + collectUses, + compileTemplate, + mixinParamNames, + transformFrontmatter, + type CompiledSource, + type CompileOptions, +} from "./engine/compiler.ts"; +export * from "./engine/lexer.ts"; +export * from "./engine/parser.ts"; +export * from "./engine/runtime.ts"; +export * from "./engine/types.ts"; +export * from "./integrations.ts"; +export * from "./markdown.ts"; diff --git a/views/integrations.ts b/views/integrations.ts new file mode 100644 index 0000000..a4594ae --- /dev/null +++ b/views/integrations.ts @@ -0,0 +1,134 @@ +import { readdir, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { + ContentSchema, + Integration, + IntegrationContext, +} from "../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./css/index.ts"; +import { markdownLoader } from "./markdown.ts"; +import { sassLoader, type SassLoaderOptions } from "./css/sass.ts"; + +export { shadcnIntegration, type ShadcnIntegrationOptions } from "../integrations/shadcn.ts"; + +export interface MarkdownIntegrationOptions { + /** Name of the collection, defaults to `notes`. */ + name?: string; + /** Directory of the collection, relative to the project root. */ + dir?: string; + /** Schema of the frontmatter. */ + schema?: ContentSchema; +} + +/** + * Markdown integration. + * + * The integration registers the markdown loader and adds the collection of + * the content directory to the project. + */ +export function markdownIntegration( + options: MarkdownIntegrationOptions = {}, +): Integration { + const name = options.name ?? "notes"; + const dir = options.dir ?? "content/notes"; + + return { + name: "markdown", + setup(context: IntegrationContext) { + context.loader("markdown", markdownLoader()); + const collections = context.config.collections ?? []; + if (collections.some((collection) => collection.name === name)) return; + collections.push({ + name, + dir, + loader: "markdown", + schema: options.schema, + }); + context.config.collections = collections; + }, + }; +} + +export interface StcssIntegrationOptions extends CompileOptions { + /** Directory of the stylesheets, relative to the project root. */ + dir?: string; + /** Route the compiled css is served at. */ + route?: string; +} + +/** + * Css integration. + * + * The integration compiles the structured stylesheets of the project and + * serves them at a single route, the browser caches the result. + */ +export function stcssIntegration( + options: StcssIntegrationOptions = {}, +): Integration { + const route = options.route ?? "/_muddle/styles.css"; + + return { + name: "stcss", + async setup(context: IntegrationContext) { + context.loader("stcss", { + name: "stcss", + extensions: [".stcss"], + load(file) { + return { data: {}, content: compileCss(file.source, options) }; + }, + }); + + const dir = resolve(context.config.root, options.dir ?? "styles"); + if (!existsSync(dir)) return; + + const files = (await readdir(dir)).filter((file) => + file.endsWith(".stcss"), + ); + const parts: string[] = []; + for (const file of files.sort()) { + const source = await readFile(join(dir, file), "utf8"); + parts.push(compileCss(source, options)); + } + const css = parts.join("\n"); + + context.route("GET", route, () => + new Response(css, { + headers: { + "content-type": "text/css; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }), + ); + }, + }; +} + +/** Registers Sass files as loaders and serves their compiled CSS alongside STCSS. */ +export function sassIntegration(options: SassLoaderOptions = {}): Integration { + const route = "/_muddle/sass.css"; + return { + name: "sass", + async setup(context) { + const loader = sassLoader(options); + context.loader("sass", loader); + const dir = resolve(context.config.root, options.dir ?? "styles"); + if (!existsSync(dir)) return; + const files = await sassFiles(dir); + context.route("GET", route, async () => { + const css = await Promise.all(files.filter((file) => !file.split("/").at(-1)?.startsWith("_")).map(async (file) => (await loader.load({ path: join(dir, file), relativePath: `styles/${file}`, source: await readFile(join(dir, file), "utf8") })).content ?? "")); + return new Response(css.join("\n"), { headers: { "content-type": "text/css; charset=utf-8" } }); + }); + }, + }; +} + +async function sassFiles(dir: string, prefix = ""): Promise<string[]> { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const relative = `${prefix}${entry.name}`; + if (entry.isDirectory()) files.push(...await sassFiles(join(dir, entry.name), `${relative}/`)); + else if (/\.s[ac]ss$/i.test(entry.name)) files.push(relative); + } + return files.sort(); +} diff --git a/views/language.ts b/views/language.ts new file mode 100644 index 0000000..73e8820 --- /dev/null +++ b/views/language.ts @@ -0,0 +1,24 @@ +export interface LanguageCompletion { label: string; detail: string; insertText: string } + +export const SPUG_COMPLETIONS: LanguageCompletion[] = [ + { label: "props", detail: "Typed component props", insertText: "props\n ${1:name}: ${2:string}" }, + { label: "javascript!", detail: "Embedded TypeScript block", insertText: "javascript!\n ${1:const value = true;}\nend" }, + { label: "component", detail: "SPUG component", insertText: "component ${1:Name}" }, + { label: "portal", detail: "Named view-transition boundary", insertText: "portal ${1:main}\n ${0}" }, + { label: "anchor", detail: "Relation identity", insertText: "${1:li}@${2:item}() ${3:content}" }, +]; + +export const STCSS_COMPLETIONS: LanguageCompletion[] = [ + { label: "@responsive", detail: "Mobile-first breakpoint", insertText: "@responsive ${1:md} {\n ${2}\n}" }, + { label: "@concentric", detail: "Inherited child radius", insertText: "@concentric ${1:1rem};" }, + { label: "border-radius: concentric", detail: "Concentric radii for children", insertText: "border-radius: concentric ${1:1rem};" }, + { label: "freeform-gradient", detail: "Stacked radial gradients", insertText: "background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:f00} ${5:70%} ${6:40%});" }, + { label: "$center", detail: "Center a known size", insertText: "$center(${1:30rem})" }, + { label: "$offset", detail: "Compose positional offsets", insertText: "$offset(${1:$center(30rem)}, ${2:$pad})" }, + { label: "$pad", detail: "Shared spacing token", insertText: "$pad(${1:1})" }, + { label: "position: fixed-center", detail: "Fixed viewport center", insertText: "position: fixed-center;" }, + { label: "position: page-center", detail: "Absolute containing-block center", insertText: "position: page-center;" }, + { label: "@predict", detail: "Prerendered geometry", insertText: "@predict max-width max-height transform;" }, + { label: "$relate", detail: "Predicted anchor metric", insertText: "$relate[${1:width}](d\"@${2:item}\")" }, + { label: "javascript!", detail: "Compile-time values", insertText: "javascript!\n const ${1:space} = ${2:'1rem'};\nend" }, +]; diff --git a/views/markdown.ts b/views/markdown.ts new file mode 100644 index 0000000..acb8171 --- /dev/null +++ b/views/markdown.ts @@ -0,0 +1,276 @@ +import type { Loader, LoaderFile } from "../contracts/index.ts"; + +/** + * Markdown support. + * + * A small, dependency free renderer that covers the subset used by content + * collections: headings, paragraphs, lists, quotes, code and the usual inline + * markup. The loader turns a markdown file into frontmatter and html. + */ + +export interface Frontmatter { + [key: string]: unknown; +} + +/** Parses the frontmatter of a markdown file. */ +export function parseFrontmatter(source: string): { + frontmatter: Frontmatter; + body: string; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source); + if (!match) return { frontmatter: {}, body: source }; + const frontmatter = parseYaml(match[1] as string); + return { frontmatter, body: source.slice(match[0].length) }; +} + +/** Parses a small subset of yaml: scalars, lists and nested maps. */ +export function parseYaml(source: string): Frontmatter { + const result: Frontmatter = {}; + const lines = source.split("\n"); + let index = 0; + + while (index < lines.length) { + const line = (lines[index] as string).trimEnd(); + index += 1; + if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; + + const separator = line.indexOf(":"); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + const raw = line.slice(separator + 1).trim(); + + if (raw.length === 0) { + const items: unknown[] = []; + while (index < lines.length) { + const next = (lines[index] as string).trim(); + if (next.length === 0) { + index += 1; + continue; + } + if (!next.startsWith("- ")) break; + items.push(parseScalar(next.slice(2).trim())); + index += 1; + } + result[key] = items.length > 0 ? items : ""; + continue; + } + + result[key] = parseScalar(raw); + } + + return result; +} + +function parseScalar(raw: string): unknown { + if (raw.startsWith("[") && raw.endsWith("]")) { + return raw + .slice(1, -1) + .split(",") + .map((entry) => parseScalar(entry.trim())) + .filter((entry) => entry !== ""); + } + if (raw === "true") return true; + if (raw === "false") return false; + if (raw === "null") return null; + if (raw !== "" && !Number.isNaN(Number(raw))) return Number(raw); + return raw.replace(/^['"]|['"]$/g, ""); +} + +/** Escapes the html entities of a text. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +/** + * Renders markdown to html. + * + * The renderer is intentionally small, it covers the constructs used by the + * content collections of a project. + */ +export function renderMarkdown(source: string): string { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index] as string; + + if (line.trim().length === 0) { + index += 1; + continue; + } + + if (/^\s*\$\$\s*$/.test(line)) { + const formula: string[] = []; + index += 1; + while (index < lines.length && !/^\s*\$\$\s*$/.test(lines[index] as string)) { + formula.push(lines[index] as string); + index += 1; + } + if (index < lines.length) index += 1; + output.push(`<div class="muddle-math">${renderLatex(formula.join("\n"), true)}</div>`); + continue; + } + + const fence = /^\s*```(\w*)\s*$/.exec(line); + if (fence) { + const language = fence[1] ?? ""; + const code: string[] = []; + index += 1; + while ( + index < lines.length && + !/^\s*```\s*$/.test(lines[index] as string) + ) { + code.push(lines[index] as string); + index += 1; + } + index += 1; + const className = language ? ` class="language-${language}"` : ""; + output.push( + `<pre><code${className}>${escapeHtml(code.join("\n"))}</code></pre>`, + ); + continue; + } + + if (/^\s*@@@\s*$/.test(line)) { + const graph: string[] = []; + index += 1; + while (index < lines.length && !/^\s*@@@\s*$/.test(lines[index] as string)) { + graph.push(lines[index] as string); + index += 1; + } + index += 1; + output.push(`<pre class="mermaid" data-muddle-animate="true">${escapeHtml(graph.join("\n"))}</pre>`); + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const level = (heading[1] as string).length; + output.push( + `<h${level}>${renderInline(heading[2] as string)}</h${level}>`, + ); + index += 1; + continue; + } + + if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { + output.push("<hr>"); + index += 1; + continue; + } + + const quote = /^\s*>\s?(.*)$/.exec(line); + if (quote) { + const quoted: string[] = []; + while (index < lines.length) { + const next = /^\s*>\s?(.*)$/.exec(lines[index] as string); + if (!next) break; + quoted.push(next[1] as string); + index += 1; + } + output.push( + `<blockquote>${renderMarkdown(quoted.join("\n"))}</blockquote>`, + ); + continue; + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); + const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line); + if (bullet || ordered) { + const tag = bullet ? "ul" : "ol"; + const items: unknown[] = []; + while (index < lines.length) { + const current = lines[index] as string; + const match = bullet + ? /^\s*[-*+]\s+(.*)$/.exec(current) + : /^\s*\d+[.)]\s+(.*)$/.exec(current); + if (!match) break; + items.push(match[1] as string); + index += 1; + } + const rendered = items + .map((item) => `<li>${renderInline(String(item))}</li>`) + .join(""); + output.push(`<${tag}>${rendered}</${tag}>`); + continue; + } + + const paragraph: string[] = []; + while (index < lines.length) { + const current = lines[index] as string; + if (current.trim().length === 0) break; + if (/^(#{1,6})\s+/.test(current)) break; + if (/^\s*([-*+]\s|\d+[.)]\s|>)/.test(current)) break; + paragraph.push(current.trim()); + index += 1; + } + output.push(`<p>${renderInline(paragraph.join(" "))}</p>`); + } + + return output.join("\n"); +} + +/** Renders the inline markup of a text. */ +export function renderInline(source: string): string { + const codes: string[] = []; + const maths: string[] = []; + let text = source.replace(/`([^`]+)`/g, (_match, code: string) => { + codes.push(code); + return `\uE000${codes.length - 1}\uE000`; + }); + text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_match, formula: string) => { + maths.push(renderLatex(formula)); + return `\uE001${maths.length - 1}\uE001`; + }); + + text = escapeHtml(text); + text = text.replace( + /!\[([^\]]*)\]\(([^)\s]+)\)/g, + '<img src="$2" alt="$1" loading="lazy">', + ); + text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>'); + text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); + text = text.replace(/__([^_]+)__/g, "<strong>$1</strong>"); + text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>"); + text = text.replace(/~~([^~]+)~~/g, "<del>$1</del>"); + + text = text.replace(/\uE000(\d+)\uE000/g, (_match, index: string) => { + return `<code>${escapeHtml(codes[Number(index)] ?? "")}</code>`; + }); + return text.replace(/\uE001(\d+)\uE001/g, (_match, index: string) => maths[Number(index)] ?? ""); +} + +/** Renders a trusted LaTex formula to KaTeX HTML and MathML. */ +export function renderLatex(source: string, displayMode = false): string { + return escapeHtml(`${displayMode ? "$$" : "$"}${source}${displayMode ? "$$" : "$"}`); +} + +export interface MarkdownLoaderOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** + * Loader for markdown files. + * + * The loader turns a markdown file into its frontmatter and its rendered + * html, exactly like a content collection needs it. + */ +export function markdownLoader(_options: MarkdownLoaderOptions = {}): Loader { + return { + name: "markdown", + extensions: [".md", ".markdown"], + load(file: LoaderFile) { + const { frontmatter, body } = parseFrontmatter(file.source); + return { + data: frontmatter, + content: renderMarkdown(body), + }; + }, + }; +} diff --git a/views/raw.ts b/views/raw.ts new file mode 100644 index 0000000..d4dcc49 --- /dev/null +++ b/views/raw.ts @@ -0,0 +1,96 @@ +/** + * Raw values. + * + * A raw value is html that is already rendered, it is never escaped. The + * wrapper marks the values that come from a trusted source, e.g. the html of + * a markdown collection. + */ +export class Raw { + constructor(readonly html: string) {} + + toString(): string { + return this.html; + } +} + +/** Marks html as pre-rendered, it is never escaped. */ +export function raw(html: string): Raw { + return new Raw(html); +} + +/** `true` when the value is a raw html value. */ +export function isRaw(value: unknown): value is Raw { + return value instanceof Raw; +} + +/** Properties that do not take a unit. */ +const UNITLESS = new Set([ + "animation-iteration-count", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "flex", + "flex-grow", + "flex-positive", + "flex-shrink", + "flex-negative", + "flex-order", + "font-weight", + "grid-area", + "grid-column", + "grid-column-end", + "grid-column-start", + "grid-row", + "grid-row-end", + "grid-row-start", + "line-clamp", + "line-height", + "opacity", + "order", + "orphans", + "scale", + "tab-size", + "widows", + "z-index", + "zoom", +]); + +/** Converts a camel case property into its css name. */ +export function cssPropertyName(property: string): string { + if (property.startsWith("--")) return property; + return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); +} + +/** + * Transforms an object into the value of a style attribute. + * + * The keys become css properties, the numbers become pixels unless the + * property does not take a unit. + */ +export function styleValue(value: unknown): string { + if (value === null || value === undefined || value === false) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (isRaw(value)) return value.html; + if (typeof value !== "object") return ""; + + const parts: string[] = []; + for (const [property, entry] of Object.entries(value as Record<string, unknown>)) { + if (entry === null || entry === undefined || entry === false) continue; + const name = cssPropertyName(property); + if (typeof entry === "number") { + parts.push(`${name}: ${UNITLESS.has(name) ? entry : `${entry}px`}`); + continue; + } + if (typeof entry === "object") { + parts.push(`${name}: ${styleValue(entry)}`); + continue; + } + parts.push(`${name}: ${String(entry)}`); + } + return parts.join("; "); +} diff --git a/views/stage.ts b/views/stage.ts new file mode 100644 index 0000000..00a784b --- /dev/null +++ b/views/stage.ts @@ -0,0 +1,69 @@ +export interface KeyframeStep { + o: Record<string, string | number>; +} + +export interface ScrollSceneOptions { + selector: string; + keyframes: Record<string, KeyframeStep | Record<string, string | number>>; + range?: string; + timeline?: "view" | "scroll" | string; + animation?: string; + duration?: string; + easing?: string; + fill?: string; +} + +export interface TransitionPortalOptions { + name: string; + old?: Record<string, string | number>; + new?: Record<string, string | number>; + duration?: string; + easing?: string; +} + +function declarations(values: Record<string, string | number>): string { + return Object.entries(values).map(([property, value]) => `${property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}: ${value};`).join(" "); +} + +/** Builds declarative native CSS scenes and view transitions without a client runtime. */ +export class StageManager { + private readonly scenes: ScrollSceneOptions[] = []; + private readonly portals: TransitionPortalOptions[] = []; + + scene(name: string, options: Omit<ScrollSceneOptions, "animation">): this { + this.scenes.push({ ...options, animation: name }); + return this; + } + + portal(name: string, options: Omit<TransitionPortalOptions, "name"> = {}): this { + this.portals.push({ name, ...options }); + return this; + } + + css(): string { + return [ + ...this.scenes.flatMap((scene) => { + const animation = scene.animation ?? "muddle-scene"; + const timeline = scene.timeline ?? "view"; + return [ + `@keyframes ${animation} { ${Object.entries(scene.keyframes).map(([offset, step]) => { + const values = "to" in step && typeof step.to === "object" ? step.to : step as Record<string, string | number>; + return `${offset} { ${declarations(values)} }`; + }).join(" ")} }`, + `${scene.selector} { animation-name: ${animation}; animation-duration: ${scene.duration ?? "1ms"}; animation-timing-function: ${scene.easing ?? "linear"}; animation-fill-mode: ${scene.fill ?? "both"}; animation-timeline: ${timeline === "view" ? "view()" : timeline === "scroll" ? "scroll()" : timeline}; animation-range: ${scene.range ?? "entry 0% cover 100%"}; }`, + ]; + }), + ...this.portals.flatMap((portal) => { + const timing = `animation-duration: ${portal.duration ?? "240ms"}; animation-timing-function: ${portal.easing ?? "ease"};`; + return [ + portal.old ? `::view-transition-old(${portal.name}) { ${timing} ${declarations(portal.old)} }` : "", + portal.new ? `::view-transition-new(${portal.name}) { ${timing} ${declarations(portal.new)} }` : "", + ].filter(Boolean); + }), + ].join("\n"); + } +} + +export function stage(): StageManager { + return new StageManager(); +} diff --git a/views/testing.ts b/views/testing.ts new file mode 100644 index 0000000..498f7a9 --- /dev/null +++ b/views/testing.ts @@ -0,0 +1,263 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { TemplateEngine } from "./engine/runtime.ts"; +import { lex } from "./engine/lexer.ts"; +import { parse } from "./engine/parser.ts"; +import type { Node } from "./engine/types.ts"; +import type { ComponentRegistry } from "./components.ts"; + +/** The helpers that are available inside a `$$$` block. */ +export const ASSERTION_HELPERS = ` +window.muddle = window.muddle || {}; +window.muddle.assert = function (condition, message) { + if (!condition) { + throw new Error(message || "assertion failed"); + } +}; +window.muddle.assertText = function (selector, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.textContent.trim() : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertHtml = function (selector, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.innerHTML.trim() : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertCount = function (selector, expected, message) { + const actual = document.querySelectorAll(selector).length; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + expected + " elements, got " + actual, + ); + } +}; +window.muddle.assertExists = function (selector, message) { + if (!document.querySelector(selector)) { + throw new Error((message || selector) + ": the element does not exist"); + } +}; +window.muddle.assertMissing = function (selector, message) { + if (document.querySelector(selector)) { + throw new Error((message || selector) + ": the element should not exist"); + } +}; +window.muddle.assertAttribute = function (selector, name, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.getAttribute(name) : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected the attribute " + name + " to be " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertState = function (selector, state, message) { + const element = document.querySelector(selector); + const actual = element ? element.getAttribute("data-state") : null; + if (actual !== state) { + throw new Error( + (message || selector) + ": expected the state " + JSON.stringify(state) + ", got " + JSON.stringify(actual), + ); + } +}; +const assert = window.muddle.assert; +const assertText = window.muddle.assertText; +const assertHtml = window.muddle.assertHtml; +const assertCount = window.muddle.assertCount; +const assertExists = window.muddle.assertExists; +const assertMissing = window.muddle.assertMissing; +const assertAttribute = window.muddle.assertAttribute; +const assertState = window.muddle.assertState; +`; + +export interface TemplateTestOptions { + /** Root directory of the project. */ + root: string; + /** Directories scanned for templates. */ + viewRoots: string[]; + /** Locals handed to the templates. */ + locals?: Record<string, unknown>; + /** Directory of the components. */ + componentsDir?: string; + /** Server components available to the templates. */ + components?: ComponentRegistry; +} + +export interface TemplateTest { + /** Absolute path of the template. */ + path: string; + /** Path of the template, relative to the root. */ + key: string; + /** The assertions of the template. */ + assertions: string; +} + +/** Reads the assertions of a template. */ +export function readAssertions(source: string): string { + const parsed = parse(lex(source)); + const blocks: string[] = []; + collect(parsed.nodes, blocks); + return blocks.join("\n"); +} + +function collect(nodes: Node[], blocks: string[]): void { + for (const node of nodes) { + if (node.kind === "test") { + if (node.code.trim().length > 0) blocks.push(node.code); + continue; + } + const children = childrenOf(node); + if (children) collect(children, blocks); + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} + +/** Lists the templates of the project. */ +export async function listTemplates(dirs: string[]): Promise<string[]> { + const files: string[] = []; + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTemplates([path]))); + continue; + } + if (entry.name.endsWith(".spug")) files.push(path); + } + } + return files; +} + +/** Collects the templates that carry assertions. */ +export async function collectTemplateTests( + options: TemplateTestOptions, +): Promise<TemplateTest[]> { + const tests: TemplateTest[] = []; + for (const file of await listTemplates(options.viewRoots)) { + const source = await readFile(file, "utf8"); + const assertions = readAssertions(source); + if (assertions.trim().length === 0) continue; + tests.push({ + path: file, + key: relative(options.root, file).replace(/\\/g, "/"), + assertions, + }); + } + return tests; +} + +export interface TemplateTestFailure { + /** Path of the template. */ + key: string; + /** The failure of the template. */ + error: Error; +} + +export interface TemplateTestRun { + /** The templates that were tested. */ + tested: TemplateTest[]; + /** The failures of the templates. */ + failures: TemplateTestFailure[]; + /** `true` when the browser could not be started. */ + skipped: boolean; + /** The reason of the skip. */ + reason?: string; +} + +/** + * Runs the tests of the templates. + * + * The templates are rendered on the server, the assertions are evaluated in a + * browser. When playwright is not installed the run is skipped, the tests of + * the templates do not fail the build. + */ +export async function runTemplateTests( + options: TemplateTestOptions, +): Promise<TemplateTestRun> { + const tests = await collectTemplateTests(options); + const failures: TemplateTestFailure[] = []; + if (tests.length === 0) return { tested: [], failures, skipped: false }; + + let chromium: typeof import("playwright").chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + tested: tests, + failures, + skipped: true, + reason: `playwright is not installed: ${(error as Error).message}`, + }; + } + + const engine = new TemplateEngine({ + viewsDir: options.viewRoots[0] ?? options.root, + componentsDir: options.componentsDir, + root: options.root, + components: options.components, + }); + + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + for (const test of tests) { + const html = await engine.render( + test.path, + options.locals ?? {}, + ); + await page.setContent(html, { waitUntil: "load" }); + try { + await page.evaluate( + `(async () => {\n${ASSERTION_HELPERS}\n${test.assertions}\n})()`, + ); + } catch (error) { + failures.push({ + key: test.key, + error: error as Error, + }); + } + } + } finally { + await browser.close(); + } + + return { tested: tests, failures, skipped: false }; +} + +/** The engine of the project, used by the runner. */ +export function createTestEngine(options: TemplateTestOptions): TemplateEngine { + return new TemplateEngine({ + viewsDir: options.viewRoots[0] ?? resolve(options.root), + componentsDir: options.componentsDir, + root: options.root, + components: options.components, + }); +}