commit 3d733cfe0b2c09400b2dc39ad7ad9c11e3ed9c6c Author: laura Date: Sat Nov 8 01:40:06 2025 -0300 initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..45d48e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2025 favewa + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa31ac4 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# 🕸️ cobweb + +a lightweight, tiny web framework for deno diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b8aa8c9 --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "tasks": {}, + "imports": { + "cobweb/jsx-runtime": "./src/jsx.ts" + }, + "fmt": { + "useTabs": true, + "semiColons": true + }, + "lint": { + "rules": { + "tags": ["recommended"], + "exclude": ["no-explicit-any", "require-await"] + } + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "cobweb", + "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..0bdf3be --- /dev/null +++ b/deno.lock @@ -0,0 +1,35 @@ +{ + "version": "5", + "redirects": { + "https://deno.land/std/fs/walk.ts": "https://deno.land/std@0.224.0/fs/walk.ts" + }, + "remote": { + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", + "https://deno.land/std@0.224.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", + "https://deno.land/std@0.224.0/fs/walk.ts": "cddf87d2705c0163bff5d7767291f05b0f46ba10b8b28f227c3849cace08d303", + "https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", + "https://deno.land/std@0.224.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", + "https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", + "https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.224.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e", + "https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", + "https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", + "https://deno.land/std@0.224.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", + "https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.224.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", + "https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", + "https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63", + "https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", + "https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.224.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660", + "https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", + "https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf", + "https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780" + } +} diff --git a/scripts/copyright.ts b/scripts/copyright.ts new file mode 100755 index 0000000..d65ce00 --- /dev/null +++ b/scripts/copyright.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { walk } from "https://deno.land/std/fs/walk.ts"; + +const copyrightHeader = `/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ +`; + +const dir = "./"; + +for await (const entry of walk(dir, { + exts: [".ts", ".tsx"], + includeDirs: false, + skip: [/node_modules/, /copyright\.ts$/], +})) { + const filePath = entry.path; + const content = await Deno.readTextFile(filePath); + + if (!content.startsWith(copyrightHeader)) { + await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content); + console.log(`Added header to ${filePath}`); + } +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..284bf5b --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +/// + +import type { JsxElement } from "cobweb/jsx-runtime"; + +type HTMLAttributeMap = Partial< + Omit & { + style?: string; + class?: string; + children?: any; + [key: `data-${string}`]: string | number | boolean | null | undefined; + [key: `aria-${string}`]: string | number | boolean | null | undefined; + } +>; + +declare global { + namespace JSX { + type Element = JsxElement; + + export interface ElementChildrenAttribute { + // deno-lint-ignore ban-types + children: {}; + } + + export type IntrinsicElements = { + [K in keyof HTMLElementTagNameMap]: HTMLAttributeMap< + HTMLElementTagNameMap[K] + >; + } & { + [K in keyof SVGElementTagNameMap]: HTMLAttributeMap< + SVGElementTagNameMap[K] + >; + }; + } +} diff --git a/src/html.ts b/src/html.ts new file mode 100644 index 0000000..b647ca7 --- /dev/null +++ b/src/html.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { type Chunk } from "./http.ts"; +import { ChunkedStream } from "./stream.ts"; + +export type Attrs = Record< + string, + string | number | boolean | null | undefined +>; + +export const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +const ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +export function escape(input: string): string { + let result = ""; + let lastIndex = 0; + + for (let i = 0; i < input.length; i++) { + const replacement = ESCAPE_MAP[input[i]]; + if (replacement) { + result += input.slice(lastIndex, i) + replacement; + lastIndex = i + 1; + } + } + + return lastIndex ? result + input.slice(lastIndex) : input; +} + +function serialize(attrs: Attrs | undefined): string { + if (!attrs) return ""; + let output = ""; + + for (const key in attrs) { + const val = attrs[key]; + if (val == null || val === false) continue; + output += " "; + output += val === true ? key : `${key}="${escape(String(val))}"`; + } + + return output; +} + +type TagRes = void | Promise; + +type TagFn = { + (attrs: Attrs, ...children: Chunk[]): TagRes; + (attrs: Attrs, fn: () => any): TagRes; + (...children: Chunk[]): TagRes; + (template: TemplateStringsArray, ...values: Chunk[]): TagRes; + (fn: () => any): TagRes; +}; + +export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & { + [key: string]: TagFn; +}; + +const isTemplateLiteral = (arg: any): arg is TemplateStringsArray => + Array.isArray(arg) && "raw" in arg; + +const isAttributes = (arg: any): arg is Record => + arg && + typeof arg === "object" && + !isTemplateLiteral(arg) && + !Array.isArray(arg) && + !(arg instanceof Promise); + +async function render(child: unknown): Promise { + if (child == null) return ""; + + if (typeof child === "string") return escape(child); + if (typeof child === "number") return String(child); + if (typeof child === "boolean") return String(Number(child)); + + if (child instanceof Promise) return render(await child); + + if (Array.isArray(child)) { + return (await Promise.all(child.map(render))).join(""); + } + + if (typeof child === "function") return render(await child()); + return escape(String(child)); +} + +export function html(chunks: ChunkedStream): HtmlProxy { + const cache = new Map(); + const write = (buf: string) => !chunks.closed && chunks.write(buf); + + const handler: ProxyHandler> = { + get(_, tag: string) { + let fn = cache.get(tag); + if (fn) return fn; + + fn = async (...args: unknown[]) => { + const attrs = isAttributes(args[0]) ? args.shift() : undefined; + + const isVoid = VOID_TAGS.has(tag.toLowerCase()); + const attributes = serialize(attrs as Attrs); + + write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); + if (isVoid) return; + + for (const child of args) { + write(await render(child)); + } + + write(``); + }; + + return (cache.set(tag, fn), fn); + }, + }; + + const proxy = new Proxy({}, handler) as HtmlProxy; + return proxy; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..30e3483 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { ChunkedStream } from "./stream.ts"; + +export interface StreamOptions { + headContent?: string; + bodyAttributes?: string; + lang?: string; +} + +export type Chunk = + | string + | AsyncIterable + | Promise + | Iterable + | null + | undefined; + +async function* normalize( + value: Chunk | undefined | null, +): AsyncIterable { + if (value == null) return; + + if (typeof value === "string") { + yield value; + } else if (value instanceof Promise) { + const resolved = await value; + if (resolved != null) yield String(resolved); + } else if (Symbol.asyncIterator in value || Symbol.iterator in value) { + for await (const chunk of value as AsyncIterable) { + if (chunk != null) yield String(chunk); + } + } else { + yield String(value); + } +} + +export type ChunkedWriter = ( + strings: TemplateStringsArray, + ...values: Chunk[] +) => Promise; + +export const makeChunkWriter = + (stream: ChunkedStream): ChunkedWriter => + async (strings, ...values) => { + const emit = (chunk: string) => + !stream.closed && + (chunk === "EOF" ? stream.close() : stream.write(chunk)); + + for (let i = 0; i < strings.length; i++) { + strings[i] && emit(strings[i]); + + for await (const chunk of normalize(values[i])) { + emit(chunk); + } + } + }; + +export function chunkedHtml() { + const chunks = new ChunkedStream(); + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + try { + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + cancel: chunks.close, + }); + + return { chunks, stream }; +} + +const DOCUMENT_TYPE = ""; +const HTML_BEGIN = (lang: string) => + ``; +const HEAD_END = "; + chunks: ChunkedStream; + close(): void; + error(err: Error): void; + readonly response: Response; +} + +export async function createHtmlStream( + options: StreamOptions = {}, +): Promise { + const { chunks, stream } = chunkedHtml(); + const writer = makeChunkWriter(chunks); + + chunks.write(DOCUMENT_TYPE); + chunks.write(HTML_BEGIN(options.lang || "en")); + options.headContent && chunks.write(options.headContent); + chunks.write(HEAD_END); + options.bodyAttributes && chunks.write(" " + options.bodyAttributes); + chunks.write(BODY_END); + + return { + write: writer, + blob: stream, + chunks, + close() { + if (!chunks.closed) { + chunks.write(HTML_END); + chunks.close(); + } + }, + error: chunks.error, + response: new Response(stream, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d0462f5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +export * from "./html.ts"; +export * from "./http.ts"; +export * from "./jsx.ts"; diff --git a/src/jsx.ts b/src/jsx.ts new file mode 100644 index 0000000..911a72c --- /dev/null +++ b/src/jsx.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { Attrs, escape, html, VOID_TAGS } from "./html.ts"; +import { ChunkedStream } from "./stream.ts"; + +export const Fragment = Symbol("Fragment"); + +type Props = Attrs & { children?: any }; +type Component = (props: Props) => JsxElement | AsyncGenerator; + +export type JsxElement = + | ((chunks: ChunkedStream) => Promise) + | AsyncGenerator; + +async function render( + child: any, + chunks: ChunkedStream, + context: ReturnType, +): Promise { + if (child == null || child === false || child === true) return; + + if (typeof child === "string") return chunks.write(escape(child)); + if (typeof child === "number") return chunks.write(String(child)); + + if (typeof child === "function") return await child(chunks); + if (child instanceof Promise) { + return await render(await child, chunks, context); + } + + if (typeof child === "object" && Symbol.asyncIterator in child) { + (async () => { + for await (const item of child) { + await render(item, chunks, context); + } + })(); + return; + } + + if (Array.isArray(child)) { + for (const item of child) await render(item, chunks, context); + return; + } + + chunks.write(escape(String(child))); +} + +export function jsx( + tag: string | Component | typeof Fragment, + props: Props | null = {}, +): JsxElement { + props ||= {}; + + return async (chunks: ChunkedStream) => { + const context = html(chunks); + const { children, ...attrs } = props; + + if (tag === Fragment) { + if (!Array.isArray(children)) { + return await render([children], chunks, context); + } + for (const child of children) { + await render(child, chunks, context); + } + return; + } + + if (typeof tag === "function") { + return await render(tag(props), chunks, context); + } + + const childr = children == null ? [] : [].concat(children); + const attributes = Object.keys(attrs).length ? attrs : {}; + + if (!childr.length || VOID_TAGS.has(tag)) { + await context[tag](childr); + } else { + await context[tag](attributes, async () => { + for (const child of childr) { + await render(child, chunks, context); + } + }); + } + }; +} + +export const jsxs = jsx; + +async function renderJsx( + element: JsxElement | JsxElement[], + chunks: ChunkedStream, +): Promise { + if (Array.isArray(element)) { + for (const el of element) { + await renderJsx(el, chunks); + } + return; + } + if (typeof element === "object" && Symbol.asyncIterator in element) { + for await (const item of element) { + await renderJsx(item, chunks); + } + return; + } + if (typeof element === "function") { + await element(chunks); + } +} + +export const raw = + (html: string): JsxElement => + async (chunks: ChunkedStream) => + void (!chunks.closed && chunks.write(html)); + +export const open = (tag: K) => + raw(`<${tag}>`); + +export const close = (tag: K) => + raw(``); + +export { renderJsx as render }; diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..a63427e --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +export class ChunkedStream implements AsyncIterable { + private readonly chunks: T[] = []; + + private readonly resolvers: ((result: IteratorResult) => void)[] = []; + private readonly rejectors: ((error: Error) => void)[] = []; + + private _error: Error | null = null; + private _closed = false; + + get closed(): boolean { + return this._closed; + } + + write(chunk: T) { + if (this._closed) throw new Error("Cannot write to closed stream"); + + const resolver = this.resolvers.shift(); + if (resolver) { + this.rejectors.shift(); + resolver({ value: chunk, done: false }); + } else { + this.chunks.push(chunk); + } + } + + close(): void { + this._closed = true; + while (this.resolvers.length) { + this.rejectors.shift(); + this.resolvers.shift()!({ value: undefined! as any, done: true }); + } + } + + error(err: Error): void { + if (this._closed) return; + + this._error = err; + this._closed = true; + + while (this.rejectors.length) { + this.rejectors.shift()!(err); + this.resolvers.shift(); + } + } + + async next(): Promise> { + if (this._error) { + throw this._error; + } + + if (this.chunks.length) { + return { value: this.chunks.shift()!, done: false }; + } + if (this._closed) return { value: undefined as any, done: true }; + + return new Promise((resolve, reject) => { + this.resolvers.push(resolve); + this.rejectors.push(reject); + }); + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } +} + +export const mapStream = ( + fn: (chunk: T, index: number) => U | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let index = 0; + for await (const chunk of source) yield await fn(chunk, index++); + }; + +export const filterStream = ( + pred: (chunk: T, index: number) => boolean | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let index = 0; + for await (const chunk of source) { + if (await pred(chunk, index++)) yield chunk; + } + }; + +export const takeStream = (count: number) => + async function* (source: AsyncIterable): AsyncIterable { + let taken = 0; + for await (const chunk of source) { + if (taken++ >= count) return; + yield chunk; + } + }; + +export const skipStream = (count: number) => + async function* (source: AsyncIterable): AsyncIterable { + let index = 0; + for await (const chunk of source) { + if (index++ >= count) yield chunk; + } + }; + +export const batchStream = (size: number) => + async function* (source: AsyncIterable): AsyncIterable { + let batch: T[] = []; + for await (const chunk of source) { + batch.push(chunk); + if (batch.length >= size) { + yield batch; + batch = []; + } + } + if (batch.length) yield batch; + }; + +export const tapStream = ( + fn: (chunk: T, index: number) => void | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let index = 0; + for await (const chunk of source) { + yield chunk; + await fn(chunk, index++); + } + }; + +export const catchStream = ( + handler: (error: Error) => void | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + try { + for await (const chunk of source) yield chunk; + } catch (err) { + await handler(err instanceof Error ? err : new Error(String(err))); + } + }; + +export const pipe = + (...fns: Array<(src: AsyncIterable) => AsyncIterable>) => + (source: AsyncIterable) => + fns.reduce((acc, fn) => fn(acc), source);