├── .github └── workflows │ ├── jsr.yml │ └── npm.yml ├── .gitignore ├── LICENSE ├── README.md ├── deno.jsonc ├── hyperless ├── .gitignore ├── LICENSE ├── README.md ├── deno.jsonc ├── mod.ts ├── package.json ├── src │ ├── attribute-map.ts │ ├── attribute-parser.ts │ ├── constants.ts │ ├── excerpt.ts │ ├── html-node.ts │ ├── html-parser.ts │ ├── html-tags.ts │ ├── html-utils.ts │ ├── normalize.ts │ ├── regexp.ts │ ├── striptags.ts │ └── utils.ts └── test │ ├── attributes_test.ts │ ├── excerpt_test.ts │ ├── html_test.ts │ ├── normalize_test.ts │ └── striptags_test.ts ├── hypermore ├── .gitignore ├── LICENSE ├── README.md ├── deno.jsonc ├── docs │ └── documentation.md ├── mod.ts ├── package.json ├── src │ ├── environment.ts │ ├── mod.ts │ ├── parse.ts │ ├── tag-cache.ts │ ├── tag-component.ts │ ├── tag-element.ts │ ├── tag-for.ts │ ├── tag-fragment.ts │ ├── tag-html.ts │ ├── tag-if.ts │ ├── tag-portal.ts │ ├── types.ts │ └── utils.ts └── test │ ├── attributes_test.ts │ ├── component_test.ts │ ├── concurrency_test.ts │ ├── element_test.ts │ ├── for_test.ts │ ├── html_test.ts │ ├── if_test.ts │ ├── misc_test.ts │ ├── mod.ts │ ├── portal_test.ts │ ├── prop_test.ts │ └── script_test.ts └── hyperserve ├── .gitignore ├── LICENSE ├── README.md ├── deno.jsonc ├── mod.ts ├── src ├── cookies.ts ├── fetch.ts ├── middleware │ ├── mod.ts │ ├── policy.ts │ ├── proxy.ts │ ├── redirect.ts │ ├── routes.ts │ ├── shared.ts │ ├── static.ts │ └── templates.ts ├── mod.ts ├── routes.ts ├── types.ts └── utils.ts └── test ├── Caddyfile ├── components ├── app-layout.html ├── my-input.html ├── test-props.html ├── wrap-main.html └── wrap-section.html ├── routes ├── (article) │ └── index.html ├── 404.html ├── 500.html ├── forms.html ├── index.html ├── methods │ ├── delete.ts │ ├── patch.ts │ ├── post.ts │ └── put.ts ├── no-trailing-slash.html ├── origin.ts ├── portal.html ├── props.html ├── throw.ts ├── trailing-slash.html └── wrappers.html ├── server_test.ts └── static └── robots.txt /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: Publish JSR 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Publish package 19 | run: npx jsr publish 20 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '22.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | - run: | 23 | (cd hyperless && npm publish --provenance --access public) 24 | (cd hypermore && npm publish --provenance --access public) 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | /**/*.* 3 | !hyperless 4 | !hypermore 5 | !hyperserve 6 | !.github 7 | !.github/workflows/*.yml 8 | !.gitignore 9 | !deno.jsonc 10 | !LICENSE 11 | !README.md 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 David Bushell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👽 Hyperspace 2 | 3 | Monorepo for: 4 | 5 | * [Hyperless](/hyperless/) 6 | * [Hypermore](/hypermore/) 7 | * [Hyperserve](/hyperserve/) 8 | 9 | * * * 10 | 11 | [MIT License](/LICENSE) | Copyright © 2024 [David Bushell](https://dbushell.com) 12 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "workspace": [ 3 | "./hyperless", 4 | "./hypermore", 5 | "./hyperserve" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /hyperless/.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | /**/*.* 3 | !.gitignore 4 | !deno.jsonc 5 | !package.json 6 | !README.md 7 | !LICENSE 8 | !mod.ts 9 | !src 10 | !src/**/*.ts 11 | !test 12 | !test/*.ts 13 | -------------------------------------------------------------------------------- /hyperless/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 David Bushell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hyperless/README.md: -------------------------------------------------------------------------------- 1 | # 🧼 Hyperless 2 | 3 | [](https://jsr.io/@dbushell/hyperless) [](https://www.npmjs.com/package/@dbushell/hyperless) 4 | 5 | HTML parser and various utilities. 6 | 7 | ## `parseHTML` 8 | 9 | Parse an HTML document or fragment into a traversable node tree. 10 | 11 | ```javascript 12 | import {parseHTML} from '@dbushell/hyperless'; 13 | const root = parseHTML('
Ceci n’est pas une paragraphe.
'); 39 | ``` 40 | 41 | Text in `` and `` are wrapped in quotation marks. 42 | 43 | ### `excerpt` 44 | 45 | Generate a text excerpt from HTML content. 46 | 47 | ```javascript 48 | import {excerpt} from '@dbushell/hyperless'; 49 | // Pass a chunk of HTML 50 | const text = excerpt(html); 51 | ``` 52 | 53 | Output is context aware trimmed to the nearest sentence, or word, to fit the maximum length as close as possible. 54 | 55 | An optional `maxLength` can be passed as the second argument (default: `300` characters). 56 | 57 | * * * 58 | 59 | [MIT License](/LICENSE) | Copyright © 2024 [David Bushell](https://dbushell.com) 60 | -------------------------------------------------------------------------------- /hyperless/deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dbushell/hyperless", 3 | "version": "0.31.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | }, 7 | "publish": { 8 | "include": ["src", "mod.ts", "deno.jsonc", "LICENSE", "README.md"], 9 | "exclude": [".github", "test", "package.json"] 10 | }, 11 | "lint": { 12 | "include": ["**/*.ts"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /hyperless/mod.ts: -------------------------------------------------------------------------------- 1 | export { inlineTags, opaqueTags, voidTags } from "./src/html-tags.ts"; 2 | export { AttributeMap } from "./src/attribute-map.ts"; 3 | export { parseAttributes } from "./src/attribute-parser.ts"; 4 | export { Node } from "./src/html-node.ts"; 5 | export { mergeInlineNodes } from "./src/html-utils.ts"; 6 | export { 7 | getParseOptions, 8 | parseHTML, 9 | type ParseOptions, 10 | } from "./src/html-parser.ts"; 11 | export { excerpt } from "./src/excerpt.ts"; 12 | export { stripTags } from "./src/striptags.ts"; 13 | export { escape, unescape } from "./src/utils.ts"; 14 | export { normalizeWords } from "./src/normalize.ts"; 15 | -------------------------------------------------------------------------------- /hyperless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dbushell/hyperless", 3 | "version": "0.31.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/dbushell/hyperspace.git" 7 | }, 8 | "description": "HTML parser and various utilities.", 9 | "keywords": [ 10 | "html", 11 | "typescript" 12 | ], 13 | "license": "MIT", 14 | "type": "module", 15 | "exports": { 16 | ".": "./mod.ts" 17 | }, 18 | "files": [ 19 | "src", 20 | "mod.ts", 21 | "package.json", 22 | "LICENSE", 23 | "README.md" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /hyperless/src/attribute-map.ts: -------------------------------------------------------------------------------- 1 | import { escape, unescape } from "./utils.ts"; 2 | 3 | /** 4 | * HTML attributes map 5 | * Keys are case-insensitive 6 | * HTML entities are automatically encoded 7 | */ 8 | export class AttributeMap extends Map{ 9 | constructor( 10 | iterable?: Iterable | null | undefined, 11 | ) { 12 | super( 13 | iterable instanceof AttributeMap 14 | ? Array.from(iterable, ([k, v]) => [k, escape(v)]) 15 | : iterable, 16 | ); 17 | } 18 | override [Symbol.iterator](): MapIterator<[string, string]> { 19 | return this.entries(); 20 | } 21 | override set(key: string, value: string): this { 22 | value = escape(unescape(value)); 23 | return super.set(key.toLowerCase(), value); 24 | } 25 | override get(key: string, decode = true): string | undefined { 26 | const value = super.get(key.toLowerCase()); 27 | if (value === undefined) return value; 28 | return decode ? unescape(value) : value; 29 | } 30 | override has(key: string): boolean { 31 | return super.has(key.toLowerCase()); 32 | } 33 | override delete(key: string): boolean { 34 | return super.delete(key.toLowerCase()); 35 | } 36 | override entries(decode = true): MapIterator<[string, string]> { 37 | const entries = super.entries(); 38 | return (function* () { 39 | for (const [k, v] of entries) yield [k, decode ? unescape(v) : v]; 40 | return undefined; 41 | })(); 42 | } 43 | override values(decode = true): MapIterator { 44 | const values = super.values(); 45 | return (function* () { 46 | for (const v of values) yield decode ? unescape(v) : v; 47 | return undefined; 48 | })(); 49 | } 50 | override forEach( 51 | callbackfn: (value: string, key: string, map: Map ) => void, 52 | thisArg?: unknown, 53 | ): void { 54 | super.forEach((value, key, map) => 55 | callbackfn.call(thisArg, unescape(value), key, map) 56 | ); 57 | } 58 | override toString(): string { 59 | const entries = Array.from(super.entries()); 60 | const attr = entries.map(([k, v]) => 61 | v === "" ? k : v.indexOf('"') === -1 ? `${k}="${v}"` : `${k}='${v}'` 62 | ); 63 | return attr.join(" "); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /hyperless/src/attribute-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse HTML Attributes 3 | * {@link https://html.spec.whatwg.org/#before-attribute-name-state} 4 | */ 5 | import { AttributeMap } from "./attribute-map.ts"; 6 | import { 7 | ASCII_WHITESPACE, 8 | INVALID_ATTRIBUTE_NAME, 9 | INVALID_ATTRIBUTE_VALUE, 10 | } from "./constants.ts"; 11 | 12 | /** Attribute parser state */ 13 | type ParseState = 14 | | "BEFORE_NAME" 15 | | "NAME" 16 | | "AFTER_NAME" 17 | | "BEFORE_VALUE" 18 | | "DOUBLE_QUOTED" 19 | | "SINGLE_QUOTED" 20 | | "UNQUOTED"; 21 | 22 | /** 23 | * Return a map of HTML attributes 24 | * @param attributes HTML tag to parse 25 | * @returns Attributes map 26 | */ 27 | export const parseAttributes = (attributes: string): AttributeMap => { 28 | const map: AttributeMap = new AttributeMap(); 29 | 30 | let state: ParseState = "BEFORE_NAME"; 31 | let name = ""; 32 | let value = ""; 33 | 34 | for (let i = 0; i < attributes.length; i++) { 35 | const char = attributes[i]; 36 | // Handle case where closing HTML tag is included to avoid error 37 | if (state === "BEFORE_NAME" || state === "NAME" || state === "UNQUOTED") { 38 | if (char === "/" || char === ">") { 39 | break; 40 | } 41 | } 42 | switch (state) { 43 | case "BEFORE_NAME": 44 | if (ASCII_WHITESPACE.has(char)) { 45 | continue; 46 | } else if (INVALID_ATTRIBUTE_NAME.has(char)) { 47 | throw new Error(`Invalid attribute name at character ${i}`); 48 | } 49 | name = char; 50 | value = ""; 51 | state = "NAME"; 52 | continue; 53 | case "NAME": 54 | if (char === "=") { 55 | state = "BEFORE_VALUE"; 56 | } else if (ASCII_WHITESPACE.has(char)) { 57 | state = "AFTER_NAME"; 58 | } else if (INVALID_ATTRIBUTE_NAME.has(char)) { 59 | throw new Error(`invalid name at ${i}`); 60 | } else { 61 | name += char; 62 | } 63 | continue; 64 | case "AFTER_NAME": 65 | if (char === "=") { 66 | state = "BEFORE_VALUE"; 67 | } else if (ASCII_WHITESPACE.has(char) === false) { 68 | // End of empty attribute 69 | map.set(name, ""); 70 | // Rewind state to match new name 71 | i--; 72 | state = "BEFORE_NAME"; 73 | } 74 | continue; 75 | case "BEFORE_VALUE": 76 | if (char === "'") { 77 | state = "SINGLE_QUOTED"; 78 | } else if (char === '"') { 79 | state = "DOUBLE_QUOTED"; 80 | } else if (ASCII_WHITESPACE.has(char)) { 81 | continue; 82 | } else if (INVALID_ATTRIBUTE_VALUE.has(char)) { 83 | throw new Error(`Invalid unquoted attribute value at character ${i}`); 84 | } else { 85 | value += char; 86 | state = "UNQUOTED"; 87 | } 88 | continue; 89 | case "DOUBLE_QUOTED": 90 | if (char === '"') { 91 | // End of double quoted attribute 92 | map.set(name, value); 93 | state = "BEFORE_NAME"; 94 | } else { 95 | value += char; 96 | } 97 | continue; 98 | case "SINGLE_QUOTED": 99 | if (char === "'") { 100 | // End of single quoted attribute 101 | map.set(name, value); 102 | state = "BEFORE_NAME"; 103 | } else { 104 | value += char; 105 | } 106 | continue; 107 | case "UNQUOTED": 108 | if (ASCII_WHITESPACE.has(char)) { 109 | // End of unquoted attribute 110 | map.set(name, value); 111 | state = "BEFORE_NAME"; 112 | } else if (INVALID_ATTRIBUTE_VALUE.has(char)) { 113 | throw new Error(`Invalid unquoted attribute value at character ${i}`); 114 | } else { 115 | value += char; 116 | } 117 | continue; 118 | } 119 | } 120 | // Handle end state 121 | switch (state) { 122 | case "NAME": 123 | map.set(name, ""); 124 | break; 125 | case "UNQUOTED": 126 | map.set(name, value); 127 | break; 128 | } 129 | return map; 130 | }; 131 | -------------------------------------------------------------------------------- /hyperless/src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ASCII whitespace as defined by the HTML spec 3 | * {@link https://infra.spec.whatwg.org/#ascii-whitespace} 4 | */ 5 | export const ASCII_WHITESPACE = new Set([ 6 | "\t", // U+0009 TAB 7 | "\n", // U+000A LF (Line Feed) 8 | "\f", // U+000C FF (Form Feed) 9 | "\r", // U+000D CR (Carriage Return) 10 | " ", // U+0020 SPACE 11 | ]); 12 | 13 | /** 14 | * C0 - U+0000 - U+001F (inclusive) 15 | * {@link https://infra.spec.whatwg.org/#c0-control} 16 | */ 17 | export const C0: Set = new Set(); 18 | for (let i = 0; i <= 0x1f; i++) { 19 | C0.add(String.fromCharCode(i)); 20 | } 21 | 22 | /** 23 | * Control characters: C0 + U+007F - U+009F (inclusive) 24 | * {@link https://infra.spec.whatwg.org/#control} 25 | */ 26 | export const CONTROLS = new Set(C0); 27 | for (let i = 0x7f; i <= 0x9f; i++) { 28 | CONTROLS.add(String.fromCharCode(i)); 29 | } 30 | 31 | /** 32 | * Attribute names must exclude these characters 33 | * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2} 34 | */ 35 | export const INVALID_ATTRIBUTE_NAME = new Set(CONTROLS); 36 | [ 37 | '"', // U+0022 Quotation Mark 38 | "'", // U+0027 Apostrophe 39 | "=", // U+003D Equals Sign 40 | ">", // U+003C Less-Than Sign 41 | "<", // U+003E Greater-Than Sign 42 | "/", // U+002F Solidus 43 | ].forEach((char) => INVALID_ATTRIBUTE_NAME.add(char)); 44 | 45 | /** 46 | * Unquoted attribute values must exclude these characters 47 | * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2} 48 | */ 49 | export const INVALID_ATTRIBUTE_VALUE = new Set(INVALID_ATTRIBUTE_NAME); 50 | [ 51 | "`", // U+0060 Grave Accent 52 | ].forEach((char) => INVALID_ATTRIBUTE_VALUE.add(char)); 53 | -------------------------------------------------------------------------------- /hyperless/src/excerpt.ts: -------------------------------------------------------------------------------- 1 | import { stripTags } from "./striptags.ts"; 2 | 3 | /** 4 | * Generate a text excerpt from HTML content. 5 | * 6 | * Output is context aware trimmed to the nearest sentence, or word, to fit the maximum length as close as possible. 7 | * 8 | * @param html Original HTML content 9 | * @param maxLength Desired excerpt length 10 | * @param trunateSuffix Text appended to excerpt 11 | * @param endChars End of sentence characters 12 | * @returns Text excerpt 13 | */ 14 | export const excerpt = ( 15 | html: string, 16 | maxLength = 300, 17 | trunateSuffix = "[…]", 18 | endChars = [".", "!", "?"], 19 | ): string => { 20 | // Remove HTML 21 | const text = stripTags(html); 22 | // Output is too short 23 | if (text.length < maxLength) { 24 | return text; 25 | } 26 | // Look for end of sentence near to desired length 27 | const offsets = endChars 28 | .map((char) => text.substring(0, maxLength).lastIndexOf(char)) 29 | .sort((a, b) => a - b); 30 | if (offsets.at(-1)) { 31 | const excerpt = text.substring(0, offsets.at(-1)! + 1); 32 | // Ensure excerpt is not too short 33 | if (maxLength - excerpt.length < maxLength * 0.2) { 34 | return (excerpt + " " + trunateSuffix).trim(); 35 | } 36 | } 37 | // Fallback to using words 38 | let excerpt = ""; 39 | const words = text.split(/\s+/); 40 | while (words.length) { 41 | // Concatenate words until maximum length is reached 42 | const word = words.shift()!; 43 | if (excerpt.length + word.length > maxLength) { 44 | break; 45 | } 46 | excerpt += word + " "; 47 | } 48 | return (excerpt + trunateSuffix).trim(); 49 | }; 50 | -------------------------------------------------------------------------------- /hyperless/src/html-node.ts: -------------------------------------------------------------------------------- 1 | import { AttributeMap } from "./attribute-map.ts"; 2 | import { parseAttributes } from "./attribute-parser.ts"; 3 | import { anyTag } from "./regexp.ts"; 4 | 5 | /** Node.type values */ 6 | export type NodeType = 7 | | "COMMENT" 8 | | "ELEMENT" 9 | | "INVISIBLE" 10 | | "OPAQUE" 11 | | "ROOT" 12 | | "STRAY" 13 | | "TEXT" 14 | | "VOID"; 15 | 16 | /** Node has open/close tags */ 17 | const renderTypes = new Set(["ELEMENT", "OPAQUE", "VOID"]); 18 | 19 | /** 20 | * HTML node 21 | */ 22 | export class Node { 23 | #attributes: AttributeMap | undefined; 24 | #children: Array = []; 25 | #tag: string; 26 | raw: string; 27 | type: NodeType; 28 | parent: Node | null = null; 29 | 30 | constructor( 31 | parent: Node | null = null, 32 | type: NodeType = "TEXT", 33 | raw = "", 34 | tag = "", 35 | attributes?: AttributeMap, 36 | ) { 37 | parent?.append(this); 38 | this.type = type; 39 | this.raw = raw; 40 | this.#tag = tag; 41 | this.#attributes = attributes ? new AttributeMap(attributes) : undefined; 42 | } 43 | 44 | /** Map of HTML attributes */ 45 | get attributes(): AttributeMap { 46 | // Parse on first request for performance 47 | if (this.#attributes === undefined) { 48 | const match = this.raw.match(new RegExp(anyTag.source, "s")); 49 | this.#attributes = parseAttributes(match?.[2] ?? ""); 50 | } 51 | return this.#attributes; 52 | } 53 | 54 | /** Array of child nodes */ 55 | get children(): Array { 56 | return this.#children; 57 | } 58 | 59 | /** Number of child nodes */ 60 | get size(): number { 61 | return this.children.length; 62 | } 63 | 64 | /** First child node */ 65 | get head(): Node | null { 66 | return this.children[0] ?? null; 67 | } 68 | 69 | /** Last child node */ 70 | get tail(): Node | null { 71 | return this.children[this.size - 1] ?? null; 72 | } 73 | 74 | /** Node index within parent children (or -1 if detached) */ 75 | get index(): number { 76 | return this.parent?.indexOf(this) ?? -1; 77 | } 78 | 79 | get depth(): number { 80 | let depth = 0; 81 | let parent = this.parent; 82 | while (parent) { 83 | if (parent.type !== "INVISIBLE") depth++; 84 | parent = parent.parent; 85 | } 86 | return depth; 87 | } 88 | 89 | /** Adjacent node after this within parent children */ 90 | get next(): Node | null { 91 | return this.parent?.children[this.index + 1] ?? null; 92 | } 93 | 94 | /** Adjacent node before this within parent children */ 95 | get previous(): Node | null { 96 | return this.parent?.children[this.index - 1] ?? null; 97 | } 98 | 99 | /** Tag name normalized to lowercase */ 100 | get tag(): string { 101 | return this.#tag.toLowerCase(); 102 | } 103 | 104 | /** Tag name unformatted */ 105 | get tagRaw(): string { 106 | return this.#tag; 107 | } 108 | 109 | /** Formatted opening tag with parsed attributes */ 110 | get tagOpen(): string { 111 | if (renderTypes.has(this.type) === false) { 112 | return ""; 113 | } 114 | let out = "<" + this.tag; 115 | const attr = this.attributes.toString(); 116 | if (attr.length) out += " " + attr; 117 | if (this.type === "VOID") out += "/"; 118 | return out + ">"; 119 | } 120 | 121 | /** Formatted closing tag */ 122 | get tagClose(): string { 123 | if (renderTypes.has(this.type) && this.type !== "VOID") { 124 | return `${this.tag}>`; 125 | } 126 | return ""; 127 | } 128 | 129 | /** Append one or more child nodes */ 130 | append(...nodes: Array ): void { 131 | for (const node of nodes) { 132 | node.detach(); 133 | node.parent = this; 134 | this.children.push(node); 135 | } 136 | } 137 | 138 | /** Remove all child nodes */ 139 | clear(): void { 140 | this.children.map((child) => (child.parent = null)); 141 | this.#children = []; 142 | } 143 | 144 | /** Create a copy of this node */ 145 | clone(deep = true): Node { 146 | const node = new Node( 147 | null, 148 | this.type, 149 | this.raw, 150 | this.#tag, 151 | this.#attributes, 152 | ); 153 | if (deep) { 154 | for (const child of this.children) { 155 | node.append(child.clone(deep)); 156 | } 157 | } 158 | return node; 159 | } 160 | 161 | /* Return child node at index */ 162 | at(index: number): Node | null { 163 | return this.children.at(index) ?? null; 164 | } 165 | 166 | /** Return index of child node (or -1 if not parent) */ 167 | indexOf(node: Node): number { 168 | return this.children.indexOf(node); 169 | } 170 | 171 | /** Add child node at index */ 172 | insertAt(node: Node, index: number): void { 173 | node.detach(); 174 | this.#children.splice(index, 0, node); 175 | node.parent = this; 176 | } 177 | 178 | /** Remove this node from its parent */ 179 | detach(): void { 180 | this.parent?.children.splice(this.index, 1); 181 | this.parent = null; 182 | } 183 | 184 | /** Add child node after this node */ 185 | after(node: Node): void { 186 | node.detach(); 187 | this.parent?.insertAt(node, this.index + 1); 188 | } 189 | 190 | /** Add child node before this node */ 191 | before(node: Node): void { 192 | node.detach(); 193 | this.parent?.insertAt(node, this.index); 194 | } 195 | 196 | /** Replace this node with another */ 197 | replace(node: Node): void { 198 | node.detach(); 199 | if (this.parent === null) return; 200 | this.parent.children[this.index] = node; 201 | node.parent = this.parent; 202 | this.parent = null; 203 | } 204 | 205 | /** Traverse node tree */ 206 | traverse(callback: (node: Node) => unknown): void { 207 | for (const child of this.#children) { 208 | if (callback(child) !== false) child.traverse(callback); 209 | } 210 | } 211 | 212 | /** Traverse tree until matching node is found */ 213 | find(match: (node: Node) => boolean): Node | null { 214 | for (const child of this.#children) { 215 | const found = match(child) ? child : child.find(match); 216 | if (found) return found; 217 | } 218 | return null; 219 | } 220 | 221 | /** Find closest matching parent node */ 222 | closest(match: (node: Node) => boolean): Node | null { 223 | let parent = this.parent; 224 | while (parent) { 225 | if (parent === null) return null; 226 | if (match(parent)) return parent; 227 | parent = parent.parent; 228 | } 229 | return null; 230 | } 231 | 232 | /** Render node to HTML string */ 233 | toString(): string { 234 | if (this.type === "COMMENT") return ""; 235 | if (this.type === "STRAY") return ""; 236 | let out = this.tagOpen || this.raw; 237 | for (const node of this.#children) { 238 | out += node.toString(); 239 | } 240 | out += this.tagClose ?? ""; 241 | return out; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /hyperless/src/html-parser.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "./html-node.ts"; 2 | import { inlineTags, opaqueTags, voidTags } from "./html-tags.ts"; 3 | import { anyTag, comment, customName } from "./regexp.ts"; 4 | 5 | /** Regular expression to match HTML comment or tag */ 6 | const commentTag = new RegExp(`^${comment.source}|^${anyTag.source}`); 7 | 8 | /** List of valid parents */ 9 | const listTags = new Set(["ol", "ul", "menu"]); 10 | 11 | /** HTML parser state */ 12 | type ParseState = "DATA" | "RAWTEXT"; 13 | 14 | /** `parseHTML` configuration */ 15 | export type ParseOptions = { 16 | rootTag: string; 17 | inlineTags: Set ; 18 | opaqueTags: Set ; 19 | voidTags: Set ; 20 | }; 21 | 22 | export const getParseOptions = (): ParseOptions => ({ 23 | rootTag: "html", 24 | inlineTags: new Set(inlineTags), 25 | opaqueTags: new Set(opaqueTags), 26 | voidTags: new Set(voidTags), 27 | }); 28 | 29 | const parseOptions: ParseOptions = getParseOptions(); 30 | 31 | /** 32 | * Parse HTML text into a Node tree 33 | * @param html HTML text to parse 34 | * @param tag Root tag name 35 | */ 36 | export const parseHTML = (html: string, options = parseOptions): Node => { 37 | // Create current parent node 38 | const root = new Node(null, "ROOT", "", options.rootTag); 39 | let parent = root; 40 | let state: ParseState = "DATA"; 41 | // Start at first potential tag 42 | let offset = html.indexOf("<"); 43 | 44 | // Check if element was not closed 45 | const closeParagraph = (nextTag: string, previousTag: string): boolean => { 46 | if (customName.test(nextTag)) return false; 47 | return ( 48 | nextTag !== "p" && 49 | previousTag === "p" && 50 | options.inlineTags.has(nextTag) === false 51 | ); 52 | }; 53 | 54 | while (offset >= 0 && offset < html.length - 2) { 55 | // Skip to start of potential tag 56 | if (offset > 0) { 57 | // Get the text skipped 58 | const text = html.substring(0, offset); 59 | // In RAWTEXT state text is concatenated otherwise append a text node 60 | if (state === "RAWTEXT") { 61 | (parent.at(0) ?? parent).raw += text; 62 | } else { 63 | parent.append(new Node(parent, "TEXT", text)); 64 | } 65 | // Reset offset and data to parse 66 | html = html.substring(offset); 67 | offset = 0; 68 | } 69 | 70 | // Match next HTML comment or tag 71 | const tag = html.match(commentTag); 72 | if (tag === null) { 73 | offset = html.substring(1).indexOf("<"); 74 | // Add one to account for substring if found 75 | if (offset >= 0) offset += 1; 76 | // Next iteration ends if no tag was found 77 | continue; 78 | } 79 | 80 | // Matched tag parts 81 | const tagText = tag[0]; 82 | const tagRaw = tag[1] ?? ""; 83 | const tagName = tagRaw.toLowerCase(); 84 | 85 | if (state === "RAWTEXT") { 86 | // Switch state if closing tag matches 87 | if (tagName === parent.tag && tagText.startsWith("")) { 88 | if (parent.parent === null) { 89 | throw new Error(); 90 | } 91 | parent.raw += `${parent.tag}>`; 92 | parent = parent.parent; 93 | state = "DATA"; 94 | } else { 95 | (parent.at(0) ?? parent).raw += tagText; 96 | } 97 | } // Append comment 98 | else if (tagText.startsWith(`/; 5 | 6 | /** 7 | * Regular expression to match HTML tag name 8 | */ 9 | export const tagName = /([a-zA-Z][\w:-]*)/; 10 | 11 | /** 12 | * Regular expression to match HTML custom element tag name 13 | * {@link https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name} 14 | */ 15 | export const customName = /([a-z][\w]*-[\w]+)/; 16 | 17 | /** 18 | * Regular expression to match HTML open tag 19 | */ 20 | export const openTag = new RegExp(`<${tagName.source}([^>]*)>`); 21 | 22 | /** 23 | * Regular expression to match HTML close tag 24 | */ 25 | export const closeTag = new RegExp(`${tagName.source}>`); 26 | 27 | /** 28 | * Regular expression to match HTML self-closing tag 29 | */ 30 | export const selfCloseTag = new RegExp(`<${tagName.source}([^>]*)/>`); 31 | 32 | /** 33 | * Regular expression to match any HTML tag 34 | * This allows which is technically incorrect 35 | */ 36 | export const anyTag = new RegExp(`?${tagName.source}([^>]*)/?>`); 37 | 38 | /** 39 | * Regular expression to match full tag with inner content 40 | * This is non-greedy and cannot handle nested tags of the same name 41 | */ 42 | export const fullTag = new RegExp(`(<${tagName.source}[^>]*>)(.*?)\\2>`, "s"); 43 | -------------------------------------------------------------------------------- /hyperless/src/striptags.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "./html-node.ts"; 2 | import { parseHTML } from "./html-parser.ts"; 3 | import { inlineTags } from "./html-tags.ts"; 4 | 5 | /** Elements to wrap in quotation marks */ 6 | const quoteTags = new Set(["blockquote", "q"]); 7 | 8 | /** Bespoke list of elements to remove with their content */ 9 | const removeTags = new Set([ 10 | "audio", 11 | "button", 12 | "canvas", 13 | "fieldset", 14 | "figure", 15 | "form", 16 | "iframe", 17 | "input", 18 | "img", 19 | "object", 20 | "picture", 21 | "pre", 22 | "progress", 23 | "select", 24 | "script", 25 | "style", 26 | "svg", 27 | "template", 28 | "textarea", 29 | "table", 30 | "video", 31 | ]); 32 | 33 | /** 34 | * Remove HTML and return text content with a few niceties. 35 | * 36 | * Text in `
` and `` are wrapped in quotation marks. 37 | * 38 | * @param html Original HTML content (or parsed Node) 39 | * @param style Starting quotation style 40 | * @returns Text with HTML removed 41 | */ 42 | export const stripTags = ( 43 | node: string | Node, 44 | style = 0, 45 | trim = true, 46 | ): string => { 47 | // Parse HTML and loop back 48 | if (typeof node === "string") { 49 | return stripTags(parseHTML(node), style, trim); 50 | } 51 | // Return text 52 | if (node.type === "TEXT") { 53 | return node.raw; 54 | } 55 | // Empty these tags 56 | if (removeTags.has(node.tag)) { 57 | return " "; 58 | } 59 | // Wrap quote in alternating typographic style 60 | if (quoteTags.has(node.tag)) { 61 | const empty = new Node(null, "ROOT"); 62 | empty.append(...node.children); 63 | const quotes = style % 2 ? "‘’" : "“”"; 64 | return quotes[0] + stripTags(empty, style + 1, true) + quotes[1]; 65 | } 66 | // Render children 67 | let out = ""; 68 | for (const child of node.children) { 69 | out += stripTags(child, style, false); 70 | } 71 | // Ensure space between blocks 72 | if (inlineTags.has(node.tag) === false) out += " "; 73 | // Remove excess whitespace 74 | if (trim) return out.replace(/\s+/g, " ").trim(); 75 | return out; 76 | }; 77 | -------------------------------------------------------------------------------- /hyperless/src/utils.ts: -------------------------------------------------------------------------------- 1 | const entities = new Map([ 2 | ["&", "&"], 3 | ["<", "<"], 4 | [">", ">"], 5 | ['"', """], 6 | ["'", "'"], 7 | ]); 8 | 9 | const encodes = new Map(Array.from(entities, ([k, v]) => [v, k])); 10 | 11 | const entityKeys = new RegExp([...entities.keys()].join("|"), "g"); 12 | 13 | const encodedKeys = new RegExp([...encodes.keys()].join("|"), "g"); 14 | 15 | /** Escape HTML entities */ 16 | export const escape = (str: string): string => 17 | str.replaceAll(entityKeys, (k) => entities.get(k)!); 18 | 19 | /** Unescape HTML entities */ 20 | export const unescape = (str: string): string => 21 | str.replaceAll(encodedKeys, (k) => encodes.get(k)!); 22 | -------------------------------------------------------------------------------- /hyperless/test/attributes_test.ts: -------------------------------------------------------------------------------- 1 | import { AttributeMap } from "../src/attribute-map.ts"; 2 | import { parseAttributes } from "../src/attribute-parser.ts"; 3 | import { anyTag } from "../src/regexp.ts"; 4 | import { assertEquals, assertObjectMatch } from "jsr:@std/assert"; 5 | 6 | /** Get unparsed attributes from the first HTML tag */ 7 | const getTagAttributes = (html: string): string => { 8 | const tagMatch = html.trim().match(new RegExp(anyTag.source, "s")); 9 | return tagMatch?.[2] ?? ""; 10 | }; 11 | 12 | Deno.test("single (pair)", () => { 13 | const html = ''; 14 | const expected = { 15 | "data-test": "test123", 16 | }; 17 | const attr = parseAttributes(getTagAttributes(html)); 18 | const actual = Object.fromEntries(attr.entries()); 19 | assertObjectMatch(actual, expected); 20 | }); 21 | 22 | Deno.test("single (self-closing)", () => { 23 | const html = ''; 24 | const expected = { 25 | "data-test": "test123", 26 | }; 27 | const attr = parseAttributes(getTagAttributes(html)); 28 | const actual = Object.fromEntries(attr.entries()); 29 | assertObjectMatch(actual, expected); 30 | }); 31 | 32 | Deno.test("single (boolean self-closing)", () => { 33 | const html = ""; 34 | const expected = { 35 | "data-test": "", 36 | }; 37 | const attr = parseAttributes(getTagAttributes(html)); 38 | const actual = Object.fromEntries(attr.entries()); 39 | assertObjectMatch(actual, expected); 40 | }); 41 | 42 | Deno.test("single (boolean)", () => { 43 | const html = ""; 44 | const expected = { 45 | "data-test": "", 46 | }; 47 | const attr = parseAttributes(getTagAttributes(html)); 48 | const actual = Object.fromEntries(attr.entries()); 49 | assertObjectMatch(actual, expected); 50 | }); 51 | 52 | Deno.test("single (unquoted)", () => { 53 | const html = ""; 54 | const expected = { 55 | "data-test": "test", 56 | }; 57 | const attr = parseAttributes(getTagAttributes(html)); 58 | const actual = Object.fromEntries(attr.entries()); 59 | assertObjectMatch(actual, expected); 60 | }); 61 | 62 | Deno.test("duplicate", () => { 63 | const html = ''; 64 | const expected = { 65 | a: "AA", 66 | b: "b", 67 | c: "", 68 | }; 69 | const attr = parseAttributes(getTagAttributes(html)); 70 | const actual = Object.fromEntries(attr.entries()); 71 | assertObjectMatch(actual, expected); 72 | }); 73 | 74 | Deno.test("multiple (whitespace)", () => { 75 | const html = `\u0009 `; 79 | const expected = { 80 | a1: "1", 81 | a2: "2", 82 | a3: "3", 83 | a4: "4", 84 | a5: "", 85 | a6: "6", 86 | a7: "7", 87 | a8: "", 88 | }; 89 | const attr = parseAttributes(getTagAttributes(html)); 90 | const actual = Object.fromEntries(attr.entries()); 91 | assertObjectMatch(actual, expected); 92 | }); 93 | 94 | Deno.test("mixed", () => { 95 | const html = 96 | ''; 97 | const expected = { 98 | "data-test": "test123", 99 | a: "'b'", 100 | c: "", 101 | boolean: "", 102 | "data-2": "2", 103 | "data:3": "3", 104 | _4: "", 105 | s1: 'x="1"', 106 | s2: "x='\u20012'", 107 | data5: "", 108 | data_six: " 6 ", 109 | }; 110 | const attr = parseAttributes(getTagAttributes(html)); 111 | const actual = Object.fromEntries(attr.entries()); 112 | assertObjectMatch(actual, expected); 113 | }); 114 | 115 | Deno.test("entities", () => { 116 | const encoded = `&<>"'`; 117 | const decoded = `&<>"'`; 118 | const entries: { [key: string]: string } = { 119 | prop: decoded, 120 | }; 121 | let attr = parseAttributes(`prop="${encoded}"`); 122 | attr = new AttributeMap(attr); 123 | assertEquals(attr.get("prop"), decoded); 124 | assertEquals(attr.get("prop", false), encoded); 125 | assertEquals(attr.toString(), `prop="${encoded}"`); 126 | assertObjectMatch(entries, Object.fromEntries(attr.entries())); 127 | assertEquals(Array.from(attr.values()).join(""), decoded); 128 | assertEquals(Array.from(attr.values(false)).join(""), encoded); 129 | for (const [k, v] of attr) { 130 | assertEquals(v, entries[k]); 131 | } 132 | attr.forEach((v, k) => { 133 | assertEquals(entries[k], v); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /hyperless/test/excerpt_test.ts: -------------------------------------------------------------------------------- 1 | import { excerpt } from "../src/excerpt.ts"; 2 | import { assertEquals } from "jsr:@std/assert"; 3 | 4 | Deno.test("short", () => { 5 | const html = "Ceci n’est pas une paragraphe.
"; 6 | const expected = "Ceci n’est pas une paragraphe."; 7 | const text = excerpt(html); 8 | assertEquals(text, expected); 9 | }); 10 | 11 | Deno.test("default", () => { 12 | const html = 13 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with. I bought a cheap GeForce GT 710 just to try the PCIe. It worked. That graphics card is terrible (more on that later). The ZimaBlade is also, frankly, terrible. The CPU thermal throttles itself to death. It gets dangerously hot. Add it to the list of crowdfunding campaigns I regret.
So I still use my Raspberry Pi and Mac Mini as home servers. Until now!
`; 14 | const expected = 15 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with. I bought a cheap GeForce GT 710 just to try the PCIe. It worked. That graphics card is terrible (more on that later). The ZimaBlade is also, frankly, terrible. The CPU thermal throttles itself to death. […]`; 16 | const text = excerpt(html); 17 | assertEquals(text, expected); 18 | }); 19 | 20 | Deno.test("max length", () => { 21 | const html = 22 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with. I bought a cheap GeForce GT 710 just to try the PCIe. It worked. That graphics card is terrible (more on that later). The ZimaBlade is also, frankly, terrible. The CPU thermal throttles itself to death. It gets dangerously hot. Add it to the list of crowdfunding campaigns I regret.
So I still use my Raspberry Pi and Mac Mini as home servers. Until now!
`; 23 | const expected = 24 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with. […]`; 25 | const text = excerpt(html, 100); 26 | assertEquals(text, expected); 27 | }); 28 | 29 | Deno.test("truncate", () => { 30 | const html = 31 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with. I bought a cheap GeForce GT 710 just to try the PCIe. It worked. That graphics card is terrible (more on that later). The ZimaBlade is also, frankly, terrible. The CPU thermal throttles itself to death. It gets dangerously hot. Add it to the list of crowdfunding campaigns I regret.
So I still use my Raspberry Pi and Mac Mini as home servers. Until now!
`; 32 | const expected = 33 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to […]`; 34 | const text = excerpt(html, 80); 35 | assertEquals(text, expected); 36 | }); 37 | 38 | Deno.test("exclamation mark end", () => { 39 | const html = 40 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with! I bought a cheap GeForce GT 710 just to try the PCIe.
`; 41 | const expected = 42 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with! …`; 43 | const text = excerpt(html, 100, "…"); 44 | assertEquals(text, expected); 45 | }); 46 | 47 | Deno.test("question mark end", () => { 48 | const html = 49 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with? I bought a cheap GeForce GT 710 just to try the PCIe.
`; 50 | const expected = 51 | `Back in March I shared my spring self-hosted update. I had a new ZimaBlade to play with?`; 52 | const text = excerpt(html, 100, ""); 53 | assertEquals(text, expected); 54 | }); 55 | -------------------------------------------------------------------------------- /hyperless/test/html_test.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "../mod.ts"; 2 | import { parseHTML } from "../src/html-parser.ts"; 3 | import { assert, assertEquals } from "jsr:@std/assert"; 4 | 5 | Deno.test("paragraph", () => { 6 | const html = "Paragraph
"; 7 | const root = parseHTML(html); 8 | const p = root.at(0)!; 9 | assertEquals(p.tag, "p"); 10 | assertEquals(p.size, 1); 11 | assertEquals(p.at(0)!.type, "TEXT"); 12 | assertEquals(p.at(0)!.raw, "Paragraph"); 13 | }); 14 | 15 | Deno.test("case-insensitive", () => { 16 | const html = "Paragraph "; 17 | const root = parseHTML(html); 18 | assertEquals(root.at(0)!.tag, "article"); 19 | }); 20 | 21 | Deno.test("custom elements", () => { 22 | const html = `23 | `.replace(/\s+/g, ""); 27 | const root = parseHTML(html); 28 | assertEquals(root.at(0)!.tag, "my-element"); 29 | assertEquals(root.at(0)!.at(1)!.tag, "my-two"); 30 | assertEquals(root.at(0)!.at(1)!.type, "ELEMENT"); 31 | assertEquals(root.at(0)!.size, 3); 32 | }); 33 | 34 | Deno.test("paragraph inline", () => { 35 | const html = "1 24 |2 25 |3 26 |This is inline
"; 36 | const root = parseHTML(html); 37 | const p = root.at(0)!; 38 | const strong = p.at(1)!; 39 | assertEquals(p.size, 2); 40 | assertEquals(p.at(0)!.type, "TEXT"); 41 | assertEquals(strong.tag, "strong"); 42 | assertEquals(strong.at(0)!.raw, "inline"); 43 | }); 44 | 45 | Deno.test("fake out tag", () => { 46 | const html = "Less than < fake tag
"; 47 | const root = parseHTML(html); 48 | const p = root.at(0)!; 49 | assertEquals(p.size, 2); 50 | }); 51 | 52 | Deno.test("void element", () => { 53 | const html = "1
2
"; 54 | const root = parseHTML(html); 55 | assertEquals(root.size, 3); 56 | assertEquals(root.at(1)!.type, "VOID"); 57 | }); 58 | 59 | Deno.test("self-closing", () => { 60 | const html = ""; 61 | const root = parseHTML(html); 62 | assertEquals(root.size, 1); 63 | assertEquals(root.at(0)!.type, "VOID"); 64 | }); 65 | 66 | Deno.test("stray node", () => { 67 | const html = "1
2
"; 68 | const root = parseHTML(html); 69 | assertEquals(root.size, 3); 70 | assertEquals(root.at(1)!.type, "STRAY"); 71 | }); 72 | 73 | Deno.test("leftover text", () => { 74 | const html = "Paragraph
Leftover"; 75 | const root = parseHTML(html); 76 | assertEquals(root.at(1)!.type, "TEXT"); 77 | assertEquals(root.at(1)!.raw, "Leftover"); 78 | }); 79 | 80 | Deno.test("unclosed", () => { 81 | const html = ` 82 | 83 |
`.replace(/\s+/g, ""); 91 | const root = parseHTML(html); 92 | assertEquals(root.at(0)!.size, 3); 93 | assertEquals( 94 | root.toString(), 95 | ` 96 |- 1b 84 |
- 2
85 |- 3 86 |
87 |
90 |- 4i 88 |
- 5 89 |
97 |
`.replace(/\s+/g, ""), 106 | ); 107 | }); 108 | 109 | Deno.test("unclosed- 1b
98 |- 2
99 |- 3 100 |
105 |101 |
104 |- 4i
102 |- 5
103 |(1)", () => { 110 | const html = `
1
2`; 111 | const root = parseHTML(html); 112 | assertEquals(root.toString(), "1
2"); 113 | }); 114 | 115 | Deno.test("unclosed(2)", () => { 116 | const html = ` 117 |
Hello, World! 118 |
119 |123 |Closed
120 |Unclosed
bold 121 |Unclosed 122 |
EOF
124 | `.replace(/\s+/g, ""); 125 | const root = parseHTML(html); 126 | assertEquals(root.size, 4); 127 | assertEquals( 128 | root.toString(), 129 | ` 130 |Hello, World!
131 |132 |136 |Closed
133 |Unclosed
134 |
boldUnclosed
135 |EOF
137 |
138 | `.replace(/\s+/g, ""), 139 | ); 140 | }); 141 | 142 | Deno.test("comments", () => { 143 | const html = ` 144 | 146 |147 | 148 |`; 149 | const root = parseHTML(html); 150 | assertEquals(root.at(1)!.type, "COMMENT"); 151 | assertEquals(root.at(3)!.at(1)!.at(0)!.type, "COMMENT"); 152 | }); 153 | 154 | Deno.test("opaque state (svg)", () => { 155 | const svg = ``; 160 | const html = `${svg}After
`; 161 | const root = parseHTML(html); 162 | assertEquals(root.size, 3); 163 | assertEquals(root.at(1)!.type, "OPAQUE"); 164 | assertEquals(root.at(1)!.size, 1); 165 | assertEquals(root.at(1)!.at(0)!.type, "TEXT"); 166 | assertEquals(root.at(1)!.toString(), svg); 167 | }); 168 | 169 | Deno.test("script fake tag", () => { 170 | const html = 171 | ` tag */after
`; 172 | const root = parseHTML(html); 173 | assertEquals(root.at(1)!.raw, " tag */"); 174 | }); 175 | 176 | Deno.test("attributes", () => { 177 | const html = 178 | 'test'; 179 | const root = parseHTML(html); 180 | assertEquals(root.at(0)!.at(0)!.raw, "test"); 181 | assertEquals(root.at(0)!.attributes.size, 11); 182 | }); 183 | 184 | Deno.test("render", () => { 185 | const html = ` 186 |Paragraph 187 | bold 188 |
189 | 190 | © 191 | `; 192 | const render = ` 193 |Paragraph 194 | bold 195 |
196 | 197 | © 198 | `; 199 | const root = parseHTML(html); 200 | assertEquals(root.toString(), render); 201 | }); 202 | 203 | Deno.test("detach", () => { 204 | const html = "1
2
3
4
5
"; 205 | const root = parseHTML(html); 206 | const c0 = root.at(0)!; 207 | const c1 = root.at(1)!; 208 | const c2 = root.at(2)!; 209 | const c3 = root.at(3)!; 210 | const c4 = root.at(4)!; 211 | assertEquals(root.size, 5); 212 | c0.detach(); 213 | assertEquals(root.size, 4); 214 | assert(root.head === c1); 215 | assert(root.tail === c4); 216 | c3.detach(); 217 | assertEquals(root.size, 3); 218 | assert(root.head === c1); 219 | assert(root.tail === c4); 220 | c4.detach(); 221 | assert(root.tail === c2); 222 | c1.detach(); 223 | assert(root.head === c2); 224 | assertEquals(root.size, 1); 225 | }); 226 | 227 | Deno.test("Node.replaceWith", () => { 228 | const html = "1
2
3
"; 229 | const root = parseHTML(html); 230 | const A = parseHTML("A
"); 231 | const B = parseHTML("B
"); 232 | const C = parseHTML("C
"); 233 | root.at(0)!.replace(A); 234 | root.at(1)!.replace(B); 235 | root.at(2)!.replace(C); 236 | assertEquals(root.toString(), "A
B
C
"); 237 | assert(root.head === A); 238 | assert(root.tail === C); 239 | }); 240 | 241 | Deno.test("Node.indexOf", () => { 242 | const html = "1
2
3
4
5
"; 243 | const root = parseHTML(html); 244 | const c0 = root.at(0)!; 245 | const c2 = root.at(2)!; 246 | const c4 = root.at(4)!; 247 | assertEquals(root.indexOf(c0), 0); 248 | assertEquals(root.indexOf(c2), 2); 249 | assertEquals(root.indexOf(c4), 4); 250 | assertEquals(c0.indexOf(c4), -1); 251 | }); 252 | 253 | Deno.test("Node.insertAt", () => { 254 | const root = new Node(); 255 | const n1 = parseHTML("1
"); 256 | const n2 = parseHTML("2
"); 257 | const n3 = parseHTML("3
"); 258 | root.insertAt(n3, 0); 259 | root.insertAt(n2, 0); 260 | root.insertAt(n1, 0); 261 | assertEquals(root.toString(), "1
2
3
"); 262 | }); 263 | 264 | Deno.test("Node.after", () => { 265 | const root = new Node(); 266 | const n1 = parseHTML("1
"); 267 | const n2 = parseHTML("2
"); 268 | const n3 = parseHTML("3
"); 269 | root.append(n1, n2, n3); 270 | assertEquals(root.toString(), "1
2
3
"); 271 | n2.after(n1); // 2 1 3 272 | n1.after(n3); 273 | n2.after(n3); // 2 3 1 274 | n1.after(n2); // 3 1 2 275 | n2.after(n1); // 3 2 1 276 | assertEquals(root.toString(), "3
2
1
"); 277 | }); 278 | 279 | Deno.test("Node.before", () => { 280 | const root = new Node(); 281 | const n1 = parseHTML("1
"); 282 | const n2 = parseHTML("2
"); 283 | const n3 = parseHTML("3
"); 284 | root.append(n1, n2, n3); 285 | assertEquals(root.toString(), "1
2
3
"); 286 | n3.before(n1); // 2 1 3 287 | n2.before(n3); // 3 2 1 288 | assertEquals(root.toString(), "3
2
1
"); 289 | }); 290 | -------------------------------------------------------------------------------- /hyperless/test/normalize_test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeWords } from "../src/normalize.ts"; 2 | import { assertEquals } from "jsr:@std/assert"; 3 | 4 | Deno.test("paragraph", () => { 5 | const html = `"Ceci n’est pas uñe\npàrâgrǽphę (I've ffl) 🫠 CECI."`; 6 | const words = normalizeWords(html, true); 7 | assertEquals(words.join(" "), "ceci nest pas une paragraephe ive ffl"); 8 | }); 9 | -------------------------------------------------------------------------------- /hyperless/test/striptags_test.ts: -------------------------------------------------------------------------------- 1 | import { stripTags } from "../src/striptags.ts"; 2 | import { assertEquals } from "jsr:@std/assert"; 3 | 4 | Deno.test("demo", () => { 5 | const html = "Ceci n’est pas une paragraphe.
"; 6 | const expected = "Ceci n’est pas une paragraphe."; 7 | const text = stripTags(html); 8 | assertEquals(text, expected); 9 | }); 10 | 11 | Deno.test("basic", () => { 12 | const html = 13 | `This is HTML.
14 |End of content.
`; 15 | const expected = "This is HTML. End of content."; 16 | const text = stripTags(html); 17 | assertEquals(text, expected); 18 | }); 19 | 20 | Deno.test("quotes", () => { 21 | const html = `22 |`; 24 | const expected = "“This is HTML.”"; 25 | const text = stripTags(html); 26 | assertEquals(text, expected); 27 | }); 28 | 29 | Deno.test("nested quotes", () => { 30 | const html = `This is HTML.
23 |31 |`; 33 | const expected = "“Blockquote with ‘inline quote’ text.”"; 34 | const text = stripTags(html); 35 | assertEquals(text, expected); 36 | }); 37 | -------------------------------------------------------------------------------- /hypermore/.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | /**/*.* 3 | !.gitignore 4 | !deno.jsonc 5 | !package.json 6 | !README.md 7 | !LICENSE 8 | !mod.ts 9 | !src 10 | !src/**/*.ts 11 | !test 12 | !test/*.ts 13 | !docs 14 | !docs/*.md 15 | -------------------------------------------------------------------------------- /hypermore/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 David Bushell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hypermore/README.md: -------------------------------------------------------------------------------- 1 | # 🎃 Hypermore 2 | 3 | [](https://jsr.io/@dbushell/hypermore) [](https://www.npmjs.com/package/@dbushell/hypermore) 4 | 5 | HTML preprocessor and template engine built with [Hyperless](https://github.com/dbushell/hyperless). 6 | 7 | ⚠️ **Work in progress!** ⚠️ 8 | 9 | [Documentation](/hypermore/docs/documentation.md) 10 | 11 | * * * 12 | 13 | [MIT License](/LICENSE) | Copyright © 2024 [David Bushell](https://dbushell.com) 14 | -------------------------------------------------------------------------------- /hypermore/deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dbushell/hypermore", 3 | "version": "0.32.0", 4 | "exports": { 5 | ".": "./mod.ts" 6 | }, 7 | "publish": { 8 | "include": ["src", "mod.ts", "deno.jsonc", "LICENSE", "README.md"], 9 | "exclude": [".github", "docs", "test", "package.json"] 10 | }, 11 | "lint": { 12 | "include": ["**/*.ts"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /hypermore/docs/documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Hypermore API is under development and subject to change. 4 | 5 | ## Usage 6 | 7 | ```javascript 8 | import { Hypermore } from "@dbushell/hypermore"; 9 | 10 | const hypermore = new Hypermore(); 11 | 12 | hypermore.render(`Blockquote with
32 |inline quotetext.{{heading}}
`, { 13 | heading: "Hello, World!", 14 | }); 15 | ``` 16 | 17 | ## Variables 18 | 19 | HTML text nodes are parsed for variables between `{{` and `}}`. 20 | 21 | ```html 22 |{{ heading }}
23 | ``` 24 | 25 | ## Expressions 26 | 27 | JavaScript expressions are also executed. 28 | 29 | ```html 30 |{{ heading.toUpperCase() }}
31 | ``` 32 | 33 | ## Custom Elements 34 | 35 | Custom element templates can be registered and reused as components. 36 | 37 | ```html 38 |39 | 41 | ``` 42 | 43 | For example: 44 | 45 | ```javascript 46 | hypermore.setTemplate("my-header", `{{heading}}
40 |{{heading}}
`); 47 | hypermore.render(``); 48 | ``` 49 | 50 | ## Slots 51 | 52 | Custom element templates can have named slots and a default slot. 53 | 54 | ```html 55 | 56 | 61 | ``` 62 | 63 | For example: 64 | 65 | ```javascript 66 | hypermore.setTemplate( 67 | "my-header", 68 | `57 | 59 |{{heading}}
58 |60 | {{heading}}
`, 69 | ); 70 | hypermore.render(` 71 | 72 | 77 | `); 78 | ``` 79 | 80 | ## Portals 81 | 82 | Fragments can be rendered into unique named portals from anywhere. 83 | 84 | ```html 85 |73 | 75 |{{heading}}
74 |Default slot content.
76 |86 | After heading content.
87 |88 | 90 | ``` 91 | 92 | For example: 93 | 94 | ```javascript 95 | hypermore.render(` 96 |Hello, World! 89 |
97 | After heading content.
98 |99 | 101 | `); 102 | ``` 103 | 104 | ## For Loops 105 | 106 | ```html 107 |Hello, World! 100 |
108 | 110 | ``` 111 | 112 | ## If Conditions 113 | 114 | ```html 115 |{{ article.title }}
109 |116 | 122 | ``` 123 | 124 | ## Element 125 | 126 | Render a dynamic element. 127 | 128 | ```html 129 |One
117 |118 | Two
119 |120 | Three
121 |Hello, World! 130 | ``` 131 | 132 | For example: 133 | 134 | ```javascript 135 | hypermore.render( 136 | `Heading `, 137 | { level: 1 }, 138 | ); 139 | ``` 140 | 141 | ## HTML 142 | 143 | Render without escaping variable HTML entities. 144 | 145 | ```html 146 |{{code}} 147 | ``` 148 | 149 | For example: 150 | 151 | ```javascript 152 | hypermore.render(`{{code}} `, { 153 | code: `Hello, World!
`, 154 | }); 155 | ``` 156 | 157 | ## Cache 158 | 159 | Render once and reuse across multiple renders. 160 | 161 | ```html 162 |{{ Date.now() }} 163 | ``` 164 | 165 | For example: 166 | 167 | ```javascript 168 | hypermore.render(`{{ Date.now() }} `); 169 | hypermore.render(``); 170 | ``` 171 | -------------------------------------------------------------------------------- /hypermore/mod.ts: -------------------------------------------------------------------------------- 1 | export type { JSONObject, Node, Options } from "./src/types.ts"; 2 | export { Hypermore } from "./src/mod.ts"; 3 | export { parseHTML } from "./src/parse.ts"; 4 | export { componentName } from "./src/utils.ts"; 5 | -------------------------------------------------------------------------------- /hypermore/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dbushell/hypermore", 3 | "version": "0.32.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/dbushell/hyperspace.git" 7 | }, 8 | "description": "Experimental HTML preprocessor and template engine.", 9 | "keywords": [ 10 | "html", 11 | "typescript" 12 | ], 13 | "license": "MIT", 14 | "type": "module", 15 | "exports": { 16 | ".": "./mod.ts" 17 | }, 18 | "files": [ 19 | "src", 20 | "mod.ts", 21 | "package.json", 22 | "LICENSE", 23 | "README.md" 24 | ], 25 | "dependencies": { 26 | "@dbushell/hyperless": "^0.31.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /hypermore/src/environment.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, JSONObject, JSONValue } from "./types.ts"; 2 | import { escapeChars } from "./utils.ts"; 3 | 4 | export const envHeader = ` 5 | let __EXPORT = ""; 6 | const __LOCALS = new Map(); 7 | const __PORTALS = new Map(); 8 | const __FRAGMENTS = new Set(); 9 | const __ENTITIES = new Map([ 10 | ["&", "&"], 11 | ["<", "<"], 12 | [">", ">"], 13 | ['"', """], 14 | ["'", "'"], 15 | ]); 16 | const __ENTITY_KEYS = new RegExp([...__ENTITIES.keys()].join("|"), "g"); 17 | const __ESC = (value, escape) => { 18 | if (escape === false) return value; 19 | return String(value).replaceAll(__ENTITY_KEYS, (k) => __ENTITIES.get(k)); 20 | }; 21 | const __ATTRIBUTES = (attr) => { 22 | const newAttr = []; 23 | attr.forEach((v, k) => { 24 | if (v === undefined || v === null) return; 25 | if (v === "") { 26 | newAttr.push(k); 27 | } else { 28 | v = __ESC(v, true); 29 | newAttr.push(v.indexOf('"') === -1 ? \`\${k}="\${v}"\` : \`\${k}='\${v}'\`); 30 | } 31 | }); 32 | return newAttr.length ? " " + newAttr.join(" ") : ""; 33 | }; 34 | const __FOR_ITEMS = (items) => { 35 | // Convert string to numeric value 36 | if (typeof items === "string") { 37 | const parseItems = Number.parseInt(items); 38 | if (isNaN(parseItems) === false) { 39 | items = parseItems; 40 | } 41 | } 42 | // Convert number to array, e.g. "5" iterates [0,1,2,3,4] 43 | if (typeof items === "number") { 44 | return [...Array(items).keys()]; 45 | } 46 | // Ensure items is iterable 47 | if (typeof items[Symbol.iterator] !== "function") { 48 | console.warn(' invalid "of" property (not iterable)'); 49 | return []; 50 | } 51 | return items; 52 | }; 53 | `; 54 | 55 | export const envFooter = ` 56 | const __FRAGMENT_VALUES = [...__FRAGMENTS.values()]; 57 | for (const [name, comment] of __PORTALS) { 58 | __EXPORT = __EXPORT.replace(comment, () => { 59 | return __FRAGMENT_VALUES 60 | .map(({ html, portal }) => (portal === name ? html : "")) 61 | .join(""); 62 | }); 63 | } 64 | return __EXPORT; 65 | `; 66 | 67 | /** Execute code and return final string */ 68 | export const renderEnv = (env: Environment): Promise => { 69 | const module = Function(`'use strict'; return async function() { 70 | try { 71 | ${env.code} 72 | } catch (err) { 73 | throw new Error(\`"\${err.message}"\`); 74 | } 75 | }`)(); 76 | return module(); 77 | }; 78 | 79 | /** Parse text and replace `{{expression}}` with code */ 80 | export const parseVars = (text: string, escape = true): string => { 81 | let out = ""; 82 | while (text.length) { 83 | // Search for next expression 84 | const next = text.indexOf("{{"); 85 | // Append remaining text if not found 86 | if (next === -1) { 87 | out += text; 88 | break; 89 | } 90 | // Append and remove text before expression 91 | out += text.substring(0, next); 92 | text = text.substring(next); 93 | // Validate expression 94 | const match = text.match(/^{{([^{].*?)}}/s); 95 | if (match === null) { 96 | // Skip and continue searching 97 | out += "{"; 98 | text = text.substring(1); 99 | } else { 100 | if (match[1].at(0) === "!") { 101 | // Ignore match if escape character was found 102 | out += "{{" + match[0].substring(3); 103 | } else { 104 | // Replace match with render function 105 | out += `\`+__ESC(${match[1]}, ${escape})+\``; 106 | } 107 | text = text.substring(match[0].length); 108 | } 109 | } 110 | return out; 111 | }; 112 | 113 | /** Encode value for JavaScript string template */ 114 | export const encodeVars = (value: JSONValue, parse = false): string => { 115 | if (value === null) return "null"; 116 | if (Array.isArray(value)) { 117 | return `[${value.map((v) => encodeVars(v, parse)).join(",")}]`; 118 | } 119 | switch (typeof value) { 120 | case "boolean": 121 | case "number": 122 | return `${value}`; 123 | case "string": 124 | value = `\`${escapeChars(value)}\``; 125 | if (parse) { 126 | value = parseVars(value, false); 127 | // Remove empty string at start and end of value 128 | if (value.startsWith("``+")) value = value.substring(3); 129 | if (value.endsWith("+``")) value = value.substring(0, value.length - 3); 130 | } 131 | return value; 132 | case "object": { 133 | return `{${ 134 | Object.entries(value) 135 | .map(([k, v]) => `'${k}':${encodeVars(v, parse)}`) 136 | .join(",") 137 | }}`; 138 | } 139 | } 140 | }; 141 | 142 | /** Add local props to JavaScript block */ 143 | export const addVars = ( 144 | props: JSONObject, 145 | prevProps: Array = [], 146 | env: Environment, 147 | parse = true, 148 | ): JSONObject => { 149 | let code = ""; 150 | let codeHead = ""; 151 | const updated: JSONObject = {}; 152 | const isLocal = Object.hasOwn(props, "$local"); 153 | const isGlobal = Object.hasOwn(props, "$global"); 154 | for (const [key, value] of Object.entries(props)) { 155 | // Check stack for previous definition 156 | for (let i = prevProps.length - 1; i > -1; i--) { 157 | if (Object.keys(prevProps[i]).includes(key)) { 158 | if (Object.hasOwn(updated, key) === false) { 159 | updated[key] = prevProps[i][key]; 160 | break; 161 | } 162 | } 163 | } 164 | if (key === "$localId") continue; 165 | 166 | // Encode and render output 167 | const valueEncode = encodeVars(value, parse); 168 | 169 | const prefix = Object.hasOwn(updated, key) ? "" : "let "; 170 | if (key === "$local") { 171 | if (typeof value === "object") { 172 | codeHead += `__LOCALS.set(\`${props.$localId}\`, ${valueEncode});\n`; 173 | codeHead += `${prefix}${key} = __LOCALS.get(\`${props.$localId}\`);\n`; 174 | } else { 175 | codeHead += `${prefix}${key} = __LOCALS.get(${valueEncode});\n`; 176 | } 177 | } else if (key === "$global") { 178 | codeHead += `${prefix}${key} = ${valueEncode};\n`; 179 | } else { 180 | if (isLocal) { 181 | code += `${prefix}${key} = $local['${key}'];\n`; 182 | } else if (isGlobal) { 183 | code += `${prefix}${key} = $global['${key}'];\n`; 184 | } else { 185 | code += `${prefix}${key} = ${valueEncode};\n`; 186 | } 187 | } 188 | } 189 | env.code += codeHead + code; 190 | return updated; 191 | }; 192 | -------------------------------------------------------------------------------- /hypermore/src/mod.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, JSONObject, Options } from "./types.ts"; 2 | import { inlineTags, Node, parseHTML } from "./parse.ts"; 3 | import { 4 | addVars, 5 | encodeVars, 6 | envFooter, 7 | envHeader, 8 | parseVars, 9 | renderEnv, 10 | } from "./environment.ts"; 11 | import { escapeChars, spaceChar, toCamelCase } from "./utils.ts"; 12 | import tagIf from "./tag-if.ts"; 13 | import tagFor from "./tag-for.ts"; 14 | import tagHtml from "./tag-html.ts"; 15 | import tagPortal from "./tag-portal.ts"; 16 | import tagElement from "./tag-element.ts"; 17 | import tagFragment from "./tag-fragment.ts"; 18 | import tagComponent from "./tag-component.ts"; 19 | import tagCache, { getCacheMap } from "./tag-cache.ts"; 20 | 21 | /** List Hypermore tags */ 22 | export const customTags = new Set([ 23 | "ssr-cache", 24 | "ssr-element", 25 | "ssr-else", 26 | "ssr-elseif", 27 | "ssr-for", 28 | "ssr-fragment", 29 | "ssr-html", 30 | "ssr-if", 31 | "ssr-portal", 32 | "ssr-slot", 33 | ]); 34 | 35 | /** List of Hypermore extensions */ 36 | const customExtensions = [ 37 | tagCache, 38 | tagElement, 39 | tagFor, 40 | tagFragment, 41 | tagHtml, 42 | tagIf, 43 | tagPortal, 44 | ]; 45 | 46 | /** Node types have open and close tags */ 47 | export const renderTypes = new Set(["ELEMENT", "OPAQUE", "VOID"]); 48 | 49 | /** Reserved property names */ 50 | export const reservedProps = new Set(["$global", "$local"]); 51 | 52 | /** Hypermore class */ 53 | export class Hypermore { 54 | autoEscape: boolean; 55 | #globalProps: JSONObject; 56 | #templates: Map ; 57 | 58 | constructor(options: Options = {}) { 59 | this.autoEscape = true; 60 | this.#globalProps = {}; 61 | this.#templates = new Map(); 62 | if (options) this.setOptions(options); 63 | } 64 | 65 | /** Update options */ 66 | setOptions(options: Options): void { 67 | if (typeof options.autoEscape === "boolean") { 68 | this.autoEscape = options.autoEscape; 69 | } 70 | if (options.globalProps) { 71 | this.#globalProps = structuredClone(options.globalProps); 72 | } 73 | options.templates?.forEach((html, name) => { 74 | this.setTemplate(name, html); 75 | }); 76 | } 77 | 78 | /** Returns `true` if named template exists */ 79 | hasTemplate(name: string): boolean { 80 | return this.#templates.has(name); 81 | } 82 | 83 | /** 84 | * Set named template by HTML 85 | * @param name Custom element name 86 | * @param html HTML string 87 | */ 88 | setTemplate(name: string, html: string): void { 89 | if (tagComponent.match(name) === false) { 90 | throw new Error(`Invalid template name: "${name}"`); 91 | } 92 | const node = parseHTML(html, { 93 | rootTag: /^\w+$/.test(name) ? name : undefined, 94 | }); 95 | this.#templates.set(name, node); 96 | } 97 | 98 | /** Duplicate named template node */ 99 | cloneTemplate(name: string, env: Environment): Node | undefined { 100 | const template = this.#templates.get(name); 101 | if (template === undefined) return undefined; 102 | const node = template.clone(); 103 | this.parseNode(node, env); 104 | return node; 105 | } 106 | 107 | /** 108 | * Render HTML from string template 109 | * @param html Template string 110 | * @returns HTML string 111 | */ 112 | async render( 113 | html: string, 114 | props?: JSONObject, 115 | options?: Options, 116 | ): Promise { 117 | if (options) this.setOptions(options); 118 | // Create new render env 119 | const env: Environment = { 120 | code: envHeader, 121 | ctx: this, 122 | node: undefined, 123 | localProps: [], 124 | portals: new Map(), 125 | caches: new Map(), 126 | }; 127 | // Add global props object 128 | addVars( 129 | { ...this.#globalProps, $global: this.#globalProps }, 130 | [], 131 | env, 132 | false, 133 | ); 134 | // Parse and validate template node 135 | const node = parseHTML(html); 136 | this.parseNode(node, env); 137 | // Render root template node 138 | await this.renderNode(node, env, props); 139 | // Replace portals with extracted fragments 140 | for (const [name, comment] of env.portals) { 141 | env.code += `__PORTALS.set('${name}', '${comment}');\n`; 142 | } 143 | env.code += envFooter; 144 | let result = ""; 145 | try { 146 | result = await renderEnv(env); 147 | } catch (err) { 148 | console.error(err); 149 | } 150 | // Extract cache element for later renders 151 | if (env.caches.size) { 152 | const map = getCacheMap(this); 153 | for (const [name, id] of env.caches) { 154 | const parts = result.split(``); 155 | if (parts.length !== 3) { 156 | console.warn(` failed`); 157 | continue; 158 | } 159 | map.set(name, parts[1]); 160 | result = result.replaceAll(``, ""); 161 | } 162 | } 163 | return result; 164 | } 165 | 166 | /** 167 | * Validate tree and remove invalid child nodes 168 | * @param root Node 169 | */ 170 | parseNode(root: Node, env: Environment): void { 171 | // Track nodes to remove after traversal 172 | const remove = new Set (); 173 | root.traverse((node) => { 174 | // Flag custom tags as invisible for render switch 175 | if (customTags.has(node.tag)) { 176 | node.type = "INVISIBLE"; 177 | } 178 | // Validate custom tags 179 | for (const tag of customExtensions) { 180 | if (tag.match(node)) { 181 | if (tag.validate(node, env) === false) { 182 | remove.add(node); 183 | } 184 | break; 185 | } 186 | } 187 | // Return early so inner tags are ignored 188 | if (tagHtml.match(node)) { 189 | return false; 190 | } 191 | }); 192 | // Remove nodes that failed validation 193 | remove.forEach((node) => node.detach()); 194 | } 195 | 196 | /** 197 | * Render Node to HTML 198 | * @param node Node 199 | * @param props Local props 200 | * @returns HTML string 201 | */ 202 | async renderNode( 203 | node: Node, 204 | env: Environment, 205 | props: JSONObject = {}, 206 | script?: string, 207 | ): Promise { 208 | env.node = node; 209 | // Start nested block scope 210 | env.code += "{\n"; 211 | // Validate new props 212 | const newProps: JSONObject = {}; 213 | for (let [key, value] of Object.entries(props)) { 214 | key = toCamelCase(key); 215 | if (reservedProps.has(key)) { 216 | console.warn(`invalid prop "${key}" is reserved`); 217 | } else { 218 | newProps[key] = value; 219 | } 220 | } 221 | if (Object.keys(props).length) { 222 | newProps.$local = { ...newProps }; 223 | newProps.$localId = crypto.randomUUID(); 224 | } 225 | // Update props stack 226 | const updatedProps = addVars(newProps, env.localProps, env); 227 | env.localProps.push(newProps); 228 | // Append exports 229 | if (script) { 230 | env.code += script + "\n"; 231 | } 232 | render: switch (node.type) { 233 | case "COMMENT": 234 | env.code += `__EXPORT += \`${escapeChars(node.raw)}\`;\n`; 235 | break render; 236 | case "OPAQUE": 237 | await this.renderParent(node, env); 238 | break render; 239 | case "ROOT": 240 | await this.renderChildren(node, env); 241 | break render; 242 | case "STRAY": 243 | console.warn(`stray closing tag "${node.tag}"`); 244 | break render; 245 | case "TEXT": { 246 | await this.renderText(node, env); 247 | break render; 248 | } 249 | case "ELEMENT": 250 | case "VOID": 251 | if (tagComponent.match(node)) { 252 | await tagComponent.render(node, env); 253 | break render; 254 | } 255 | await this.renderParent(node, env); 256 | break render; 257 | case "INVISIBLE": 258 | switch (node.tag) { 259 | case "ssr-else": 260 | console.warn(` outside of `); 261 | break render; 262 | case "ssr-elseif": 263 | console.warn(` outside of `); 264 | break render; 265 | case "ssr-if": 266 | await tagIf.render(node, env); 267 | break render; 268 | case "ssr-for": 269 | await tagFor.render(node, env); 270 | break render; 271 | case "ssr-html": 272 | await tagHtml.render(node, env); 273 | break render; 274 | case "ssr-cache": 275 | await tagCache.render(node, env); 276 | break render; 277 | case "ssr-element": 278 | await tagElement.render(node, env); 279 | break render; 280 | case "ssr-fragment": 281 | await tagFragment.render(node, env); 282 | break render; 283 | case "ssr-portal": 284 | await tagPortal.render(node, env); 285 | break render; 286 | case "ssr-slot": 287 | await this.renderChildren(node, env); 288 | break render; 289 | } 290 | await this.renderParent(node, env); 291 | break render; 292 | } 293 | // End nested block scope 294 | env.code += "}\n"; 295 | // Reset prop values and stack 296 | if (Object.keys(updatedProps).length) { 297 | if (Object.hasOwn(updatedProps, "$local")) { 298 | updatedProps["$local"] = updatedProps["$localId"]; 299 | } 300 | addVars(updatedProps, env.localProps, env); 301 | } 302 | env.localProps.pop(); 303 | } 304 | 305 | /** 306 | * Render children of Node to HTML 307 | * @param node Node 308 | * @returns HTML string 309 | */ 310 | async renderChildren(node: Node, env: Environment): Promise { 311 | for (const child of node.children) { 312 | await this.renderNode(child, env); 313 | } 314 | } 315 | 316 | /** 317 | * Render parent Node to HTML 318 | * @param node Node 319 | * @returns HTML string 320 | */ 321 | async renderParent(node: Node, env: Environment): Promise { 322 | if (renderTypes.has(node.type)) { 323 | let tagOpen = node.tagOpen; 324 | // Parse attributes 325 | if (node.attributes.size) { 326 | tagOpen = `<${node.tag}\`+__ATTRIBUTES(__ATTR)+\``; 327 | tagOpen += node.type === "VOID" ? "/>" : ">"; 328 | // Setup attribute callback 329 | env.code += `\nconst __ATTR = new Map();\n`; 330 | for (let [key, value] of node.attributes) { 331 | value = encodeVars(value, true); 332 | env.code += `try { __ATTR.set('${key}', ${value}); } catch {}\n`; 333 | } 334 | } 335 | const autoEscape = env.ctx.autoEscape; 336 | env.ctx.autoEscape = false; 337 | await this.renderNode(new Node(null, "TEXT", tagOpen), env); 338 | env.ctx.autoEscape = autoEscape; 339 | } 340 | if (node.type === "OPAQUE") { 341 | const out = node.children.map((n) => n.toString()).join(""); 342 | env.code += `__EXPORT += \`${escapeChars(out)}\`;\n`; 343 | } else { 344 | await this.renderChildren(node, env); 345 | } 346 | if (renderTypes.has(node.type)) { 347 | await this.renderNode(new Node(null, "TEXT", node.tagClose), env); 348 | } 349 | } 350 | 351 | /** 352 | * Render text Node to HTML 353 | * @param node Node 354 | * @returns HTML string 355 | */ 356 | async renderText(node: Node, env: Environment): Promise { 357 | if (node.raw.length === 0) return; 358 | let text = node.raw; 359 | const inline = inlineTags.has(node.parent?.tag ?? ""); 360 | // Collapse full whitespace 361 | if (/^\s*$/.test(text)) { 362 | // Skip if start or end node 363 | // remove ROOT check parent === null? 364 | if (inline || node.parent?.type === "ROOT") { 365 | if (node.next === null || node.previous === null) return; 366 | } 367 | text = spaceChar(text, inline); 368 | } else { 369 | let match: RegExpMatchArray | null; 370 | // Collapse leading whitespace 371 | if ((match = text.match(/^\s{2,}/))) { 372 | text = spaceChar(match[0], inline) + text.trimStart(); 373 | } 374 | // Collapse trailing whitespace 375 | if ((match = text.match(/\s{2,}$/))) { 376 | text = text.trimEnd() + spaceChar(match[0], inline); 377 | } 378 | // Collapse inner whitespace 379 | text = text.replace(/\s{2,}/g, (str) => spaceChar(str, inline)); 380 | // Escape and parse text 381 | if (env.ctx.autoEscape) text = escapeChars(text); 382 | text = parseVars(text, env.ctx.autoEscape); 383 | } 384 | env.code += `__EXPORT += \`${text}\`;\n`; 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /hypermore/src/parse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | escape, 3 | getParseOptions, 4 | inlineTags, 5 | Node, 6 | parseHTML as originalParseHTML, 7 | type ParseOptions, 8 | unescape, 9 | } from "@dbushell/hyperless"; 10 | 11 | export { escape, inlineTags, Node, unescape }; 12 | 13 | // Extend defaults with special tags 14 | const parseOptions = getParseOptions(); 15 | parseOptions.voidTags.add("ssr-else"); 16 | parseOptions.voidTags.add("ssr-elseif"); 17 | 18 | // Allow shadow DOM templates 19 | parseOptions.opaqueTags.delete("template"); 20 | 21 | /** Parse HTML text into Node tree */ 22 | export const parseHTML = ( 23 | html: string, 24 | options?: Partial , 25 | ): Node => 26 | originalParseHTML(html, { 27 | ...parseOptions, 28 | ...options, 29 | }); 30 | -------------------------------------------------------------------------------- /hypermore/src/tag-cache.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag } from "./types.ts"; 2 | import type { Hypermore } from "./mod.ts"; 3 | import { Node } from "./parse.ts"; 4 | import { escapeChars } from "./utils.ts"; 5 | 6 | const tagName = "ssr-cache"; 7 | 8 | const cacheMap = new WeakMap >(); 9 | 10 | /** Return map linked to context (create new if needed) */ 11 | export const getCacheMap = (ctx: Hypermore): Map => { 12 | if (cacheMap.has(ctx)) return cacheMap.get(ctx)!; 13 | const map = new Map (); 14 | cacheMap.set(ctx, map); 15 | return map; 16 | }; 17 | 18 | const match = (node: string | Node): boolean => 19 | (typeof node === "string" ? node : node.tag) === tagName; 20 | 21 | const validate = (node: Node, env: Environment): boolean => { 22 | const name = node.attributes.get("name"); 23 | if (name === undefined) { 24 | console.warn(` missing "name" property`); 25 | return false; 26 | } 27 | const map = getCacheMap(env.ctx); 28 | if (map.has(name) === false && node.size === 0) { 29 | console.warn(` with no content`); 30 | return false; 31 | } 32 | return true; 33 | }; 34 | 35 | const render = async (node: Node, env: Environment): Promise => { 36 | const name = node.attributes.get("name")!; 37 | // Return cached HTML 38 | const map = getCacheMap(env.ctx); 39 | if (map.has(name)) { 40 | env.code += `__EXPORT += \`${escapeChars(map.get(name)!)}\`;\n`; 41 | return; 42 | } 43 | // Flag element to be cached after final render 44 | const id = crypto.randomUUID(); 45 | env.caches.set(name, id); 46 | node.insertAt(new Node(null, "COMMENT", ``), 0); 47 | node.append(new Node(null, "COMMENT", ``)); 48 | await env.ctx.renderChildren(node, env); 49 | }; 50 | 51 | const Tag: HyperTag = { 52 | tagName, 53 | match, 54 | render, 55 | validate, 56 | }; 57 | 58 | export default Tag; 59 | -------------------------------------------------------------------------------- /hypermore/src/tag-component.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag } from "./types.ts"; 2 | import { Node } from "./parse.ts"; 3 | 4 | /** Cache of cloned templates */ 5 | const components = new WeakSet (); 6 | 7 | const componentTypes = new Set(["ELEMENT", "VOID"]); 8 | 9 | const match = (node: string | Node): boolean => { 10 | if (node instanceof Node) { 11 | if (componentTypes.has(node.type) === false) { 12 | return false; 13 | } 14 | } 15 | const tagName = typeof node === "string" ? node : node.tag; 16 | // Disallow reserved prefix 17 | if (tagName.startsWith("ssr-")) return false; 18 | // Match custom element naming pattern 19 | return /([a-z][\w]*-[\w]+)/.test(tagName); 20 | }; 21 | 22 | // Already cloned, or template exists for tag name 23 | const validate = (node: Node, env: Environment): boolean => 24 | components.has(node) || env.ctx.hasTemplate(node.tag); 25 | 26 | const render = async (node: Node, env: Environment): Promise => { 27 | // Ignore custom elements with no template 28 | if (validate(node, env) === false) { 29 | await env.ctx.renderParent(node, env); 30 | return; 31 | } 32 | // Ignore elements within block 33 | const parent = node.closest((n) => n.tag === "ssr-html"); 34 | if (parent) { 35 | await env.ctx.renderParent(node, env); 36 | return; 37 | } 38 | 39 | // Clone the component 40 | const template = env.ctx.cloneTemplate(node.tag, env)!; 41 | template.type = "INVISIBLE"; 42 | components.add(template); 43 | 44 | const slots = new Map (); 45 | const targets = new Map (); 46 | const fragments = new Set (); 47 | const cleared = new Set (); 48 | 49 | // Find all slots in component template 50 | template.traverse((n) => { 51 | if (n.tag !== "ssr-slot") return; 52 | const name = n.attributes.get("name"); 53 | slots.set(name ?? "default", n); 54 | }); 55 | 56 | // Avoid infinite loops 57 | const nested = new Set (); 58 | template.traverse((n) => { 59 | if (n.tag === "ssr-if" || n.tag === "ssr-for") return false; 60 | if (n.tag === node.tag) nested.add(n); 61 | }); 62 | if (nested.size) { 63 | nested.forEach((n) => n.detach()); 64 | console.warn(`<${node.tag}> infinite nested loop`); 65 | } 66 | 67 | // Find fragments and assign their children to slot 68 | node.traverse((n) => { 69 | if (match(n)) return false; 70 | if (n.tag !== "ssr-fragment") return; 71 | fragments.add(n); 72 | const slot = n.attributes.get("slot")!; 73 | if (slot) n.children.forEach((c) => targets.set(c, slot)); 74 | }); 75 | 76 | // Assign top-level childen to default slot 77 | if (node.size && slots.has("default")) { 78 | node.children.forEach((n) => { 79 | if (n.tag === "ssr-fragment") return; 80 | targets.set(n, "default"); 81 | }); 82 | } 83 | 84 | // Assign target nodes to their slots 85 | for (const [target, name] of targets.entries()) { 86 | const slot = slots.get(name); 87 | if (slot === undefined) continue; 88 | // Clear fallback content 89 | if (cleared.has(slot) === false) { 90 | cleared.add(slot); 91 | slot.clear(); 92 | } 93 | slot.append(target); 94 | } 95 | 96 | // Find component script 97 | let script: Node | undefined; 98 | for (const child of template.children) { 99 | if (child.tag !== "script") continue; 100 | if (child.attributes.get("context") !== "component") continue; 101 | script = child; 102 | break; 103 | } 104 | let code = ""; 105 | if (script) { 106 | script.detach(); 107 | code = script.at(0)!.raw; 108 | } 109 | 110 | const props = Object.fromEntries(node.attributes); 111 | 112 | await env.ctx.renderNode(template, env, props, code); 113 | }; 114 | 115 | const Tag: HyperTag = { 116 | tagName: "ssr-component", 117 | match, 118 | render, 119 | validate, 120 | }; 121 | 122 | export default Tag; 123 | -------------------------------------------------------------------------------- /hypermore/src/tag-element.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag } from "./types.ts"; 2 | import { Node } from "./parse.ts"; 3 | 4 | const tagName = "ssr-element"; 5 | 6 | const match = (node: string | Node): boolean => 7 | (typeof node === "string" ? node : node.tag) === tagName; 8 | 9 | const validate = (node: Node): boolean => { 10 | if (node.attributes.has("tag") === false) { 11 | console.warn(` missing "tag" property`); 12 | return false; 13 | } 14 | return true; 15 | }; 16 | 17 | const render = async (node: Node, env: Environment): Promise => { 18 | // Create new node from tag attribute 19 | const tag = node.attributes.get("tag")!; 20 | const raw = node.raw.replace(" 8 | (typeof node === "string" ? node : node.tag) === tagName; 9 | 10 | const validate = (node: Node): boolean => { 11 | if (node.size === 0) { 12 | console.warn(` with no statement`); 13 | return false; 14 | } 15 | if (node.attributes.has("of") === false) { 16 | console.warn(` missing "of" property`); 17 | return false; 18 | } 19 | const itemProp = node.attributes.get("item"); 20 | if (itemProp === undefined || isVariable(itemProp) === false) { 21 | console.warn(` invalid "item" property`); 22 | return false; 23 | } 24 | const indexProp = node.attributes.get("index"); 25 | if (indexProp && isVariable(indexProp) === false) { 26 | console.warn(` invalid "index" property`); 27 | return false; 28 | } 29 | return true; 30 | }; 31 | 32 | const render = async (node: Node, env: Environment): Promise => { 33 | const item = node.attributes.get("item")!; 34 | const index = node.attributes.get("index"); 35 | const expression = node.attributes.get("of")!; 36 | // Stack items prop 37 | const forProps = { __ITEMS: `{{${expression}}}` }; 38 | addVars(forProps, env.localProps, env); 39 | env.localProps.push(forProps); 40 | env.code += 41 | `for (const [__INDEX, __ITEM] of [...__FOR_ITEMS(__ITEMS)].entries()) {\n`; 42 | // Stack item prop 43 | const itemProps = { [item]: "{{__ITEM}}" }; 44 | if (index) itemProps[index] = "{{__INDEX}}"; 45 | const updatedProps = addVars(itemProps, env.localProps, env); 46 | env.localProps.push(itemProps); 47 | // Render children 48 | env.uuid = "__INDEX"; 49 | for (const child of node.children) { 50 | await env.ctx.renderNode(child, env); 51 | } 52 | env.uuid = undefined; 53 | env.code += `}\n`; 54 | // Reset props stack 55 | addVars(updatedProps, env.localProps, env); 56 | env.localProps.pop(); 57 | env.localProps.pop(); 58 | }; 59 | 60 | const Tag: HyperTag = { 61 | tagName, 62 | match, 63 | render, 64 | validate, 65 | }; 66 | 67 | export default Tag; 68 | -------------------------------------------------------------------------------- /hypermore/src/tag-fragment.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag, Node } from "./types.ts"; 2 | 3 | const tagName = "ssr-fragment"; 4 | 5 | const match = (node: string | Node): boolean => 6 | (typeof node === "string" ? node : node.tag) === tagName; 7 | 8 | const validate = (node: Node): boolean => { 9 | const slot = node.attributes.get("slot"); 10 | const portal = node.attributes.get("portal"); 11 | if (slot === undefined && portal === undefined) { 12 | console.warn(` missing "slot" or "portal" property`); 13 | return false; 14 | } 15 | return true; 16 | }; 17 | 18 | const render = async (node: Node, env: Environment): Promise => { 19 | const portal = node.attributes.get("portal"); 20 | if (portal === undefined) { 21 | console.warn(` unknown`); 22 | return; 23 | } 24 | env.code += `const __TMP = __EXPORT; 25 | __FRAGMENTS.add({portal: '${portal}', html: (() => { 26 | let __EXPORT = '';\n`; 27 | await env.ctx.renderChildren(node, env); 28 | env.code += `return __EXPORT; 29 | })()}); 30 | __EXPORT = __TMP; 31 | `; 32 | }; 33 | 34 | const Tag: HyperTag = { 35 | tagName, 36 | match, 37 | render, 38 | validate, 39 | }; 40 | 41 | export default Tag; 42 | -------------------------------------------------------------------------------- /hypermore/src/tag-html.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag, Node } from "./types.ts"; 2 | 3 | const tagName = "ssr-html"; 4 | 5 | const match = (node: string | Node): boolean => 6 | (typeof node === "string" ? node : node.tag) === tagName; 7 | 8 | const validate = (node: Node): boolean => { 9 | if (node.size === 0) { 10 | console.warn(` with no content`); 11 | return false; 12 | } 13 | return true; 14 | }; 15 | 16 | const render = async (node: Node, env: Environment): Promise => { 17 | // Disable auto escape and re-renable to previous state later 18 | const autoEscape = env.ctx.autoEscape; 19 | env.ctx.autoEscape = false; 20 | await env.ctx.renderChildren(node, env); 21 | env.ctx.autoEscape = autoEscape; 22 | }; 23 | 24 | const Tag: HyperTag = { 25 | tagName, 26 | match, 27 | render, 28 | validate, 29 | }; 30 | 31 | export default Tag; 32 | -------------------------------------------------------------------------------- /hypermore/src/tag-if.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag } from "./types.ts"; 2 | import { addVars } from "./environment.ts"; 3 | import { Node } from "./parse.ts"; 4 | 5 | const tagName = "ssr-if"; 6 | 7 | const match = (node: string | Node): boolean => 8 | (typeof node === "string" ? node : node.tag) === tagName; 9 | 10 | const validate = (node: Node): boolean => { 11 | if (node.size === 0) { 12 | console.warn(` with no statement`); 13 | return false; 14 | } 15 | if (node.attributes.has("condition") === false) { 16 | console.warn(` missing "condition" property`); 17 | return false; 18 | } 19 | return true; 20 | }; 21 | 22 | const render = async (node: Node, env: Environment): Promise => { 23 | // First condition 24 | const expression = node.attributes.get("condition")!; 25 | 26 | // List of conditions to check in order 27 | const conditions = [{ expression, statement: new Node(null, "INVISIBLE") }]; 28 | 29 | // Iterate over child nodes 30 | // and are added as new conditions 31 | // All other nodes are appended to the last condition 32 | for (const child of [...node.children]) { 33 | if (child.tag === "ssr-else") { 34 | conditions.push({ 35 | expression: "true", 36 | statement: new Node(null, "INVISIBLE"), 37 | }); 38 | continue; 39 | } 40 | if (child.tag === "ssr-elseif") { 41 | let expression = child.attributes.get("condition"); 42 | if (expression === undefined) { 43 | console.warn(` with invalid condition`); 44 | expression = "false"; 45 | } 46 | conditions.push({ 47 | expression, 48 | statement: new Node(null, "INVISIBLE"), 49 | }); 50 | continue; 51 | } 52 | // Append to last condition 53 | conditions.at(-1)?.statement.append(child); 54 | } 55 | 56 | // Add callbacks to render statements 57 | for (let i = 0; i < conditions.length; i++) { 58 | const { expression, statement } = conditions[i]; 59 | env.code += `const __S${i} = () => {\n`; 60 | await env.ctx.renderChildren(statement, env); 61 | env.code += `}\n`; 62 | addVars({ [`__C${i}`]: `{{${expression}}}` }, [], env, true); 63 | } 64 | 65 | for (let i = 0; i < conditions.length; i++) { 66 | // Alternate conditions 67 | if (i === 0) { 68 | env.code += `if (__C${i}) {\n`; 69 | } else if (i < conditions.length - 1) { 70 | env.code += `} else if (__C${i}) {\n`; 71 | } else { 72 | env.code += `} else {\n`; 73 | } 74 | env.code += `__S${i}();\n`; 75 | } 76 | // Close final statement 77 | env.code += "}\n"; 78 | }; 79 | 80 | const Tag: HyperTag = { 81 | tagName, 82 | match, 83 | render, 84 | validate, 85 | }; 86 | 87 | export default Tag; 88 | -------------------------------------------------------------------------------- /hypermore/src/tag-portal.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, HyperTag } from "./types.ts"; 2 | import { Node } from "./parse.ts"; 3 | 4 | const tagName = "ssr-portal"; 5 | 6 | const match = (node: string | Node): boolean => 7 | (typeof node === "string" ? node : node.tag) === tagName; 8 | 9 | const validate = (node: Node, env: Environment): boolean => { 10 | const name = node.attributes.get("name"); 11 | if (name === undefined) { 12 | console.warn(` missing "name" property`); 13 | return false; 14 | } 15 | // Fragments may appear before or after this portal in the node tree 16 | // A temporary comment is replaced later with extracted fragments 17 | // Random UUID is used to avoid authored comment conflicts 18 | const comment = ``; 19 | node.append(new Node(node, "COMMENT", comment)); 20 | env.portals.set(name, comment); 21 | return true; 22 | }; 23 | 24 | const render = async (node: Node, env: Environment): Promise => { 25 | await env.ctx.renderChildren(node, env); 26 | }; 27 | 28 | const Tag: HyperTag = { 29 | tagName, 30 | match, 31 | render, 32 | validate, 33 | }; 34 | 35 | export default Tag; 36 | -------------------------------------------------------------------------------- /hypermore/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "./parse.ts"; 2 | import type { Hypermore } from "./mod.ts"; 3 | 4 | export type { Hypermore, Node }; 5 | 6 | /** Props value */ 7 | export type JSONValue = 8 | | boolean 9 | | number 10 | | null 11 | | string 12 | | JSONArray 13 | | JSONObject; 14 | 15 | /** Props array */ 16 | export type JSONArray = Array ; 17 | 18 | /** Props object */ 19 | export interface JSONObject { 20 | [key: string]: JSONValue; 21 | } 22 | 23 | /** Hypermore class configuration */ 24 | export type Options = { 25 | autoEscape?: boolean; 26 | globalProps?: JSONObject; 27 | templates?: Map ; 28 | }; 29 | 30 | /** Hypermore enviroment object */ 31 | export type Environment = { 32 | /** Current instance */ 33 | ctx: Hypermore; 34 | /** Current local props */ 35 | localProps: Array ; 36 | /** Reference to current node rendering */ 37 | node: Node | undefined; 38 | /** Discovered portal names and comment placeholders */ 39 | portals: Map ; 40 | /** Cache name and IDs to render and store */ 41 | caches: Map ; 42 | /** Compiled code to evaluate */ 43 | code: string; 44 | /** Loop index */ 45 | uuid?: string; 46 | }; 47 | 48 | /** Hypermore HTML tag extension */ 49 | export type HyperTag = { 50 | /** Custom tag name */ 51 | tagName: string; 52 | /** Node tag matches the custom tag name */ 53 | match: (node: string | Node) => boolean; 54 | /** Node is a valid instance of the custom tag */ 55 | validate: (node: Node, env: Environment) => boolean; 56 | /** Node to HTML string */ 57 | render: (node: Node, env: Environment) => Promise ; 58 | }; 59 | -------------------------------------------------------------------------------- /hypermore/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** Returns true if name is valid variable */ 2 | export const isVariable = (name: string): boolean => { 3 | return /^[a-zA-Z_$]\w*$/.test(name); 4 | }; 5 | 6 | /** Return custom element name from path */ 7 | export const componentName = (path: string | URL): string => { 8 | let name = path.toString(); 9 | name = name.split("/").at(-1) ?? name; 10 | name = name.split(".", 1)[0]; 11 | name = name.replace(/[^\w:-]/g, ""); 12 | return toKebabCase(name); 13 | }; 14 | 15 | /** Escape characters for Javascript string template */ 16 | export const escapeChars = (str: string, chars = ["`", "${"]): string => { 17 | str = str.replace(/\\/g, "\\\\"); 18 | for (const c of chars) str = str.replaceAll(c, "\\" + c); 19 | return str; 20 | }; 21 | 22 | /** Replacement character for text nodes */ 23 | export const spaceChar = (str: string, inline = false) => { 24 | return inline ? " " : str.indexOf("\n") > -1 ? "\n" : " "; 25 | }; 26 | 27 | /** 28 | * @std/text - https://jsr.io/@std/text 29 | * Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. 30 | */ 31 | const CAPITALIZED_WORD_REGEXP = /\p{Lu}\p{Ll}+/u; 32 | const ACRONYM_REGEXP = /\p{Lu}+(?=(\p{Lu}\p{Ll})|\P{L}|\b)/u; 33 | const LOWERCASED_WORD_REGEXP = /(\p{Ll}+)/u; 34 | const ANY_LETTERS = /\p{L}+/u; 35 | const DIGITS_REGEXP = /\p{N}+/u; 36 | 37 | const WORD_OR_NUMBER_REGEXP = new RegExp( 38 | `${CAPITALIZED_WORD_REGEXP.source}|${ACRONYM_REGEXP.source}|${LOWERCASED_WORD_REGEXP.source}|${ANY_LETTERS.source}|${DIGITS_REGEXP.source}`, 39 | "gu", 40 | ); 41 | 42 | export function splitToWords(input: string) { 43 | return input.match(WORD_OR_NUMBER_REGEXP) ?? []; 44 | } 45 | 46 | export function capitalizeWord(word: string): string { 47 | return word 48 | ? word?.[0]?.toLocaleUpperCase() + word.slice(1).toLocaleLowerCase() 49 | : word; 50 | } 51 | 52 | export function toKebabCase(input: string): string { 53 | input = input.trim(); 54 | return splitToWords(input).join("-").toLocaleLowerCase(); 55 | } 56 | 57 | export function toCamelCase(input: string): string { 58 | input = input.trim(); 59 | const [first = "", ...rest] = splitToWords(input); 60 | return [first.toLocaleLowerCase(), ...rest.map(capitalizeWord)].join(""); 61 | } 62 | -------------------------------------------------------------------------------- /hypermore/test/attributes_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore } from "./mod.ts"; 3 | 4 | Deno.test("attributes", async (test) => { 5 | await test.step("undefined expression", async () => { 6 | const html = ``; 7 | const output = await hypermore.render(html); 8 | assertEquals(output, ``); 9 | }); 10 | await test.step("null expression", async () => { 11 | const html = ``; 12 | const output = await hypermore.render(html); 13 | assertEquals(output, ``); 14 | }); 15 | await test.step("false expression", async () => { 16 | const html = ``; 17 | const output = await hypermore.render(html); 18 | assertEquals(output, ``); 19 | }); 20 | await test.step("false string", async () => { 21 | const html = ``; 22 | const output = await hypermore.render(html); 23 | assertEquals(output, ``); 24 | }); 25 | await test.step("true expression", async () => { 26 | const html = ``; 27 | const output = await hypermore.render(html); 28 | assertEquals(output, ``); 29 | }); 30 | await test.step("true string", async () => { 31 | const html = ``; 32 | const output = await hypermore.render(html); 33 | assertEquals(output, ``); 34 | }); 35 | await test.step("no attributes", async () => { 36 | const html = ``; 37 | const output = await hypermore.render(html); 38 | assertEquals(output, ``); 39 | }); 40 | await test.step("empty string", async () => { 41 | const html = ``; 42 | const output = await hypermore.render(html); 43 | assertEquals(output, ``); 44 | }); 45 | await test.step("random expressions", async () => { 46 | const html = ``; 47 | const output = await hypermore.render(html); 48 | assertEquals(output, ``); 49 | }); 50 | await test.step("grave attribute", async () => { 51 | const html = ``; 52 | const output = await hypermore.render(html); 53 | assertEquals(output, ``); 54 | }); 55 | await test.step("escaped attribute", async () => { 56 | const html = ``; 57 | const output = await hypermore.render(html); 58 | assertEquals(output, ``); 59 | }); 60 | await test.step("undefined attribute", async () => { 61 | const html = ``; 62 | const output = await hypermore.render(html); 63 | assertEquals(output, ``); 64 | }); 65 | await test.step("for loop attributes", async () => { 66 | const html = 67 | ` `; 68 | const output = await hypermore.render(html); 69 | assertEquals(output, ``); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /hypermore/test/component_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { globalProps, hypermore, warn } from "./mod.ts"; 3 | 4 | hypermore.setTemplate("s-1", ` `); 5 | hypermore.setTemplate("s-2", ``); 6 | hypermore.setTemplate( 7 | "s-3", 8 | ``, 9 | ); 10 | hypermore.setTemplate( 11 | "s-4", 12 | ` `, 13 | ); 14 | hypermore.setTemplate("loop-slot", ` `); 15 | hypermore.setTemplate("void-slot", ` `); 16 | hypermore.setTemplate( 17 | "fallback-slot", 18 | ` `, 19 | ); 20 | hypermore.setTemplate( 21 | "named-slot", 22 | ` Fallack! `, 23 | ); 24 | hypermore.setTemplate( 25 | "my-button", 26 | ``, 27 | ); 28 | 29 | Deno.test("components", async (test) => { 30 | warn.capture(); 31 | await test.step("basic import", async () => { 32 | const html = ` Unused! Center! `; 33 | const output = await hypermore.render(html); 34 | assertEquals(output, ``); 35 | }); 36 | await test.step("unknown import", async () => { 37 | const html = `Pass! `; 38 | const output = await hypermore.render(html); 39 | assertEquals( 40 | output, 41 | `{{$global.number}} `, 42 | ); 43 | }); 44 | await test.step("infinite loop", async () => { 45 | const html = 46 | `${globalProps.number} 1 `; 47 | const output = await hypermore.render(html); 48 | assertEquals(output, `123`); 49 | assertEquals(warn.stack.pop(), ["2 3 infinite nested loop"]); 50 | }); 51 | warn.release(); 52 | await test.step("nested slot (1)", async () => { 53 | const html = ` Pass! `; 54 | const output = await hypermore.render(html); 55 | assertEquals( 56 | output, 57 | ``, 58 | ); 59 | }); 60 | await test.step("nested slot (2)", async () => { 61 | const html = `Pass!`; 62 | const output = await hypermore.render(html); 63 | assertEquals( 64 | output, 65 | ` {{pass}} `, 66 | ); 67 | }); 68 | await test.step("nested slot (3)", async () => { 69 | const html = `Pass!`; 70 | const output = await hypermore.render(html); 71 | assertEquals( 72 | output, 73 | ` Pass! `, 74 | ); 75 | }); 76 | await test.step("void slot", async () => { 77 | const html = `Pass!`; 78 | const output = await hypermore.render(html); 79 | assertEquals(output, `Pass! `); 80 | }); 81 | await test.step("fallback slot (empty)", async () => { 82 | const html = `Pass! `; 83 | const output = await hypermore.render(html); 84 | assertEquals(output, ``); 85 | }); 86 | await test.step("fallback slot (populated)", async () => { 87 | const html = `Fallack! `; 88 | const output = await hypermore.render(html); 89 | assertEquals(output, `Populated! `); 90 | }); 91 | await test.step("named slot (1)", async () => { 92 | const html = 93 | `Populated! `; 94 | const output = await hypermore.render(html); 95 | assertEquals(output, ` Start! Start! Center! `); 96 | }); 97 | await test.step("named slot (2)", async () => { 98 | const html = 99 | ``; 100 | const output = await hypermore.render(html); 101 | assertEquals(output, ` Start! End! Start! Center! End! `); 102 | }); 103 | await test.step("named slot + unused", async () => { 104 | const html = 105 | ``; 106 | const output = await hypermore.render(html); 107 | assertEquals(output, ` End! Unused! Center! End! `); 108 | }); 109 | await test.step("named slot + default", async () => { 110 | const html = 111 | ``; 112 | const output = await hypermore.render(html); 113 | assertEquals(output, ` Start! Middle! End! Start! Middle! End! `); 114 | }); 115 | await test.step("named slot + props", async () => { 116 | const html = 117 | ``; 118 | const output = await hypermore.render(html); 119 | assertEquals(output, ` {{start}} {{end}} Start! Center! End! `); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /hypermore/test/concurrency_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { Hypermore } from "../mod.ts"; 3 | 4 | export const hypermore = new Hypermore({ 5 | globalProps: { 6 | heading: "Pass", 7 | }, 8 | }); 9 | 10 | Deno.test("concurrency", async () => { 11 | const promises: Array> = []; 12 | for (let i = 0; i < 100; i++) { 13 | const expected = ` Pass ${i}
`; 14 | promises.push( 15 | hypermore 16 | .render("{{heading}} {{i}}
", { 17 | i, 18 | }) 19 | .then((actual) => { 20 | assertEquals(actual, expected); 21 | }), 22 | ); 23 | } 24 | await Promise.all(promises); 25 | }); 26 | -------------------------------------------------------------------------------- /hypermore/test/element_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore, warn } from "./mod.ts"; 3 | 4 | Deno.test("tag", async (test) => { 5 | warn.capture(); 6 | await test.step("missing tag", async () => { 7 | const html = ` Fail! `; 8 | const output = await hypermore.render(html); 9 | assertEquals(output, ""); 10 | assertEquals(warn.stack.pop(), ['missing "tag" property']); 11 | }); 12 | warn.release(); 13 | await test.step("open and close tags", async () => { 14 | const html = ` Pass! `; 15 | const output = await hypermore.render(html); 16 | assertEquals(output, "Pass!
"); 17 | }); 18 | await test.step("additional attributes", async () => { 19 | const html = 20 | `Pass! `; 21 | const output = await hypermore.render(html); 22 | assertEquals(output, 'Pass!
'); 23 | }); 24 | await test.step("attribute expression", async () => { 25 | const html = `Pass! `; 26 | const output = await hypermore.render(html, { tag: "h1" }); 27 | assertEquals(output, 'Pass!
'); 28 | }); 29 | await test.step("inherited attribute", async () => { 30 | const html = 31 | ``; 32 | const output = await hypermore.render(html); 33 | assertEquals(output, ' Pass! Pass!
'); 34 | }); 35 | await test.step("non-inherited attribute", async () => { 36 | const html = 37 | ``; 38 | const output = await hypermore.render(html); 39 | assertEquals(output, ' Pass! Pass!
'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /hypermore/test/for_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore, warn } from "./mod.ts"; 3 | 4 | Deno.test("tag", async (test) => { 5 | warn.capture(); 6 | await test.step("void statement", async () => { 7 | const html = ` `; 8 | const output = await hypermore.render(html); 9 | assertEquals(output, ""); 10 | assertEquals(warn.stack.pop(), [" with no statement"]); 11 | }); 12 | await test.step("empty statement", async () => { 13 | const html = ` `; 14 | const output = await hypermore.render(html); 15 | assertEquals(output, ""); 16 | assertEquals(warn.stack.pop(), [" with no statement"]); 17 | }); 18 | await test.step('missing "of" property', async () => { 19 | const html = ` Fail!`; 20 | const output = await hypermore.render(html); 21 | assertEquals(output, ""); 22 | assertEquals(warn.stack.pop(), [' missing "of" property']); 23 | }); 24 | await test.step('missing "item" property', async () => { 25 | const html = ` Fail!`; 26 | const output = await hypermore.render(html); 27 | assertEquals(output, ""); 28 | assertEquals(warn.stack.pop(), [' invalid "item" property']); 29 | }); 30 | await test.step('invalid "item" property', async () => { 31 | const html = ` Fail!`; 32 | const output = await hypermore.render(html); 33 | assertEquals(output, ""); 34 | assertEquals(warn.stack.pop(), [' invalid "item" property']); 35 | }); 36 | await test.step('invalid "index" property', async () => { 37 | const html = ` Fail!`; 38 | const output = await hypermore.render(html); 39 | assertEquals(output, ""); 40 | assertEquals(warn.stack.pop(), [' invalid "index" property']); 41 | }); 42 | await test.step("number range", async () => { 43 | const html = ` {{n + 1}}`; 44 | const output = await hypermore.render(html); 45 | assertEquals(output, "123"); 46 | }); 47 | await test.step("characters", async () => { 48 | const html = ` {{n}}`; 49 | const output = await hypermore.render(html); 50 | assertEquals(output, "abc"); 51 | }); 52 | await test.step("array", async () => { 53 | const html = ` {{n}}`; 54 | const output = await hypermore.render(html); 55 | assertEquals(output, "123abc"); 56 | }); 57 | await test.step("array (global prop)", async () => { 58 | const html = ` {{n}}`; 59 | const output = await hypermore.render(html); 60 | assertEquals(output, "123abc"); 61 | }); 62 | await test.step("props reset", async () => { 63 | const html = ` {{n}}{{i}}{{n}}`; 64 | const output = await hypermore.render(html, { 65 | i: "A", 66 | n: "B", 67 | }); 68 | assertEquals(output, "012AB"); 69 | }); 70 | warn.release(); 71 | }); 72 | -------------------------------------------------------------------------------- /hypermore/test/html_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { globalProps, hypermore, warn } from "./mod.ts"; 3 | 4 | Deno.test(" tag", async (test) => { 5 | await test.step("unescaped variable", async () => { 6 | const html = ` {{$global.entities}} `; 7 | const output = await hypermore.render(html); 8 | assertEquals(output, globalProps.entities); 9 | }); 10 | await test.step("unrendered component", async () => { 11 | const html = ``; 12 | const output = await hypermore.render(html); 13 | assertEquals(output, ' '); 14 | }); 15 | await test.step("unrendered ", async () => { 16 | const html = 17 | ` `; 18 | const output = await hypermore.render(html); 19 | assertEquals( 20 | output, 21 | ` {{$global.entities}} ${globalProps.entities} `, 22 | ); 23 | }); 24 | await test.step("unrendered", async () => { 25 | const html = 26 | ` `; 27 | const output = await hypermore.render(html); 28 | assertEquals( 29 | output, 30 | ` {{$global.entities}} ${globalProps.entities} `, 31 | ); 32 | }); 33 | warn.capture(); 34 | await test.step("empty html", async () => { 35 | const html = ``; 36 | const output = await hypermore.render(html); 37 | assertEquals(output, ""); 38 | assertEquals(warn.stack.pop(), [" with no content"]); 39 | }); 40 | warn.release(); 41 | }); 42 | -------------------------------------------------------------------------------- /hypermore/test/if_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore, warn } from "./mod.ts"; 3 | 4 | Deno.test(" tag", async (test) => { 5 | warn.capture(); 6 | await test.step("void statement", async () => { 7 | const html = ` `; 8 | const output = await hypermore.render(html); 9 | assertEquals(output, ""); 10 | assertEquals(warn.stack.pop(), [" with no statement"]); 11 | }); 12 | await test.step("empty statement", async () => { 13 | const html = ` `; 14 | const output = await hypermore.render(html); 15 | assertEquals(output, ""); 16 | assertEquals(warn.stack.pop(), [" with no statement"]); 17 | }); 18 | await test.step("missing condition", async () => { 19 | const html = ` Fail!`; 20 | const output = await hypermore.render(html); 21 | assertEquals(output, ""); 22 | assertEquals(warn.stack.pop(), [' missing "condition" property']); 23 | }); 24 | await test.step("true condition", async () => { 25 | const html = ` Pass!`; 26 | const output = await hypermore.render(html); 27 | assertEquals(output, "Pass!"); 28 | }); 29 | await test.step("false condition", async () => { 30 | const html = ` Fail!`; 31 | const output = await hypermore.render(html); 32 | assertEquals(output, ""); 33 | }); 34 | await test.step(" condition", async () => { 35 | const html = ` 36 | 37 | Fail! 38 | 39 | Pass! 40 | `; 41 | const output = await hypermore.render(html); 42 | assertEquals(output.trim(), "Pass!"); 43 | }); 44 | await test.step(" condition", async () => { 45 | const html = ` 46 | 47 | Fail 1 48 | 49 | Fail 2 50 | 51 | Pass! 52 | 53 | Fail 3 54 | `; 55 | const output = await hypermore.render(html); 56 | assertEquals(output.trim(), "Pass!"); 57 | }); 58 | await test.step(" condition", async () => { 59 | const html = ` 60 | 61 | Fail 1 62 | 63 | Fail 2 64 | 65 | Pass! 66 | `; 67 | const output = await hypermore.render(html); 68 | assertEquals(output.trim(), "Pass!"); 69 | }); 70 | warn.release(); 71 | }); 72 | -------------------------------------------------------------------------------- /hypermore/test/misc_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore } from "./mod.ts"; 3 | 4 | Deno.test("misc", async (test) => { 5 | await test.step("inline svg", async () => { 6 | const html = ``; 9 | const output = await hypermore.render(html); 10 | assertEquals(output, html); 11 | }); 12 | await test.step("inline style", async () => { 13 | const html = ``; 18 | const output = await hypermore.render(html); 19 | assertEquals(output, html); 20 | }); 21 | await test.step("inline script", async () => { 22 | const html = ``; 27 | const output = await hypermore.render(html); 28 | assertEquals(output, html); 29 | }); 30 | await test.step("opaque tag attributes", async () => { 31 | const html = 32 | ``; 33 | const output = await hypermore.render(html); 34 | assertEquals( 35 | output, 36 | ``, 37 | ); 38 | }); 39 | await test.step("html comment", async () => { 40 | const html = ``; 41 | const output = await hypermore.render(html); 42 | assertEquals(output, html); 43 | }); 44 | await test.step("escape grave text", async () => { 45 | const html = `{{html}}`; 46 | const output = await hypermore.render(html, { 47 | html: "console.log(`1`,`2`,${a});", 48 | }); 49 | assertEquals(output, "console.log(`1`,`2`,${a});"); 50 | }); 51 | await test.step("escape grave script", async () => { 52 | const html = ` {{html}} `; 53 | const output = await hypermore.render(html, { 54 | html: "", 55 | }); 56 | assertEquals(output, ""); 57 | }); 58 | await test.step("escape grave prop", async () => { 59 | const html = ``; 60 | const output = await hypermore.render(html, { 61 | html: "", 62 | }); 63 | assertEquals(output, ""); 64 | }); 65 | await test.step("text node", async () => { 66 | const html = "test `test ${test} {{'te`st'}}"; 67 | const output = await hypermore.render(html); 68 | assertEquals(output, "test `test ${test} te`st"); 69 | }); 70 | await test.step("code element", async () => { 71 | const html = " `test` ${code} {{code}}
"; 72 | const output = await hypermore.render(html); 73 | assertEquals(output, html); 74 | }); 75 | await test.step("text variable escape", async () => { 76 | const html = "{{!ignore}} {{! console.error('Fail!); }}"; 77 | const output = await hypermore.render(html); 78 | assertEquals(output, html.replaceAll("{{!", "{{")); 79 | }); 80 | await test.step("function expression", async () => { 81 | const html = "{{[1,2,3].find(n => n === 3)}}"; 82 | const output = await hypermore.render(html); 83 | assertEquals(output, "3"); 84 | }); 85 | await test.step("function props", async () => { 86 | const html = ``; 87 | const output = await hypermore.render(html); 88 | assertEquals(output, ` 3
`); 89 | }); 90 | await test.step("custom element + template", async () => { 91 | const html = 92 | `{{p2}}`; 93 | const output = await hypermore.render(html, { 94 | shadowrootmode: "open", 95 | p1: 1, 96 | p2: 2, 97 | }); 98 | assertEquals( 99 | output, 100 | ` {{p1}} 2`, 101 | ); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /hypermore/test/mod.ts: -------------------------------------------------------------------------------- 1 | import { Hypermore } from "../mod.ts"; 2 | 3 | const entities = new Map([ 4 | ["&", "&"], 5 | ["<", "<"], 6 | [">", ">"], 7 | ['"', """], 8 | ["'", "'"], 9 | ]); 10 | 11 | export const globalProps = { 12 | number: 42, 13 | entities: [...entities.keys()].join(""), 14 | encodedEntities: [...entities.values()].join(""), 15 | escapeApostrophe: "It's It''s It'''s It\\'s It\\\\'s", 16 | escapeApostropheEncoded: 17 | "It's It''s It'''s It\\'s It\\\\'s", 18 | array: [1, 2, 3, "a", "b", "c"], 19 | }; 20 | 21 | export const hypermore = new Hypermore({ 22 | globalProps, 23 | }); 24 | 25 | hypermore.setTemplate("my-html", ` 1 {{html}} `); 26 | hypermore.setTemplate("my-prop", `{{number}}
`); 27 | hypermore.setTemplate("my-basic", `Pass! `); 28 | hypermore.setTemplate("single-slot", ``); 29 | 30 | const consoleWarn = console.warn; 31 | 32 | const warnStack: Array > = []; 33 | 34 | const captureWarn = () => { 35 | console.warn = (...data) => warnStack.push(data); 36 | }; 37 | 38 | const releaseWarn = () => { 39 | console.warn = consoleWarn; 40 | while (warnStack.length) warnStack.pop(); 41 | }; 42 | 43 | export const warn = { 44 | capture: captureWarn, 45 | release: releaseWarn, 46 | stack: warnStack, 47 | }; 48 | -------------------------------------------------------------------------------- /hypermore/test/portal_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore, warn } from "./mod.ts"; 3 | 4 | Deno.test("portals", async (test) => { 5 | warn.capture(); 6 | await test.step("missing name", async () => { 7 | const html = ` `; 8 | const output = await hypermore.render(html); 9 | assertEquals(output, ``); 10 | assertEquals(warn.stack.pop(), [' missing "name" property']); 11 | }); 12 | await test.step("fragment before", async () => { 13 | const html = 14 | ` Before! End!`; 15 | const output = await hypermore.render(html); 16 | assertEquals(output, `Before! End!`); 17 | }); 18 | await test.step("fragment after", async () => { 19 | const html = 20 | ` End! After!`; 21 | const output = await hypermore.render(html); 22 | assertEquals(output, `After! End!`); 23 | }); 24 | await test.step("inner content", async () => { 25 | const html = 26 | ` End! Start! `; 27 | const output = await hypermore.render(html); 28 | assertEquals(output, `Start! End!`); 29 | }); 30 | await test.step("missing name", async () => { 31 | const html = 32 | ` Missing! End!`; 33 | const output = await hypermore.render(html); 34 | assertEquals(output, ` End!`); 35 | }); 36 | await test.step("props", async () => { 37 | const html = 38 | ` {{prop1}}{{prop2}} `; 39 | const output = await hypermore.render( 40 | html, 41 | { 42 | prop1: "1", 43 | }, 44 | { 45 | globalProps: { 46 | prop2: "2", 47 | }, 48 | }, 49 | ); 50 | assertEquals(output, `12`); 51 | }); 52 | warn.release(); 53 | }); 54 | -------------------------------------------------------------------------------- /hypermore/test/prop_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "jsr:@std/assert"; 2 | import { globalProps, hypermore } from "./mod.ts"; 3 | 4 | Deno.test("props", async (test) => { 5 | await test.step("interpolation", async () => { 6 | const html = ` {{$global.number}}
`; 7 | const output = await hypermore.render(html); 8 | assertEquals(output, `${globalProps.number}
`); 9 | }); 10 | await test.step("escape apostrophe (global)", async () => { 11 | const html = `{{$global.escapeApostrophe}}
`; 12 | const output = await hypermore.render(html); 13 | assertEquals(output, `${globalProps.escapeApostropheEncoded}
`); 14 | }); 15 | await test.step("encode entities (global)", async () => { 16 | const html = `{{$global.entities}}
`; 17 | const output = await hypermore.render(html); 18 | assertEquals(output, `${globalProps.encodedEntities}
`); 19 | }); 20 | await test.step("escape apostrophe (prop)", async () => { 21 | const html = ``; 22 | const output = await hypermore.render(html); 23 | assertEquals(output, ` ${globalProps.escapeApostropheEncoded}
`); 24 | }); 25 | await test.step("encode entities (prop)", async () => { 26 | const html = ``; 27 | const output = await hypermore.render(html); 28 | assertEquals(output, ` ${globalProps.encodedEntities}
`); 29 | }); 30 | await test.step("type preservation", async () => { 31 | const html = `{{typeof $global.number}}
`; 32 | const output = await hypermore.render(html); 33 | assertEquals(output, `number
`); 34 | }); 35 | await test.step("expression", async () => { 36 | const html = `{{$global.array.join('')}}
`; 37 | const output = await hypermore.render(html); 38 | assertEquals(output, `123abc
`); 39 | }); 40 | await test.step("missing error", async () => { 41 | const html = `{{ missing.join('') }}
`; 42 | try { 43 | await hypermore.render(html); 44 | } catch (err) { 45 | assert(err instanceof Error); 46 | assertEquals(err.message.includes(`"missing is not defined"`), true); 47 | assertEquals(err.message.includes(`{{ missing.join('') }}`), true); 48 | } 49 | }); 50 | await test.step("component missing prop", async () => { 51 | const html = ``; 52 | try { 53 | await hypermore.render(html); 54 | } catch (err) { 55 | assert(err instanceof Error); 56 | assertEquals(err.message.includes(`expression: "{{prop}}"`), true); 57 | assertEquals(err.message.includes(`element: `), true); 58 | } 59 | }); 60 | await test.step("global as local prop", async () => { 61 | const html = ` {{number}}
`; 62 | const output = await hypermore.render(html); 63 | assertEquals(output, `${globalProps.number}
`); 64 | }); 65 | await test.step("override global", async () => { 66 | const html = `{{number}}
`; 67 | const output = await hypermore.render(html, { number: 777 }); 68 | assertEquals(output, `777
`); 69 | }); 70 | await test.step("override global attribute", async () => { 71 | const html = `{{number}}`; 72 | const output = await hypermore.render(html); 73 | assertEquals(output, `42 777
`); 74 | }); 75 | await test.step("local prop reset", async () => { 76 | const html = `{{number}}{{number}}`; 77 | const output = await hypermore.render(html, { 78 | number: 42, 79 | }); 80 | assertEquals(output, `42 777
42`); 81 | }); 82 | await test.step("$local prop reset", async () => { 83 | const html = 84 | `{{$local.number}}{{$local.number}}`; 85 | const output = await hypermore.render(html, { 86 | number: 42, 87 | }); 88 | assertEquals(output, `42 777
42`); 89 | }); 90 | await test.step("camel case conversion", async () => { 91 | const html = 92 | `{{ camelCase }} `; 93 | const output = await hypermore.render(html); 94 | assertEquals(output, `Pass!`); 95 | }); 96 | // warn.capture(); 97 | // await test.step("reserved prop name", async () => { 98 | // const html = `{{ $global }} `; 99 | // const output = await hypermore.render(html, { 100 | // "$global": "test", 101 | // }); 102 | // console.log(output); 103 | // assertEquals(output, `[object Object]`); 104 | // assertEquals(warn.stack.pop(), ['invalid prop "$global" is reserved']); 105 | // }); 106 | // warn.release(); 107 | await test.step("props propagation", async () => { 108 | const output = await hypermore.render( 109 | ``, 110 | { 111 | heading: "Pass!", 112 | }, 113 | ); 114 | assertEquals(output, ` {{heading}}
Pass!
`); 115 | }); 116 | await test.step("props propagation", async () => { 117 | hypermore.setTemplate("my-h2", `{{heading}}
`); 118 | const output = await hypermore.render( 119 | ``, 120 | { 121 | heading: "Pass!", 122 | }, 123 | ); 124 | assertEquals(output, ` {{heading}}
Pass!
Pass!
`); 125 | }); 126 | await test.step("duplicate props name", async () => { 127 | hypermore.setTemplate("my-h3", `{{id}}
`); 128 | const output = await hypermore.render(``, { 129 | id: "Pass!", 130 | }); 131 | assertEquals(output, ` Pass!
`); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /hypermore/test/script_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert"; 2 | import { hypermore } from "./mod.ts"; 3 | 4 | hypermore.setTemplate( 5 | "my-time", 6 | ` 14 | {{date}} 15 | `, 16 | ); 17 | hypermore.setTemplate( 18 | "my-default", 19 | ` 23 | {{heading}} {{description}} {{end}} 24 | `, 25 | ); 26 | 27 | Deno.test(" 8 | 9 |{{params.slug}}
10 | 11 | -------------------------------------------------------------------------------- /hyperserve/test/routes/404.html: -------------------------------------------------------------------------------- 1 |Custom 404
2 | -------------------------------------------------------------------------------- /hyperserve/test/routes/500.html: -------------------------------------------------------------------------------- 1 |Custom 500
2 | -------------------------------------------------------------------------------- /hyperserve/test/routes/forms.html: -------------------------------------------------------------------------------- 1 |2 | 4 | 5 |Test Forms 3 |6 | 10 | -------------------------------------------------------------------------------- /hyperserve/test/routes/index.html: -------------------------------------------------------------------------------- 1 |7 | 8 | 9 | 2 | 4 | 5 |Test Title 3 |6 | 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/methods/delete.ts: -------------------------------------------------------------------------------- 1 | import type { HyperHandle } from "jsr:@dbushell/hyperserve"; 2 | 3 | export const DELETE: HyperHandle = ({ request }) => { 4 | return Response.json({ 5 | method: request.method.toUpperCase(), 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/methods/patch.ts: -------------------------------------------------------------------------------- 1 | import type { HyperHandle } from "jsr:@dbushell/hyperserve"; 2 | 3 | export const PATCH: HyperHandle = ({ request }) => { 4 | return Response.json({ 5 | method: request.method.toUpperCase(), 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/methods/post.ts: -------------------------------------------------------------------------------- 1 | import type { HyperHandle } from "jsr:@dbushell/hyperserve"; 2 | 3 | export const POST: HyperHandle = async ({ request }) => { 4 | return Response.json(await request.json()); 5 | }; 6 | -------------------------------------------------------------------------------- /hyperserve/test/routes/methods/put.ts: -------------------------------------------------------------------------------- 1 | import type { HyperHandle } from "jsr:@dbushell/hyperserve"; 2 | 3 | export const PUT: HyperHandle = ({ request }) => { 4 | return Response.json({ 5 | method: request.method.toUpperCase(), 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/no-trailing-slash.html: -------------------------------------------------------------------------------- 1 |Test Heading
7 |2 | 4 | -------------------------------------------------------------------------------- /hyperserve/test/routes/origin.ts: -------------------------------------------------------------------------------- 1 | import type { HyperHandle } from "jsr:@dbushell/hyperserve"; 2 | 3 | export const GET: HyperHandle = ({ request }) => { 4 | return Response.json({ 5 | url: request.url, 6 | "x-forwarded-host": request.headers.get("x-forwarded-host"), 7 | "x-forwarded-proto": request.headers.get("x-forwarded-proto"), 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /hyperserve/test/routes/portal.html: -------------------------------------------------------------------------------- 1 |No Trailing Slash
3 |2 | 4 | 5 |{{deployHash}} 3 |6 | 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/props.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | {{$global.deployHash}} 8 | {{number}} 9 |{{deployHash}}
7 |10 | -------------------------------------------------------------------------------- /hyperserve/test/routes/throw.ts: -------------------------------------------------------------------------------- 1 | import type {HyperHandle} from 'jsr:@dbushell/hyperserve'; 2 | 3 | // Purposefully throw an "Internal Server Error" 4 | export const GET: HyperHandle = () => { 5 | throw new Error('Throw 500'); 6 | }; 7 | -------------------------------------------------------------------------------- /hyperserve/test/routes/trailing-slash.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 8 | -------------------------------------------------------------------------------- /hyperserve/test/routes/wrappers.html: -------------------------------------------------------------------------------- 1 |Trailing Slash
7 |2 | 4 | 5 |Test Wrapper 3 |6 | 10 | -------------------------------------------------------------------------------- /hyperserve/test/server_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "jsr:@std/assert"; 2 | import { parseHTML } from "@dbushell/hypermore"; 3 | import { Hyperserve } from "../mod.ts"; 4 | 5 | const dir = new URL("./", import.meta.url).pathname; 6 | 7 | const origin = new URL(Deno.env.get("ORIGIN") ?? "http://localhost:8080"); 8 | 9 | if (Deno.env.has("ORIGIN")) { 10 | Deno.env.set("DENO_TLS_CA_STORE", "system"); 11 | const cmd = new Deno.Command("caddy", { 12 | args: ["run", "--config", "test/Caddyfile"], 13 | stdout: "null", 14 | stderr: "null", 15 | }); 16 | cmd.spawn(); 17 | console.log("Starting caddy proxy..."); 18 | await new Promise((resolve) => setTimeout(resolve, 1000)); 19 | } 20 | 21 | const ssr = new Hyperserve(dir, { 22 | origin, 23 | dev: true, 24 | serve: { hostname: "127.0.0.1", port: 8080 }, 25 | }); 26 | 27 | await ssr.init(); 28 | 29 | const headers = new Headers(); 30 | headers.append("accept", "text/html"); 31 | 32 | Deno.test("200 response", async () => { 33 | const response = await fetch(origin, { headers }); 34 | await response.body?.cancel(); 35 | assertEquals(response.status, 200); 36 | }); 37 | 38 | Deno.test("404 response", async () => { 39 | const response = await fetch(new URL("/fake-path", origin), { headers }); 40 | await response.body?.cancel(); 41 | assertEquals(response.status, 404); 42 | }); 43 | 44 | Deno.test("404 custom template", async () => { 45 | const response = await fetch(new URL("/fake-path", origin), { headers }); 46 | const html = await response.text(); 47 | const root = parseHTML(html); 48 | const node = root.find((n) => n.tag === "h1"); 49 | assertEquals(node?.children[0]?.toString(), "Custom 404"); 50 | }); 51 | 52 | Deno.test("500 custom template", async () => { 53 | const consoleError = console.error; 54 | console.error = () => {}; 55 | const response = await fetch(new URL("/throw", origin), { headers }); 56 | const html = await response.text(); 57 | const root = parseHTML(html); 58 | const node = root.find((n) => n.tag === "h1"); 59 | assertEquals(node?.children[0]?.toString(), "Custom 500"); 60 | console.error = consoleError; 61 | }); 62 | 63 | Deno.test("text/html content-type", async () => { 64 | const response = await fetch(origin, { headers }); 65 | await response.body?.cancel(); 66 | assert(response.headers.get("content-type")?.startsWith("text/html")); 67 | }); 68 | 69 | Deno.test("7 | 9 |Test Wrappers
8 |rendered", async () => { 70 | const response = await fetch(origin, { headers }); 71 | const html = await response.text(); 72 | const root = parseHTML(html); 73 | const node = root.find((n) => n.tag === "title"); 74 | assertEquals(node?.children[0]?.toString(), "Test Title"); 75 | }); 76 | 77 | Deno.test(" rendered", async () => { 78 | const response = await fetch(origin, { headers }); 79 | const html = await response.text(); 80 | const root = parseHTML(html); 81 | const node = root.find((n) => n.tag === "h1"); 82 | assertEquals(node?.children[0]?.toString(), "Test Heading"); 83 | }); 84 | 85 | Deno.test("props rendered", async () => { 86 | const response = await fetch(new URL("/props", origin), { headers }); 87 | const html = await response.text(); 88 | const root = parseHTML(html); 89 | const node0 = root.find((n) => n.attributes.get("id") === "hash"); 90 | const node1 = root.find((n) => n.attributes.get("id") === "number1"); 91 | const node2 = root.find((n) => n.attributes.get("id") === "number2"); 92 | assertEquals(node0?.children[0]?.toString(), ssr.deployHash); 93 | assertEquals(node1?.children[0]?.toString(), "42"); 94 | assertEquals(node2?.children[0]?.toString(), "777"); 95 | }); 96 | 97 | Deno.test("portal rendered", async () => { 98 | const response = await fetch(new URL("/portal", origin), { headers }); 99 | const html = await response.text(); 100 | const root = parseHTML(html); 101 | const n1 = root.find((n) => n.attributes.get("name") === "deployHash"); 102 | const n2 = root.find((n) => n.tag === "title"); 103 | const n3 = root.find((n) => n.tag === "h1"); 104 | assertEquals(n1?.attributes.get("content"), ssr.deployHash); 105 | assertEquals(n2?.children[0]?.toString(), ssr.deployHash); 106 | assertEquals(n3?.children[0]?.toString(), ssr.deployHash); 107 | }); 108 | 109 | Deno.test("wrappers rendered", async () => { 110 | const response = await fetch(new URL("/wrappers", origin), { headers }); 111 | const html = await response.text(); 112 | const root = parseHTML(html); 113 | const node = root.find((n) => n.tag === "h1"); 114 | assertEquals(node?.children[0]?.toString(), "Test Wrappers"); 115 | }); 116 | 117 | Deno.test("forms rendered", async () => { 118 | const response = await fetch(new URL("/forms", origin), { headers }); 119 | const html = await response.text(); 120 | const root = parseHTML(html); 121 | const n1 = root.find((n) => n.attributes.get("id") === "input-1"); 122 | const n2 = root.find((n) => n.attributes.get("id") === "input-2"); 123 | const n3 = root.find((n) => n.attributes.get("id") === "input-3"); 124 | assertEquals(n1?.toString(), ''); 125 | assertEquals(n2?.toString(), ''); 126 | assertEquals( 127 | n3?.toString(), 128 | '', 129 | ); 130 | }); 131 | 132 | Deno.test("POST", async (test) => { 133 | const postURL = new URL("/methods/post", origin); 134 | await test.step("GET 404", async () => { 135 | const response = await fetch(postURL, { headers }); 136 | await response.body?.cancel(); 137 | assertEquals(response.status, 404); 138 | }); 139 | await test.step("POST 200", async () => { 140 | const response = await fetch(postURL, { 141 | headers, 142 | method: "POST", 143 | body: JSON.stringify({ 144 | pass: "Pass!", 145 | }), 146 | }); 147 | const data = await response.json(); 148 | assertEquals(response.status, 200); 149 | assertEquals(data.pass, "Pass!"); 150 | }); 151 | }); 152 | 153 | Deno.test("DELETE", async (test) => { 154 | const postURL = new URL("/methods/delete", origin); 155 | await test.step("GET 404", async () => { 156 | const response = await fetch(postURL, { headers }); 157 | await response.body?.cancel(); 158 | assertEquals(response.status, 404); 159 | }); 160 | await test.step("DELETE 200", async () => { 161 | const response = await fetch(postURL, { 162 | headers, 163 | method: "DELETE", 164 | }); 165 | const data = await response.json(); 166 | assertEquals(response.status, 200); 167 | assertEquals(data.method, "DELETE"); 168 | }); 169 | }); 170 | 171 | Deno.test("PATCH", async (test) => { 172 | const postURL = new URL("/methods/patch", origin); 173 | await test.step("GET 404", async () => { 174 | const response = await fetch(postURL, { headers }); 175 | await response.body?.cancel(); 176 | assertEquals(response.status, 404); 177 | }); 178 | await test.step("PATCH 200", async () => { 179 | const response = await fetch(postURL, { 180 | headers, 181 | method: "PATCH", 182 | }); 183 | const data = await response.json(); 184 | assertEquals(response.status, 200); 185 | assertEquals(data.method, "PATCH"); 186 | }); 187 | }); 188 | 189 | Deno.test("PUT", async (test) => { 190 | const postURL = new URL("/methods/put", origin); 191 | await test.step("GET 404", async () => { 192 | const response = await fetch(postURL, { headers }); 193 | await response.body?.cancel(); 194 | assertEquals(response.status, 404); 195 | }); 196 | await test.step("PUT 200", async () => { 197 | const response = await fetch(postURL, { 198 | headers, 199 | method: "PUT", 200 | }); 201 | const data = await response.json(); 202 | assertEquals(response.status, 200); 203 | assertEquals(data.method, "PUT"); 204 | }); 205 | }); 206 | 207 | Deno.test("redirects", async (test) => { 208 | await test.step("trailing slash 308", async () => { 209 | const response = await fetch(new URL("/trailing-slash", origin), { 210 | headers, 211 | redirect: "manual", 212 | }); 213 | await response.body?.cancel(); 214 | assertEquals(response.status, 308); 215 | }); 216 | await test.step("trailing slash redirect", async () => { 217 | const response = await fetch(new URL("/trailing-slash", origin), { 218 | headers, 219 | }); 220 | await response.body?.cancel(); 221 | assertEquals(response.status, 200); 222 | assert(response.redirected); 223 | }); 224 | await test.step("no trailing slash 308", async () => { 225 | const response = await fetch(new URL("/no-trailing-slash/", origin), { 226 | headers, 227 | redirect: "manual", 228 | }); 229 | await response.body?.cancel(); 230 | assertEquals(response.status, 308); 231 | }); 232 | await test.step("no trailing slash redirect", async () => { 233 | const response = await fetch(new URL("/no-trailing-slash/", origin), { 234 | headers, 235 | }); 236 | await response.body?.cancel(); 237 | assertEquals(response.status, 200); 238 | assert(response.redirected); 239 | }); 240 | }); 241 | 242 | Deno.test("static asset", async () => { 243 | const response = await fetch(new URL("/robots.txt", origin), { headers }); 244 | const text = (await response.text()).trim(); 245 | assertEquals(text, "User-agent: * Allow: /"); 246 | assert(response.headers.get("content-type")?.startsWith("text/plain")); 247 | }); 248 | 249 | Deno.test("URL pattern", async () => { 250 | const response = await fetch(new URL("/2024/03/02/slug/", origin), { 251 | headers, 252 | }); 253 | const html = await response.text(); 254 | const root = parseHTML(html); 255 | const node1 = root.find((n) => n.tag === "h1"); 256 | const node2 = root.find((n) => n.tag === "time"); 257 | assertEquals(response.status, 200); 258 | assertEquals(node1?.children[0]?.toString(), "slug"); 259 | assertEquals(node2?.children[0]?.toString(), "2024-03-02"); 260 | }); 261 | 262 | if (Deno.env.has("ORIGIN")) { 263 | Deno.test("origin", async () => { 264 | const originURL = new URL("/origin", origin); 265 | const response = await fetch(originURL, { headers }); 266 | const data = await response.json(); 267 | assertEquals(new URL(data.url).href, originURL.href); 268 | assertEquals(data["x-forwarded-host"], "localhost"); 269 | assertEquals(data["x-forwarded-proto"], "https"); 270 | }); 271 | } 272 | -------------------------------------------------------------------------------- /hyperserve/test/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * Allow: / 2 | --------------------------------------------------------------------------------