From d11550aeb1f68ecbdddd1e8879018d5a61b3f7fe Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Wed, 11 May 2022 20:43:38 +1000 Subject: [PATCH] v0.3.0 --- CHANGELOG.md | 29 ---- README.md | 67 +-------- deps.ts | 46 ++++-- server.ts => listen.ts | 119 ++++++++++++---- mod.ts | 44 +++--- render.tsx | 269 +++++++++++++++++++++++++++++++++++ ssr/document.tsx | 50 ------- ssr/jsx.tsx | 100 ------------- ssr/uno.ts | 50 ------- storage/drivers/postgres.ts | 38 ----- storage/sessions/memory.ts | 69 --------- storage/sessions/postgres.ts | 83 ----------- stream.tsx | 65 +++++++++ transform.ts | 201 ++++++++++++++++++++++++++ types.ts | 76 ---------- util.ts | 25 ---- 16 files changed, 687 insertions(+), 644 deletions(-) delete mode 100644 CHANGELOG.md rename server.ts => listen.ts (72%) create mode 100644 render.tsx delete mode 100644 ssr/document.tsx delete mode 100644 ssr/jsx.tsx delete mode 100644 ssr/uno.ts delete mode 100644 storage/drivers/postgres.ts delete mode 100644 storage/sessions/memory.ts delete mode 100644 storage/sessions/postgres.ts create mode 100644 stream.tsx create mode 100644 transform.ts delete mode 100644 types.ts delete mode 100644 util.ts diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 18e2f09..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Changelog - -## v0.2.0 (2022-02-16) - -### Added - -- `ctx.res.markForDownload()` helper. -- `naddder.useMiddleware(callback)` registers route-inspecific callbacks, always - called _after_ any available route handlers. -- `Document` wrapper for JSX pages (inc. doctype, head, and body markup and - class transformation with [Uno CSS](https://github.com/unocss/unocss)). -- WebSocket connections can be grouped into channels for message broadcasting. - -### Changed - -- Replaced the [Windi CSS](http://windicss.org/) engine with - an [Uno CSS](https://github.com/unocss/unocss) engine. -- Response helpers are built into `ctx.res`. -- WebSocket connections are established by upgrading a route handler, - instead of requiring their own separate handler. - -### Fixed - -- JSX now handles `0` and `false` values properly. -- The `Session` interface is exported. - -## v0.1.0 (2022-02-06) - -Initial release. diff --git a/README.md b/README.md index 3bbeb6d..55ba7a0 100644 --- a/README.md +++ b/README.md @@ -2,67 +2,6 @@ **nadder** is an opinionated web server framework for Deno. -It includes [URL Pattern](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) -routing, post-route middleware, helpers for creating/reading/manipulating cookies and responses, -upgrading HTTP connections to WebSocket connections (inc. sorting into channels), -PostgreSQL (or in-memory) session storage (inc. garbage collection and expiry), a React-free -JSX transformer and atomic CSS with [Uno](https://github.com/unocss/unocss). - -## Quick start - -```tsx -/** - * @jsx h - * @jsxFrag jsxFrag - */ - -import 'https://deno.land/x/dotenv@v3.1.0/load.ts'; - -import nadder, { - Document, - h, - jsxFrag, - postgresConnection, - postgresSession, -} from 'https://deno.land/x/nadder@v0.2.0/mod.ts'; - -const postgres = postgresConnection({ - password: Deno.env.get('POSTGRES_PWD'), - hostname: Deno.env.get('POSTGRES_HOST'), - }), - session = await postgresSession(postgres); - -nadder.handleRoute('GET', '/{index.html}?', async (ctx) => { - const count = (((await session.get(ctx, 'count')) as number) ?? -1) + 1; - await session.set(ctx, 'count', count); - - ctx.res.body = await Document( - 'Home', - <> -

Hello world!

-

- Load count: {count} -

- - ); - ctx.res.inferContentType('html'); -}); - -nadder.listenAndServe(); -``` - -All features are made available as exports of the `mod.ts` file -and documented in the `types.ts` file. - -For convenience, the following dependencies are re-exported: - -- `setCookie`, `deleteCookie`, `Cookie`, `HTTPStatus` and `HTTPStatusText` from [`std/http`](https://deno.land/std/http). - ---- - -Changes to this project are recorded in the [CHANGELOG](CHANGELOG.md). - -This project is licensed under the [MIT License](LICENSE). - -To support future development of this project, please consider -[sponsoring the author](https://github.com/sponsors/dragonwocky). +It is mostly for personal use and so as of yet is undocumented. +It includes utility classes, server-side jsx rendering, asset minification, +WebSocket channels, HTTP middleware, markdown rendering, and etc. diff --git a/deps.ts b/deps.ts index 766aeb5..aeb3021 100644 --- a/deps.ts +++ b/deps.ts @@ -1,27 +1,47 @@ -export * as path from "https://deno.land/std@0.125.0/path/mod.ts"; -export { readableStreamFromReader } from "https://deno.land/std@0.125.0/streams/mod.ts"; +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ -export { serve as stdServe } from "https://deno.land/std@0.125.0/http/server.ts"; +export { serve } from "https://deno.land/std@0.138.0/http/server.ts"; export { Status as HTTPStatus, STATUS_TEXT as HTTPStatusText, -} from "https://deno.land/std@0.125.0/http/http_status.ts"; -export { contentType } from "https://deno.land/x/media_types@v2.12.1/mod.ts"; +} from "https://deno.land/std@0.138.0/http/http_status.ts"; +export { contentType } from "https://deno.land/x/media_types@v3.0.2/mod.ts"; + export { deleteCookie, getCookies, setCookie, -} from "https://deno.land/std@0.125.0/http/cookie.ts"; -export type { Cookie } from "https://deno.land/std@0.125.0/http/cookie.ts"; +} from "https://deno.land/std@0.138.0/http/cookie.ts"; +export type { Cookie } from "https://deno.land/std@0.138.0/http/cookie.ts"; + +export { readableStreamFromReader } from "https://deno.land/std@0.138.0/streams/mod.ts"; +export { basename } from "https://deno.land/std@0.138.0/path/mod.ts"; -export const modernNormalize = - `/*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */*,::after,::before{box-sizing:border-box}html{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}abbr[title]{text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}`; +export * as swc from "https://deno.land/x/swc@0.1.4/mod.ts"; -export { createGenerator as unoGenerator } from "https://esm.sh/@unocss/core@0.24.2"; -export { default as unoPreset } from "https://esm.sh/@unocss/preset-mini@0.24.2"; -export { presetTypography as unoTypography } from "https://esm.sh/@unocss/preset-typography@0.24.2"; +export { default as postcss } from "https://deno.land/x/postcss@8.4.13/mod.js"; +export { default as autoprefixer } from "https://esm.sh/autoprefixer@10.4.4"; +export { default as selectorParser } from "https://esm.sh/postcss-selector-parser@6.0.10"; +export { default as valueParser } from "https://esm.sh/postcss-value-parser@4.2.0"; export { micromark } from "https://esm.sh/micromark@3.0.10"; export { gfm, gfmHtml } from "https://esm.sh/micromark-extension-gfm@2.0.1"; +export { math, mathHtml } from "https://esm.sh/micromark-extension-math@2.0.2"; +export { default as hljs } from "https://esm.sh/highlight.js@11.5.0"; +export type { HtmlExtension } from "https://esm.sh/micromark-util-types@1.0.2/index.d.ts"; + +export { createGenerator } from "https://cdn.skypack.dev/@unocss/core@0.30.11"; +export { default as presetWind } from "https://cdn.skypack.dev/@unocss/preset-wind@0.30.11"; +export { default as presetTypography } from "https://cdn.skypack.dev/@unocss/preset-typography@0.30.11"; +export { default as presetIcons } from "https://cdn.skypack.dev/@unocss/preset-icons@0.30.11"; -export * as postgres from "https://deno.land/x/postgres@v0.15.0/mod.ts"; +export const iconifyCollections = async (...sets: string[]) => { + const reqs = [], sourceUrl = "https://esm.sh/@iconify/json@2.1.18/json"; + for (const set of sets) { + reqs.push( + fetch(`${sourceUrl}/${set}.json`) + .then((res) => res.json()).then((json) => [set, json]), + ); + } + return { collections: Object.fromEntries(await Promise.all(reqs)) }; +}; diff --git a/server.ts b/listen.ts similarity index 72% rename from server.ts rename to listen.ts index cc72176..4b6ca5c 100644 --- a/server.ts +++ b/listen.ts @@ -1,21 +1,75 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import type { Callback, Context, Mutable, RequestMethod } from "./types.ts"; -import { RequestMethods } from "./types.ts"; +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ + import { + basename, contentType, getCookies, HTTPStatus, HTTPStatusText, - path, readableStreamFromReader, - stdServe, + serve, } from "./deps.ts"; +type Mutable = { -readonly [P in keyof T]: T[P] }; + +// https://www.iana.org/assignments/http-methods/http-methods.xhtml +const RequestMethods = [ + "POST", // create + "GET", // read + "PUT", // replace + "PATCH", // update + "DELETE", // delete + "*", +] as const; +type RequestMethod = typeof RequestMethods[number]; +type RequestBody = + | string + | number + | boolean + | Record + | unknown[] + | null + | undefined + | FormData + | Blob + | ArrayBuffer; + +type Callback = (ctx: Context) => void | Promise; +interface Context { + readonly req: { + method: RequestMethod; + ip: string | null; + url: URL; + body: RequestBody; + bodyType: undefined | "json" | "text" | "formData" | "blob"; + queryParams: URLSearchParams; + pathParams: Record; + cookies: Record; + headers: Headers; + }; + res: { + body: BodyInit; + status: number; + headers: Headers; + readonly sent: boolean; + sendStatus: (status: HTTPStatus) => void; + sendJSON: (data: unknown) => void; + sendFile: (filepath: string) => Promise; + sendFileStream: (filepath: string) => Promise; + inferContentType: (lookup: string) => void; + markForDownload: (filename?: string) => void; + }; + upgrade: { + readonly available: boolean; + readonly socket: () => WebSocket | undefined; + channel: { + readonly name: string; + join: (name: string) => void; + broadcast: (message: unknown) => void; + }; + }; +} + const middleware: Callback[] = [], useMiddleware = (callback: Callback) => middleware.push(callback); @@ -24,17 +78,17 @@ const routes: [RequestMethod, URLPattern, Callback][] = [], method: RequestMethod, path: string, callback: Callback, - ) => routes.push([method, new URLPattern({ pathname: path }), callback]); - -const getRoute = (method: RequestMethod, href: string) => { - for (const route of routes) { - if (!["*", route[0]].includes(method)) continue; - if (!route[1].test(href)) continue; - const pathParams = route[1].exec(href)?.pathname?.groups ?? {}; - return { callback: route[2], pathParams }; - } - return undefined; -}; + ) => routes.push([method, new URLPattern({ pathname: path }), callback]), + getRoute = (method: RequestMethod, href: string) => { + href = href.replaceAll(/\/$/g, ""); + for (const route of routes) { + if (!["*", route[0]].includes(method)) continue; + if (!route[1].test(href)) continue; + const pathParams = route[1].exec(href)?.pathname?.groups ?? {}; + return { callback: route[2], pathParams }; + } + return undefined; + }; const activeSocketConnections: Map> = new Map(), removeSocketFromChannel = (name: string, socket: WebSocket) => { @@ -53,7 +107,7 @@ const listenAndServe = (port = 3000, log = console.log) => { log(`✨ server started at http://localhost:${port}/`); log("listening for requests..."); log(""); - stdServe(async (req, conn) => { + serve(async (req, conn) => { let override: Response | undefined, socket: WebSocket | undefined; const url = new URL(req.url), @@ -63,6 +117,7 @@ const listenAndServe = (port = 3000, log = console.log) => { ip: (conn.remoteAddr as Deno.NetAddr).hostname, url, body: undefined, + bodyType: undefined, queryParams: new URLSearchParams(url.search), pathParams: {}, cookies: getCookies(req.headers), @@ -82,13 +137,13 @@ const listenAndServe = (port = 3000, log = console.log) => { if (data instanceof Map || data instanceof Set) data = [...data]; ctx.res.body = JSON.stringify(data, null, 2); ctx.res.inferContentType("json"); - ctx.res.sendStatus(HTTPStatus.OK); + ctx.res.status = HTTPStatus.OK; }, sendFile: async (filepath) => { try { ctx.res.body = await Deno.readTextFile(filepath); - ctx.res.inferContentType(path.basename(filepath)); - ctx.res.sendStatus(HTTPStatus.OK); + ctx.res.inferContentType(basename(filepath)); + ctx.res.status = HTTPStatus.OK; } catch { ctx.res.sendStatus(HTTPStatus.NotFound); } @@ -97,9 +152,8 @@ const listenAndServe = (port = 3000, log = console.log) => { try { const file = await Deno.open(filepath, { read: true }); ctx.res.body = readableStreamFromReader(file); - file.close(); - ctx.res.inferContentType(path.basename(filepath)); - ctx.res.sendStatus(HTTPStatus.OK); + ctx.res.inferContentType(basename(filepath)); + ctx.res.status = HTTPStatus.OK; } catch { ctx.res.sendStatus(HTTPStatus.NotFound); } @@ -181,11 +235,17 @@ const listenAndServe = (port = 3000, log = console.log) => { contentType.includes("multipart/form-data"); if (isJSON) { ctx.req.body = await req.json(); + ctx.req.bodyType = "json"; } else if (isText) { ctx.req.body = await req.text(); + ctx.req.bodyType = "text"; } else if (isFormData) { ctx.req.body = await req.formData(); - } else ctx.req.body = await req.blob(); + ctx.req.bodyType = "formData"; + } else { + ctx.req.body = await req.blob(); + ctx.req.bodyType = "blob"; + } } const route = getRoute(ctx.req.method, ctx.req.url.href); @@ -211,3 +271,4 @@ const listenAndServe = (port = 3000, log = console.log) => { }; export { handleRoute, listenAndServe, useMiddleware }; +export type { Context }; diff --git a/mod.ts b/mod.ts index 5ec14c7..de6ffd7 100644 --- a/mod.ts +++ b/mod.ts @@ -1,21 +1,29 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ -export * as default from "./server.ts"; +import { handleRoute, listenAndServe, useMiddleware } from "./listen.ts"; +export default { handleRoute, listenAndServe, useMiddleware }; +export type { Context } from "./listen.ts"; -export { HTTPStatus, HTTPStatusText } from "./deps.ts"; -export { deleteCookie, setCookie } from "./deps.ts"; - -export { postgresConnection } from "./storage/drivers/postgres.ts"; -export { memorySession } from "./storage/sessions/memory.ts"; -export { postgresSession } from "./storage/sessions/postgres.ts"; - -export { h, jsxFrag, jsxToString } from "./ssr/jsx.tsx"; -export { createUnoGenerator, expandUtilityGroups } from "./ssr/uno.ts"; -export { Document } from "./ssr/document.tsx"; - -export type { Context, Session } from "./types.ts"; +export { + contentType, + deleteCookie, + getCookies, + HTTPStatus, + HTTPStatusText, + setCookie, +} from "./deps.ts"; export type { Cookie } from "./deps.ts"; + +export { + asyncJsxToSync, + escapeHtml, + h, + isElement, + jsxFrag, + jsxToString, + primitiveToString, + renderDocument, + setTheme, +} from "./render.tsx"; +export { Skeleton, Spinner, Stream } from "./stream.tsx"; +export { transform, transformFile } from "./transform.ts"; diff --git a/render.tsx b/render.tsx new file mode 100644 index 0000000..cbb8e9e --- /dev/null +++ b/render.tsx @@ -0,0 +1,269 @@ +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ + +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import { + createGenerator, + iconifyCollections, + presetIcons, + presetWind, +} from "./deps.ts"; +import { transform } from "./transform.ts"; + +declare global { + namespace JSX { + interface IntrinsicElements { + [k: string]: unknown; + } + type Props = { [k: string]: unknown }; + type Children = unknown[]; + interface Element { + type: string; + props: Props; + children: Children; + } + } +} + +// conditionals +const isElement = ($: unknown): $ is JSX.Element => { + if (!$) return false; + const hasType = Object.prototype.hasOwnProperty.call($, "type"), + hasProps = Object.prototype.hasOwnProperty.call($, "props"), + hasChildren = Object.prototype.hasOwnProperty.call($, "children"), + isElement = hasType && hasProps && hasChildren; + return isElement; + }, + isSelfClosingTag = (tag: string) => { + return [ + "area", + "base", + "basefont", + "br", + "col", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "spacer", + "track", + "wbr", + ].includes(tag); + }, + shouldDangerouslySetInnerHTML = ($: JSX.Element) => { + return !!$.props.dangerouslySetInnerHTML || + ["style", "script"].includes($.type); + }; + +// transformers +const escapeHtml = (str: string) => { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/'/g, "'") + .replace(/"/g, """) + .replace(/\\/g, "\"); + }, + expandUtilityGroups = (cls: string) => { + // e.g. "font-(bold mono) m(y-[calc(10px+1em)] x-3) dark:(font-blue hover:(p-8 h-full))" + // -> "font-bold font-mono my-[calc(10px+1em)] mx-3 dark:font-blue dark:hover:p-8 dark:hover:h-full" + cls = cls.replaceAll(/\s+/g, " ").trim(); + type Replacer = Parameters[1]; + const replaceAll = (pattern: RegExp, replacer: Replacer) => { + while (pattern.test(cls)) cls = cls.replaceAll(pattern, replacer); + }; + // escape () within [] + replaceAll( + /\[[^\]]*?(? match.replaceAll(/(?=\\]+)\((([^(]|(?<=\\)\()*?[^\\])\)/g, + (_match, prefix, g) => { + return g.split(/(? prefix + u).join(" "); + }, + ); + // unescape () within [] + cls = cls.replaceAll(/\\(\(|\))/g, (_match, bracket) => bracket); + return cls; + }, + asyncJsxToSync = async ($: unknown) => { + $ = await $; + if (Array.isArray($)) { + $ = await Promise.all($.map(asyncJsxToSync)); + } else if (isElement($)) { + $.type = await $.type; + $.children = await Promise.all($.children.map(asyncJsxToSync)); + for (const key in $.props) $.props[key] = await $.props[key]; + } + return $; + }, + attrsToString = (props: JSX.Props) => { + return Object.entries(props) + .filter(([_k, v]) => v || v === 0) + .reduce((attrs, [k, _v]) => { + const v = _v as string | number | true; + return `${attrs} ` + + (v === true ? k : `${k}="${escapeHtml(v.toString())}"`); + }, ""); + }, + primitiveToString = ($: unknown, escaped = false) => { + if ($ === undefined || $ === null) return ""; + if (typeof $ === "number" || typeof $ === "boolean") return $.toString(); + if (typeof $ === "string") return escaped ? escapeHtml($) : $; + return escaped ? escapeHtml(JSON.stringify($)) : JSON.stringify($); + }, + jsxToString = ($: unknown, dangerouslySetInnerHTML = false): string => { + if (isElement($) && $.type === "") $ = $.children; + if (Array.isArray($)) { + const innerHTML = $.map(($$) => jsxToString($$, dangerouslySetInnerHTML)) + .join(""); + return innerHTML; + } else if (isElement($)) { + dangerouslySetInnerHTML = dangerouslySetInnerHTML || + shouldDangerouslySetInnerHTML($); + let innerHTML = $.children.map(($$) => + jsxToString($$, dangerouslySetInnerHTML) + ).join(""); + const openingTag = `${$.type}${attrsToString($.props)}`, + closingTag = $.type; + if ($.type === "script") { + const ts = typeof $.props.lang === "string" && + ["ts", "typescript"].includes($.props.lang.toLowerCase()); + innerHTML = transform(ts ? "ts" : "js", innerHTML); + } + if ($.type === "style") innerHTML = transform("css", innerHTML); + return isSelfClosingTag($.type) + ? `<${openingTag}/>` + : `<${openingTag}>${innerHTML}`; + } else return primitiveToString($); + }; + +// factories +const h = ( + type: JSX.Element["type"] | CallableFunction, + props: JSX.Props, + ...children: JSX.Children + ): JSX.Element => { + props = props ?? {}; + children = (children ?? []).flat(Infinity) + .reduce((childList, $$) => { + const isDuplicate = isElement($$) && childList.includes($$), + isValueless = $$ === undefined || $$ === null, + isFragment = isElement($$) && $$.type === ""; + if (!isDuplicate && !isValueless) { + if (isFragment) childList.push(...$$.children); + else childList.push($$); + } + return childList; + }, []); + const $ = type instanceof Function + ? type(props, children) + : { type, props, children }; + return $; + }, + jsxFrag = (_props: JSX.Props, children: JSX.Children) => ({ + type: "", + props: {}, + children, + }); + +// configuration +let unoTheme: unknown = undefined; +const setTheme = (theme: unknown) => { + unoTheme = theme; + return unoTheme; +}; + +// renderers +const renderStylesheet = async (className: string) => { + const uno = createGenerator({ + presets: [ + presetWind({ dark: "class", variablePrefix: "uno-" }), + presetIcons(await iconifyCollections("twemoji", "ph")), + ], + theme: unoTheme, + }), + { css } = await uno.generate(className, // + { id: undefined, scope: undefined, minify: true }); + return css; + }, + renderIsland = async ($: JSX.Element | JSX.Element[]) => { + let className = ""; + const Island = <>{await asyncJsxToSync($)}, + recurseNodes = ($: unknown) => { + if (!isElement($)) return; + if ($.props.class) { + $.props.class = expandUtilityGroups(primitiveToString($.props.class)); + className += ` ${$.props.class}`; + } + for (const $child of $.children) recurseNodes($child); + }; + recurseNodes(Island); + Island.children.unshift(); + return jsxToString(Island); + }, + renderDocument = async ($: JSX.Element | JSX.Element[]) => { + const Document = ( + + + + + + + {await asyncJsxToSync($)} + + ); + + let className = ""; + const magicElements: Map = new Map(), + recurseNodes = ($: unknown, $parent?: JSX.Element) => { + if (!isElement($)) return; + for (const type of ["html", "head", "title", "body"]) { + if ($.type !== type) continue; + if (magicElements.has(type)) { + const $magic = magicElements.get(type)!; + $magic.props = { ...$magic.props, ...$.props }; + $magic.children.push(...$.children); + $parent?.children.splice($parent.children.indexOf($), 1); + } else magicElements.set(type, $); + } + if ($.props.class) { + $.props.class = expandUtilityGroups(primitiveToString($.props.class)); + className += ` ${$.props.class}`; + } + for (const $child of [...$.children]) recurseNodes($child, $); + }; + recurseNodes(Document); + + const Head = magicElements.get("head")!; + Head.children.push(); + return `${jsxToString(Document)}`; + }; + +export { + asyncJsxToSync, + escapeHtml, + h, + isElement, + jsxFrag, + jsxToString, + primitiveToString, + renderDocument, + renderIsland, + setTheme, +}; diff --git a/ssr/document.tsx b/ssr/document.tsx deleted file mode 100644 index 947e5da..0000000 --- a/ssr/document.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -/** - * @jsx h - * @jsxFrag jsxFrag - */ - -import { createUnoGenerator, expandUtilityGroups } from "./uno.ts"; -import { h, jsxFrag, jsxToString } from "./jsx.tsx"; - -const uno = createUnoGenerator(); - -const Document = async ( - title: string, - children: JSX.Fragment, - { lang = "en", head = <> } = {}, -) => { - const classList: string[] = [], - recurse = (_$: JSX.Node) => { - if (!Object.prototype.hasOwnProperty.call(_$, "props")) return; - const $ = _$ as JSX.Element, cls = $.props?.class?.toString() ?? ""; - if (cls) { - $.props.class = expandUtilityGroups(cls); - classList.push($.props.class); - } - if (!Object.prototype.hasOwnProperty.call(_$, "children")) return; - $.children.forEach(recurse); - }; - const body = [children].flat(); - body.forEach(recurse); - const css = await uno(classList.join(" ")); - return "" + jsxToString( - - - - - {title} - - {head} - - {body} - , - ); -}; - -export { Document }; diff --git a/ssr/jsx.tsx b/ssr/jsx.tsx deleted file mode 100644 index eb90542..0000000 --- a/ssr/jsx.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -/** - * @jsx h - * @jsxFrag jsxFrag - */ - -import { escapeHtml } from "../util.ts"; - -declare global { - namespace JSX { - type IntrinsicElements = { [k: string]: JSX.Props }; - type Props = { [k: string]: string | number | boolean | null | undefined }; - type Node = JSX.Element | JSX.Props[string]; - type Fragment = JSX.Node | JSX.Node[]; - type Component = ( - props: { - [k: string]: JSX.Fragment | (() => JSX.Fragment); - }, - children: JSX.Node[], - ) => JSX.Element; - interface Element { - type: string; - props: JSX.Props; - children: JSX.Node[]; - } - } -} - -const selfClosingTags = [ - "area", - "base", - "basefont", - "br", - "col", - "embed", - "hr", - "img", - "input", - "keygen", - "link", - "meta", - "param", - "source", - "spacer", - "track", - "wbr", -]; - -const renderToString = ( - $: JSX.Node, - dangerouslySetInnerHTML = false, -): string => { - if ($ === undefined || $ === null) return ""; - if (typeof $ === "string") return dangerouslySetInnerHTML ? $ : escapeHtml($); - if (typeof $ === "number" || typeof $ === "boolean") return $.toString(); - dangerouslySetInnerHTML = dangerouslySetInnerHTML || - !!$.props.dangerouslySetInnerHTML || - ["style", "script"].includes(($ as JSX.Element).type); - const attrs = Object.entries($.props) - .filter(([_k, v]) => v || v === 0) - .reduce((attrs, [k, _v]) => { - const v = _v as string | number | true; - return `${attrs} ` + - (v === true ? k : `${k}="${escapeHtml(v.toString())}"`); - }, ""), - innerHTML = $.children.map(($child) => - renderToString($child, dangerouslySetInnerHTML) - ).join(""); - return selfClosingTags.includes($.type) - ? `<${$.type}${attrs}/>` - : `<${$.type}${attrs}>${innerHTML}`; -}; - -const h = ( - type: JSX.Element["type"] | JSX.Component, - props: JSX.Props, - ...children: JSX.Node[] -): JSX.Fragment => { - props = props ?? {}; - children = children.flat(Infinity); - return type instanceof Function - ? type(props, children) - : { type, props, children }; -}; - -const jsxFrag = (_props: JSX.Props, children: JSX.Node[]) => children; - -const jsxToString = ( - $: JSX.Fragment, -): string => { - if (Array.isArray($)) return $.map(($node) => renderToString($node)).join(""); - return renderToString($); -}; - -export { h, jsxFrag, jsxToString }; diff --git a/ssr/uno.ts b/ssr/uno.ts deleted file mode 100644 index a404edb..0000000 --- a/ssr/uno.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import { - modernNormalize, - unoGenerator, - unoPreset, - unoTypography, -} from "../deps.ts"; -import { reduceTemplate } from "../util.ts"; - -const expandUtilityGroups = ( - t: string | TemplateStringsArray | string[], - ...s: unknown[] -) => { - // e.g. font-(bold mono) m(y-4 x-3) dark:(font-blue hover:(p-8 h-full)) - // returns: font-bold font-mono my-4 mx-3 dark:font-blue dark:hover:p-8 dark:hover:h-full - let className = typeof t === "string" ? t : reduceTemplate(t, ...s); - const pattern = /([^\s'"`;>=]+)\(([^(]+?)\)/g, - replace = () => { - className = className.replaceAll( - pattern, - (_, variant, group) => - group.split(/\s+/).map((utility: string) => `${variant}${utility}`) - .join(" "), - ); - }; - while (pattern.test(className)) replace(); - return className; -}; - -type UnoConfig = Parameters[0]; -type UnoGenerator = ReturnType; -type GenerateOptions = Parameters[1]; - -const createUnoGenerator = (config: UnoConfig = {}) => { - config.presets = config.presets ?? - [unoPreset({ dark: "class", variablePrefix: "uno-" }), unoTypography()]; - config.preflights = config.preflights ?? [{ getCSS: () => modernNormalize }]; - const engine = unoGenerator(config); - return async ( - classList: string, - options: GenerateOptions = { minify: true }, - ) => (await engine.generate(classList, options)).css; -}; - -export { createUnoGenerator, expandUtilityGroups }; diff --git a/storage/drivers/postgres.ts b/storage/drivers/postgres.ts deleted file mode 100644 index f9f25ce..0000000 --- a/storage/drivers/postgres.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import { postgres } from "../../deps.ts"; - -const postgresConnection = ({ - user = Deno.env.get("POSTGRES_USER") ?? "postgres", - password = Deno.env.get("POSTGRES_PWD"), - hostname = Deno.env.get("POSTGRES_HOST"), - port = Deno.env.get("POSTGRES_PORT") ?? "6543", - database = Deno.env.get("POSTGRES_DB") ?? "postgres", -} = {}) => { - // creates lazy/on-demand connections - // for concurrent query execution handling - // = more performant and reusable than a normal client - const config = { user, password, hostname, port, database }, - pool = new postgres.Pool(config, 3, true), - query = async (...args: unknown[]): Promise => { - const connection = await pool.connect(); - try { - // @ts-ignore pass all args - const res = await connection.queryObject(...args); - return res; - } catch (err) { - console.error(err); - return err; - } finally { - // returns connection to the pool for reuse - connection.release(); - } - }; - return query; -}; - -export { postgresConnection }; diff --git a/storage/sessions/memory.ts b/storage/sessions/memory.ts deleted file mode 100644 index 730374e..0000000 --- a/storage/sessions/memory.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import type { Context, Session } from "../../types.ts"; -import { getCookies, setCookie } from "../../deps.ts"; -import { isValidUUID } from "../../util.ts"; - -const memorySession = ({ - cookie = "session_id", - expiry = 3600, -} = {}): Session => { - const sessions = new Map(), - expiries = new Map(); - - const extractSession = (ctx: Context) => { - const headers = ctx.res.headers, - cached = ctx.req.cookies[cookie] ?? - (headers ? getCookies(ctx.res.headers)[cookie] : ""), - valid = isValidUUID(cached) ? cached : undefined; - return valid; - }, - sessionExists = (id: string) => sessions.has(id), - uniqueSession = (): string => { - const id = crypto.randomUUID(); - return sessionExists(id) ? uniqueSession() : id; - }, - collectGarbage = () => { - const now = Date.now(); - for (const [id, expiry] of expiries) { - if (now < expiry) continue; - expiries.delete(id); - sessions.delete(id); - } - }; - - const set = (id: string, key: string, value: unknown) => { - sessions.get(id)[key] = value; - }, - get = (id: string, key: string) => sessions.get(id)[key], - init = (ctx: Context) => { - collectGarbage(); - const id = extractSession(ctx) ?? uniqueSession(); - if (!sessionExists(id)) { - expiries.set(id, Date.now() + 1000 * expiry); - sessions.set(id, {}); - } - setCookie(ctx.res.headers, { - name: cookie, - value: id, - httpOnly: true, - maxAge: expiry, - }); - return id; - }; - - return { - get: (ctx, key) => { - collectGarbage(); - const id = extractSession(ctx); - return id ? get(id, key) : undefined; - }, - set: (ctx, key, value) => set(init(ctx), key, value), - }; -}; - -export { memorySession }; diff --git a/storage/sessions/postgres.ts b/storage/sessions/postgres.ts deleted file mode 100644 index 2947644..0000000 --- a/storage/sessions/postgres.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import type { Context, Session } from "../../types.ts"; -import { getCookies, setCookie } from "../../deps.ts"; -import { isValidUUID } from "../../util.ts"; - -const postgresSession = async (query: CallableFunction, { - cookie = "session_id", - expiry = 3600, - table = "sessions", -} = {}): Promise => { - await query(` - CREATE TABLE IF NOT EXISTS ${table} ( - id UUID PRIMARY KEY, - expiry TIMESTAMP NOT NULL, - state JSONB NOT NULL - ) - `); - - const extractSession = (ctx: Context) => { - const headers = ctx.res.headers, - cached = ctx.req.cookies[cookie] ?? - (headers ? getCookies(ctx.res.headers)[cookie] : ""), - valid = isValidUUID(cached) ? cached : undefined; - return valid; - }, - sessionExists = async (id: string) => { - const q = `SELECT EXISTS(SELECT 1 FROM ${table} where id = $id)`, - // deno-lint-ignore no-explicit-any - res: any = await query(q, { id }); - return res.rows?.[0]?.exists; - }, - uniqueSession = async (): Promise => { - const id = crypto.randomUUID(); - return await sessionExists(id) ? uniqueSession() : id; - }, - collectGarbage = () => query(`DELETE FROM ${table} WHERE expiry < NOW()`); - - const set = async (id: string, key: string, value: unknown) => { - const q = `UPDATE ${table} SET state = state || $state WHERE ID = $id`; - await query(q, { id, state: JSON.stringify({ [key]: value }) }); - }, - get = async (id: string, key: string) => { - const q = `SELECT state::json->$key FROM ${table} WHERE id = $id`, - // deno-lint-ignore no-explicit-any - res: any = await query(q, { id, key }); - return res.rows?.[0]?.["?column?"]; - }, - init = async (ctx: Context) => { - await collectGarbage(); - const id = extractSession(ctx) ?? await uniqueSession(); - if (!(await sessionExists(id))) { - const timestamp = (new Date(Date.now() + 1000 * expiry)).toISOString(), - q = ` - INSERT INTO ${table} (id, expiry, state) - VALUES ($id, $expiry, $state) - `; - await query(q, { id, expiry: timestamp, state: {} }); - } - setCookie(ctx.res.headers, { - name: cookie, - value: id, - httpOnly: true, - maxAge: expiry, - }); - return id; - }; - - return { - get: async (ctx, key) => { - await collectGarbage(); - const id = extractSession(ctx); - return id ? await get(id, key) : undefined; - }, - set: async (ctx, key, value) => set(await init(ctx), key, value), - }; -}; - -export { postgresSession }; diff --git a/stream.tsx b/stream.tsx new file mode 100644 index 0000000..e53dc1c --- /dev/null +++ b/stream.tsx @@ -0,0 +1,65 @@ +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ + +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import { HTTPStatus } from "./deps.ts"; +import { handleRoute } from "./listen.ts"; +import { h, jsxFrag, renderIsland } from "./render.tsx"; + +const cache: Map> = new Map(), + getId = () => { + let uniqueId = crypto.randomUUID(); + while (cache.has(uniqueId)) uniqueId = crypto.randomUUID(); + return uniqueId; + }; + +const Skeleton = ( + props: JSX.Props, + children: JSX.Children, + ) => { + props.class = `block bg-neutral-300 motion-safe:animate-pulse + ${props.class ?? ""}`; + return
{children}
; + }, + Spinner = () => ( +
+ +
+ ), + Failure = () => ( +
+ + Something went wrong. +
+ ); + +const Stream = ( + { + placeholder = , + failed = , + }: { placeholder?: unknown; failed?: unknown }, + children: JSX.Children, +) => { + const id = getId(), + island = renderIsland(<>{children}); + cache.set(id, island); + return ( + + {placeholder} + {failed} + + ); +}; + +handleRoute("GET", "/_stream/:id", async (ctx) => { + const { id } = ctx.req.pathParams as { id: string }; + if (!cache.has(id)) ctx.res.sendStatus(HTTPStatus.NotFound); + ctx.res.body = await cache.get(id)!; + ctx.res.inferContentType("html"); + cache.delete(id); +}); + +export { Skeleton, Spinner, Stream }; diff --git a/transform.ts b/transform.ts new file mode 100644 index 0000000..e01acc6 --- /dev/null +++ b/transform.ts @@ -0,0 +1,201 @@ +/*! mit license (c) dragonwocky (https://dragonwocky.me/) */ + +import { + autoprefixer, + gfm, + gfmHtml, + hljs, + HtmlExtension, + math, + mathHtml, + micromark, + postcss, + selectorParser, + swc, + valueParser, +} from "./deps.ts"; + +// modified from https://github.com/jake-low/postcss-minify/blob/master/index.js +const selectorProcessor = selectorParser((selectors) => + selectors.walk((selector) => { + selector.spaces = { before: "", after: "" }; + // @ts-expect-error: raw may not exist on all nodes + if (selector?.raws?.spaces) selector.raws.spaces = {}; + }) + ), + valueMinifier = (value: string) => { + const parsed = valueParser(value.trim()); + parsed.walk((node) => { + // @ts-expect-error: before may not exist on all nodes + if (node.before) node.before = ""; + // @ts-expect-error: after may not exist on all nodes + if (node.after) node.after = ""; + if (node.type === "space") node.value = " "; + }); + return parsed.toString(); + }, + cssMinifier: Parameters[0] = { + postcssPlugin: "postcss-minify", + AtRule: (atrule) => { + atrule.raws = { before: "", after: "", afterName: " " }; + atrule.params = valueMinifier(atrule.params); + }, + Comment: (comment) => { + if (comment.text[0] === "!") { + comment.raws.before = ""; + comment.raws.after = ""; + } else comment.remove(); + }, + Declaration: (decl) => { + decl.raws = { before: "", between: ":" }; + decl.value = valueMinifier(decl.value); + }, + Rule: (rule) => { + rule.raws = { before: "", between: "", after: "", semicolon: false }; + rule.selector = selectorProcessor.processSync(rule.selector); + }, + }; + +// hijacking https://github.com/micromark/micromark/blob/main/packages/micromark/dev/lib/compile.js +const hljsHtml: HtmlExtension = { + enter: { + codeFenced() { + this.lineEndingIfNeeded(); + this.tag("
 0 &&
+            !this.getData("lastWasTag");
+        if (runsToEndOfContainer) this.raw("\n");
+
+        if (this.getData("flowCodeSeenData")) this.lineEndingIfNeeded();
+        this.tag("
"); + if (stillWithinFences) this.lineEndingIfNeeded(); + + // reset data + this.setData("flowCodeSeenData"); + this.setData("fencesCount"); + this.setData("slurpOneLineEnding"); + this.setData("codeLang"); + this.setData("codeLines"); + }, + }, + }, + // allow
, escape other html + detailsHtml: HtmlExtension = { + exit: { + htmlFlowData(token) { + const re = /<\/{0,1}(?:details|summary)(?:.{0}|\s[^>]+?)>/g, + value = this.sliceSerialize(token), + flow: string[] = []; + let match, i = 0; + while ((match = re.exec(value)) != null) { + const start = match.index, + end = match.index + match[0].length; + flow.push(this.encode(value.slice(i, start))); + flow.push(value.slice(start, end)); + i = end; + } + flow.push(this.encode(value.slice(i))); + this.raw(flow.join("")); + }, + htmlTextData(token) { + detailsHtml.exit!.htmlFlowData.call(this, token); + }, + }, + }; + +const _script = (script: string, syntax: "typescript" | "ecmascript") => { + const { code } = swc.transform(script, { + // @ts-expect-error: missing deno_swc type definitions + minify: true, + jsc: { + minify: { compress: true, mangle: { topLevel: true } }, + target: "es2019", + parser: { syntax }, + }, + module: { type: "es6" }, + }); + return code; + }, + _stylesheet = (css: string) => { + return postcss([autoprefixer as Parameters[0], cssMinifier]) + .process(css).css; + }, + _markdown = (md: string) => { + return micromark(md, "utf8", { + extensions: [gfm(), math()], + htmlExtensions: [gfmHtml(), mathHtml(), hljsHtml, detailsHtml], + }); + }; + +const cache: Map = new Map(), + processors: Map string> = new Map([ + ["ts", (ts: string) => _script(ts, "typescript")], + ["js", (js: string) => _script(js, "ecmascript")], + ["css", _stylesheet], + ["md", _markdown], + ]), + transform = (lang: "ts" | "js" | "css" | "md" | string, code: string) => { + if (!cache.has(code)) { + cache.set(code, processors.get(lang)?.(code) ?? code); + } + return cache.get(code)!; + }; + +const files: Map = new Map(), + transformFile = async (path: string) => { + try { + const { mtime, isFile } = await Deno.stat(path); + if (!isFile || !mtime) return undefined; + if (files.has(path)) { + const [atime] = files.get(path)!, + expired = mtime.getTime() !== atime; + if (expired) files.delete(path); + } + if (!files.has(path)) { + const file = await Deno.readTextFile(path); + files.set(path, [mtime.getTime(), file]); + } + const lang = path.endsWith(".mjs") ? "js" : path.split(".").at(-1) ?? "", + [, file] = files.get(path)!; + return transform(lang, file); + } catch (err) { + if (err instanceof Deno.errors.NotFound) return undefined; + throw err; + } + }; + +// bundle: --unstable +// const { files } = await Deno.emit(path, { bundle: "module", check: false }), +// bundle = transform(files["deno:///bundle.js"]); + +export { transform, transformFile }; diff --git a/types.ts b/types.ts deleted file mode 100644 index a4c216e..0000000 --- a/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -import { HTTPStatus } from "./deps.ts"; - -type Mutable = { -readonly [P in keyof T]: T[P] }; - -// full list: https://www.iana.org/assignments/http-methods/http-methods.xhtml -const RequestMethods = [ - "POST", // Create - "GET", // Read - "PUT", // Replace - "PATCH", // Update - "DELETE", // Delete - "*", -] as const; -type RequestMethod = typeof RequestMethods[number]; - -type RequestBody = - | string - | number - | boolean - | Record - | unknown[] - | null - | undefined - | FormData - | Blob - | ArrayBuffer; - -type Callback = (ctx: Context) => void | Promise; - -interface Context { - readonly req: { - method: RequestMethod; - ip: string | null; - url: URL; - body: RequestBody; - queryParams: URLSearchParams; - pathParams: Record; - cookies: Record; - headers: Headers; - }; - res: { - body: BodyInit; - status: number; - headers: Headers; - readonly sent: boolean; - sendStatus: (status: HTTPStatus) => void; - sendJSON: (data: unknown) => void; - sendFile: (filepath: string) => Promise; - sendFileStream: (filepath: string) => Promise; - inferContentType: (lookup: string) => void; - markForDownload: (filename?: string) => void; - }; - upgrade: { - readonly available: boolean; - readonly socket: () => WebSocket | undefined; - channel: { - readonly name: string; - join: (name: string) => void; - broadcast: (message: unknown) => void; - }; - }; -} - -interface Session { - get: (ctx: Context, key: string) => unknown | Promise; - set: (ctx: Context, key: string, value: unknown) => void | Promise; -} - -export type { Callback, Context, Mutable, RequestBody, RequestMethod, Session }; -export { RequestMethods }; diff --git a/util.ts b/util.ts deleted file mode 100644 index b76f3aa..0000000 --- a/util.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * nadder - * (c) 2022 dragonwocky (https://dragonwocky.me/) - * (https://github.com/dragonwocky/nadder) under the MIT license - */ - -const reduceTemplate = ( - t: TemplateStringsArray | string[], - ...s: unknown[] -) => t.reduce((p, v) => p + v + (s.shift() ?? ""), "").trim(); - -const escapeHtml = (str: string) => - str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/'/g, "'") - .replace(/"/g, """) - .replace(/\\/g, "\"); - -const isValidUUID = (uuid: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i - .test(uuid); - -export { escapeHtml, isValidUUID, reduceTemplate };