From 7a1e9066a8573002e4614b614cd20a28daa8c90c Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Sun, 6 Feb 2022 22:43:18 +1100 Subject: [PATCH] upload --- .vscode/settings.json | 5 ++ CHANGELOG.md | 5 ++ LICENSE | 21 ++++++ README.md | 79 ++++++++++++++++++++++ deps.ts | 23 +++++++ mod.ts | 27 ++++++++ postgres.ts | 36 ++++++++++ response.ts | 50 ++++++++++++++ server.ts | 153 ++++++++++++++++++++++++++++++++++++++++++ session.ts | 146 ++++++++++++++++++++++++++++++++++++++++ ssr.tsx | 116 ++++++++++++++++++++++++++++++++ util.ts | 19 ++++++ 12 files changed, 680 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 deps.ts create mode 100644 mod.ts create mode 100644 postgres.ts create mode 100644 response.ts create mode 100644 server.ts create mode 100644 session.ts create mode 100644 ssr.tsx create mode 100644 util.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e40716f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20caf81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.1.0 (2022-02-06) + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e85e052 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) dragonwocky (https://dragonwocky.me) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfee78c --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# 🐍 nadder + +**nadder** is a HTTP and WebSocket router for Deno, +built primarily for personal use. + +- It can handle **any request method**. +- It matches routes based on the + **[URL Pattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)**. +- It includes a **PostgreSQL** connection wrapper. +- It includes in-memory or PostgresSQL **session storage** with garbage collection and expiry. +- It includes a **[Windi CSS](https://windicss.org/) processor**. +- It includes a **JSX transformer** (without React). +- It provides utilities for responding to requests with + **static files, HTML, JSON, or HTTP status codes**. +- It provides simple **reading and manipulation of cookies**. + +## Quick start + +```tsx +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import 'https://deno.land/x/dotenv/load.ts'; + +import { + h, + jsxFrag, + jsxResponse, + postgresConnection, + postgresSession, + route, + serve, + windiInstance, +} from 'https://deno.land/x/nadder/mod.ts'; + +const postgres = postgresConnection({ + password: Deno.env.get('POSTGRES_PWD'), + hostname: Deno.env.get('POSTGRES_HOST'), + }), + session = await postgresSession(postgres); + +route('GET', '/{index.html}?', async (ctx) => { + const count = (((await session.get(ctx, 'count')) as number) ?? -1) + 1; + await session.set(ctx, 'count', count); + + const { tw, sheet } = windiInstance(); + jsxResponse( + ctx, + <> +

+ Page load count: {count} +

+ {sheet()} + + ); +}); + +serve(); +``` + +All other features are also made available as exports of the `mod.ts` file +(inc. e.g. registering WebSocket listeners, sending JSON responses, serving files +and initialising in-memory session storage). + +For convenience, the following dependencies are re-exported: + +- `setCookie`, `deleteCookie`, `Cookie`, `HTTPStatus` and `HTTPStatusText` from [`std/http`](https://deno.land/std/http). +- `contentType` from [`https://deno.land/x/media_types`](https://deno.land/x/media_types) + +--- + +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). diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..e0a97af --- /dev/null +++ b/deps.ts @@ -0,0 +1,23 @@ +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"; + +export { serve as stdServe } from "https://deno.land/std@0.125.0/http/server.ts"; + +export type { Cookie } from "https://deno.land/std@0.125.0/http/cookie.ts"; +export { + deleteCookie, + getCookies, + setCookie, +} from "https://deno.land/std@0.125.0/http/cookie.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"; + +export { default as WindiProcessor } from "https://esm.sh/windicss@3.4.3"; +export { StyleSheet } from "https://esm.sh/windicss@3.4.3/utils/style"; + +export * as postgres from "https://deno.land/x/postgres@v0.15.0/mod.ts"; diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..75d5acb --- /dev/null +++ b/mod.ts @@ -0,0 +1,27 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +export { postgresConnection } from "./postgres.ts"; +export { + fileResponse, + htmlResponse, + jsonResponse, + jsxResponse, + statusResponse, +} from "./response.ts"; +export { route, serve, ws } from "./server.ts"; +export { memorySession, postgresSession } from "./session.ts"; +export { h, jsxFrag, jsxToString, windiInstance } from "./ssr.tsx"; +export { + contentType, + deleteCookie, + HTTPStatus, + HTTPStatusText, + setCookie, +} from "./deps.ts"; + +export type { HTTPMethod, RouteContext, SocketContext } from "./server.ts"; +export type { Cookie } from "./deps.ts"; diff --git a/postgres.ts b/postgres.ts new file mode 100644 index 0000000..e2662da --- /dev/null +++ b/postgres.ts @@ -0,0 +1,36 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { postgres } from "./deps.ts"; + +export const postgresConnection = ({ + user = "postgres", + password = Deno.env.get("POSTGRES_PWD"), + hostname = Deno.env.get("POSTGRES_HOST"), + port = "6543", + database = "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; +}; diff --git a/response.ts b/response.ts new file mode 100644 index 0000000..55732ae --- /dev/null +++ b/response.ts @@ -0,0 +1,50 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { RouteContext } from "./server.ts"; +import { jsxToString } from "./ssr.tsx"; +import { + contentType, + HTTPStatus, + HTTPStatusText, + path, + readableStreamFromReader, +} from "./deps.ts"; + +export const jsonResponse = (ctx: RouteContext, data: unknown) => { + if (data instanceof Map || data instanceof Set) data = [...data]; + ctx.res.body = JSON.stringify(data, null, 2); + ctx.res.status = HTTPStatus.OK; + ctx.res.headers.set("content-type", contentType("json")!); +}; + +export const statusResponse = (ctx: RouteContext, status: number) => { + ctx.res.body = `${status} ${HTTPStatusText.get(status) ?? ""}`; + ctx.res.status = status; +}; + +export const htmlResponse = (ctx: RouteContext, html: string) => { + ctx.res.body = html; + ctx.res.status = HTTPStatus.OK; + ctx.res.headers.set("content-type", contentType("html")!); +}; + +export const jsxResponse = (ctx: RouteContext, $: JSX.Element) => { + htmlResponse(ctx, `${jsxToString($)}`); +}; + +export const fileResponse = async (ctx: RouteContext, filepath: string) => { + try { + const stat = await Deno.stat(filepath); + if (stat.isDirectory) filepath = path.join(filepath, "index.html"); + const file = await Deno.open(filepath, { read: true }); + ctx.res.body = readableStreamFromReader(file); + ctx.res.headers.set("content-type", contentType(path.basename(filepath))!); + ctx.res.status = HTTPStatus.OK; + } catch { + statusResponse(ctx, HTTPStatus.NotFound); + } +}; diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..651251b --- /dev/null +++ b/server.ts @@ -0,0 +1,153 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { getCookies, HTTPStatus, stdServe } from "./deps.ts"; +import { statusResponse } from "./response.ts"; + +export type HTTPMethod = + | "GET" + | "HEAD" + | "POST" + | "PATCH" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE"; + +interface RequestContext { + method: HTTPMethod; + ip: string | null; + url: URL; + body: + | string + | number + | boolean + | Record + | unknown[] + | Blob + | null + | undefined; + queryParams: URLSearchParams; + pathParams: Record; + cookies: Record; + headers: Headers; +} +interface ResponseContext { + body: + | string + | Blob + | BufferSource + | FormData + | ReadableStream + | URLSearchParams; + status: number; + headers: Headers; +} + +export interface RouteContext { + req: Readonly; + res: ResponseContext; +} +export interface SocketContext { + req: Readonly; + socket: WebSocket; +} + +type RouteHandler = (ctx: RouteContext) => void | Promise; +const _routes: { [k in HTTPMethod]?: [URLPattern, RouteHandler][] } = {}; +export const route = ( + method: HTTPMethod, + route: string, + handler: RouteHandler, +) => { + if (!_routes[method]) _routes[method] = []; + _routes[method]!.push([new URLPattern({ pathname: route }), handler]); +}; + +type SocketHandler = (ctx: SocketContext) => void | Promise; +const _ws: [URLPattern, SocketHandler][] = []; +export const ws = (route: string, handler: SocketHandler) => { + _ws.push([new URLPattern({ pathname: route }), handler]); +}; + +export const serve = (port = 3000) => { + console.log(""); + console.log(`✨ server started at http://localhost:${port}/`); + console.log("listening for requests..."); + console.log(""); + stdServe(async (req, conn) => { + const url = new URL(req.url), + ctx: { req: RequestContext; res: ResponseContext } = { + req: { + method: req.method, + ip: ( conn.remoteAddr).hostname, + url, + body: undefined, + queryParams: new URLSearchParams(url.search), + pathParams: {}, + cookies: getCookies(req.headers), + headers: req.headers, + }, + res: { + body: "", + status: HTTPStatus.OK, + headers: new Headers(), + }, + }; + + try { + console.log(`[${ctx.req.ip}] ${ctx.req.method} ${ctx.req.url.pathname}`); + + if (req.body) { + const contentType = req.headers.get("content-type") ?? "", + isJSON = contentType.includes("application/json"), + isText = contentType.includes("text/plain"), + isFormData = + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data"); + if (isJSON) { + ctx.req.body = await req.json(); + } else if (isText) { + ctx.req.body = await req.text(); + } else if (isFormData) { + ctx.req.body = Object.fromEntries((await req.formData()).entries()); + } else ctx.req.body = await req.blob(); + } + + const ws = _ws.find(([pattern, _handler]) => { + return pattern.test(ctx.req.url.href); + }); + if (ws && req.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(req), + [pattern, handler] = ws; + ctx.req.pathParams = pattern.exec(ctx.req.url.href)?.pathname.groups ?? + {}; + await handler({ req: ctx.req, socket }); + return response; + } + + if (!_routes[ctx.req.method]) _routes[ctx.req.method] = []; + const route = _routes[ctx.req.method]!.find(([pattern, _handler]) => { + return pattern.test(ctx.req.url.href); + }); + if (route) { + const [pattern, handler] = route; + ctx.req.pathParams = pattern.exec(ctx.req.url.href)?.pathname.groups ?? + {}; + await handler(ctx); + } else statusResponse(ctx, HTTPStatus.NotFound); + } catch (err) { + console.error(`[${ctx.req.ip}]`, err); + statusResponse(ctx, HTTPStatus.InternalServerError); + } + + return new Response(ctx.res.body, { + status: ctx.res.status, + headers: ctx.res.headers, + }); + }, { port }); +}; diff --git a/session.ts b/session.ts new file mode 100644 index 0000000..7c6f02c --- /dev/null +++ b/session.ts @@ -0,0 +1,146 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { RouteContext, SocketContext } from "./server.ts"; +import { getCookies, setCookie } from "./deps.ts"; + +export interface Session { + get: ( + ctx: RouteContext | SocketContext, + key: string, + ) => unknown | Promise; + set: (ctx: RouteContext, key: string, value: unknown) => void | Promise; +} + +const validSession = (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) + ? uuid + : null, + extractSession = (ctx: RouteContext | SocketContext, cookie: string) => { + const headers = ( ctx).res?.headers, + id = validSession( + ctx.req.cookies[cookie] ?? + (headers ? getCookies(headers)[cookie] : ""), + ); + return id; + }; + +export const memorySession = ({ + cookie = "session_id", + expiry = 3600, +} = {}): Session => { + const sessions = new Map(), + expiries = new Map(); + + const 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: RouteContext) => { + collectGarbage(); + const id = extractSession(ctx, cookie) ?? 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, cookie); + return id ? get(id, key) : undefined; + }, + set: (ctx, key, value) => set(init(ctx), key, value), + }; +}; + +export 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 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: RouteContext) => { + await collectGarbage(); + const id = extractSession(ctx, cookie) ?? 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, cookie); + return id ? await get(id, key) : undefined; + }, + set: async (ctx, key, value) => set(await init(ctx), key, value), + }; +}; diff --git a/ssr.tsx b/ssr.tsx new file mode 100644 index 0000000..3486919 --- /dev/null +++ b/ssr.tsx @@ -0,0 +1,116 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import { StyleSheet, WindiProcessor } from "./deps.ts"; +import { escapeHtml, reduceTemplate } from "./util.ts"; + +declare global { + namespace JSX { + type IntrinsicElements = { [k: string]: JSX.Props }; + type Props = { [k: string]: string | number | boolean }; + type Node = JSX.Element | string | number | boolean | undefined; + type Component = (props: Props, children: JSX.Node[]) => Element; + interface Element { + type: string; + props: JSX.Props; + children: JSX.Node[]; + } + } +} + +export const h = ( + type: JSX.Element["type"] | JSX.Component, + props: JSX.Props, + ...children: JSX.Node[] +): JSX.Element => { + props = props ?? {}; + children = children.flat(Infinity); + return type instanceof Function + ? type(props, children) + : { type, props, children }; +}; + +export const jsxFrag = (_: unknown, children: JSX.Node[]) => children; + +const renderToString = ($: JSX.Node, parentType = ""): string => { + if (!$) return ""; + if (typeof $ === "string") { + return ["script", "style"].includes(parentType) ? $ : escapeHtml($); + } + if (typeof $ === "number" || typeof $ === "boolean") return $.toString(); + const attrs = Object.entries($.props) + .filter(([k, v]) => !!v || v === 0) + .reduce((attrs, [k, v]) => { + return `${attrs} ` + + (v === true ? k : `${k}="${escapeHtml(v.toString())}"`); + }, ""), + innerHTML = $.children.map(($child) => renderToString($child, $.type)).join( + "", + ); + return [ + "area", + "base", + "basefont", + "br", + "col", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "spacer", + "track", + "wbr", + ].includes($.type) + ? `<${$.type}${attrs}/>` + : `<${$.type}${attrs}>${innerHTML}`; +}; +export const jsxToString = ( + $: JSX.Node | JSX.Node[], +): string => { + if (Array.isArray($)) return $.map(($node) => renderToString($node)).join(""); + return renderToString($); +}; + +const preflight = + `/*! 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 const windiInstance = ( + mode: "interpret" | "compile" = "interpret", + config: Record = {}, +) => { + const processor = new WindiProcessor(); + config = processor.loadConfig(config); + let stylesheet = new StyleSheet(); + return { + tw: (t: TemplateStringsArray | string[], ...s: unknown[]) => { + const className = reduceTemplate(t, ...s); + if (mode === "compile") { + const compiled = processor.compile(className, config.prefix as string); + stylesheet = stylesheet.extend(compiled.styleSheet); + return [compiled.className, ...compiled.ignored].join(" "); + } + const interpreted = processor.interpret(className); + stylesheet = stylesheet.extend(interpreted.styleSheet); + return [...interpreted.success, ...interpreted.ignored].join(" "); + }, + sheet: () => ( + + ), + }; +}; diff --git a/util.ts b/util.ts new file mode 100644 index 0000000..e8242a0 --- /dev/null +++ b/util.ts @@ -0,0 +1,19 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +export const reduceTemplate = ( + t: TemplateStringsArray | string[], + ...s: unknown[] +) => t.reduce((p, v) => p + v + (s.shift() ?? ""), "").trim(); + +export const escapeHtml = (str: string) => + str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/'/g, "'") + .replace(/"/g, """) + .replace(/\\/g, "\");