From 7e4cc5f63fad6a45ae83d6d8cab1cac48eeb4a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 21:32:25 +0900 Subject: [PATCH 01/52] wip: init --- package.json | 3 +- src/html/LICENSE | 35 ++ src/html/README.md | 119 ++++ src/html/package.json | 37 ++ src/html/src/index.ts | 580 ++++++++++++++++++++ src/html/src/selector.ts | 277 ++++++++++ src/html/src/transformers/inline.ts | 134 +++++ src/html/src/transformers/sanitize.ts | 154 ++++++ src/html/src/transformers/scope.ts | 233 ++++++++ src/html/src/transformers/swap.ts | 26 + src/html/test/basic.test.ts | 95 ++++ src/html/test/html.test.ts | 25 + src/html/test/markdown.test.ts | 21 + src/html/test/script.test.ts | 57 ++ src/html/test/selector.test.ts | 308 +++++++++++ src/html/test/svg.test.ts | 15 + src/html/test/transformers/inline.test.tsx | 195 +++++++ src/html/test/transformers/sanitize.test.ts | 110 ++++ src/html/test/transformers/scope.test.tsx | 80 +++ src/html/test/transformers/swap.test.ts | 68 +++ tsconfig.json | 1 + 21 files changed, 2572 insertions(+), 1 deletion(-) create mode 100644 src/html/LICENSE create mode 100644 src/html/README.md create mode 100644 src/html/package.json create mode 100644 src/html/src/index.ts create mode 100644 src/html/src/selector.ts create mode 100644 src/html/src/transformers/inline.ts create mode 100644 src/html/src/transformers/sanitize.ts create mode 100644 src/html/src/transformers/scope.ts create mode 100644 src/html/src/transformers/swap.ts create mode 100644 src/html/test/basic.test.ts create mode 100644 src/html/test/html.test.ts create mode 100644 src/html/test/markdown.test.ts create mode 100644 src/html/test/script.test.ts create mode 100644 src/html/test/selector.test.ts create mode 100644 src/html/test/svg.test.ts create mode 100644 src/html/test/transformers/inline.test.tsx create mode 100644 src/html/test/transformers/sanitize.test.ts create mode 100644 src/html/test/transformers/scope.test.tsx create mode 100644 src/html/test/transformers/swap.test.ts diff --git a/package.json b/package.json index 6271dbd4..4ac8c27d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "linter" ], "workspaces": [ - "examples/*" + "examples/*", + "src/html" ], "gitHooks": { "pre-commit": "lint-staged" diff --git a/src/html/LICENSE b/src/html/LICENSE new file mode 100644 index 00000000..2386115b --- /dev/null +++ b/src/html/LICENSE @@ -0,0 +1,35 @@ +MIT License Copyright (c) 2022 Nate Moore + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- + +Portions of this code were borrowed from https://github.com/developit/htmlParser + +The MIT License (MIT) + +Copyright (c) 2013 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/html/README.md b/src/html/README.md new file mode 100644 index 00000000..3b699548 --- /dev/null +++ b/src/html/README.md @@ -0,0 +1,119 @@ +# `ultrahtml` + +A 1.75kB library for enhancing `html`. `ultrahtml` has zero dependencies and is compatible with any JavaScript runtime. + +### Features + +- Tiny, fault-tolerant and friendly HTML-like parser. Works with HTML, Astro, Vue, Svelte, and any other HTML-like syntax. +- Built-in AST `walk` utility +- Built-in `transform` utility for easy output manipulation +- Automatic but configurable sanitization, see [Sanitization](#sanitization) +- Handy `html` template utility +- `querySelector` and `querySelectorAll` support using `ultrahtml/selector` + +#### `walk` + +The `walk` function provides full control over the AST. It can be used to scan for text, elements, components, or any other validation you might want to do. + +> **Note** > `walk` is `async` and **must** be `await`ed. Use `walkSync` if it is guaranteed there are no `async` components in the tree. + +```js +import { parse, walk, ELEMENT_NODE } from "ultrahtml"; + +const ast = parse(`

Hello world!

`); +await walk(ast, async (node) => { + if (node.type === ELEMENT_NODE && node.name === "script") { + throw new Error("Found a script!"); + } +}); +``` + +#### `walkSync` + +The `walkSync` function is identical to the `walk` function, but is synchronous. This should only be used when it is guaranteed there are no `async` components in the tree. + +```js +import { parse, walkSync, ELEMENT_NODE } from "ultrahtml"; + +const ast = parse(`

Hello world!

`); +walkSync(ast, (node) => { + if (node.type === ELEMENT_NODE && node.name === "script") { + throw new Error("Found a script!"); + } +}); +``` + +#### `render` + +The `render` function allows you to serialize an AST back into a string. + +> **Note** +> By default, `render` will sanitize your markup, removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. + +```js +import { parse, render } from "ultrahtml"; + +const ast = parse(`

Hello world!

`); +const output = await render(ast); +``` + +#### `transform` + +The `transform` function provides a straight-forward way to modify any markup. Sanitize content, swap in-place elements/Components, and more using a set of built-in transformers, or write your own custom transformer. + +```js +import { transform, html } from "ultrahtml"; +import swap from "ultrahtml/transformers/swap"; +import sanitize from "ultrahtml/transformers/sanitize"; + +const output = await transform(`

Hello world!

`, [ + swap({ + h1: "h2", + h3: (props, children) => html`

${children}

`, + }), + sanitize({ allowElements: ["h1", "h2", "h3"] }), +]); + +console.log(output); //

Hello world!

+``` + +#### `transformSync` + +The `transformSync` function is identical to the `transform` function, but is synchronous. This should only be used when it is guaranteed there are no `async` functions in the transformers. + +```js +import { transformSync, html } from "ultrahtml"; +import swap from "ultrahtml/transformers/swap"; +import sanitize from "ultrahtml/transformers/sanitize"; + +const output = transformSync(`

Hello world!

`, [ + swap({ + h1: "h2", + h3: (props, children) => html`

${children}

`, + }), + sanitize({ allowElements: ["h1", "h2", "h3"] }), +]); + +console.log(output); //

Hello world!

+``` + +#### Sanitization + +`ultrahtml/transformers/sanitize` implements an extension of a proposed HTML Sanitizer API (circa 2022.) Although that proposal has since been withdrawn, it remains as the foundation of `ultrahtml`'s API. In a future major version of `ultrahtml`, we hope to track against [WICG Sanitizer API proposal](https://wicg.github.io/sanitizer-api/) if it becomes an official WHATWG specification. + +| Option | Type | Default | Description | +| ------------------- | -------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| allowElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be dropped. | +| blockElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should remove, but keep their child elements. | +| unblockElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be removed, but keep their child content. | +| dropElements | `string[]` | `["script"]` | An array of strings indicating elements (including nested elements) that the sanitizer should remove. | +| allowAttributes | `Record` | `undefined` | An object where each key is the attribute name and the value is an Array of allowed tag names. Matching attributes will not be removed. All attributes that are not in the array will be dropped. | +| dropAttributes | `Record` | `undefined` | An object where each key is the attribute name and the value is an Array of dropped tag names. Matching attributes will be removed. | +| allowComponents | `boolean` | `false` | A boolean value set to false (default) to remove components and their children. If set to true, components will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). | +| allowCustomElements | `boolean` | `false` | A boolean value set to false (default) to remove custom elements and their children. If set to true, custom elements will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). | +| allowComments | `boolean` | `false` | A boolean value set to false (default) to remove HTML comments. Set to true in order to keep comments. | + +## Acknowledgements + +- [Jason Miller](https://twitter.com/_developit)'s [`htmlParser`](https://github.com/developit/htmlParser) provided a great, lightweight base for this parser +- [Titus Wormer](https://twitter.com/wooorm)'s [`mdx`](https://mdxjs.com) for inspiration diff --git a/src/html/package.json b/src/html/package.json new file mode 100644 index 00000000..e704f555 --- /dev/null +++ b/src/html/package.json @@ -0,0 +1,37 @@ +{ + "name": "ultrahtml", + "type": "module", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "scripts": { + "dev": "vitest", + "test": "vitest run" + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json", + "./selector": { + "types": "./dist/selector.d.ts", + "import": "./dist/selector.js" + }, + "./transformers/*": { + "types": "./dist/transformers/*.d.ts", + "import": "./dist/transformers/*.js" + } + }, + "devDependencies": { + "@types/stylis": "^4.2.6", + "markdown-it": "^13.0.2", + "media-query-fns": "^2.0.0", + "parsel-js": "^1.1.2", + "stylis": "^4.3.4", + "vitest": "^2.1.1" + } +} diff --git a/src/html/src/index.ts b/src/html/src/index.ts new file mode 100644 index 00000000..020b5608 --- /dev/null +++ b/src/html/src/index.ts @@ -0,0 +1,580 @@ +export type Node = + | DocumentNode + | ElementNode + | TextNode + | CommentNode + | DoctypeNode; +export type NodeType = + | typeof DOCUMENT_NODE + | typeof ELEMENT_NODE + | typeof TEXT_NODE + | typeof COMMENT_NODE + | typeof DOCTYPE_NODE; +export interface Location { + start: number; + end: number; +} +interface BaseNode { + type: NodeType; + loc: [Location, Location]; + parent: Node; + [key: string]: any; +} + +interface LiteralNode extends BaseNode { + value: string; +} + +interface ParentNode extends BaseNode { + children: Node[]; +} + +export interface DocumentNode extends Omit { + type: typeof DOCUMENT_NODE; + attributes: Record; + parent: undefined; +} + +export interface ElementNode extends ParentNode { + type: typeof ELEMENT_NODE; + name: string; + attributes: Record; +} + +export interface TextNode extends LiteralNode { + type: typeof TEXT_NODE; +} + +export interface CommentNode extends LiteralNode { + type: typeof COMMENT_NODE; +} + +export interface DoctypeNode extends LiteralNode { + type: typeof DOCTYPE_NODE; +} + +export const DOCUMENT_NODE = 0; +export const ELEMENT_NODE = 1; +export const TEXT_NODE = 2; +export const COMMENT_NODE = 3; +export const DOCTYPE_NODE = 4; + +export function h( // TODO: remove this function, as it is about JSX. + type: any, + props: null | Record = {}, + ...children: any[] +) { + const vnode: ElementNode = { + type: ELEMENT_NODE, + name: typeof type === "function" ? type.name : type, + attributes: props || {}, + children: children.map(child => + typeof child === "string" + ? { type: TEXT_NODE, value: escapeHTML(String(child)) } + : child, + ), + parent: undefined as any, + loc: [] as any, + }; + if (typeof type === "function") { + __unsafeRenderFn(vnode, type); + } + return vnode; +} +export const Fragment = Symbol("Fragment"); + +const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); +const RAW_TAGS = new Set(["script", "style"]); +const DOM_PARSER_RE = + /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; + +const ATTR_KEY_IDENTIFIER = /[\@\.a-z0-9_\:\-]/i; + +function splitAttrs(str?: string) { + let obj: Record = {}; + if (str) { + let state: "none" | "key" | "value" = "none"; + let currentKey: string | undefined; + let currentValue: string = ""; + let tokenStartIndex: number | undefined; + let valueDelimiter: '"' | "'" | undefined; + for (let currentIndex = 0; currentIndex < str.length; currentIndex++) { + const currentChar = str[currentIndex]; + + if (state === "none") { + if (ATTR_KEY_IDENTIFIER.test(currentChar)) { + // add attribute + if (currentKey) { + obj[currentKey] = currentValue; + currentKey = undefined; + currentValue = ""; + } + + tokenStartIndex = currentIndex; + state = "key"; + } else if (currentChar === "=" && currentKey) { + state = "value"; + } + } else if (state === "key") { + if (!ATTR_KEY_IDENTIFIER.test(currentChar)) { + currentKey = str.substring(tokenStartIndex!, currentIndex); + if (currentChar === "=") { + state = "value"; + } else { + state = "none"; + } + } + } else { + if ( + currentChar === valueDelimiter && + currentIndex > 0 && + str[currentIndex - 1] !== "\\" + ) { + if (valueDelimiter) { + currentValue = str.substring( + tokenStartIndex!, + currentIndex, + ); + valueDelimiter = undefined; + state = "none"; + } + } else if ( + (currentChar === '"' || currentChar === "'") && + !valueDelimiter + ) { + tokenStartIndex = currentIndex + 1; + valueDelimiter = currentChar; + } + } + } + if ( + state === "key" && + tokenStartIndex != undefined && + tokenStartIndex < str.length + ) { + currentKey = str.substring(tokenStartIndex, str.length); + } + if (currentKey) { + obj[currentKey] = currentValue; + } + } + return obj; +} + +export function parse(input: string | ReturnType): any { + let str = typeof input === "string" ? input : input.value; + let doc: Node, + parent: Node, + token: any, + text, + i, + bStart, + bText, + bEnd, + tag: Node; + const tags: Node[] = []; + DOM_PARSER_RE.lastIndex = 0; + parent = doc = { + type: DOCUMENT_NODE, + children: [] as Node[], + } as any; + + let lastIndex = 0; + function commitTextNode() { + text = str.substring( + lastIndex, + DOM_PARSER_RE.lastIndex - token[0].length, + ); + if (text) { + (parent as ParentNode).children.push({ + type: TEXT_NODE, + value: text, + parent, + } as any); + } + } + + while ((token = DOM_PARSER_RE.exec(str))) { + bStart = token[5] || token[8]; + bText = token[6] || token[9]; + bEnd = token[7] || token[10]; + if (RAW_TAGS.has(parent.name) && token[2] !== parent.name) { + i = DOM_PARSER_RE.lastIndex - token[0].length; + if (parent.children.length > 0) { + parent.children[0].value += token[0]; + } + continue; + } else if (bStart === "`; + case DOCTYPE_NODE: + return ``; + } +} + +export async function render(node: Node): Promise { + switch (node.type) { + case DOCUMENT_NODE: + return Promise.all( + node.children.map((child: Node) => render(child)), + ).then(res => res.join("")); + case ELEMENT_NODE: + return renderElement(node); + case TEXT_NODE: + return `${node.value}`; + case COMMENT_NODE: + return ``; + case DOCTYPE_NODE: + return ``; + } +} + +export interface Transformer { + (node: Node): Node | Promise; +} + +export interface TransformerSync { + (node: Node): Node; +} + +function parseTransformArgs( + markup: string | Node, + transformers: Transformer[], +) { + if (!Array.isArray(transformers)) { + throw new Error( + `Invalid second argument for \`transform\`! Expected \`Transformer[]\` but got \`${typeof transformers}\``, + ); + } + const doc = typeof markup === "string" ? parse(markup) : markup; + return { doc }; +} + +export async function transform( + markup: string | Node, + transformers: Transformer[] = [], +): Promise { + const { doc } = parseTransformArgs(markup, transformers); + + let newDoc = doc; + for (const t of transformers) { + newDoc = await t(newDoc); + } + return render(newDoc); +} + +export function transformSync( + markup: string | Node, + transformers: TransformerSync[] = [], +): string { + const { doc } = parseTransformArgs(markup, transformers); + + let newDoc = doc; + for (const t of transformers) { + newDoc = t(newDoc); + } + return renderSync(newDoc); +} diff --git a/src/html/src/selector.ts b/src/html/src/selector.ts new file mode 100644 index 00000000..2e5def6b --- /dev/null +++ b/src/html/src/selector.ts @@ -0,0 +1,277 @@ +import type { Node } from './index.js'; +import { ELEMENT_NODE, TEXT_NODE, walkSync } from './index.js'; +import type { AST, AttributeToken } from 'parsel-js'; +import { + parse, + specificity as getSpecificity, + specificityToNumber, +} from 'parsel-js'; + +export function specificity(selector: string) { + return specificityToNumber(getSpecificity(selector), 10); +} + +export function matches(node: Node, selector: string): boolean { + const match = selectorToMatch(selector); + return match(node, node.parent, nthChildIndex(node, node.parent)); +} + +export function querySelector(node: Node, selector: string): Node { + const match = selectorToMatch(selector); + try { + return select( + node, + (n: Node, parent?: Node, index?: number) => { + let m = match(n, parent, index); + if (!m) return false; + return m; + }, + { single: true }, + )[0]; + } catch (e) { + if (e instanceof Error) { + throw e; + } + return e as Node; + } +} + +export function querySelectorAll(node: Node, selector: string): Node[] { + const match = selectorToMatch(selector); + return select(node, (n: Node, parent?: Node, index?: number) => { + let m = match(n, parent, index); + if (!m) return false; + return m; + }); +} + +export default querySelectorAll; + +interface Matcher { + (n: Node, parent?: Node, index?: number): boolean; +} + +function select( + node: Node, + match: Matcher, + opts: { single?: boolean } = { single: false }, +): Node[] { + let nodes: Node[] = []; + walkSync(node, (n, parent, index): void => { + if (n && n.type !== ELEMENT_NODE) return; + if (match(n, parent, index)) { + if (opts.single) throw n; + nodes.push(n); + } + }); + return nodes; +} + +const getAttributeMatch = (selector: AttributeToken) => { + const { operator = '=' } = selector; + switch (operator) { + case '=': + return (a: string, b: string) => a === b; + case '~=': + return (a: string, b: string) => a.split(/\s+/g).includes(b); + case '|=': + return (a: string, b: string) => a.startsWith(b + '-'); + case '*=': + return (a: string, b: string) => a.indexOf(b) > -1; + case '$=': + return (a: string, b: string) => a.endsWith(b); + case '^=': + return (a: string, b: string) => a.startsWith(b); + } + return (a: string, b: string) => false; +}; + +const nthChildIndex = (node: Node, parent?: Node) => + parent?.children + .filter((n: Node) => n.type === ELEMENT_NODE) + .findIndex((n: Node) => n === node); +const nthChild = (formula: string) => { + let [_, A = '1', B = '0'] = + /^\s*(?:(-?(?:\d+)?)n)?\s*\+?\s*(\d+)?\s*$/gm.exec(formula) ?? []; + if (A.length === 0) A = '1'; + const a = Number.parseInt(A === '-' ? '-1' : A); + const b = Number.parseInt(B); + return (n: number) => a * n + b; +}; +const lastChild = (node: Node, parent?: Node) => + parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).pop() === node; +const firstChild = (node: Node, parent?: Node) => + parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).shift() === + node; +const onlyChild = (node: Node, parent?: Node) => + parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).length === 1; + +const createMatch = (selector: AST): Matcher => { + switch (selector.type) { + case 'type': + return (node: Node) => { + if (selector.content === '*') return true; + return node.name === selector.name; + }; + case 'class': + return (node: Node) => + node.attributes?.class?.split(/\s+/g).includes(selector.name); + case 'id': + return (node: Node) => node.attributes?.id === selector.name; + case 'pseudo-class': { + switch (selector.name) { + case 'global': + return (...args) => + selectorToMatch(parse(selector.argument!)!)(...args); + case 'not': + return (...args) => !createMatch(selector.subtree!)(...args); + case 'is': + return (...args) => selectorToMatch(selector.subtree!)(...args); + case 'where': + return (...args) => selectorToMatch(selector.subtree!)(...args); + case 'root': + return (node: Node, parent?: Node) => + node.type === ELEMENT_NODE && node.name === 'html'; + case 'empty': + return (node: Node) => + node.type === ELEMENT_NODE && + (node.children.length === 0 || + node.children.every( + (n: Node) => n.type === TEXT_NODE && n.value.trim() === '', + )); + case 'first-child': + return (node: Node, parent?: Node) => firstChild(node, parent); + case 'last-child': + return (node: Node, parent?: Node) => lastChild(node, parent); + case 'only-child': + return (node: Node, parent?: Node) => onlyChild(node, parent); + case 'nth-child': + return (node: Node, parent?: Node) => { + const target = nthChildIndex(node, parent) + 1; + if (Number.isNaN(Number(selector.argument))) { + switch (selector.argument) { + case 'odd': + return Math.abs(target % 2) == 1; + case 'even': + return target % 2 === 0; + default: { + if (!selector.argument) { + throw new Error(`Unsupported empty nth-child selector!`); + } + const nth = nthChild(selector.argument); + const elements = parent?.children.filter( + (n: Node) => n.type === ELEMENT_NODE, + ); + const childIndex = nthChildIndex(node, parent) + 1; + for (let i = 0; i < elements.length; i++) { + let n = nth(i); + if (n > elements.length) return false; + if (n === childIndex) return true; + } + return false; + } + } + } + return target === Number(selector.argument); + }; + default: + throw new Error(`Unhandled pseudo-class: ${selector.name}!`); + } + } + case 'attribute': + return (node: Node) => { + let { caseSensitive, name, value } = selector; + if (!node.attributes) return false; + const attrs = Object.entries(node.attributes as Record); + for (let [attr, attrVal] of attrs) { + if (caseSensitive === 'i') { + value = name.toLowerCase(); + attrVal = attr.toLowerCase(); + } + if (attr !== name) continue; + if (!value) return true; + if ( + (value[0] === '"' || value[0] === "'") && + value[0] === value[value.length - 1] + ) { + value = JSON.parse(value); + } + if (value) { + return getAttributeMatch(selector)(attrVal, value); + } + } + return false; + }; + case 'universal': + return (_: Node) => { + return true; + }; + default: { + throw new Error(`Unhandled selector: ${selector.type}`); + } + } +}; + +const selectorToMatch = (sel: string | AST): Matcher => { + let selector = typeof sel === 'string' ? parse(sel) : sel; + switch (selector?.type) { + case 'list': { + const matchers = selector.list.map((s: any) => createMatch(s)); + return (node: Node, parent?: Node, index?: number) => { + for (const match of matchers) { + if (match(node, parent!)) return true; + } + return false; + }; + } + case 'compound': { + const matchers = selector.list.map((s: any) => createMatch(s)); + return (node: Node, parent?: Node, index?: number) => { + for (const match of matchers) { + if (!match(node, parent!)) return false; + } + return true; + }; + } + case 'complex': { + const { left, right, combinator } = selector; + const matchLeft = selectorToMatch(left); + const matchRight = selectorToMatch(right); + let leftMatches = new WeakSet(); + return (node: Node, parent?: Node, i: number = 0) => { + if (matchLeft(node)) { + leftMatches.add(node); + } else if (parent && leftMatches.has(parent) && combinator === ' ') { + leftMatches.add(node); + } + if (!matchRight(node)) return false; + switch (combinator) { + case ' ': // fall-through + case '>': + return parent ? leftMatches.has(parent) : false; + case '~': { + if (!parent) return false; + for (let sibling of parent.children.slice(0, i)) { + if (leftMatches.has(sibling)) return true; + } + return false; + } + case '+': { + if (!parent) return false; + let prevSiblings = parent.children + .slice(0, i) + .filter((el: Node) => el.type === ELEMENT_NODE); + if (prevSiblings.length === 0) return false; + const prev = prevSiblings[prevSiblings.length - 1]; + if (!prev) return false; + if (leftMatches.has(prev)) return true; + } + default: + return false; + } + }; + } + default: + return createMatch(selector!) as Matcher; + } +}; diff --git a/src/html/src/transformers/inline.ts b/src/html/src/transformers/inline.ts new file mode 100644 index 00000000..7a0a13fd --- /dev/null +++ b/src/html/src/transformers/inline.ts @@ -0,0 +1,134 @@ +import { walkSync, ELEMENT_NODE, TEXT_NODE, Node } from '../index.js'; +import { querySelectorAll, specificity } from '../selector.js'; +import { type Element as CSSEntry, compile } from 'stylis'; +import { compileQuery, matches, type Environment } from 'media-query-fns'; + +export interface InlineOptions { + /** Emit `style` attributes as objects rather than strings. */ + useObjectSyntax: boolean; + env: Partial & { width: number; height: number }; +} +export default function inline(opts?: Partial) { + const { useObjectSyntax = false } = opts ?? {}; + return (doc: Node): Node => { + const style: string[] = useObjectSyntax ? [':where([style]) {}'] : []; + const actions: (() => void)[] = []; + walkSync(doc, (node: Node, parent?: Node) => { + if (node.type === ELEMENT_NODE) { + if (node.name === 'style') { + style.push( + node.children + .map((c: Node) => (c.type === TEXT_NODE ? c.value : '')) + .join(''), + ); + actions.push(() => { + parent!.children = parent!.children.filter((c: Node) => c !== node); + }); + } + } + }); + for (const action of actions) { + action(); + } + const styles = style.join('\n'); + const css = compile(styles); + const selectors = new Map>(); + + function applyRule(rule: CSSEntry) { + if (rule.type === 'rule') { + const rules = Object.fromEntries( + (rule.children as unknown as Element[]).map((child: any) => [ + child.props, + child.children, + ]), + ); + for (const selector of rule.props) { + const value = Object.assign(selectors.get(selector) ?? {}, rules); + selectors.set(selector, value); + } + } else if (rule.type === '@media' && opts?.env) { + const env = getEnvironment(opts.env); + const args = Array.isArray(rule.props) ? rule.props : [rule.props]; + const queries = args.map((arg) => compileQuery(arg)); + for (const query of queries) { + if (matches(query, env)) { + for (const child of rule.children) { + applyRule(child as CSSEntry); + } + return; + } + } + } + } + for (const rule of css) { + applyRule(rule); + } + const rules = new Map>(); + for (const [selector, styles] of Array.from(selectors).sort(([a], [b]) => { + const $a = specificity(a); + const $b = specificity(b); + if ($a > $b) return 1; + if ($b > $a) return -1; + return 0; + })) { + const nodes = querySelectorAll(doc, selector); + for (const node of nodes) { + const curr = rules.get(node) ?? {}; + rules.set(node, Object.assign(curr, styles)); + } + } + + for (const [node, rule] of rules) { + let style = node.attributes.style ?? ''; + let styleObj: Record = {}; + for (const decl of compile(style)) { + if (decl.type === 'decl') { + if ( + typeof decl.props === 'string' && + typeof decl.children === 'string' + ) { + styleObj[decl.props] = decl.children; + } + } + } + styleObj = Object.assign({}, rule, styleObj); + if (useObjectSyntax) { + node.attributes.style = styleObj; + } else { + node.attributes.style = `${Object.entries(styleObj) + .map(([decl, value]) => `${decl}:${value.replace('!important', '')};`) + .join('')}`; + } + } + return doc; + }; +} + +type AlwaysDefinedValues = + | 'widthPx' + | 'heightPx' + | 'deviceWidthPx' + | 'deviceHeightPx' + | 'dppx'; +type ResolvedEnvironment = Omit, AlwaysDefinedValues> & + Record; +function getEnvironment(baseEnv: InlineOptions['env']): ResolvedEnvironment { + const { + width, + height, + dppx = 1, + widthPx = width, + heightPx = height, + deviceWidthPx = width * dppx, + deviceHeightPx = height * dppx, + ...env + } = baseEnv; + return { + widthPx, + heightPx, + deviceWidthPx, + deviceHeightPx, + dppx, + ...env, + }; +} diff --git a/src/html/src/transformers/sanitize.ts b/src/html/src/transformers/sanitize.ts new file mode 100644 index 00000000..6a9992c0 --- /dev/null +++ b/src/html/src/transformers/sanitize.ts @@ -0,0 +1,154 @@ +import { ElementNode, ELEMENT_NODE, Node, walkSync } from '../index.js'; + +export interface SanitizeOptions { + /** An Array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be dropped. */ + allowElements?: string[]; + /** An Array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be removed while keeping their child content. */ + unblockElements?: string[]; + /** An Array of strings indicating elements that the sanitizer should remove, but keeping their child elements. */ + blockElements?: string[]; + /** An Array of strings indicating elements (including nested elements) that the sanitizer should remove. */ + dropElements?: string[]; + /** An Object where each key is the attribute name and the value is an Array of allowed tag names. Matching attributes will not be removed. All attributes that are not in the array will be dropped. */ + allowAttributes?: Record; + /** An Object where each key is the attribute name and the value is an Array of dropped tag names. Matching attributes will be removed. */ + dropAttributes?: Record; + /** A Boolean value set to false (default) to remove components and their children. If set to true, components will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ + allowComponents?: boolean; + /** A Boolean value set to false (default) to remove custom elements and their children. If set to true, custom elements will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ + allowCustomElements?: boolean; + /** A Boolean value set to false (default) to remove HTML comments. Set to true in order to keep comments. */ + allowComments?: boolean; +} + +function resolveSantizeOptions( + sanitize?: SanitizeOptions, +): Required { + if (sanitize === undefined) { + return { + allowElements: [] as string[], + dropElements: ['script'], + allowComponents: false, + allowCustomElements: false, + allowComments: false, + } as Required; + } else { + const dropElements = new Set([]); + if (!sanitize.allowElements?.includes('script')) { + dropElements.add('script'); + } + for (const dropElement of sanitize.dropElements ?? []) { + dropElements.add(dropElement); + } + return { + allowComponents: false, + allowCustomElements: false, + allowComments: false, + ...sanitize, + dropElements: Array.from(dropElements), + } as Required; + } +} + +type NodeKind = 'element' | 'component' | 'custom-element'; +function getNodeKind(node: ElementNode): NodeKind { + if (node.name.includes('-')) return 'custom-element'; + if (/[\_\$A-Z]/.test(node.name[0]) || node.name.includes('.')) + return 'component'; + return 'element'; +} + +type ActionType = 'allow' | 'drop' | 'block'; +function getAction( + name: string, + kind: NodeKind, + sanitize: Required, +): ActionType { + if (sanitize.allowElements?.length > 0) { + if (sanitize.allowElements.includes(name)) return 'allow'; + } + if (sanitize.blockElements?.length > 0) { + if (sanitize.blockElements.includes(name)) return 'block'; + } + if (sanitize.dropElements?.length > 0) { + if (sanitize.dropElements.find((n) => n === name)) return 'drop'; + } + if (kind === 'component' && !sanitize.allowComponents) return 'drop'; + if (kind === 'custom-element' && !sanitize.allowCustomElements) return 'drop'; + if (sanitize.unblockElements) { + return sanitize.unblockElements.some((n) => n === name) ? 'allow' : 'block'; + } + return sanitize.allowElements?.length > 0 ? 'drop' : 'allow'; +} + +function sanitizeAttributes( + node: ElementNode, + sanitize: Required, +): Record { + const attrs: Record = node.attributes; + for (const key of Object.keys(node.attributes)) { + if ( + (sanitize.allowAttributes?.[key] && + sanitize.allowAttributes?.[key].includes(node.name)) || + sanitize.allowAttributes?.[key]?.includes('*') + ) { + continue; + } + if ( + (sanitize.dropAttributes?.[key] && + sanitize.dropAttributes?.[key].includes(node.name)) || + sanitize.dropAttributes?.[key]?.includes('*') + ) { + delete attrs[key]; + } + } + return attrs; +} + +function sanitizeElement( + opts: Required, + node: ElementNode, + parent: Node, +) { + const kind = getNodeKind(node); + const { name } = node; + const action = getAction(name, kind, opts); + if (action === 'drop') + return () => { + parent!.children = parent!.children.filter( + (child: Node) => child !== node, + ); + }; + if (action === 'block') + return () => { + parent!.children = parent!.children + .map((child: Node) => (child === node ? child.children : child)) + .flat(1); + }; + + return () => { + node.attributes = sanitizeAttributes(node, opts); + }; +} + +export default function sanitize(opts?: SanitizeOptions) { + const sanitize = resolveSantizeOptions(opts); + return (doc: Node): Node => { + let actions: any[] = []; + walkSync(doc, (node: Node, parent?: Node) => { + switch (node.type) { + case ELEMENT_NODE: { + actions.push(sanitizeElement(sanitize, node, parent!)); + return; + } + default: + return; + } + }); + // Execute actions in reverse order so that children are mutated before parents. + for (let i = actions.length - 1; i >= 0; i--) { + actions[i](); + } + return doc; + }; +} diff --git a/src/html/src/transformers/scope.ts b/src/html/src/transformers/scope.ts new file mode 100644 index 00000000..48d803fc --- /dev/null +++ b/src/html/src/transformers/scope.ts @@ -0,0 +1,233 @@ +import type { AST } from 'parsel-js'; +import type { ElementNode, Node } from '../index.js'; + +import { parse } from 'parsel-js'; +import { compile, middleware, serialize, stringify } from 'stylis'; +import { ELEMENT_NODE, TEXT_NODE, render, walkSync } from '../index.js'; +import { matches } from '../selector.js'; + +export interface ScopeOptions { + hash?: string; + attribute?: string; +} +export default function scope(opts: ScopeOptions = {}) { + return async (doc: Node): Promise => { + const hash = opts.hash ?? shorthash(await render(doc)); + const actions: (() => void)[] = []; + let hasStyle = false; + const selectors = new Set(); + const nodes = new Set(); + walkSync(doc, (node: Node) => { + if (node.type === ELEMENT_NODE && node.name === 'style') { + if (!opts.attribute || hasAttribute(node, opts.attribute)) { + hasStyle = true; + if (opts.attribute) { + delete node.attributes[opts.attribute]; + } + for (const selector of getSelectors(node.children[0].value)) { + selectors.add(selector); + } + } + } + if (node.type === ELEMENT_NODE) { + nodes.add(node); + } + }); + if (hasStyle) { + walkSync(doc, (node: Node) => { + if (node.type === ELEMENT_NODE) { + actions.push(() => scopeElement(node, hash, selectors)); + if (node.name === 'style') { + actions.push(() => { + node.children = node.children.map((c: Node) => { + if (c.type !== TEXT_NODE) return c; + c.value = scopeCSS(c.value, hash); + if (c.value === '') { + node.parent.children = node.parent.children.filter( + (s: Node) => s !== node, + ); + } + return c; + }); + }); + } + } + }); + } + for (const action of actions) { + action(); + } + + return doc; + }; +} + +const NEVER_SCOPED = new Set([ + 'base', + 'font', + 'frame', + 'frameset', + 'head', + 'link', + 'meta', + 'noframes', + 'noscript', + 'script', + 'style', + 'title', +]); + +function hasAttribute(node: ElementNode, name: string) { + if (name in node.attributes) { + return node.attributes[name] !== 'false'; + } + return false; +} + +function scopeElement(node: ElementNode, hash: string, selectors: Set) { + const { name } = node; + if (!name) return; + if (name.length < 1) return; + if (NEVER_SCOPED.has(name)) return; + if (node.attributes['data-scope']) return; + for (const selector of selectors) { + if (matches(node, selector)) { + node.attributes['data-scope'] = hash; + return; + } + } +} + +function scopeSelector(selector: string, hash: string): string { + const ast = parse(selector); + const scope = (node: AST): string => { + switch (node.type) { + case 'pseudo-class': { + if (node.name === 'root') return node.content; + if (node.name === 'global') return node.argument!; + return `${node.content}:where([data-scope="${hash}"])`; + } + case 'compound': + return `${selector}:where([data-scope="${hash}"])`; + case 'complex': { + const { left, right, combinator } = node; + return `${scope(left)}${combinator}${scope(right)}`; + } + case 'list': + return node.list.map((s) => scope(s)).join(' '); + default: + return `${node.content}:where([data-scope="${hash}"])`; + } + }; + return scope(ast!); +} + +function scopeCSS(css: string, hash: string) { + return serialize( + compile(css), + middleware([ + (element) => { + if (element.type === 'rule') { + if (Array.isArray(element.props)) { + element.props = element.props.map((prop) => + scopeSelector(prop, hash), + ); + } else { + element.props = scopeSelector(element.props, hash); + } + } + }, + stringify, + ]), + ); +} + +function getSelectors(css: string) { + const selectors = new Set(); + serialize( + compile(css), + middleware([ + (element) => { + if (element.type === 'rule') { + if (Array.isArray(element.props)) { + for (const p of element.props) { + selectors.add(p); + } + } else { + selectors.add(element.props); + } + } + }, + ]), + ); + return Array.from(selectors); +} + +/** + * shorthash - https://github.com/bibig/node-shorthash + * + * @license + * + * (The MIT License) + * + * Copyright (c) 2013 Bibig + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +const dictionary = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY'; +const binary = dictionary.length; + +// refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ +function bitwise(str: string) { + let hash = 0; + if (str.length === 0) return hash; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = (hash << 5) - hash + ch; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +} + +function shorthash(text: string) { + let num: number; + let result = ''; + + let integer = bitwise(text); + const sign = integer < 0 ? 'Z' : ''; // It it's negative, start with Z, which isn't in the dictionary + + integer = Math.abs(integer); + + while (integer >= binary) { + num = integer % binary; + integer = Math.floor(integer / binary); + result = dictionary[num] + result; + } + + if (integer > 0) { + result = dictionary[integer] + result; + } + + return sign + result; +} diff --git a/src/html/src/transformers/swap.ts b/src/html/src/transformers/swap.ts new file mode 100644 index 00000000..ba558ae3 --- /dev/null +++ b/src/html/src/transformers/swap.ts @@ -0,0 +1,26 @@ +import { ElementNode, RenderFn } from '../index.js'; +import { Node, __unsafeRenderFn } from '../index.js'; +import { querySelectorAll } from '../selector.js'; + +export default function swap( + components: Record< + string, + string | ((props: Record, ...children: any[]) => any) + > = {}, +) { + return (doc: Node): Node => { + for (const [selector, component] of Object.entries(components)) { + for (const node of querySelectorAll(doc, selector)) { + if (typeof component === 'string') { + node.name = component; + if (RenderFn in node) { + delete (node as any)[RenderFn]; + } + } else if (typeof component === 'function') { + __unsafeRenderFn(node as ElementNode, component); + } + } + } + return doc; + }; +} diff --git a/src/html/test/basic.test.ts b/src/html/test/basic.test.ts new file mode 100644 index 00000000..fdd7ffa0 --- /dev/null +++ b/src/html/test/basic.test.ts @@ -0,0 +1,95 @@ +import { parse, render } from '../src/'; +import { describe, expect, it, test } from 'vitest'; + +test('sanity', () => { + expect(parse).toBeTypeOf('function'); +}); + +describe('input === output', () => { + it('works for elements', async () => { + const input = `

Hello world!

`; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it('works for custom elements', async () => { + const input = `Hello world!`; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it('works for comments', async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it('works for text', async () => { + const input = `Hmm...`; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it('works for doctype', async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it('works for html:5', async () => { + const input = `Document`; + const output = await render(parse(input)); + + expect(output).toEqual(input); + }); +}); + +describe('attributes', () => { + it('simple', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ a: 'b', c: '1' }); + }); + it('empty', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ test: '' }); + }); + it('@', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ '@on.click': 'doThing' }); + }); + it('namespace', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ 'on:click': 'alert()' }); + }); + it('simple and empty', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ test: '', a: 'b', c: '1' }); + }); + it('with linebreaks', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ a: '1\n2\n3' }); + }); + it('with single quote', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ a: "nate'\ns" }); + }); + it('with escaped double quote', async () => { + const { + children: [{ attributes }], + } = parse(`
`); + expect(attributes).toMatchObject({ a: '"never\nmore"' }); + }); +}); diff --git a/src/html/test/html.test.ts b/src/html/test/html.test.ts new file mode 100644 index 00000000..689e3447 --- /dev/null +++ b/src/html/test/html.test.ts @@ -0,0 +1,25 @@ +import { html, attrs } from '../src/'; +import { describe, expect, it } from 'vitest'; + +describe('html', () => { + it('works', () => { + const { value } = html`

${'Hello world!'}

`; + expect(value).toEqual(`

Hello world!

`); + }); + it('escapes', () => { + const { value } = html`

${'
'}

`; + expect(value).toEqual(`

<div></div>

`); + }); + it('nested', () => { + const { value } = html`

${html`
`}

`; + expect(value).toEqual(`

`); + }); + it('attrs', () => { + const { value } = html`

`; + expect(value).toEqual(`

`); + }); + it('spread', () => { + const { value } = html`

`; + expect(value).toEqual(`

`); + }); +}); diff --git a/src/html/test/markdown.test.ts b/src/html/test/markdown.test.ts new file mode 100644 index 00000000..f06c21e1 --- /dev/null +++ b/src/html/test/markdown.test.ts @@ -0,0 +1,21 @@ +import { parse, render } from "../src/"; +import { describe, expect, it } from "vitest"; +import Markdown from "markdown-it"; + +const md = new Markdown(); + +const src = `Token CSS is a new tool that seamlessly integrates [Design Tokens](https://design-tokens.github.io/community-group/format/#design-token) into your development workflow. Conceptually, it is similar to tools +like [Tailwind](https://tailwindcss.com), [Styled System](https://styled-system.com/), and many CSS-in-JS libraries that provide tokenized _constraints_ for your styles—but there's one big difference. + +# Hello world! + +**Token CSS embraces \`.css\` files and \``; + let meta = 0; + await walk(parse(input), async (node, parent) => { + if ( + node.type === ELEMENT_NODE && + node.name === "meta" && + parent?.name === "head" + ) { + meta++; + } + }); + expect(meta).toEqual(11); + }); + it("works with `; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it("works with inside script", async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it("works with <\\/script> inside script", async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); +}); + +describe("style", () => { + it("works for elements", async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it("works without quotes", async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); +}); diff --git a/src/html/test/selector.test.ts b/src/html/test/selector.test.ts new file mode 100644 index 00000000..0f13c593 --- /dev/null +++ b/src/html/test/selector.test.ts @@ -0,0 +1,308 @@ +import $, { querySelector, querySelectorAll } from '../src/selector'; +import { parse, render } from '../src'; +import { describe, expect, it, test } from 'vitest'; + +test('sanity', () => { + expect(querySelector).toBeTypeOf('function'); + expect(querySelectorAll).toBeTypeOf('function'); + expect($).toBeTypeOf('function'); + expect($).toEqual(querySelectorAll); +}); + +describe('type selector', () => { + it('type', async () => { + const input = `

Hello world!

`; + const output = await render(querySelector(parse(input), 'h1')); + expect(output).toEqual(input); + }); + it('compound type class', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1.foo')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute = case sensitive', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test="FOO"]')[0]; + expect(el).toBeUndefined(); + }); + it('compound type attribute = case insensitive', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test="FOO" i]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute =', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test="foo"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute ~=', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test~="foo"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute *=', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test*="foo"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute $=', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test$=".com"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual( + `

Hello world!

`, + ); + }); + it('compound type attribute ^=', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test^="awe"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attribute |=', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll(parse(input), 'h1[data-test|="en"]')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`

Hello world!

`); + }); + it('compound type attributes', async () => { + const input = `

Hello world!

No

`; + const el = querySelectorAll( + parse(input), + 'h1[data-test^="https://"][data-test$=.com]', + )[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual( + `

Hello world!

`, + ); + }); +}); + +describe('id selector', () => { + it('id', async () => { + const input = `

Hello world!

`; + const output = await render(querySelectorAll(parse(input), '#foo')[0]); + expect(output).toEqual(input); + }); +}); + +describe('list selector', () => { + it('list', async () => { + const input = `

Hello world!

Goodbye world!

`; + const els = querySelectorAll(parse(input), 'h1, h2'); + expect(els.length).toEqual(2); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual(input); + }); +}); + +describe('complex selector', () => { + it('deep', async () => { + const input = `

Hello
world
!

`; + const el = querySelectorAll(parse(input), 'h1 span')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('>', async () => { + const input = `

Hello world!

`; + const el = querySelectorAll(parse(input), 'h1 > span')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('~', async () => { + const input = `world !`; + const el = querySelectorAll(parse(input), 'span ~ span')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('!'); + }); + it('* + *', async () => { + const input = `

Hello

world

!

`; + const el = querySelectorAll(parse(input), '* + *'); + expect(el).toBeDefined(); + expect(el.length).toEqual(2); + const a = await render(el[0]); + const b = await render(el[1]); + expect(a).toEqual('world'); + expect(b).toEqual('

!

'); + }); +}); + +describe('pseudo-class', () => { + it('root', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), ':root')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(input); + }); + it('empty', async () => { + const input = `

Hello

`; + const el = querySelectorAll(parse(input), ':empty')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(``); + }); + it('first-child', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), 'span:first-child')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('only-child', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), 'span:only-child')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('last-child', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), 'span:last-child')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('nth-child(1)', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), 'span:nth-child(1)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('nth-child(odd)', async () => { + const input = `

Hello world

`; + const el = querySelectorAll(parse(input), 'span:nth-child(odd)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('nth-child(even)', async () => { + const input = `

Hello world!

`; + const el = querySelectorAll(parse(input), 'span:nth-child(even)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('!'); + }); + it('nth-child(2n + 1)', async () => { + const input = `

Hello world!

`; + const el = querySelectorAll(parse(input), 'span:nth-child(2n + 1)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('world'); + }); + it('nth-child(2n)', async () => { + const input = `

Hello world!

`; + const el = querySelectorAll(parse(input), 'span:nth-child(2n)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual('!'); + }); + it('nth-child(3n)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll(parse(input), 'span:nth-child(3n)'); + expect(els.length).toBe(2); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('cf'); + }); + it('nth-child(n+4)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll(parse(input), 'span:nth-child(n+4)'); + expect(els.length).toBe(3); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('def'); + }); + it('nth-child(n + 4)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll(parse(input), 'span:nth-child(n + 4)'); + expect(els.length).toBe(3); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('def'); + }); + it('nth-child(2n+4)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll(parse(input), 'span:nth-child(2n+4)'); + expect(els.length).toBe(2); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('df'); + }); + it('nth-child(-n+3)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll(parse(input), 'span:nth-child(-n+3)'); + expect(els.length).toBe(3); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('abc'); + }); + it('nth-child(n+3):nth-child(-n+5)', async () => { + const input = `

abcdef

`; + const els = querySelectorAll( + parse(input), + 'span:nth-child(n+3):nth-child(-n+5)', + ); + expect(els.length).toBe(3); + const output = await (await Promise.all(els.map((el) => render(el)))).join( + '', + ); + expect(output).toEqual('cde'); + }); +}); + +describe('functional pseudo-class', () => { + it('not', async () => { + const input = `

Helloworld

`; + const el = querySelectorAll(parse(input), 'h1 > span:not(#foo)')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`world`); + }); + it('is', async () => { + const input = `

Helloworld

`; + const el = querySelectorAll(parse(input), 'h1 > span:is(#foo, [id])')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`Hello`); + }); + it('where', async () => { + const input = `

Helloworld

`; + const el = querySelectorAll(parse(input), 'h1 > span:where(#foo, [id])')[0]; + expect(el).toBeDefined(); + const output = await render(el); + expect(output).toEqual(`Hello`); + }); +}); diff --git a/src/html/test/svg.test.ts b/src/html/test/svg.test.ts new file mode 100644 index 00000000..72e400f8 --- /dev/null +++ b/src/html/test/svg.test.ts @@ -0,0 +1,15 @@ +import { parse, render, renderSync } from "../src/index.ts"; +import { describe, expect, it } from "vitest"; + +describe("svg", () => { + it("render as self-closing", async () => { + const input = ``; + const output = await render(parse(input)); + expect(output).toEqual(input); + }); + it("renderSync as self-closing", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); +}); diff --git a/src/html/test/transformers/inline.test.tsx b/src/html/test/transformers/inline.test.tsx new file mode 100644 index 00000000..f205f69b --- /dev/null +++ b/src/html/test/transformers/inline.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect } from "vitest"; +import { h, Fragment, parse, transform } from "../../src/index"; +import inline from "../../src/transformers/inline"; +import { setTimeout as sleep } from "timers/promises"; + +describe("inline", () => { + it("works with a simple input", async () => { + const input = `
Hello world
`; + const output = await transform(input, [inline()]); + expect(output).toEqual(`
Hello world
`); + }); + + it("works with multiple inputs", async () => { + const input = `
Hello world
`; + const output = await transform(input, [inline()]); + expect(output).toEqual(`
Hello world
`); + }); + + it("inline styles override external", async () => { + const input = `
Hello world
`; + const output = await transform(input, [inline()]); + expect(output).toEqual(`
Hello world
`); + }); + + it("inline styles with class", async () => { + const input = `
Hello world
+ `; + const output = await transform(input, [inline()]); + expect(output.trim()).toEqual( + `
Hello world
`, + ); + }); + + it("inlines styles when @media is matched", async () => { + const input = `
Hello world
+ `; + const output = await transform(input, [ + inline({ env: { width: 961, height: 1280 } }), + ]); + expect(output.trim()).toEqual( + `
Hello world
`, + ); + }); + + it("does not inline styles when @media cannot be matched", async () => { + const input = `
Hello world
+ `; + const output = await transform(input, [ + inline({ env: { width: 959, height: 1280 } }), + ]); + expect(output.trim()).toEqual( + `
Hello world
`, + ); + }); +}); + +describe("object syntax", () => { + it("emits style as object", async () => { + const inliner = inline({ useObjectSyntax: true }); + const input = `
Hello world
`; + const doc = await parse(input); + const output = inliner(doc); + expect(output.children[0].attributes.style).toEqual({ color: "red" }); + }); + + it("emits plain style attribute as object", async () => { + const inliner = inline({ useObjectSyntax: true }); + const input = `
Hello world
`; + const doc = await parse(input); + const output = inliner(doc); + expect(output.children[0].attributes.style).toEqual({ color: "blue" }); + }); + + it("works for complex properties", async () => { + const inliner = inline({ useObjectSyntax: true }); + const input = ` +
Hello world
+ + `.trim(); + const doc = await parse(input); + const output = inliner(doc); + expect(output.children[0].attributes.style).toEqual({ + background: "linear-gradient(135deg, #ef629f, #eecda3)", + display: "flex", + width: "100%", + height: "100%", + }); + }); +}); + +// describe("inline link", () => { +// it("works with sync resolveAsset", async () => { +// const input = `
Hello world
`; +// const output = await transform(input, [ +// inline({ +// resolveAsset({ attributes: { href } }) { +// if (href === "/assets/1.css") { +// return "div{color:red;}"; +// } +// }, +// }), +// ]); +// expect(output).toEqual(`
Hello world
`); +// }); +// it("ignores unresolved assets", async () => { +// const input = `
Hello world
`; +// const output = await transform(input, [ +// inline({ +// resolveAsset({ attributes: { href } }) { +// if (href === "/assets/1.css") { +// return; +// } +// }, +// }), +// ]); +// expect(output).toEqual(input); +// }); +// it("works with async resolveAsset", async () => { +// const input = `
Hello world
`; +// const output = await transform(input, [ +// inline({ +// async resolveAsset({ attributes: { href } }) { +// if (href === "/assets/1.css") { +// return "div{color:red;}"; +// } +// }, +// }), +// ]); +// expect(output).toEqual(`
Hello world
`); +// }); +// it("works with multiple inputs", async () => { +// const input = `
Hello world
`; +// const output = await transform(input, [ +// inline({ +// resolveAsset({ attributes: { href } }) { +// if (href === "/assets/1.css") { +// return "div{color:green;}"; +// } +// if (href === "/assets/2.css") { +// return "div{color:red;}"; +// } +// }, +// }), +// ]); +// expect(output).toEqual(`
Hello world
`); +// }); +// it("works with multiple inputs and maintains order", async () => { +// const input = `
Hello world
`; +// const output = await transform(input, [ +// inline({ +// async resolveAsset({ attributes: { href } }) { +// if (href === "/assets/1.css") { +// await sleep(200) +// return "div{color:green;}"; +// } +// if (href === "/assets/2.css") { +// return "div{color:red;}"; +// } +// }, +// }), +// ]); +// expect(output).toEqual(`
Hello world
`); +// }); +// }); diff --git a/src/html/test/transformers/sanitize.test.ts b/src/html/test/transformers/sanitize.test.ts new file mode 100644 index 00000000..8c0a31d5 --- /dev/null +++ b/src/html/test/transformers/sanitize.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import sanitize from '../../src/transformers/sanitize.js'; +import { transform } from '../../src/index.js'; + +describe('sanitize', () => { + it('drop', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [sanitize({ dropElements: ['h1'] })]); + expect(output).toEqual(''); + }); + it('block', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ blockElements: ['h1'] }), + ]); + expect(output).toEqual('Hello world!'); + }); + it('block with multiple elements', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ blockElements: ['h1', 'strong'] }), + ]); + expect(output).toEqual('Hello world!'); + }); + it('allow', async () => { + const input = ``; + const output = await transform(input, [ + sanitize({ allowElements: ['script'] }), + ]); + expect(output).toEqual(''); + }); + describe('unblock', () => { + it('empty unblock array blocks all elements', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ unblockElements: [] }), + ]); + expect(output).toEqual('Hello world!'); + }); + it('unblock array blocks unlisted elements', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ unblockElements: ['strong'] }), + ]); + expect(output).toEqual('Hello world!'); + }); + }); + it('allow drops everything else', async () => { + const input = `

Hello world!

This is not allowed

`; + const output = await transform(input, [ + sanitize({ allowElements: ['h1', 'h2', 'h3'] }), + ]); + expect(output).toEqual('

Hello world!

'); + }); + it('drop script by default', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [sanitize()]); + expect(output).toEqual('

Hello world!

'); + }); + it('explicit components are automatically allowed', async () => { + const input = `Hello world!`; + const output = await transform(input); + expect(output).toEqual('Hello world!'); + }); + it('attribute drop', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ dropAttributes: { no: ['h1'] } }), + ]); + expect(output).toEqual('

Hello world!

'); + }); + it('attribute drop *', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ dropAttributes: { no: ['*'] } }), + ]); + expect(output).toEqual('

Hello world!

'); + }); + it('attribute allow', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ allowAttributes: { yes: ['h1'] } }), + ]); + expect(output).toEqual('

Hello world!

'); + }); + it('attribute allow', async () => { + const input = `

Hello world!

`; + const output = await transform(input, [ + sanitize({ + dropAttributes: { yes: ['*'] }, + allowAttributes: { yes: ['h1'] }, + }), + ]); + expect(output).toEqual('

Hello world!

'); + }); + it('gracefully handles invalid drop', async () => { + const input = `Hello world!`; + const output = await transform(input, [ + sanitize({ dropAttributes: { nonexistent: ['a'] } }), + ]); + expect(output).toEqual(`Hello world!`); + }); + it('gracefully handles invalid allow', async () => { + const input = `Hello world!`; + const output = await transform(input, [ + sanitize({ allowAttributes: { nonexistent: ['a'] } }), + ]); + expect(output).toEqual(`Hello world!`); + }); +}); diff --git a/src/html/test/transformers/scope.test.tsx b/src/html/test/transformers/scope.test.tsx new file mode 100644 index 00000000..3cdddab5 --- /dev/null +++ b/src/html/test/transformers/scope.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { h, Fragment, transform } from "../../src/index"; +import scope from "../../src/transformers/scope"; + +describe("scope", () => { + it("works with a simple input", async () => { + const input = `
Hello world
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + + it("only scopes seen selectors", async () => { + const input = `
Hello world
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + + it("keeps unmatched selectors", async () => { + const input = `
Hello world
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + + it("keeps unmatched :global selectors", async () => { + const input = `
Hello world
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + + it("keeps :root selector", async () => { + const input = `
Hello world
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + it("scopes div > * + * selector", async () => { + const input = `
Helloworld!
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Helloworld!
`, + ); + }); + it("keeps div > :global(* + *) selector", async () => { + const input = `
Helloworld!
`; + const output = await transform(input, [scope({ hash: "XXX" })]); + expect(output).toEqual( + `
Helloworld!
`, + ); + }); +}); + +describe("attribute", () => { + it("ignores style without attribute", async () => { + const input = `
Hello world
`; + const output = await transform(input, [ + scope({ hash: "XXX", attribute: "scoped" }), + ]); + expect(output).toEqual( + `
Hello world
`, + ); + }); + it("scopes style with attribute", async () => { + const input = `
Hello world
`; + const output = await transform(input, [ + scope({ hash: "XXX", attribute: "scoped" }), + ]); + expect(output).toEqual( + `
Hello world
`, + ); + }); +}); diff --git a/src/html/test/transformers/swap.test.ts b/src/html/test/transformers/swap.test.ts new file mode 100644 index 00000000..0a5963f2 --- /dev/null +++ b/src/html/test/transformers/swap.test.ts @@ -0,0 +1,68 @@ +import { html, transform } from '../../src/index.js'; +import swap from '../../src/transformers/swap.js'; +import { describe, expect, it } from 'vitest'; + +describe('html API', () => { + it('function for element', async () => { + const components = { + h1: (_, children) => html`${children}`, + }; + const input = `

Hello world!

`; + const output = await transform(input, [swap(components)]); + expect(output).toEqual(`Hello world!`); + }); + it('function for component', async () => { + const components = { + Title: (_, children) => html`

${children}

`, + }; + const input = `Hello world!`; + const output = await transform(input, [swap(components)]); + expect(output).toEqual(`

Hello world!

`); + }); + it('string for element to Component', async () => { + const components = { + h1: 'Title', + Title: (_, children) => html`${children}`, + }; + const input = `

Hello world!

`; + const output = await transform(input, [swap(components)]); + expect(output).toEqual(`Hello world!`); + }); + it('string for element to Component', async () => { + const components = { + h1: 'Title', + Title: (_, children) => html`${children}`, + }; + const input = `

Hello world!

`; + const output = await transform(input, [swap(components)]); + expect(output).toEqual(`Hello world!`); + }); + it('async Component', async () => { + const components = { + Title: async (_, children) => html`${children}`, + }; + const input = `Hello world!`; + const output = await transform(input, [swap(components)]); + expect(output).toEqual(`Hello world!`); + }); + + it('readme example', async () => { + const Title = (props, children) => + html`

${children}

`; + const output = await transform(`

Hello world!

`, [ + swap({ h1: Title }), + ]); + expect(output).toEqual(`

Hello world!

`); + }); + it('transforms custom components', async () => { + const CustomElement = (props, children) => + html`${children}`; + const output = await transform( + `Hello world!`, + [swap({ 'custom-element': CustomElement })], + ); + expect(output).toEqual( + `Hello world!`, + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9cd89cf8..feb52517 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "include": ["src/**/*.js", "src/**/*.ts"], + "exclude": ["src/html/**"], "compilerOptions": { "declaration": true, "allowJs": true, From c64db5adeba765370901621bf236441485934f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 21:39:41 +0900 Subject: [PATCH 02/52] wip: remove `h` function --- src/html/src/index.ts | 22 ---------------------- src/html/src/transformers/swap.ts | 9 ++++----- src/html/test/transformers/inline.test.tsx | 3 +-- src/html/test/transformers/scope.test.tsx | 2 +- 4 files changed, 6 insertions(+), 30 deletions(-) diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 020b5608..791078eb 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -59,28 +59,6 @@ export const TEXT_NODE = 2; export const COMMENT_NODE = 3; export const DOCTYPE_NODE = 4; -export function h( // TODO: remove this function, as it is about JSX. - type: any, - props: null | Record = {}, - ...children: any[] -) { - const vnode: ElementNode = { - type: ELEMENT_NODE, - name: typeof type === "function" ? type.name : type, - attributes: props || {}, - children: children.map(child => - typeof child === "string" - ? { type: TEXT_NODE, value: escapeHTML(String(child)) } - : child, - ), - parent: undefined as any, - loc: [] as any, - }; - if (typeof type === "function") { - __unsafeRenderFn(vnode, type); - } - return vnode; -} export const Fragment = Symbol("Fragment"); const VOID_TAGS = new Set([ diff --git a/src/html/src/transformers/swap.ts b/src/html/src/transformers/swap.ts index ba558ae3..3aec9be0 100644 --- a/src/html/src/transformers/swap.ts +++ b/src/html/src/transformers/swap.ts @@ -1,6 +1,5 @@ -import { ElementNode, RenderFn } from '../index.js'; -import { Node, __unsafeRenderFn } from '../index.js'; -import { querySelectorAll } from '../selector.js'; +import { ElementNode, RenderFn, Node, __unsafeRenderFn } from "../index.js"; +import { querySelectorAll } from "../selector.js"; export default function swap( components: Record< @@ -11,12 +10,12 @@ export default function swap( return (doc: Node): Node => { for (const [selector, component] of Object.entries(components)) { for (const node of querySelectorAll(doc, selector)) { - if (typeof component === 'string') { + if (typeof component === "string") { node.name = component; if (RenderFn in node) { delete (node as any)[RenderFn]; } - } else if (typeof component === 'function') { + } else if (typeof component === "function") { __unsafeRenderFn(node as ElementNode, component); } } diff --git a/src/html/test/transformers/inline.test.tsx b/src/html/test/transformers/inline.test.tsx index f205f69b..b5d4f47a 100644 --- a/src/html/test/transformers/inline.test.tsx +++ b/src/html/test/transformers/inline.test.tsx @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; -import { h, Fragment, parse, transform } from "../../src/index"; +import { parse, transform } from "../../src/index"; import inline from "../../src/transformers/inline"; -import { setTimeout as sleep } from "timers/promises"; describe("inline", () => { it("works with a simple input", async () => { diff --git a/src/html/test/transformers/scope.test.tsx b/src/html/test/transformers/scope.test.tsx index 3cdddab5..87cdbc7a 100644 --- a/src/html/test/transformers/scope.test.tsx +++ b/src/html/test/transformers/scope.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { h, Fragment, transform } from "../../src/index"; +import { transform } from "../../src/index"; import scope from "../../src/transformers/scope"; describe("scope", () => { From bda877554ec0a7d252e4a3074cfa3592dae42efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 21:45:13 +0900 Subject: [PATCH 03/52] wip: remove `sanitize` --- src/html/src/transformers/sanitize.ts | 154 -------------------- src/html/test/transformers/sanitize.test.ts | 110 -------------- 2 files changed, 264 deletions(-) delete mode 100644 src/html/src/transformers/sanitize.ts delete mode 100644 src/html/test/transformers/sanitize.test.ts diff --git a/src/html/src/transformers/sanitize.ts b/src/html/src/transformers/sanitize.ts deleted file mode 100644 index 6a9992c0..00000000 --- a/src/html/src/transformers/sanitize.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { ElementNode, ELEMENT_NODE, Node, walkSync } from '../index.js'; - -export interface SanitizeOptions { - /** An Array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be dropped. */ - allowElements?: string[]; - /** An Array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be removed while keeping their child content. */ - unblockElements?: string[]; - /** An Array of strings indicating elements that the sanitizer should remove, but keeping their child elements. */ - blockElements?: string[]; - /** An Array of strings indicating elements (including nested elements) that the sanitizer should remove. */ - dropElements?: string[]; - /** An Object where each key is the attribute name and the value is an Array of allowed tag names. Matching attributes will not be removed. All attributes that are not in the array will be dropped. */ - allowAttributes?: Record; - /** An Object where each key is the attribute name and the value is an Array of dropped tag names. Matching attributes will be removed. */ - dropAttributes?: Record; - /** A Boolean value set to false (default) to remove components and their children. If set to true, components will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ - allowComponents?: boolean; - /** A Boolean value set to false (default) to remove custom elements and their children. If set to true, custom elements will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). */ - allowCustomElements?: boolean; - /** A Boolean value set to false (default) to remove HTML comments. Set to true in order to keep comments. */ - allowComments?: boolean; -} - -function resolveSantizeOptions( - sanitize?: SanitizeOptions, -): Required { - if (sanitize === undefined) { - return { - allowElements: [] as string[], - dropElements: ['script'], - allowComponents: false, - allowCustomElements: false, - allowComments: false, - } as Required; - } else { - const dropElements = new Set([]); - if (!sanitize.allowElements?.includes('script')) { - dropElements.add('script'); - } - for (const dropElement of sanitize.dropElements ?? []) { - dropElements.add(dropElement); - } - return { - allowComponents: false, - allowCustomElements: false, - allowComments: false, - ...sanitize, - dropElements: Array.from(dropElements), - } as Required; - } -} - -type NodeKind = 'element' | 'component' | 'custom-element'; -function getNodeKind(node: ElementNode): NodeKind { - if (node.name.includes('-')) return 'custom-element'; - if (/[\_\$A-Z]/.test(node.name[0]) || node.name.includes('.')) - return 'component'; - return 'element'; -} - -type ActionType = 'allow' | 'drop' | 'block'; -function getAction( - name: string, - kind: NodeKind, - sanitize: Required, -): ActionType { - if (sanitize.allowElements?.length > 0) { - if (sanitize.allowElements.includes(name)) return 'allow'; - } - if (sanitize.blockElements?.length > 0) { - if (sanitize.blockElements.includes(name)) return 'block'; - } - if (sanitize.dropElements?.length > 0) { - if (sanitize.dropElements.find((n) => n === name)) return 'drop'; - } - if (kind === 'component' && !sanitize.allowComponents) return 'drop'; - if (kind === 'custom-element' && !sanitize.allowCustomElements) return 'drop'; - if (sanitize.unblockElements) { - return sanitize.unblockElements.some((n) => n === name) ? 'allow' : 'block'; - } - return sanitize.allowElements?.length > 0 ? 'drop' : 'allow'; -} - -function sanitizeAttributes( - node: ElementNode, - sanitize: Required, -): Record { - const attrs: Record = node.attributes; - for (const key of Object.keys(node.attributes)) { - if ( - (sanitize.allowAttributes?.[key] && - sanitize.allowAttributes?.[key].includes(node.name)) || - sanitize.allowAttributes?.[key]?.includes('*') - ) { - continue; - } - if ( - (sanitize.dropAttributes?.[key] && - sanitize.dropAttributes?.[key].includes(node.name)) || - sanitize.dropAttributes?.[key]?.includes('*') - ) { - delete attrs[key]; - } - } - return attrs; -} - -function sanitizeElement( - opts: Required, - node: ElementNode, - parent: Node, -) { - const kind = getNodeKind(node); - const { name } = node; - const action = getAction(name, kind, opts); - if (action === 'drop') - return () => { - parent!.children = parent!.children.filter( - (child: Node) => child !== node, - ); - }; - if (action === 'block') - return () => { - parent!.children = parent!.children - .map((child: Node) => (child === node ? child.children : child)) - .flat(1); - }; - - return () => { - node.attributes = sanitizeAttributes(node, opts); - }; -} - -export default function sanitize(opts?: SanitizeOptions) { - const sanitize = resolveSantizeOptions(opts); - return (doc: Node): Node => { - let actions: any[] = []; - walkSync(doc, (node: Node, parent?: Node) => { - switch (node.type) { - case ELEMENT_NODE: { - actions.push(sanitizeElement(sanitize, node, parent!)); - return; - } - default: - return; - } - }); - // Execute actions in reverse order so that children are mutated before parents. - for (let i = actions.length - 1; i >= 0; i--) { - actions[i](); - } - return doc; - }; -} diff --git a/src/html/test/transformers/sanitize.test.ts b/src/html/test/transformers/sanitize.test.ts deleted file mode 100644 index 8c0a31d5..00000000 --- a/src/html/test/transformers/sanitize.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import sanitize from '../../src/transformers/sanitize.js'; -import { transform } from '../../src/index.js'; - -describe('sanitize', () => { - it('drop', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [sanitize({ dropElements: ['h1'] })]); - expect(output).toEqual(''); - }); - it('block', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ blockElements: ['h1'] }), - ]); - expect(output).toEqual('Hello world!'); - }); - it('block with multiple elements', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ blockElements: ['h1', 'strong'] }), - ]); - expect(output).toEqual('Hello world!'); - }); - it('allow', async () => { - const input = ``; - const output = await transform(input, [ - sanitize({ allowElements: ['script'] }), - ]); - expect(output).toEqual(''); - }); - describe('unblock', () => { - it('empty unblock array blocks all elements', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ unblockElements: [] }), - ]); - expect(output).toEqual('Hello world!'); - }); - it('unblock array blocks unlisted elements', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ unblockElements: ['strong'] }), - ]); - expect(output).toEqual('Hello world!'); - }); - }); - it('allow drops everything else', async () => { - const input = `

Hello world!

This is not allowed

`; - const output = await transform(input, [ - sanitize({ allowElements: ['h1', 'h2', 'h3'] }), - ]); - expect(output).toEqual('

Hello world!

'); - }); - it('drop script by default', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [sanitize()]); - expect(output).toEqual('

Hello world!

'); - }); - it('explicit components are automatically allowed', async () => { - const input = `Hello world!`; - const output = await transform(input); - expect(output).toEqual('Hello world!'); - }); - it('attribute drop', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ dropAttributes: { no: ['h1'] } }), - ]); - expect(output).toEqual('

Hello world!

'); - }); - it('attribute drop *', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ dropAttributes: { no: ['*'] } }), - ]); - expect(output).toEqual('

Hello world!

'); - }); - it('attribute allow', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ allowAttributes: { yes: ['h1'] } }), - ]); - expect(output).toEqual('

Hello world!

'); - }); - it('attribute allow', async () => { - const input = `

Hello world!

`; - const output = await transform(input, [ - sanitize({ - dropAttributes: { yes: ['*'] }, - allowAttributes: { yes: ['h1'] }, - }), - ]); - expect(output).toEqual('

Hello world!

'); - }); - it('gracefully handles invalid drop', async () => { - const input = `Hello world!`; - const output = await transform(input, [ - sanitize({ dropAttributes: { nonexistent: ['a'] } }), - ]); - expect(output).toEqual(`Hello world!`); - }); - it('gracefully handles invalid allow', async () => { - const input = `Hello world!`; - const output = await transform(input, [ - sanitize({ allowAttributes: { nonexistent: ['a'] } }), - ]); - expect(output).toEqual(`Hello world!`); - }); -}); From b8a0cfe47c3993e1cef61a8b14ca5e357e54c18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:28:21 +0900 Subject: [PATCH 04/52] wip: remove `inline` --- src/html/package.json | 1 - src/html/src/transformers/inline.ts | 134 -------------- src/html/test/transformers/inline.test.tsx | 194 --------------------- 3 files changed, 329 deletions(-) delete mode 100644 src/html/src/transformers/inline.ts delete mode 100644 src/html/test/transformers/inline.test.tsx diff --git a/src/html/package.json b/src/html/package.json index e704f555..28bfcf6b 100644 --- a/src/html/package.json +++ b/src/html/package.json @@ -29,7 +29,6 @@ "devDependencies": { "@types/stylis": "^4.2.6", "markdown-it": "^13.0.2", - "media-query-fns": "^2.0.0", "parsel-js": "^1.1.2", "stylis": "^4.3.4", "vitest": "^2.1.1" diff --git a/src/html/src/transformers/inline.ts b/src/html/src/transformers/inline.ts deleted file mode 100644 index 7a0a13fd..00000000 --- a/src/html/src/transformers/inline.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { walkSync, ELEMENT_NODE, TEXT_NODE, Node } from '../index.js'; -import { querySelectorAll, specificity } from '../selector.js'; -import { type Element as CSSEntry, compile } from 'stylis'; -import { compileQuery, matches, type Environment } from 'media-query-fns'; - -export interface InlineOptions { - /** Emit `style` attributes as objects rather than strings. */ - useObjectSyntax: boolean; - env: Partial & { width: number; height: number }; -} -export default function inline(opts?: Partial) { - const { useObjectSyntax = false } = opts ?? {}; - return (doc: Node): Node => { - const style: string[] = useObjectSyntax ? [':where([style]) {}'] : []; - const actions: (() => void)[] = []; - walkSync(doc, (node: Node, parent?: Node) => { - if (node.type === ELEMENT_NODE) { - if (node.name === 'style') { - style.push( - node.children - .map((c: Node) => (c.type === TEXT_NODE ? c.value : '')) - .join(''), - ); - actions.push(() => { - parent!.children = parent!.children.filter((c: Node) => c !== node); - }); - } - } - }); - for (const action of actions) { - action(); - } - const styles = style.join('\n'); - const css = compile(styles); - const selectors = new Map>(); - - function applyRule(rule: CSSEntry) { - if (rule.type === 'rule') { - const rules = Object.fromEntries( - (rule.children as unknown as Element[]).map((child: any) => [ - child.props, - child.children, - ]), - ); - for (const selector of rule.props) { - const value = Object.assign(selectors.get(selector) ?? {}, rules); - selectors.set(selector, value); - } - } else if (rule.type === '@media' && opts?.env) { - const env = getEnvironment(opts.env); - const args = Array.isArray(rule.props) ? rule.props : [rule.props]; - const queries = args.map((arg) => compileQuery(arg)); - for (const query of queries) { - if (matches(query, env)) { - for (const child of rule.children) { - applyRule(child as CSSEntry); - } - return; - } - } - } - } - for (const rule of css) { - applyRule(rule); - } - const rules = new Map>(); - for (const [selector, styles] of Array.from(selectors).sort(([a], [b]) => { - const $a = specificity(a); - const $b = specificity(b); - if ($a > $b) return 1; - if ($b > $a) return -1; - return 0; - })) { - const nodes = querySelectorAll(doc, selector); - for (const node of nodes) { - const curr = rules.get(node) ?? {}; - rules.set(node, Object.assign(curr, styles)); - } - } - - for (const [node, rule] of rules) { - let style = node.attributes.style ?? ''; - let styleObj: Record = {}; - for (const decl of compile(style)) { - if (decl.type === 'decl') { - if ( - typeof decl.props === 'string' && - typeof decl.children === 'string' - ) { - styleObj[decl.props] = decl.children; - } - } - } - styleObj = Object.assign({}, rule, styleObj); - if (useObjectSyntax) { - node.attributes.style = styleObj; - } else { - node.attributes.style = `${Object.entries(styleObj) - .map(([decl, value]) => `${decl}:${value.replace('!important', '')};`) - .join('')}`; - } - } - return doc; - }; -} - -type AlwaysDefinedValues = - | 'widthPx' - | 'heightPx' - | 'deviceWidthPx' - | 'deviceHeightPx' - | 'dppx'; -type ResolvedEnvironment = Omit, AlwaysDefinedValues> & - Record; -function getEnvironment(baseEnv: InlineOptions['env']): ResolvedEnvironment { - const { - width, - height, - dppx = 1, - widthPx = width, - heightPx = height, - deviceWidthPx = width * dppx, - deviceHeightPx = height * dppx, - ...env - } = baseEnv; - return { - widthPx, - heightPx, - deviceWidthPx, - deviceHeightPx, - dppx, - ...env, - }; -} diff --git a/src/html/test/transformers/inline.test.tsx b/src/html/test/transformers/inline.test.tsx deleted file mode 100644 index b5d4f47a..00000000 --- a/src/html/test/transformers/inline.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { parse, transform } from "../../src/index"; -import inline from "../../src/transformers/inline"; - -describe("inline", () => { - it("works with a simple input", async () => { - const input = `
Hello world
`; - const output = await transform(input, [inline()]); - expect(output).toEqual(`
Hello world
`); - }); - - it("works with multiple inputs", async () => { - const input = `
Hello world
`; - const output = await transform(input, [inline()]); - expect(output).toEqual(`
Hello world
`); - }); - - it("inline styles override external", async () => { - const input = `
Hello world
`; - const output = await transform(input, [inline()]); - expect(output).toEqual(`
Hello world
`); - }); - - it("inline styles with class", async () => { - const input = `
Hello world
- `; - const output = await transform(input, [inline()]); - expect(output.trim()).toEqual( - `
Hello world
`, - ); - }); - - it("inlines styles when @media is matched", async () => { - const input = `
Hello world
- `; - const output = await transform(input, [ - inline({ env: { width: 961, height: 1280 } }), - ]); - expect(output.trim()).toEqual( - `
Hello world
`, - ); - }); - - it("does not inline styles when @media cannot be matched", async () => { - const input = `
Hello world
- `; - const output = await transform(input, [ - inline({ env: { width: 959, height: 1280 } }), - ]); - expect(output.trim()).toEqual( - `
Hello world
`, - ); - }); -}); - -describe("object syntax", () => { - it("emits style as object", async () => { - const inliner = inline({ useObjectSyntax: true }); - const input = `
Hello world
`; - const doc = await parse(input); - const output = inliner(doc); - expect(output.children[0].attributes.style).toEqual({ color: "red" }); - }); - - it("emits plain style attribute as object", async () => { - const inliner = inline({ useObjectSyntax: true }); - const input = `
Hello world
`; - const doc = await parse(input); - const output = inliner(doc); - expect(output.children[0].attributes.style).toEqual({ color: "blue" }); - }); - - it("works for complex properties", async () => { - const inliner = inline({ useObjectSyntax: true }); - const input = ` -
Hello world
- - `.trim(); - const doc = await parse(input); - const output = inliner(doc); - expect(output.children[0].attributes.style).toEqual({ - background: "linear-gradient(135deg, #ef629f, #eecda3)", - display: "flex", - width: "100%", - height: "100%", - }); - }); -}); - -// describe("inline link", () => { -// it("works with sync resolveAsset", async () => { -// const input = `
Hello world
`; -// const output = await transform(input, [ -// inline({ -// resolveAsset({ attributes: { href } }) { -// if (href === "/assets/1.css") { -// return "div{color:red;}"; -// } -// }, -// }), -// ]); -// expect(output).toEqual(`
Hello world
`); -// }); -// it("ignores unresolved assets", async () => { -// const input = `
Hello world
`; -// const output = await transform(input, [ -// inline({ -// resolveAsset({ attributes: { href } }) { -// if (href === "/assets/1.css") { -// return; -// } -// }, -// }), -// ]); -// expect(output).toEqual(input); -// }); -// it("works with async resolveAsset", async () => { -// const input = `
Hello world
`; -// const output = await transform(input, [ -// inline({ -// async resolveAsset({ attributes: { href } }) { -// if (href === "/assets/1.css") { -// return "div{color:red;}"; -// } -// }, -// }), -// ]); -// expect(output).toEqual(`
Hello world
`); -// }); -// it("works with multiple inputs", async () => { -// const input = `
Hello world
`; -// const output = await transform(input, [ -// inline({ -// resolveAsset({ attributes: { href } }) { -// if (href === "/assets/1.css") { -// return "div{color:green;}"; -// } -// if (href === "/assets/2.css") { -// return "div{color:red;}"; -// } -// }, -// }), -// ]); -// expect(output).toEqual(`
Hello world
`); -// }); -// it("works with multiple inputs and maintains order", async () => { -// const input = `
Hello world
`; -// const output = await transform(input, [ -// inline({ -// async resolveAsset({ attributes: { href } }) { -// if (href === "/assets/1.css") { -// await sleep(200) -// return "div{color:green;}"; -// } -// if (href === "/assets/2.css") { -// return "div{color:red;}"; -// } -// }, -// }), -// ]); -// expect(output).toEqual(`
Hello world
`); -// }); -// }); From bcd4ee5698363c3ece8163f55e9ff835bdfa9feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:33:17 +0900 Subject: [PATCH 05/52] wip: remove `swap` --- src/html/src/transformers/swap.ts | 25 --------- src/html/test/transformers/swap.test.ts | 68 ------------------------- 2 files changed, 93 deletions(-) delete mode 100644 src/html/src/transformers/swap.ts delete mode 100644 src/html/test/transformers/swap.test.ts diff --git a/src/html/src/transformers/swap.ts b/src/html/src/transformers/swap.ts deleted file mode 100644 index 3aec9be0..00000000 --- a/src/html/src/transformers/swap.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ElementNode, RenderFn, Node, __unsafeRenderFn } from "../index.js"; -import { querySelectorAll } from "../selector.js"; - -export default function swap( - components: Record< - string, - string | ((props: Record, ...children: any[]) => any) - > = {}, -) { - return (doc: Node): Node => { - for (const [selector, component] of Object.entries(components)) { - for (const node of querySelectorAll(doc, selector)) { - if (typeof component === "string") { - node.name = component; - if (RenderFn in node) { - delete (node as any)[RenderFn]; - } - } else if (typeof component === "function") { - __unsafeRenderFn(node as ElementNode, component); - } - } - } - return doc; - }; -} diff --git a/src/html/test/transformers/swap.test.ts b/src/html/test/transformers/swap.test.ts deleted file mode 100644 index 0a5963f2..00000000 --- a/src/html/test/transformers/swap.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { html, transform } from '../../src/index.js'; -import swap from '../../src/transformers/swap.js'; -import { describe, expect, it } from 'vitest'; - -describe('html API', () => { - it('function for element', async () => { - const components = { - h1: (_, children) => html`${children}`, - }; - const input = `

Hello world!

`; - const output = await transform(input, [swap(components)]); - expect(output).toEqual(`Hello world!`); - }); - it('function for component', async () => { - const components = { - Title: (_, children) => html`

${children}

`, - }; - const input = `Hello world!`; - const output = await transform(input, [swap(components)]); - expect(output).toEqual(`

Hello world!

`); - }); - it('string for element to Component', async () => { - const components = { - h1: 'Title', - Title: (_, children) => html`${children}`, - }; - const input = `

Hello world!

`; - const output = await transform(input, [swap(components)]); - expect(output).toEqual(`Hello world!`); - }); - it('string for element to Component', async () => { - const components = { - h1: 'Title', - Title: (_, children) => html`${children}`, - }; - const input = `

Hello world!

`; - const output = await transform(input, [swap(components)]); - expect(output).toEqual(`Hello world!`); - }); - it('async Component', async () => { - const components = { - Title: async (_, children) => html`${children}`, - }; - const input = `Hello world!`; - const output = await transform(input, [swap(components)]); - expect(output).toEqual(`Hello world!`); - }); - - it('readme example', async () => { - const Title = (props, children) => - html`

${children}

`; - const output = await transform(`

Hello world!

`, [ - swap({ h1: Title }), - ]); - expect(output).toEqual(`

Hello world!

`); - }); - it('transforms custom components', async () => { - const CustomElement = (props, children) => - html`${children}`; - const output = await transform( - `Hello world!`, - [swap({ 'custom-element': CustomElement })], - ); - expect(output).toEqual( - `Hello world!`, - ); - }); -}); From 96e5c7a76443588018a3687bcd8faaacecd61b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:39:22 +0900 Subject: [PATCH 06/52] wip: move license --- src/html/LICENSE | 35 ----------------------------------- src/html/src/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 35 deletions(-) delete mode 100644 src/html/LICENSE diff --git a/src/html/LICENSE b/src/html/LICENSE deleted file mode 100644 index 2386115b..00000000 --- a/src/html/LICENSE +++ /dev/null @@ -1,35 +0,0 @@ -MIT License Copyright (c) 2022 Nate Moore - -Permission is hereby granted, free of -charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice -(including the next paragraph) shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- - -Portions of this code were borrowed from https://github.com/developit/htmlParser - -The MIT License (MIT) - -Copyright (c) 2013 Jason Miller - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 791078eb..9c0367d9 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -1,3 +1,43 @@ +/** + * ultrahtml - https://github.com/natemoo-re/ultrahtml + * + * @license + * + * MIT License Copyright (c) 2022 Nate Moore + * + * Permission is hereby granted, free of + * charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * The above copyright notice and this permission notice + * (including the next paragraph) shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * --- + * + * Portions of this code were borrowed from https://github.com/developit/htmlParser + * + * The MIT License (MIT) + * + * Copyright (c) 2013 Jason Miller + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + export type Node = | DocumentNode | ElementNode From eab09a0e452e7e346e792c25d930e820b35d8ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:42:19 +0900 Subject: [PATCH 07/52] wip: remove `scope` --- src/html/package.json | 14 +- src/html/src/transformers/scope.ts | 233 ---------------------- src/html/test/transformers/scope.test.tsx | 80 -------- 3 files changed, 1 insertion(+), 326 deletions(-) delete mode 100644 src/html/src/transformers/scope.ts delete mode 100644 src/html/test/transformers/scope.test.tsx diff --git a/src/html/package.json b/src/html/package.json index 28bfcf6b..ac0ed804 100644 --- a/src/html/package.json +++ b/src/html/package.json @@ -1,36 +1,24 @@ { "name": "ultrahtml", "type": "module", - "types": "./dist/index.d.ts", - "main": "./dist/index.js", "scripts": { "dev": "vitest", "test": "vitest run" }, - "files": [ - "dist", - "CHANGELOG.md" - ], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./package.json": "./package.json", "./selector": { "types": "./dist/selector.d.ts", "import": "./dist/selector.js" }, - "./transformers/*": { - "types": "./dist/transformers/*.d.ts", - "import": "./dist/transformers/*.js" - } + "./package.json": "./package.json" }, "devDependencies": { - "@types/stylis": "^4.2.6", "markdown-it": "^13.0.2", "parsel-js": "^1.1.2", - "stylis": "^4.3.4", "vitest": "^2.1.1" } } diff --git a/src/html/src/transformers/scope.ts b/src/html/src/transformers/scope.ts deleted file mode 100644 index 48d803fc..00000000 --- a/src/html/src/transformers/scope.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { AST } from 'parsel-js'; -import type { ElementNode, Node } from '../index.js'; - -import { parse } from 'parsel-js'; -import { compile, middleware, serialize, stringify } from 'stylis'; -import { ELEMENT_NODE, TEXT_NODE, render, walkSync } from '../index.js'; -import { matches } from '../selector.js'; - -export interface ScopeOptions { - hash?: string; - attribute?: string; -} -export default function scope(opts: ScopeOptions = {}) { - return async (doc: Node): Promise => { - const hash = opts.hash ?? shorthash(await render(doc)); - const actions: (() => void)[] = []; - let hasStyle = false; - const selectors = new Set(); - const nodes = new Set(); - walkSync(doc, (node: Node) => { - if (node.type === ELEMENT_NODE && node.name === 'style') { - if (!opts.attribute || hasAttribute(node, opts.attribute)) { - hasStyle = true; - if (opts.attribute) { - delete node.attributes[opts.attribute]; - } - for (const selector of getSelectors(node.children[0].value)) { - selectors.add(selector); - } - } - } - if (node.type === ELEMENT_NODE) { - nodes.add(node); - } - }); - if (hasStyle) { - walkSync(doc, (node: Node) => { - if (node.type === ELEMENT_NODE) { - actions.push(() => scopeElement(node, hash, selectors)); - if (node.name === 'style') { - actions.push(() => { - node.children = node.children.map((c: Node) => { - if (c.type !== TEXT_NODE) return c; - c.value = scopeCSS(c.value, hash); - if (c.value === '') { - node.parent.children = node.parent.children.filter( - (s: Node) => s !== node, - ); - } - return c; - }); - }); - } - } - }); - } - for (const action of actions) { - action(); - } - - return doc; - }; -} - -const NEVER_SCOPED = new Set([ - 'base', - 'font', - 'frame', - 'frameset', - 'head', - 'link', - 'meta', - 'noframes', - 'noscript', - 'script', - 'style', - 'title', -]); - -function hasAttribute(node: ElementNode, name: string) { - if (name in node.attributes) { - return node.attributes[name] !== 'false'; - } - return false; -} - -function scopeElement(node: ElementNode, hash: string, selectors: Set) { - const { name } = node; - if (!name) return; - if (name.length < 1) return; - if (NEVER_SCOPED.has(name)) return; - if (node.attributes['data-scope']) return; - for (const selector of selectors) { - if (matches(node, selector)) { - node.attributes['data-scope'] = hash; - return; - } - } -} - -function scopeSelector(selector: string, hash: string): string { - const ast = parse(selector); - const scope = (node: AST): string => { - switch (node.type) { - case 'pseudo-class': { - if (node.name === 'root') return node.content; - if (node.name === 'global') return node.argument!; - return `${node.content}:where([data-scope="${hash}"])`; - } - case 'compound': - return `${selector}:where([data-scope="${hash}"])`; - case 'complex': { - const { left, right, combinator } = node; - return `${scope(left)}${combinator}${scope(right)}`; - } - case 'list': - return node.list.map((s) => scope(s)).join(' '); - default: - return `${node.content}:where([data-scope="${hash}"])`; - } - }; - return scope(ast!); -} - -function scopeCSS(css: string, hash: string) { - return serialize( - compile(css), - middleware([ - (element) => { - if (element.type === 'rule') { - if (Array.isArray(element.props)) { - element.props = element.props.map((prop) => - scopeSelector(prop, hash), - ); - } else { - element.props = scopeSelector(element.props, hash); - } - } - }, - stringify, - ]), - ); -} - -function getSelectors(css: string) { - const selectors = new Set(); - serialize( - compile(css), - middleware([ - (element) => { - if (element.type === 'rule') { - if (Array.isArray(element.props)) { - for (const p of element.props) { - selectors.add(p); - } - } else { - selectors.add(element.props); - } - } - }, - ]), - ); - return Array.from(selectors); -} - -/** - * shorthash - https://github.com/bibig/node-shorthash - * - * @license - * - * (The MIT License) - * - * Copyright (c) 2013 Bibig - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - */ - -const dictionary = - '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY'; -const binary = dictionary.length; - -// refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ -function bitwise(str: string) { - let hash = 0; - if (str.length === 0) return hash; - for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i); - hash = (hash << 5) - hash + ch; - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} - -function shorthash(text: string) { - let num: number; - let result = ''; - - let integer = bitwise(text); - const sign = integer < 0 ? 'Z' : ''; // It it's negative, start with Z, which isn't in the dictionary - - integer = Math.abs(integer); - - while (integer >= binary) { - num = integer % binary; - integer = Math.floor(integer / binary); - result = dictionary[num] + result; - } - - if (integer > 0) { - result = dictionary[integer] + result; - } - - return sign + result; -} diff --git a/src/html/test/transformers/scope.test.tsx b/src/html/test/transformers/scope.test.tsx deleted file mode 100644 index 87cdbc7a..00000000 --- a/src/html/test/transformers/scope.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { transform } from "../../src/index"; -import scope from "../../src/transformers/scope"; - -describe("scope", () => { - it("works with a simple input", async () => { - const input = `
Hello world
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - - it("only scopes seen selectors", async () => { - const input = `
Hello world
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - - it("keeps unmatched selectors", async () => { - const input = `
Hello world
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - - it("keeps unmatched :global selectors", async () => { - const input = `
Hello world
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - - it("keeps :root selector", async () => { - const input = `
Hello world
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - it("scopes div > * + * selector", async () => { - const input = `
Helloworld!
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Helloworld!
`, - ); - }); - it("keeps div > :global(* + *) selector", async () => { - const input = `
Helloworld!
`; - const output = await transform(input, [scope({ hash: "XXX" })]); - expect(output).toEqual( - `
Helloworld!
`, - ); - }); -}); - -describe("attribute", () => { - it("ignores style without attribute", async () => { - const input = `
Hello world
`; - const output = await transform(input, [ - scope({ hash: "XXX", attribute: "scoped" }), - ]); - expect(output).toEqual( - `
Hello world
`, - ); - }); - it("scopes style with attribute", async () => { - const input = `
Hello world
`; - const output = await transform(input, [ - scope({ hash: "XXX", attribute: "scoped" }), - ]); - expect(output).toEqual( - `
Hello world
`, - ); - }); -}); From aec1af4ee9d2db931c67a7ae73ef68ea67502d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:49:35 +0900 Subject: [PATCH 08/52] wip: remove `__unsafe` --- src/html/src/index.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 9c0367d9..39267a8b 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -405,20 +405,6 @@ function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { return v; } -export function __unsafeHTML(str: string) { - return mark(str); -} -export function __unsafeRenderFn( - node: ElementNode, - fn: (props: Record, ...children: Node[]) => Node, -) { - Object.defineProperty(node, RenderFn, { - value: fn, - enumerable: false, - }); - return node; -} - const ESCAPE_CHARS: Record = { "&": "&", "<": "<", From b53155c5ad59e249a44049bb46965cb2ae7934b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:52:06 +0900 Subject: [PATCH 09/52] wip: remove `walk` --- src/html/src/index.ts | 24 ------------------------ src/html/test/script.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 39267a8b..43618cf8 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -354,29 +354,10 @@ export function parse(input: string | ReturnType): any { return doc; } -export interface Visitor { - (node: Node, parent?: Node, index?: number): void | Promise; -} - export interface VisitorSync { (node: Node, parent?: Node, index?: number): void; } -class Walker { - constructor(private callback: Visitor) {} - async visit(node: Node, parent?: Node, index?: number): Promise { - await this.callback(node, parent, index); - if (Array.isArray(node.children)) { - let promises: Promise[] = []; - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - promises.push(this.visit(child, node, i)); - } - await Promise.all(promises); - } - } -} - class WalkerSync { constructor(private callback: VisitorSync) {} visit(node: Node, parent?: Node, index?: number): void { @@ -442,11 +423,6 @@ export function html(tmpl: TemplateStringsArray, ...vals: any[]) { return mark(buf); } -export function walk(node: Node, callback: Visitor): Promise { - const walker = new Walker(callback); - return walker.visit(node); -} - export function walkSync(node: Node, callback: VisitorSync): void { const walker = new WalkerSync(callback); return walker.visit(node); diff --git a/src/html/test/script.test.ts b/src/html/test/script.test.ts index 0d6a5879..dcc6cb39 100644 --- a/src/html/test/script.test.ts +++ b/src/html/test/script.test.ts @@ -1,4 +1,4 @@ -import { parse, render, walk, ELEMENT_NODE } from "../src"; +import { parse, render, walkSync, ELEMENT_NODE } from "../src"; import { describe, expect, it } from "vitest"; describe("script", () => { @@ -15,7 +15,7 @@ describe("script", () => { it("works with HTML Sanitizer API - Web APIs | MDN`; let meta = 0; - await walk(parse(input), async (node, parent) => { + walkSync(parse(input), async (node, parent) => { if ( node.type === ELEMENT_NODE && node.name === "meta" && From b22b0556c3273ac1fbc82128e48c303bbc3cbf8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 22:56:22 +0900 Subject: [PATCH 10/52] wip: remove `transform` --- src/html/src/index.ts | 47 ------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 43618cf8..d998e2c1 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -511,50 +511,3 @@ export async function render(node: Node): Promise { return ``; } } - -export interface Transformer { - (node: Node): Node | Promise; -} - -export interface TransformerSync { - (node: Node): Node; -} - -function parseTransformArgs( - markup: string | Node, - transformers: Transformer[], -) { - if (!Array.isArray(transformers)) { - throw new Error( - `Invalid second argument for \`transform\`! Expected \`Transformer[]\` but got \`${typeof transformers}\``, - ); - } - const doc = typeof markup === "string" ? parse(markup) : markup; - return { doc }; -} - -export async function transform( - markup: string | Node, - transformers: Transformer[] = [], -): Promise { - const { doc } = parseTransformArgs(markup, transformers); - - let newDoc = doc; - for (const t of transformers) { - newDoc = await t(newDoc); - } - return render(newDoc); -} - -export function transformSync( - markup: string | Node, - transformers: TransformerSync[] = [], -): string { - const { doc } = parseTransformArgs(markup, transformers); - - let newDoc = doc; - for (const t of transformers) { - newDoc = t(newDoc); - } - return renderSync(newDoc); -} From dfbc76bd30d688339b849618f8bbfe2c6d497a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:03:04 +0900 Subject: [PATCH 11/52] wip: remove `render` --- src/html/src/index.ts | 37 ----- src/html/test/basic.test.ts | 66 ++++---- src/html/test/markdown.test.ts | 4 +- src/html/test/script.test.ts | 16 +- src/html/test/selector.test.ts | 292 +++++++++++++++++---------------- src/html/test/svg.test.ts | 7 +- 6 files changed, 195 insertions(+), 227 deletions(-) diff --git a/src/html/src/index.ts b/src/html/src/index.ts index d998e2c1..8e102628 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -438,26 +438,6 @@ function canSelfClose(node: Node): boolean { return false; } -async function renderElement(node: Node): Promise { - const { name, attributes = {} } = node; - const children = await Promise.all( - node.children.map((child: Node) => render(child)), - ).then(res => res.join("")); - if (RenderFn in node) { - const value = await (node as any)[RenderFn](attributes, mark(children)); - if (value && (value as any)[HTMLString]) return value.value; - return escapeHTML(String(value)); - } - if (name === Fragment) return children; - const isSelfClosing = canSelfClose(node); - if (isSelfClosing || VOID_TAGS.has(name)) { - return `<${node.name}${attrs(attributes).value}${ - isSelfClosing ? " /" : "" - }>`; - } - return `<${node.name}${attrs(attributes).value}>${children}`; -} - function renderElementSync(node: Node): string { const { name, attributes = {} } = node; const children = node.children @@ -494,20 +474,3 @@ export function renderSync(node: Node): string { return ``; } } - -export async function render(node: Node): Promise { - switch (node.type) { - case DOCUMENT_NODE: - return Promise.all( - node.children.map((child: Node) => render(child)), - ).then(res => res.join("")); - case ELEMENT_NODE: - return renderElement(node); - case TEXT_NODE: - return `${node.value}`; - case COMMENT_NODE: - return ``; - case DOCTYPE_NODE: - return ``; - } -} diff --git a/src/html/test/basic.test.ts b/src/html/test/basic.test.ts index fdd7ffa0..5af85eca 100644 --- a/src/html/test/basic.test.ts +++ b/src/html/test/basic.test.ts @@ -1,95 +1,95 @@ -import { parse, render } from '../src/'; -import { describe, expect, it, test } from 'vitest'; +import { parse, renderSync } from "../src/"; +import { describe, expect, it, test } from "vitest"; -test('sanity', () => { - expect(parse).toBeTypeOf('function'); +test("sanity", () => { + expect(parse).toBeTypeOf("function"); }); -describe('input === output', () => { - it('works for elements', async () => { +describe("input === output", () => { + it("works for elements", async () => { const input = `

Hello world!

`; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); - it('works for custom elements', async () => { + it("works for custom elements", async () => { const input = `Hello world!`; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); - it('works for comments', async () => { + it("works for comments", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); - it('works for text', async () => { + it("works for text", async () => { const input = `Hmm...`; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); - it('works for doctype', async () => { + it("works for doctype", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); - it('works for html:5', async () => { + it("works for html:5", async () => { const input = `Document`; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); }); -describe('attributes', () => { - it('simple', async () => { +describe("attributes", () => { + it("simple", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ a: 'b', c: '1' }); + expect(attributes).toMatchObject({ a: "b", c: "1" }); }); - it('empty', async () => { + it("empty", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ test: '' }); + expect(attributes).toMatchObject({ test: "" }); }); - it('@', async () => { + it("@", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ '@on.click': 'doThing' }); + expect(attributes).toMatchObject({ "@on.click": "doThing" }); }); - it('namespace', async () => { + it("namespace", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ 'on:click': 'alert()' }); + expect(attributes).toMatchObject({ "on:click": "alert()" }); }); - it('simple and empty', async () => { + it("simple and empty", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ test: '', a: 'b', c: '1' }); + expect(attributes).toMatchObject({ test: "", a: "b", c: "1" }); }); - it('with linebreaks', async () => { + it("with linebreaks", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ a: '1\n2\n3' }); + expect(attributes).toMatchObject({ a: "1\n2\n3" }); }); - it('with single quote', async () => { + it("with single quote", async () => { const { children: [{ attributes }], } = parse(`
`); expect(attributes).toMatchObject({ a: "nate'\ns" }); }); - it('with escaped double quote', async () => { + it("with escaped double quote", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ a: '"never\nmore"' }); + expect(attributes).toMatchObject({ a: ""never\nmore"" }); }); }); diff --git a/src/html/test/markdown.test.ts b/src/html/test/markdown.test.ts index f06c21e1..b181f305 100644 --- a/src/html/test/markdown.test.ts +++ b/src/html/test/markdown.test.ts @@ -1,4 +1,4 @@ -import { parse, render } from "../src/"; +import { parse, renderSync } from "../src/"; import { describe, expect, it } from "vitest"; import Markdown from "markdown-it"; @@ -15,7 +15,7 @@ like [Tailwind](https://tailwindcss.com), [Styled System](https://styled-system. describe("markdown", () => { it("works", async () => { const input = md.render(src); - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(input).eq(output); }); }); diff --git a/src/html/test/script.test.ts b/src/html/test/script.test.ts index dcc6cb39..eb9a0ed0 100644 --- a/src/html/test/script.test.ts +++ b/src/html/test/script.test.ts @@ -1,15 +1,15 @@ -import { parse, render, walkSync, ELEMENT_NODE } from "../src"; +import { parse, renderSync, walkSync, ELEMENT_NODE } from "../src"; import { describe, expect, it } from "vitest"; describe("script", () => { it("works for elements", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); it("works without quotes", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); it("works with `; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); it("works with inside script", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); it("works with <\\/script> inside script", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); }); @@ -46,12 +46,12 @@ describe("script", () => { describe("style", () => { it("works for elements", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); it("works without quotes", async () => { const input = ``; - const output = await render(parse(input)); + const output = renderSync(parse(input)); expect(output).toEqual(input); }); }); diff --git a/src/html/test/selector.test.ts b/src/html/test/selector.test.ts index 0f13c593..f68c2d00 100644 --- a/src/html/test/selector.test.ts +++ b/src/html/test/selector.test.ts @@ -1,308 +1,318 @@ -import $, { querySelector, querySelectorAll } from '../src/selector'; -import { parse, render } from '../src'; -import { describe, expect, it, test } from 'vitest'; +import $, { querySelector, querySelectorAll } from "../src/selector"; +import { parse, renderSync } from "../src"; +import { describe, expect, it, test } from "vitest"; -test('sanity', () => { - expect(querySelector).toBeTypeOf('function'); - expect(querySelectorAll).toBeTypeOf('function'); - expect($).toBeTypeOf('function'); +test("sanity", () => { + expect(querySelector).toBeTypeOf("function"); + expect(querySelectorAll).toBeTypeOf("function"); + expect($).toBeTypeOf("function"); expect($).toEqual(querySelectorAll); }); -describe('type selector', () => { - it('type', async () => { +describe("type selector", () => { + it("type", async () => { const input = `

Hello world!

`; - const output = await render(querySelector(parse(input), 'h1')); + const output = renderSync(querySelector(parse(input), "h1")); expect(output).toEqual(input); }); - it('compound type class', async () => { + it("compound type class", async () => { const input = `

Hello world!

No

`; - const el = querySelectorAll(parse(input), 'h1.foo')[0]; + const el = querySelectorAll(parse(input), "h1.foo")[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attribute', async () => { + it("compound type attribute", async () => { const input = `

Hello world!

No

`; - const el = querySelectorAll(parse(input), 'h1[data-test]')[0]; + const el = querySelectorAll(parse(input), "h1[data-test]")[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attribute = case sensitive', async () => { + it("compound type attribute = case sensitive", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test="FOO"]')[0]; expect(el).toBeUndefined(); }); - it('compound type attribute = case insensitive', async () => { + it("compound type attribute = case insensitive", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test="FOO" i]')[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attribute =', async () => { + it("compound type attribute =", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test="foo"]')[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attribute ~=', async () => { + it("compound type attribute ~=", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test~="foo"]')[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attribute *=', async () => { + it("compound type attribute *=", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test*="foo"]')[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual(`

Hello world!

`); + const output = renderSync(el); + expect(output).toEqual( + `

Hello world!

`, + ); }); - it('compound type attribute $=', async () => { + it("compound type attribute $=", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test$=".com"]')[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual( `

Hello world!

`, ); }); - it('compound type attribute ^=', async () => { + it("compound type attribute ^=", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test^="awe"]')[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual(`

Hello world!

`); + const output = renderSync(el); + expect(output).toEqual( + `

Hello world!

`, + ); }); - it('compound type attribute |=', async () => { + it("compound type attribute |=", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll(parse(input), 'h1[data-test|="en"]')[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`

Hello world!

`); }); - it('compound type attributes', async () => { + it("compound type attributes", async () => { const input = `

Hello world!

No

`; const el = querySelectorAll( parse(input), 'h1[data-test^="https://"][data-test$=.com]', )[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual( `

Hello world!

`, ); }); }); -describe('id selector', () => { - it('id', async () => { +describe("id selector", () => { + it("id", async () => { const input = `

Hello world!

`; - const output = await render(querySelectorAll(parse(input), '#foo')[0]); + const output = renderSync(querySelectorAll(parse(input), "#foo")[0]); expect(output).toEqual(input); }); }); -describe('list selector', () => { - it('list', async () => { +describe("list selector", () => { + it("list", async () => { const input = `

Hello world!

Goodbye world!

`; - const els = querySelectorAll(parse(input), 'h1, h2'); + const els = querySelectorAll(parse(input), "h1, h2"); expect(els.length).toEqual(2); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); expect(output).toEqual(input); }); }); -describe('complex selector', () => { - it('deep', async () => { +describe("complex selector", () => { + it("deep", async () => { const input = `

Hello
world
!

`; - const el = querySelectorAll(parse(input), 'h1 span')[0]; + const el = querySelectorAll(parse(input), "h1 span")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('>', async () => { + it(">", async () => { const input = `

Hello world!

`; - const el = querySelectorAll(parse(input), 'h1 > span')[0]; + const el = querySelectorAll(parse(input), "h1 > span")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('~', async () => { + it("~", async () => { const input = `world !`; - const el = querySelectorAll(parse(input), 'span ~ span')[0]; + const el = querySelectorAll(parse(input), "span ~ span")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('!'); + const output = renderSync(el); + expect(output).toEqual("!"); }); - it('* + *', async () => { + it("* + *", async () => { const input = `

Hello

world

!

`; - const el = querySelectorAll(parse(input), '* + *'); + const el = querySelectorAll(parse(input), "* + *"); expect(el).toBeDefined(); expect(el.length).toEqual(2); - const a = await render(el[0]); - const b = await render(el[1]); - expect(a).toEqual('world'); - expect(b).toEqual('

!

'); + const a = renderSync(el[0]); + const b = renderSync(el[1]); + expect(a).toEqual("world"); + expect(b).toEqual("

!

"); }); }); -describe('pseudo-class', () => { - it('root', async () => { +describe("pseudo-class", () => { + it("root", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), ':root')[0]; + const el = querySelectorAll(parse(input), ":root")[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(input); }); - it('empty', async () => { + it("empty", async () => { const input = `

Hello

`; - const el = querySelectorAll(parse(input), ':empty')[0]; + const el = querySelectorAll(parse(input), ":empty")[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(``); }); - it('first-child', async () => { + it("first-child", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), 'span:first-child')[0]; + const el = querySelectorAll(parse(input), "span:first-child")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('only-child', async () => { + it("only-child", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), 'span:only-child')[0]; + const el = querySelectorAll(parse(input), "span:only-child")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('last-child', async () => { + it("last-child", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), 'span:last-child')[0]; + const el = querySelectorAll(parse(input), "span:last-child")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('nth-child(1)', async () => { + it("nth-child(1)", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), 'span:nth-child(1)')[0]; + const el = querySelectorAll(parse(input), "span:nth-child(1)")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('nth-child(odd)', async () => { + it("nth-child(odd)", async () => { const input = `

Hello world

`; - const el = querySelectorAll(parse(input), 'span:nth-child(odd)')[0]; + const el = querySelectorAll(parse(input), "span:nth-child(odd)")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('nth-child(even)', async () => { + it("nth-child(even)", async () => { const input = `

Hello world!

`; - const el = querySelectorAll(parse(input), 'span:nth-child(even)')[0]; + const el = querySelectorAll(parse(input), "span:nth-child(even)")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('!'); + const output = renderSync(el); + expect(output).toEqual("!"); }); - it('nth-child(2n + 1)', async () => { + it("nth-child(2n + 1)", async () => { const input = `

Hello world!

`; - const el = querySelectorAll(parse(input), 'span:nth-child(2n + 1)')[0]; + const el = querySelectorAll(parse(input), "span:nth-child(2n + 1)")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('world'); + const output = renderSync(el); + expect(output).toEqual("world"); }); - it('nth-child(2n)', async () => { + it("nth-child(2n)", async () => { const input = `

Hello world!

`; - const el = querySelectorAll(parse(input), 'span:nth-child(2n)')[0]; + const el = querySelectorAll(parse(input), "span:nth-child(2n)")[0]; expect(el).toBeDefined(); - const output = await render(el); - expect(output).toEqual('!'); + const output = renderSync(el); + expect(output).toEqual("!"); }); - it('nth-child(3n)', async () => { + it("nth-child(3n)", async () => { const input = `

abcdef

`; - const els = querySelectorAll(parse(input), 'span:nth-child(3n)'); + const els = querySelectorAll(parse(input), "span:nth-child(3n)"); expect(els.length).toBe(2); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('cf'); + expect(output).toEqual("cf"); }); - it('nth-child(n+4)', async () => { + it("nth-child(n+4)", async () => { const input = `

abcdef

`; - const els = querySelectorAll(parse(input), 'span:nth-child(n+4)'); + const els = querySelectorAll(parse(input), "span:nth-child(n+4)"); expect(els.length).toBe(3); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('def'); + expect(output).toEqual("def"); }); - it('nth-child(n + 4)', async () => { + it("nth-child(n + 4)", async () => { const input = `

abcdef

`; - const els = querySelectorAll(parse(input), 'span:nth-child(n + 4)'); + const els = querySelectorAll(parse(input), "span:nth-child(n + 4)"); expect(els.length).toBe(3); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('def'); + expect(output).toEqual("def"); }); - it('nth-child(2n+4)', async () => { + it("nth-child(2n+4)", async () => { const input = `

abcdef

`; - const els = querySelectorAll(parse(input), 'span:nth-child(2n+4)'); + const els = querySelectorAll(parse(input), "span:nth-child(2n+4)"); expect(els.length).toBe(2); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('df'); + expect(output).toEqual("df"); }); - it('nth-child(-n+3)', async () => { + it("nth-child(-n+3)", async () => { const input = `

abcdef

`; - const els = querySelectorAll(parse(input), 'span:nth-child(-n+3)'); + const els = querySelectorAll(parse(input), "span:nth-child(-n+3)"); expect(els.length).toBe(3); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('abc'); + expect(output).toEqual("abc"); }); - it('nth-child(n+3):nth-child(-n+5)', async () => { + it("nth-child(n+3):nth-child(-n+5)", async () => { const input = `

abcdef

`; const els = querySelectorAll( parse(input), - 'span:nth-child(n+3):nth-child(-n+5)', + "span:nth-child(n+3):nth-child(-n+5)", ); expect(els.length).toBe(3); - const output = await (await Promise.all(els.map((el) => render(el)))).join( - '', + const output = (await Promise.all(els.map(el => renderSync(el)))).join( + "", ); - expect(output).toEqual('cde'); + expect(output).toEqual("cde"); }); }); -describe('functional pseudo-class', () => { - it('not', async () => { +describe("functional pseudo-class", () => { + it("not", async () => { const input = `

Helloworld

`; - const el = querySelectorAll(parse(input), 'h1 > span:not(#foo)')[0]; + const el = querySelectorAll(parse(input), "h1 > span:not(#foo)")[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`world`); }); - it('is', async () => { + it("is", async () => { const input = `

Helloworld

`; - const el = querySelectorAll(parse(input), 'h1 > span:is(#foo, [id])')[0]; + const el = querySelectorAll( + parse(input), + "h1 > span:is(#foo, [id])", + )[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`Hello`); }); - it('where', async () => { + it("where", async () => { const input = `

Helloworld

`; - const el = querySelectorAll(parse(input), 'h1 > span:where(#foo, [id])')[0]; + const el = querySelectorAll( + parse(input), + "h1 > span:where(#foo, [id])", + )[0]; expect(el).toBeDefined(); - const output = await render(el); + const output = renderSync(el); expect(output).toEqual(`Hello`); }); }); diff --git a/src/html/test/svg.test.ts b/src/html/test/svg.test.ts index 72e400f8..3c02c64c 100644 --- a/src/html/test/svg.test.ts +++ b/src/html/test/svg.test.ts @@ -1,12 +1,7 @@ -import { parse, render, renderSync } from "../src/index.ts"; +import { parse, renderSync } from "../src/index.ts"; import { describe, expect, it } from "vitest"; describe("svg", () => { - it("render as self-closing", async () => { - const input = ``; - const output = await render(parse(input)); - expect(output).toEqual(input); - }); it("renderSync as self-closing", async () => { const input = ``; const output = renderSync(parse(input)); From 669e92a05a65a9390efbcda92bec2409b6d73657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:19:51 +0900 Subject: [PATCH 12/52] wip: remove `html` --- src/html/src/index.ts | 25 ++----------------------- src/html/test/html.test.ts | 25 ------------------------- 2 files changed, 2 insertions(+), 48 deletions(-) delete mode 100644 src/html/test/html.test.ts diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 8e102628..0d47ef03 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -195,8 +195,8 @@ function splitAttrs(str?: string) { return obj; } -export function parse(input: string | ReturnType): any { - let str = typeof input === "string" ? input : input.value; +export function parse(input: string): any { + let str = input; let doc: Node, parent: Node, token: any, @@ -401,27 +401,6 @@ export function attrs(attributes: Record) { } return mark(attrStr, [HTMLString, AttrString]); } -export function html(tmpl: TemplateStringsArray, ...vals: any[]) { - let buf = ""; - for (let i = 0; i < tmpl.length; i++) { - buf += tmpl[i]; - const expr = vals[i]; - if (buf.endsWith("...") && expr && typeof expr === "object") { - buf = buf.slice(0, -3).trimEnd(); - buf += attrs(expr).value; - } else if (expr && expr[AttrString]) { - buf = buf.trimEnd(); - buf += expr.value; - } else if (expr && expr[HTMLString]) { - buf += expr.value; - } else if (typeof expr === "string") { - buf += escapeHTML(expr); - } else if (expr || expr === 0) { - buf += String(expr); - } - } - return mark(buf); -} export function walkSync(node: Node, callback: VisitorSync): void { const walker = new WalkerSync(callback); diff --git a/src/html/test/html.test.ts b/src/html/test/html.test.ts deleted file mode 100644 index 689e3447..00000000 --- a/src/html/test/html.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { html, attrs } from '../src/'; -import { describe, expect, it } from 'vitest'; - -describe('html', () => { - it('works', () => { - const { value } = html`

${'Hello world!'}

`; - expect(value).toEqual(`

Hello world!

`); - }); - it('escapes', () => { - const { value } = html`

${'
'}

`; - expect(value).toEqual(`

<div></div>

`); - }); - it('nested', () => { - const { value } = html`

${html`
`}

`; - expect(value).toEqual(`

`); - }); - it('attrs', () => { - const { value } = html`

`; - expect(value).toEqual(`

`); - }); - it('spread', () => { - const { value } = html`

`; - expect(value).toEqual(`

`); - }); -}); From 1856a9b9c67b30578d9add103e1533c5008af667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:31:06 +0900 Subject: [PATCH 13/52] wip: update docs --- src/html/README.md | 73 +++---------------------------------------- src/html/src/index.ts | 27 +++++++++++----- 2 files changed, 24 insertions(+), 76 deletions(-) diff --git a/src/html/README.md b/src/html/README.md index 3b699548..f149da1f 100644 --- a/src/html/README.md +++ b/src/html/README.md @@ -1,14 +1,7 @@ -# `ultrahtml` - -A 1.75kB library for enhancing `html`. `ultrahtml` has zero dependencies and is compatible with any JavaScript runtime. - ### Features - Tiny, fault-tolerant and friendly HTML-like parser. Works with HTML, Astro, Vue, Svelte, and any other HTML-like syntax. - Built-in AST `walk` utility -- Built-in `transform` utility for easy output manipulation -- Automatic but configurable sanitization, see [Sanitization](#sanitization) -- Handy `html` template utility - `querySelector` and `querySelectorAll` support using `ultrahtml/selector` #### `walk` @@ -43,76 +36,20 @@ walkSync(ast, (node) => { }); ``` -#### `render` +#### `renderSync` -The `render` function allows you to serialize an AST back into a string. +The `renderSync` function allows you to serialize an AST back into a string. > **Note** -> By default, `render` will sanitize your markup, removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. +> By default, `renderSync` will sanitize your markup, removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. ```js -import { parse, render } from "ultrahtml"; +import { parse, renderSync } from "ultrahtml"; const ast = parse(`

Hello world!

`); -const output = await render(ast); -``` - -#### `transform` - -The `transform` function provides a straight-forward way to modify any markup. Sanitize content, swap in-place elements/Components, and more using a set of built-in transformers, or write your own custom transformer. - -```js -import { transform, html } from "ultrahtml"; -import swap from "ultrahtml/transformers/swap"; -import sanitize from "ultrahtml/transformers/sanitize"; - -const output = await transform(`

Hello world!

`, [ - swap({ - h1: "h2", - h3: (props, children) => html`

${children}

`, - }), - sanitize({ allowElements: ["h1", "h2", "h3"] }), -]); - -console.log(output); //

Hello world!

+console.log(renderSync(ast)); //

Hello world!

``` -#### `transformSync` - -The `transformSync` function is identical to the `transform` function, but is synchronous. This should only be used when it is guaranteed there are no `async` functions in the transformers. - -```js -import { transformSync, html } from "ultrahtml"; -import swap from "ultrahtml/transformers/swap"; -import sanitize from "ultrahtml/transformers/sanitize"; - -const output = transformSync(`

Hello world!

`, [ - swap({ - h1: "h2", - h3: (props, children) => html`

${children}

`, - }), - sanitize({ allowElements: ["h1", "h2", "h3"] }), -]); - -console.log(output); //

Hello world!

-``` - -#### Sanitization - -`ultrahtml/transformers/sanitize` implements an extension of a proposed HTML Sanitizer API (circa 2022.) Although that proposal has since been withdrawn, it remains as the foundation of `ultrahtml`'s API. In a future major version of `ultrahtml`, we hope to track against [WICG Sanitizer API proposal](https://wicg.github.io/sanitizer-api/) if it becomes an official WHATWG specification. - -| Option | Type | Default | Description | -| ------------------- | -------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| allowElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be dropped. | -| blockElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should remove, but keep their child elements. | -| unblockElements | `string[]` | `undefined` | An array of strings indicating elements that the sanitizer should not remove. All elements not in the array will be removed, but keep their child content. | -| dropElements | `string[]` | `["script"]` | An array of strings indicating elements (including nested elements) that the sanitizer should remove. | -| allowAttributes | `Record` | `undefined` | An object where each key is the attribute name and the value is an Array of allowed tag names. Matching attributes will not be removed. All attributes that are not in the array will be dropped. | -| dropAttributes | `Record` | `undefined` | An object where each key is the attribute name and the value is an Array of dropped tag names. Matching attributes will be removed. | -| allowComponents | `boolean` | `false` | A boolean value set to false (default) to remove components and their children. If set to true, components will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). | -| allowCustomElements | `boolean` | `false` | A boolean value set to false (default) to remove custom elements and their children. If set to true, custom elements will be subject to built-in and custom configuration checks (and will be retained or dropped based on those checks). | -| allowComments | `boolean` | `false` | A boolean value set to false (default) to remove HTML comments. Set to true in order to keep comments. | - ## Acknowledgements - [Jason Miller](https://twitter.com/_developit)'s [`htmlParser`](https://github.com/developit/htmlParser) provided a great, lightweight base for this parser diff --git a/src/html/src/index.ts b/src/html/src/index.ts index 0d47ef03..9b0627cf 100644 --- a/src/html/src/index.ts +++ b/src/html/src/index.ts @@ -1,5 +1,5 @@ /** - * ultrahtml - https://github.com/natemoo-re/ultrahtml + * Portions of this code were borrowed from `ultrahtml` - https://github.com/natemoo-re/ultrahtml * * @license * @@ -26,16 +26,27 @@ * * --- * - * Portions of this code were borrowed from https://github.com/developit/htmlParser + * Portions of this code were borrowed from `htmlParser` - https://github.com/developit/htmlParser * - * The MIT License (MIT) + * The MIT License (MIT) Copyright (c) 2013 Jason Miller * - * Copyright (c) 2013 Jason Miller - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * Permission is hereby granted, free of + * charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions of the Software. * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. */ export type Node = From 63d8351d6c74dc1f5cf65e9460117dcbe71c3798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:40:11 +0900 Subject: [PATCH 14/52] wip: remove default export `querySelectorAll` --- src/html/src/selector.ts | 136 +++++++++++++++++++-------------- src/html/test/selector.test.ts | 4 +- 2 files changed, 78 insertions(+), 62 deletions(-) diff --git a/src/html/src/selector.ts b/src/html/src/selector.ts index 2e5def6b..60426d41 100644 --- a/src/html/src/selector.ts +++ b/src/html/src/selector.ts @@ -1,11 +1,11 @@ -import type { Node } from './index.js'; -import { ELEMENT_NODE, TEXT_NODE, walkSync } from './index.js'; -import type { AST, AttributeToken } from 'parsel-js'; +import type { Node } from "./index.js"; +import { ELEMENT_NODE, TEXT_NODE, walkSync } from "./index.js"; +import type { AST, AttributeToken } from "parsel-js"; import { parse, specificity as getSpecificity, specificityToNumber, -} from 'parsel-js'; +} from "parsel-js"; export function specificity(selector: string) { return specificityToNumber(getSpecificity(selector), 10); @@ -45,8 +45,6 @@ export function querySelectorAll(node: Node, selector: string): Node[] { }); } -export default querySelectorAll; - interface Matcher { (n: Node, parent?: Node, index?: number): boolean; } @@ -68,19 +66,19 @@ function select( } const getAttributeMatch = (selector: AttributeToken) => { - const { operator = '=' } = selector; + const { operator = "=" } = selector; switch (operator) { - case '=': + case "=": return (a: string, b: string) => a === b; - case '~=': + case "~=": return (a: string, b: string) => a.split(/\s+/g).includes(b); - case '|=': - return (a: string, b: string) => a.startsWith(b + '-'); - case '*=': + case "|=": + return (a: string, b: string) => a.startsWith(b + "-"); + case "*=": return (a: string, b: string) => a.indexOf(b) > -1; - case '$=': + case "$=": return (a: string, b: string) => a.endsWith(b); - case '^=': + case "^=": return (a: string, b: string) => a.startsWith(b); } return (a: string, b: string) => false; @@ -91,15 +89,16 @@ const nthChildIndex = (node: Node, parent?: Node) => .filter((n: Node) => n.type === ELEMENT_NODE) .findIndex((n: Node) => n === node); const nthChild = (formula: string) => { - let [_, A = '1', B = '0'] = + let [_, A = "1", B = "0"] = /^\s*(?:(-?(?:\d+)?)n)?\s*\+?\s*(\d+)?\s*$/gm.exec(formula) ?? []; - if (A.length === 0) A = '1'; - const a = Number.parseInt(A === '-' ? '-1' : A); + if (A.length === 0) A = "1"; + const a = Number.parseInt(A === "-" ? "-1" : A); const b = Number.parseInt(B); return (n: number) => a * n + b; }; const lastChild = (node: Node, parent?: Node) => - parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).pop() === node; + parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).pop() === + node; const firstChild = (node: Node, parent?: Node) => parent?.children.filter((n: Node) => n.type === ELEMENT_NODE).shift() === node; @@ -108,61 +107,72 @@ const onlyChild = (node: Node, parent?: Node) => const createMatch = (selector: AST): Matcher => { switch (selector.type) { - case 'type': + case "type": return (node: Node) => { - if (selector.content === '*') return true; + if (selector.content === "*") return true; return node.name === selector.name; }; - case 'class': + case "class": return (node: Node) => node.attributes?.class?.split(/\s+/g).includes(selector.name); - case 'id': + case "id": return (node: Node) => node.attributes?.id === selector.name; - case 'pseudo-class': { + case "pseudo-class": { switch (selector.name) { - case 'global': + case "global": return (...args) => selectorToMatch(parse(selector.argument!)!)(...args); - case 'not': - return (...args) => !createMatch(selector.subtree!)(...args); - case 'is': - return (...args) => selectorToMatch(selector.subtree!)(...args); - case 'where': - return (...args) => selectorToMatch(selector.subtree!)(...args); - case 'root': + case "not": + return (...args) => + !createMatch(selector.subtree!)(...args); + case "is": + return (...args) => + selectorToMatch(selector.subtree!)(...args); + case "where": + return (...args) => + selectorToMatch(selector.subtree!)(...args); + case "root": return (node: Node, parent?: Node) => - node.type === ELEMENT_NODE && node.name === 'html'; - case 'empty': + node.type === ELEMENT_NODE && node.name === "html"; + case "empty": return (node: Node) => node.type === ELEMENT_NODE && (node.children.length === 0 || node.children.every( - (n: Node) => n.type === TEXT_NODE && n.value.trim() === '', + (n: Node) => + n.type === TEXT_NODE && + n.value.trim() === "", )); - case 'first-child': - return (node: Node, parent?: Node) => firstChild(node, parent); - case 'last-child': - return (node: Node, parent?: Node) => lastChild(node, parent); - case 'only-child': - return (node: Node, parent?: Node) => onlyChild(node, parent); - case 'nth-child': + case "first-child": + return (node: Node, parent?: Node) => + firstChild(node, parent); + case "last-child": + return (node: Node, parent?: Node) => + lastChild(node, parent); + case "only-child": + return (node: Node, parent?: Node) => + onlyChild(node, parent); + case "nth-child": return (node: Node, parent?: Node) => { const target = nthChildIndex(node, parent) + 1; if (Number.isNaN(Number(selector.argument))) { switch (selector.argument) { - case 'odd': + case "odd": return Math.abs(target % 2) == 1; - case 'even': + case "even": return target % 2 === 0; default: { if (!selector.argument) { - throw new Error(`Unsupported empty nth-child selector!`); + throw new Error( + `Unsupported empty nth-child selector!`, + ); } const nth = nthChild(selector.argument); const elements = parent?.children.filter( (n: Node) => n.type === ELEMENT_NODE, ); - const childIndex = nthChildIndex(node, parent) + 1; + const childIndex = + nthChildIndex(node, parent) + 1; for (let i = 0; i < elements.length; i++) { let n = nth(i); if (n > elements.length) return false; @@ -175,16 +185,20 @@ const createMatch = (selector: AST): Matcher => { return target === Number(selector.argument); }; default: - throw new Error(`Unhandled pseudo-class: ${selector.name}!`); + throw new Error( + `Unhandled pseudo-class: ${selector.name}!`, + ); } } - case 'attribute': + case "attribute": return (node: Node) => { let { caseSensitive, name, value } = selector; if (!node.attributes) return false; - const attrs = Object.entries(node.attributes as Record); + const attrs = Object.entries( + node.attributes as Record, + ); for (let [attr, attrVal] of attrs) { - if (caseSensitive === 'i') { + if (caseSensitive === "i") { value = name.toLowerCase(); attrVal = attr.toLowerCase(); } @@ -202,7 +216,7 @@ const createMatch = (selector: AST): Matcher => { } return false; }; - case 'universal': + case "universal": return (_: Node) => { return true; }; @@ -213,9 +227,9 @@ const createMatch = (selector: AST): Matcher => { }; const selectorToMatch = (sel: string | AST): Matcher => { - let selector = typeof sel === 'string' ? parse(sel) : sel; + let selector = typeof sel === "string" ? parse(sel) : sel; switch (selector?.type) { - case 'list': { + case "list": { const matchers = selector.list.map((s: any) => createMatch(s)); return (node: Node, parent?: Node, index?: number) => { for (const match of matchers) { @@ -224,7 +238,7 @@ const selectorToMatch = (sel: string | AST): Matcher => { return false; }; } - case 'compound': { + case "compound": { const matchers = selector.list.map((s: any) => createMatch(s)); return (node: Node, parent?: Node, index?: number) => { for (const match of matchers) { @@ -233,7 +247,7 @@ const selectorToMatch = (sel: string | AST): Matcher => { return true; }; } - case 'complex': { + case "complex": { const { left, right, combinator } = selector; const matchLeft = selectorToMatch(left); const matchRight = selectorToMatch(right); @@ -241,22 +255,26 @@ const selectorToMatch = (sel: string | AST): Matcher => { return (node: Node, parent?: Node, i: number = 0) => { if (matchLeft(node)) { leftMatches.add(node); - } else if (parent && leftMatches.has(parent) && combinator === ' ') { + } else if ( + parent && + leftMatches.has(parent) && + combinator === " " + ) { leftMatches.add(node); } if (!matchRight(node)) return false; switch (combinator) { - case ' ': // fall-through - case '>': + case " ": // fall-through + case ">": return parent ? leftMatches.has(parent) : false; - case '~': { + case "~": { if (!parent) return false; for (let sibling of parent.children.slice(0, i)) { if (leftMatches.has(sibling)) return true; } return false; } - case '+': { + case "+": { if (!parent) return false; let prevSiblings = parent.children .slice(0, i) diff --git a/src/html/test/selector.test.ts b/src/html/test/selector.test.ts index f68c2d00..d94b0955 100644 --- a/src/html/test/selector.test.ts +++ b/src/html/test/selector.test.ts @@ -1,12 +1,10 @@ -import $, { querySelector, querySelectorAll } from "../src/selector"; +import { querySelector, querySelectorAll } from "../src/selector"; import { parse, renderSync } from "../src"; import { describe, expect, it, test } from "vitest"; test("sanity", () => { expect(querySelector).toBeTypeOf("function"); expect(querySelectorAll).toBeTypeOf("function"); - expect($).toBeTypeOf("function"); - expect($).toEqual(querySelectorAll); }); describe("type selector", () => { From 46272d9a158918cf0b5e9ed071ac211e341c607d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:46:00 +0900 Subject: [PATCH 15/52] wip: remove `specificity` --- src/html/src/selector.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/html/src/selector.ts b/src/html/src/selector.ts index 60426d41..0b5bc311 100644 --- a/src/html/src/selector.ts +++ b/src/html/src/selector.ts @@ -1,15 +1,7 @@ import type { Node } from "./index.js"; import { ELEMENT_NODE, TEXT_NODE, walkSync } from "./index.js"; import type { AST, AttributeToken } from "parsel-js"; -import { - parse, - specificity as getSpecificity, - specificityToNumber, -} from "parsel-js"; - -export function specificity(selector: string) { - return specificityToNumber(getSpecificity(selector), 10); -} +import { parse } from "parsel-js"; export function matches(node: Node, selector: string): boolean { const match = selectorToMatch(selector); From 8ff94c261bc3ce7750eaf9d375c2d618359a215e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Mon, 28 Jul 2025 23:50:26 +0900 Subject: [PATCH 16/52] wip: remove `markdown-it` --- src/html/package.json | 1 - src/html/test/markdown.test.ts | 14 ++------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/html/package.json b/src/html/package.json index ac0ed804..121f42ed 100644 --- a/src/html/package.json +++ b/src/html/package.json @@ -17,7 +17,6 @@ "./package.json": "./package.json" }, "devDependencies": { - "markdown-it": "^13.0.2", "parsel-js": "^1.1.2", "vitest": "^2.1.1" } diff --git a/src/html/test/markdown.test.ts b/src/html/test/markdown.test.ts index b181f305..2e31d1fa 100644 --- a/src/html/test/markdown.test.ts +++ b/src/html/test/markdown.test.ts @@ -1,20 +1,10 @@ import { parse, renderSync } from "../src/"; import { describe, expect, it } from "vitest"; -import Markdown from "markdown-it"; - -const md = new Markdown(); - -const src = `Token CSS is a new tool that seamlessly integrates [Design Tokens](https://design-tokens.github.io/community-group/format/#design-token) into your development workflow. Conceptually, it is similar to tools -like [Tailwind](https://tailwindcss.com), [Styled System](https://styled-system.com/), and many CSS-in-JS libraries that provide tokenized _constraints_ for your styles—but there's one big difference. - -# Hello world! - -**Token CSS embraces \`.css\` files and \``; + let meta = 0; + walkSync(parse(input), async (node, parent) => { + if ( + node.type === ELEMENT_NODE && + node.name === "meta" && + parent?.name === "head" + ) { + meta++; + } + }); + expect(meta).toEqual(11); + }); + it("works with `; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works with inside script", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works with <\\/script> inside script", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); - it("with linebreaks", async () => { - const { - children: [{ attributes }], - } = parse(`
`); - expect(attributes).toMatchObject({ a: "1\n2\n3" }); + + describe("style", () => { + it("works for elements", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works without quotes", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); - it("with single quote", async () => { - const { - children: [{ attributes }], - } = parse(`
`); - expect(attributes).toMatchObject({ a: "nate'\ns" }); + + describe("selector", () => { + test("sanity", () => { + expect(querySelector).toBeTypeOf("function"); + expect(querySelectorAll).toBeTypeOf("function"); + }); + + describe("type selector", () => { + it("type", async () => { + const input = `

Hello world!

`; + const output = renderSync(querySelector(parse(input), "h1")); + expect(output).toEqual(input); + }); + }); + + /* + + describe("id selector", () => { + it("id", async () => { + const input = `

Hello world!

`; + const output = renderSync(querySelectorAll(parse(input), "#foo")[0]); + expect(output).toEqual(input); + }); + }); + + */ }); - it("with escaped double quote", async () => { - const { - children: [{ attributes }], - } = parse(`
`); - expect(attributes).toMatchObject({ a: ""never\nmore"" }); + + describe("svg", () => { + it("renderSync as self-closing", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); }); diff --git a/src/html/test/markdown.test.ts b/src/html/test/markdown.test.ts deleted file mode 100644 index 2e31d1fa..00000000 --- a/src/html/test/markdown.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { parse, renderSync } from "../src/"; -import { describe, expect, it } from "vitest"; - -describe("markdown", () => { - it("works", async () => { - const input = - '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; - const output = renderSync(parse(input)); - expect(input).eq(output); - }); -}); diff --git a/src/html/test/script.test.ts b/src/html/test/script.test.ts deleted file mode 100644 index eb9a0ed0..00000000 --- a/src/html/test/script.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { parse, renderSync, walkSync, ELEMENT_NODE } from "../src"; -import { describe, expect, it } from "vitest"; - -describe("script", () => { - it("works for elements", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works without quotes", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with HTML Sanitizer API - Web APIs | MDN`; - let meta = 0; - walkSync(parse(input), async (node, parent) => { - if ( - node.type === ELEMENT_NODE && - node.name === "meta" && - parent?.name === "head" - ) { - meta++; - } - }); - expect(meta).toEqual(11); - }); - it("works with `; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with inside script", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with <\\/script> inside script", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); -}); - -describe("style", () => { - it("works for elements", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works without quotes", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); -}); diff --git a/src/html/test/selector.test.ts b/src/html/test/selector.test.ts deleted file mode 100644 index 3d6b51a1..00000000 --- a/src/html/test/selector.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { querySelector, querySelectorAll } from "../src"; -import { parse, renderSync } from "../src"; -import { describe, expect, it, test } from "vitest"; - -test("sanity", () => { - expect(querySelector).toBeTypeOf("function"); - expect(querySelectorAll).toBeTypeOf("function"); -}); - -describe("type selector", () => { - it("type", async () => { - const input = `

Hello world!

`; - const output = renderSync(querySelector(parse(input), "h1")); - expect(output).toEqual(input); - }); -}); - -/* - -describe("id selector", () => { - it("id", async () => { - const input = `

Hello world!

`; - const output = renderSync(querySelectorAll(parse(input), "#foo")[0]); - expect(output).toEqual(input); - }); -}); - -*/ diff --git a/src/html/test/svg.test.ts b/src/html/test/svg.test.ts deleted file mode 100644 index 3c02c64c..00000000 --- a/src/html/test/svg.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { parse, renderSync } from "../src/index.ts"; -import { describe, expect, it } from "vitest"; - -describe("svg", () => { - it("renderSync as self-closing", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); -}); From de7c5c7914999af4067093d55409a68592fb73cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Tue, 29 Jul 2025 23:41:54 +0900 Subject: [PATCH 22/52] wip: move files to top-level --- src/html/{test => }/basic.test.ts | 2 +- src/html/{src => }/index.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/html/{test => }/basic.test.ts (99%) rename src/html/{src => }/index.ts (100%) diff --git a/src/html/test/basic.test.ts b/src/html/basic.test.ts similarity index 99% rename from src/html/test/basic.test.ts rename to src/html/basic.test.ts index acfde6b4..52e6c677 100644 --- a/src/html/test/basic.test.ts +++ b/src/html/basic.test.ts @@ -5,7 +5,7 @@ import { ELEMENT_NODE, querySelector, querySelectorAll, -} from "../src/"; +} from "./index.js"; import { describe, expect, it, test } from "vitest"; describe("html", () => { diff --git a/src/html/src/index.ts b/src/html/index.ts similarity index 100% rename from src/html/src/index.ts rename to src/html/index.ts From 6cc05af404ef50a0e92cab839db0ccd941fd492c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Tue, 29 Jul 2025 23:42:42 +0900 Subject: [PATCH 23/52] wip: revert `tsconfig.json` --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index feb52517..9cd89cf8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "include": ["src/**/*.js", "src/**/*.ts"], - "exclude": ["src/html/**"], "compilerOptions": { "declaration": true, "allowJs": true, From 3079f87178c5f91f2c9d875e875b8c82b9363fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Tue, 29 Jul 2025 23:49:40 +0900 Subject: [PATCH 24/52] wip: update comments --- src/html/index.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/html/index.ts b/src/html/index.ts index 784684a6..d1879bd1 100644 --- a/src/html/index.ts +++ b/src/html/index.ts @@ -1,7 +1,9 @@ /** - * Portions of this code were borrowed from `ultrahtml` - https://github.com/natemoo-re/ultrahtml + * @fileoverview A simple HTML parser and helper functions for handling HTML nodes in Markdown rules. + * @author Json Miller, Nate Moore, 루밀LuMir(lumirlumir) + * @license MIT * - * @license + * Portions of this code were borrowed from `ultrahtml` - https://github.com/natemoo-re/ultrahtml * * MIT License Copyright (c) 2022 Nate Moore * @@ -49,6 +51,10 @@ * THE SOFTWARE. */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + export type Node = | DocumentNode | ElementNode @@ -104,6 +110,10 @@ export interface DoctypeNode extends LiteralNode { type: typeof DOCTYPE_NODE; } +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + export const DOCUMENT_NODE = 0; export const ELEMENT_NODE = 1; export const TEXT_NODE = 2; @@ -132,8 +142,7 @@ const VOID_TAGS = new Set([ const RAW_TAGS = new Set(["script", "style"]); const DOM_PARSER_RE = /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; - -const ATTR_KEY_IDENTIFIER = /[\@\.a-z0-9_\:\-]/i; +const ATTR_KEY_IDENTIFIER_RE = /[\@\.a-z0-9_\:\-]/i; function splitAttrs(str?: string) { let obj: Record = {}; @@ -147,7 +156,7 @@ function splitAttrs(str?: string) { const currentChar = str[currentIndex]; if (state === "none") { - if (ATTR_KEY_IDENTIFIER.test(currentChar)) { + if (ATTR_KEY_IDENTIFIER_RE.test(currentChar)) { // add attribute if (currentKey) { obj[currentKey] = currentValue; @@ -161,7 +170,7 @@ function splitAttrs(str?: string) { state = "value"; } } else if (state === "key") { - if (!ATTR_KEY_IDENTIFIER.test(currentChar)) { + if (!ATTR_KEY_IDENTIFIER_RE.test(currentChar)) { currentKey = str.substring(tokenStartIndex!, currentIndex); if (currentChar === "=") { state = "value"; From 0feadbeb416262c4d94823cd5752bb5ac46c07fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Tue, 29 Jul 2025 23:52:02 +0900 Subject: [PATCH 25/52] wip --- src/html/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/html/index.ts b/src/html/index.ts index d1879bd1..10b25d35 100644 --- a/src/html/index.ts +++ b/src/html/index.ts @@ -3,6 +3,8 @@ * @author Json Miller, Nate Moore, 루밀LuMir(lumirlumir) * @license MIT * + * --- + * * Portions of this code were borrowed from `ultrahtml` - https://github.com/natemoo-re/ultrahtml * * MIT License Copyright (c) 2022 Nate Moore From 4e784cf1157fa3f259d5cdf0eb3cdd32348893ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:05:07 +0900 Subject: [PATCH 26/52] wip: cleanup --- src/html/index.ts | 211 +++++++++++++++++++++++++--------------------- 1 file changed, 113 insertions(+), 98 deletions(-) diff --git a/src/html/index.ts b/src/html/index.ts index 10b25d35..eb86a5b7 100644 --- a/src/html/index.ts +++ b/src/html/index.ts @@ -53,6 +53,9 @@ * THE SOFTWARE. */ +// TODO: if (selector.content === "*") return true; +// TODO: getElementByTagName, getElementById, getElementsByClassName, getElementByName + //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- @@ -63,16 +66,19 @@ export type Node = | TextNode | CommentNode | DoctypeNode; + export type NodeType = | typeof DOCUMENT_NODE | typeof ELEMENT_NODE | typeof TEXT_NODE | typeof COMMENT_NODE | typeof DOCTYPE_NODE; + export interface Location { start: number; end: number; } + interface BaseNode { type: NodeType; loc: [Location, Location]; @@ -112,8 +118,12 @@ export interface DoctypeNode extends LiteralNode { type: typeof DOCTYPE_NODE; } +interface VisitorSync { + (node: Node, parent?: Node, index?: number): void; +} + //----------------------------------------------------------------------------- -// Helpers +// Helpers: Constants //----------------------------------------------------------------------------- export const DOCUMENT_NODE = 0; @@ -122,7 +132,10 @@ export const TEXT_NODE = 2; export const COMMENT_NODE = 3; export const DOCTYPE_NODE = 4; -export const Fragment = Symbol("Fragment"); +const Fragment = Symbol("Fragment"); +const HTMLString = Symbol("HTMLString"); +const AttrString = Symbol("AttrString"); +const RenderFn = Symbol("RenderFn"); const VOID_TAGS = new Set([ "area", @@ -142,10 +155,74 @@ const VOID_TAGS = new Set([ "wbr", ]); const RAW_TAGS = new Set(["script", "style"]); +const ESCAPE_CHARS: Record = { + "&": "&", + "<": "<", + ">": ">", +}; + const DOM_PARSER_RE = /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; const ATTR_KEY_IDENTIFIER_RE = /[\@\.a-z0-9_\:\-]/i; +//----------------------------------------------------------------------------- +// Helpers: Functions +//----------------------------------------------------------------------------- + +function attrs(attributes: Record) { + let attrStr = ""; + for (const [key, value] of Object.entries(attributes)) { + attrStr += ` ${key}="${value}"`; + } + return mark(attrStr, [HTMLString, AttrString]); +} + +function canSelfClose(node: Node): boolean { + if (node.children.length === 0) { + let n: Node | undefined = node; + while ((n = n.parent)) { + if (n.name === "svg") return true; + } + } + return false; +} + +function escapeHTML(str: string): string { + return str.replace(/[&<>]/g, c => ESCAPE_CHARS[c] || c); +} + +function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { + const v = { value: str }; + for (const tag of tags) { + Object.defineProperty(v, tag, { + value: true, + enumerable: false, + writable: false, + }); + } + return v; +} + +function renderElementSync(node: Node): string { + const { name, attributes = {} } = node; + const children = node.children + .map((child: Node) => renderSync(child)) + .join(""); + if (RenderFn in node) { + const value = (node as any)[RenderFn](attributes, mark(children)); + if (value && (value as any)[HTMLString]) return value.value; + return escapeHTML(String(value)); + } + if (name === Fragment) return children; + const isSelfClosing = canSelfClose(node); + if (isSelfClosing || VOID_TAGS.has(name)) { + return `<${node.name}${attrs(attributes).value}${ + isSelfClosing ? " /" : "" + }>`; + } + return `<${node.name}${attrs(attributes).value}>${children}`; +} + function splitAttrs(str?: string) { let obj: Record = {}; if (str) { @@ -217,6 +294,40 @@ function splitAttrs(str?: string) { return obj; } +function select( + node: Node, + opts: { single?: boolean } = { single: false }, +): Node[] { + let nodes: Node[] = []; + walkSync(node, (n): void => { + if (n && n.type !== ELEMENT_NODE) return; + if (opts.single) throw n; + nodes.push(n); + }); + return nodes; +} + +//----------------------------------------------------------------------------- +// Helpers: Classes +//----------------------------------------------------------------------------- + +class WalkerSync { + constructor(private callback: VisitorSync) {} + visit(node: Node, parent?: Node, index?: number): void { + this.callback(node, parent, index); + if (Array.isArray(node.children)) { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + this.visit(child, node, i); + } + } + } +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + export function parse(input: string): any { let str = input; let doc: Node, @@ -376,89 +487,11 @@ export function parse(input: string): any { return doc; } -export interface VisitorSync { - (node: Node, parent?: Node, index?: number): void; -} - -class WalkerSync { - constructor(private callback: VisitorSync) {} - visit(node: Node, parent?: Node, index?: number): void { - this.callback(node, parent, index); - if (Array.isArray(node.children)) { - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - this.visit(child, node, i); - } - } - } -} - -const HTMLString = Symbol("HTMLString"); -const AttrString = Symbol("AttrString"); -export const RenderFn = Symbol("RenderFn"); -function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { - const v = { value: str }; - for (const tag of tags) { - Object.defineProperty(v, tag, { - value: true, - enumerable: false, - writable: false, - }); - } - return v; -} - -const ESCAPE_CHARS: Record = { - "&": "&", - "<": "<", - ">": ">", -}; -function escapeHTML(str: string): string { - return str.replace(/[&<>]/g, c => ESCAPE_CHARS[c] || c); -} -export function attrs(attributes: Record) { - let attrStr = ""; - for (const [key, value] of Object.entries(attributes)) { - attrStr += ` ${key}="${value}"`; - } - return mark(attrStr, [HTMLString, AttrString]); -} - export function walkSync(node: Node, callback: VisitorSync): void { const walker = new WalkerSync(callback); return walker.visit(node); } -function canSelfClose(node: Node): boolean { - if (node.children.length === 0) { - let n: Node | undefined = node; - while ((n = n.parent)) { - if (n.name === "svg") return true; - } - } - return false; -} - -function renderElementSync(node: Node): string { - const { name, attributes = {} } = node; - const children = node.children - .map((child: Node) => renderSync(child)) - .join(""); - if (RenderFn in node) { - const value = (node as any)[RenderFn](attributes, mark(children)); - if (value && (value as any)[HTMLString]) return value.value; - return escapeHTML(String(value)); - } - if (name === Fragment) return children; - const isSelfClosing = canSelfClose(node); - if (isSelfClosing || VOID_TAGS.has(name)) { - return `<${node.name}${attrs(attributes).value}${ - isSelfClosing ? " /" : "" - }>`; - } - return `<${node.name}${attrs(attributes).value}>${children}`; -} - export function renderSync(node: Node): string { switch (node.type) { case DOCUMENT_NODE: @@ -476,8 +509,6 @@ export function renderSync(node: Node): string { } } -// -------------------------------------------------------------------------------- - export function querySelector(node: Node, selector: string): Node { try { return select(node, { single: true })[0]; @@ -492,19 +523,3 @@ export function querySelector(node: Node, selector: string): Node { export function querySelectorAll(node: Node, selector: string): Node[] { return select(node); } - -function select( - node: Node, - opts: { single?: boolean } = { single: false }, -): Node[] { - let nodes: Node[] = []; - walkSync(node, (n): void => { - if (n && n.type !== ELEMENT_NODE) return; - if (opts.single) throw n; - nodes.push(n); - }); - return nodes; -} - -// TODO: if (selector.content === "*") return true; -// TODO: getElementByTagName, getElementById, getElementsByClassName, getElementByName From d1b86696f2eb3ba5ff9b58d84d7b5ed11b3e0b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:12:05 +0900 Subject: [PATCH 27/52] wip: tests --- src/html/basic.test.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/html/basic.test.ts b/src/html/basic.test.ts index 52e6c677..3bd54bff 100644 --- a/src/html/basic.test.ts +++ b/src/html/basic.test.ts @@ -1,19 +1,24 @@ +import assert from "node:assert"; import { parse, - renderSync, walkSync, - ELEMENT_NODE, + renderSync, querySelector, querySelectorAll, + ELEMENT_NODE, } from "./index.js"; -import { describe, expect, it, test } from "vitest"; +import { describe, expect, it } from "vitest"; describe("html", () => { - describe("basic", () => { - test("sanity", () => { - expect(parse).toBeTypeOf("function"); - }); + it("sanity", () => { + assert.strictEqual(typeof parse, "function"); + assert.strictEqual(typeof walkSync, "function"); + assert.strictEqual(typeof renderSync, "function"); + assert.strictEqual(typeof querySelector, "function"); + assert.strictEqual(typeof querySelectorAll, "function"); + }); + describe("basic", () => { describe("input === output", () => { it("works for elements", async () => { const input = `

Hello world!

`; @@ -177,11 +182,6 @@ more"">`); }); describe("selector", () => { - test("sanity", () => { - expect(querySelector).toBeTypeOf("function"); - expect(querySelectorAll).toBeTypeOf("function"); - }); - describe("type selector", () => { it("type", async () => { const input = `

Hello world!

`; From 31172b2400f59965a21fc5a5a64329ceacc96e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:20:11 +0900 Subject: [PATCH 28/52] wip: cleanup tests --- src/html/basic.test.ts | 196 +++++++++++++++++++++++++++-------------- 1 file changed, 128 insertions(+), 68 deletions(-) diff --git a/src/html/basic.test.ts b/src/html/basic.test.ts index 3bd54bff..8c2e53c3 100644 --- a/src/html/basic.test.ts +++ b/src/html/basic.test.ts @@ -1,3 +1,62 @@ +/** + * @fileoverview Tests for the html.js + * @author Json Miller, Nate Moore, 루밀LuMir(lumirlumir) + * @license MIT + * + * --- + * + * Portions of this code were borrowed from `ultrahtml` - https://github.com/natemoo-re/ultrahtml + * + * MIT License Copyright (c) 2022 Nate Moore + * + * Permission is hereby granted, free of + * charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * The above copyright notice and this permission notice + * (including the next paragraph) shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * --- + * + * Portions of this code were borrowed from `htmlParser` - https://github.com/developit/htmlParser + * + * The MIT License (MIT) Copyright (c) 2013 Jason Miller + * + * Permission is hereby granted, free of + * charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the + * following conditions: + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF + * ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO + * EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + import assert from "node:assert"; import { parse, @@ -9,6 +68,10 @@ import { } from "./index.js"; import { describe, expect, it } from "vitest"; +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + describe("html", () => { it("sanity", () => { assert.strictEqual(typeof parse, "function"); @@ -18,7 +81,7 @@ describe("html", () => { assert.strictEqual(typeof querySelectorAll, "function"); }); - describe("basic", () => { + describe("parse(), renderSync()", () => { describe("input === output", () => { it("works for elements", async () => { const input = `

Hello world!

`; @@ -51,6 +114,12 @@ describe("html", () => { expect(output).toEqual(input); }); + it("works for long inputs", async () => { + const input = + '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; + const output = renderSync(parse(input)); + expect(input).eq(output); + }); }); describe("attributes", () => { @@ -115,73 +184,72 @@ more"">`); }); }); }); - }); - describe("markdown", () => { - it("works", async () => { - const input = - '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; - const output = renderSync(parse(input)); - expect(input).eq(output); + describe("script", () => { + it("works for elements", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works without quotes", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works with HTML Sanitizer API - Web APIs | MDN`; + let meta = 0; + walkSync(parse(input), async (node, parent) => { + if ( + node.type === ELEMENT_NODE && + node.name === "meta" && + parent?.name === "head" + ) { + meta++; + } + }); + expect(meta).toEqual(11); + }); + it("works with `; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works with inside script", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works with <\\/script> inside script", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); - }); - describe("script", () => { - it("works for elements", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works without quotes", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with HTML Sanitizer API - Web APIs | MDN`; - let meta = 0; - walkSync(parse(input), async (node, parent) => { - if ( - node.type === ELEMENT_NODE && - node.name === "meta" && - parent?.name === "head" - ) { - meta++; - } - }); - expect(meta).toEqual(11); - }); - it("works with `; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with inside script", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works with <\\/script> inside script", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); + describe("style", () => { + it("works for elements", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); + it("works without quotes", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); - }); - describe("style", () => { - it("works for elements", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - it("works without quotes", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); + describe("svg", () => { + it("renderSync as self-closing", async () => { + const input = ``; + const output = renderSync(parse(input)); + expect(output).toEqual(input); + }); }); }); - describe("selector", () => { + describe("querySelector()", () => { describe("type selector", () => { it("type", async () => { const input = `

Hello world!

`; @@ -202,12 +270,4 @@ more"">`); */ }); - - describe("svg", () => { - it("renderSync as self-closing", async () => { - const input = ``; - const output = renderSync(parse(input)); - expect(output).toEqual(input); - }); - }); }); From c10a48a110912b4f9c85b36aac81625bfbf4c038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:27:11 +0900 Subject: [PATCH 29/52] wip: migrate to `node:assert` --- src/html/basic.test.ts | 77 +++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/src/html/basic.test.ts b/src/html/basic.test.ts index 8c2e53c3..c8aae29f 100644 --- a/src/html/basic.test.ts +++ b/src/html/basic.test.ts @@ -66,7 +66,7 @@ import { querySelectorAll, ELEMENT_NODE, } from "./index.js"; -import { describe, expect, it } from "vitest"; +import { describe, it } from "vitest"; //----------------------------------------------------------------------------- // Tests @@ -86,39 +86,45 @@ describe("html", () => { it("works for elements", async () => { const input = `

Hello world!

`; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works for custom elements", async () => { const input = `Hello world!`; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works for comments", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works for text", async () => { const input = `Hmm...`; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works for doctype", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works for html:5", async () => { const input = `Document`; const output = renderSync(parse(input)); - expect(output).toEqual(input); + assert.strictEqual(output, input); }); it("works for long inputs", async () => { const input = '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; const output = renderSync(parse(input)); - expect(input).eq(output); + + assert.strictEqual(output, input); }); }); @@ -127,33 +133,36 @@ describe("html", () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ a: "b", c: "1" }); + + assert.deepStrictEqual(attributes, { a: "b", c: "1" }); }); it("empty", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ test: "" }); + + assert.deepStrictEqual(attributes, { test: "" }); }); it("@", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ - "@on.click": "doThing", - }); + + assert.deepStrictEqual(attributes, { "@on.click": "doThing" }); }); it("namespace", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ "on:click": "alert()" }); + + assert.deepStrictEqual(attributes, { "on:click": "alert()" }); }); it("simple and empty", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ + + assert.deepStrictEqual(attributes, { test: "", a: "b", c: "1", @@ -165,21 +174,23 @@ describe("html", () => { } = parse(`
`); - expect(attributes).toMatchObject({ a: "1\n2\n3" }); + + assert.deepStrictEqual(attributes, { a: "1\n2\n3" }); }); it("with single quote", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ a: "nate'\ns" }); + assert.deepStrictEqual(attributes, { a: "nate'\ns" }); }); it("with escaped double quote", async () => { const { children: [{ attributes }], } = parse(`
`); - expect(attributes).toMatchObject({ + + assert.deepStrictEqual(attributes, { a: ""never\nmore"", }); }); @@ -189,12 +200,14 @@ more"">`); it("works for elements", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works without quotes", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works with HTML Sanitizer API - Web APIs | MDN`; @@ -208,22 +221,26 @@ more"">`); meta++; } }); - expect(meta).toEqual(11); + + assert.strictEqual(meta, 11); }); it("works with `; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works with inside script", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works with <\\/script> inside script", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); }); @@ -231,12 +248,14 @@ more"">`); it("works for elements", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); it("works without quotes", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); }); @@ -244,7 +263,8 @@ more"">`); it("renderSync as self-closing", async () => { const input = ``; const output = renderSync(parse(input)); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); }); }); @@ -254,7 +274,8 @@ more"">`); it("type", async () => { const input = `

Hello world!

`; const output = renderSync(querySelector(parse(input), "h1")); - expect(output).toEqual(input); + + assert.strictEqual(output, input); }); }); From bb36950b6b60018dc70e9e12b8404c42238eb6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:28:08 +0900 Subject: [PATCH 30/52] wip: rename --- src/html/{basic.test.ts => html.test.ts} | 2 +- src/html/{index.ts => html.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/html/{basic.test.ts => html.test.ts} (99%) rename src/html/{index.ts => html.ts} (100%) diff --git a/src/html/basic.test.ts b/src/html/html.test.ts similarity index 99% rename from src/html/basic.test.ts rename to src/html/html.test.ts index c8aae29f..78f19038 100644 --- a/src/html/basic.test.ts +++ b/src/html/html.test.ts @@ -65,7 +65,7 @@ import { querySelector, querySelectorAll, ELEMENT_NODE, -} from "./index.js"; +} from "./html.js"; import { describe, it } from "vitest"; //----------------------------------------------------------------------------- diff --git a/src/html/index.ts b/src/html/html.ts similarity index 100% rename from src/html/index.ts rename to src/html/html.ts From abd42bd0bc397a99d7cef82eca9d9b462e9dd406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:31:43 +0900 Subject: [PATCH 31/52] wip: simplify and cleanup --- package.json | 3 +-- src/{html => }/README.md | 0 src/{html => }/html.ts | 0 src/html/package.json | 11 ----------- src/html/html.test.ts => tests/html.test.js | 5 ++--- 5 files changed, 3 insertions(+), 16 deletions(-) rename src/{html => }/README.md (100%) rename src/{html => }/html.ts (100%) delete mode 100644 src/html/package.json rename src/html/html.test.ts => tests/html.test.js (84%) diff --git a/package.json b/package.json index 4ac8c27d..6271dbd4 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,7 @@ "linter" ], "workspaces": [ - "examples/*", - "src/html" + "examples/*" ], "gitHooks": { "pre-commit": "lint-staged" diff --git a/src/html/README.md b/src/README.md similarity index 100% rename from src/html/README.md rename to src/README.md diff --git a/src/html/html.ts b/src/html.ts similarity index 100% rename from src/html/html.ts rename to src/html.ts diff --git a/src/html/package.json b/src/html/package.json deleted file mode 100644 index 36c09f53..00000000 --- a/src/html/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "ultrahtml", - "type": "module", - "scripts": { - "dev": "vitest", - "test": "vitest run" - }, - "devDependencies": { - "vitest": "^2.1.1" - } -} diff --git a/src/html/html.test.ts b/tests/html.test.js similarity index 84% rename from src/html/html.test.ts rename to tests/html.test.js index 78f19038..20396da1 100644 --- a/src/html/html.test.ts +++ b/tests/html.test.js @@ -65,8 +65,7 @@ import { querySelector, querySelectorAll, ELEMENT_NODE, -} from "./html.js"; -import { describe, it } from "vitest"; +} from "../dist/esm/html.js"; //----------------------------------------------------------------------------- // Tests @@ -210,7 +209,7 @@ more"">`); assert.strictEqual(output, input); }); it("works with HTML Sanitizer API - Web APIs | MDN`; + const input = `')HTML Sanitizer API - Web APIs | MDN`; let meta = 0; walkSync(parse(input), async (node, parent) => { if ( From 971184b50776babb7321ec0b1f0ab3bf0289d3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:42:31 +0900 Subject: [PATCH 32/52] wip: remove `README.md` and update JSDoc --- src/README.md | 56 --------------------------------------------------- src/html.ts | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 56 deletions(-) delete mode 100644 src/README.md diff --git a/src/README.md b/src/README.md deleted file mode 100644 index f149da1f..00000000 --- a/src/README.md +++ /dev/null @@ -1,56 +0,0 @@ -### Features - -- Tiny, fault-tolerant and friendly HTML-like parser. Works with HTML, Astro, Vue, Svelte, and any other HTML-like syntax. -- Built-in AST `walk` utility -- `querySelector` and `querySelectorAll` support using `ultrahtml/selector` - -#### `walk` - -The `walk` function provides full control over the AST. It can be used to scan for text, elements, components, or any other validation you might want to do. - -> **Note** > `walk` is `async` and **must** be `await`ed. Use `walkSync` if it is guaranteed there are no `async` components in the tree. - -```js -import { parse, walk, ELEMENT_NODE } from "ultrahtml"; - -const ast = parse(`

Hello world!

`); -await walk(ast, async (node) => { - if (node.type === ELEMENT_NODE && node.name === "script") { - throw new Error("Found a script!"); - } -}); -``` - -#### `walkSync` - -The `walkSync` function is identical to the `walk` function, but is synchronous. This should only be used when it is guaranteed there are no `async` components in the tree. - -```js -import { parse, walkSync, ELEMENT_NODE } from "ultrahtml"; - -const ast = parse(`

Hello world!

`); -walkSync(ast, (node) => { - if (node.type === ELEMENT_NODE && node.name === "script") { - throw new Error("Found a script!"); - } -}); -``` - -#### `renderSync` - -The `renderSync` function allows you to serialize an AST back into a string. - -> **Note** -> By default, `renderSync` will sanitize your markup, removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. - -```js -import { parse, renderSync } from "ultrahtml"; - -const ast = parse(`

Hello world!

`); -console.log(renderSync(ast)); //

Hello world!

-``` - -## Acknowledgements - -- [Jason Miller](https://twitter.com/_developit)'s [`htmlParser`](https://github.com/developit/htmlParser) provided a great, lightweight base for this parser -- [Titus Wormer](https://twitter.com/wooorm)'s [`mdx`](https://mdxjs.com) for inspiration diff --git a/src/html.ts b/src/html.ts index eb86a5b7..f894c562 100644 --- a/src/html.ts +++ b/src/html.ts @@ -487,11 +487,46 @@ export function parse(input: string): any { return doc; } +/** + * The `walkSync` function provides full control over the AST. + * It can be used to scan for text, elements, components, + * or any other validation you might want to do. + * + * `walkSync` is **synchronous**. This should only be used + * when it is guaranteed there are no `async` components in the tree. + * + * @example + * ```js + * import { parse, walkSync, ELEMENT_NODE } from "path/to/html.js"; + * + * const ast = parse(`

Hello world!

`); + * walkSync(ast, (node) => { + * if (node.type === ELEMENT_NODE && node.name === "script") { + * throw new Error("Found a script!"); + * } + * }); + * ``` + */ export function walkSync(node: Node, callback: VisitorSync): void { const walker = new WalkerSync(callback); return walker.visit(node); } +/** + * The `renderSync` function allows you to serialize an AST back into a string. + * + * - **Note**: By default, `renderSync` will sanitize your markup, + * removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. + * + * @example + * + * ```js + * import { parse, renderSync } from "path/to/html.js"; + * + * const ast = parse(`

Hello world!

`); + * console.log(renderSync(ast)); //

Hello world!

+ * ``` + */ export function renderSync(node: Node): string { switch (node.type) { case DOCUMENT_NODE: From 4774f5d04100ff3c0e722b3ca06bf07396275c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 01:47:56 +0900 Subject: [PATCH 33/52] wip: update JSDoc --- src/html.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/html.ts b/src/html.ts index f894c562..f40c2e2a 100644 --- a/src/html.ts +++ b/src/html.ts @@ -328,6 +328,23 @@ class WalkerSync { // Exports //----------------------------------------------------------------------------- +/** + * The `parse` function takes a string of HTML and returns an AST (Abstract Syntax Tree). + * + * @example + * ```js + * import { parse } from "path/to/html.js"; + * + * const ast = parse(`

Hello world!

`); + * console.log(ast); + * // { + * // type: 0, // DOCUMENT_NODE + * // children: [ + * // ... + * // ] + * // } + * ``` + */ export function parse(input: string): any { let str = input; let doc: Node, From 9b3e742fa3c364caaec710378e18af67314c2b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 02:04:45 +0900 Subject: [PATCH 34/52] wip: refactor `walkSync` --- src/html.ts | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/html.ts b/src/html.ts index f40c2e2a..6af153d2 100644 --- a/src/html.ts +++ b/src/html.ts @@ -307,23 +307,6 @@ function select( return nodes; } -//----------------------------------------------------------------------------- -// Helpers: Classes -//----------------------------------------------------------------------------- - -class WalkerSync { - constructor(private callback: VisitorSync) {} - visit(node: Node, parent?: Node, index?: number): void { - this.callback(node, parent, index); - if (Array.isArray(node.children)) { - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - this.visit(child, node, i); - } - } - } -} - //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- @@ -350,11 +333,11 @@ export function parse(input: string): any { let doc: Node, parent: Node, token: any, - text, - i, - bStart, - bText, - bEnd, + text: string, + i: number, + bStart: string, + bText: string, + bEnd: string, tag: Node; const tags: Node[] = []; DOM_PARSER_RE.lastIndex = 0; @@ -525,8 +508,17 @@ export function parse(input: string): any { * ``` */ export function walkSync(node: Node, callback: VisitorSync): void { - const walker = new WalkerSync(callback); - return walker.visit(node); + function visit(node: Node, parent?: Node, index?: number): void { + callback(node, parent, index); + if (Array.isArray(node.children)) { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + visit(child, node, i); + } + } + } + + return visit(node); } /** From d6c56300df4bde0c023f714944c2aed7c5cc7083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 02:08:01 +0900 Subject: [PATCH 35/52] wip: remove `VisitorSync` type --- src/html.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/html.ts b/src/html.ts index 6af153d2..d9747ecf 100644 --- a/src/html.ts +++ b/src/html.ts @@ -118,10 +118,6 @@ export interface DoctypeNode extends LiteralNode { type: typeof DOCTYPE_NODE; } -interface VisitorSync { - (node: Node, parent?: Node, index?: number): void; -} - //----------------------------------------------------------------------------- // Helpers: Constants //----------------------------------------------------------------------------- @@ -507,7 +503,10 @@ export function parse(input: string): any { * }); * ``` */ -export function walkSync(node: Node, callback: VisitorSync): void { +export function walkSync( + node: Node, + callback: (node: Node, parent?: Node, index?: number) => void, +): void { function visit(node: Node, parent?: Node, index?: number): void { callback(node, parent, index); if (Array.isArray(node.children)) { From 58840f8ab8964d8e35392d4c173412b6ac24def5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:09:30 +0900 Subject: [PATCH 36/52] wip: remove `XXX_NODE` constants --- src/html.ts | 60 +++++++++++++++++++++------------------------- tests/html.test.js | 3 +-- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/html.ts b/src/html.ts index d9747ecf..5e21f2bb 100644 --- a/src/html.ts +++ b/src/html.ts @@ -68,11 +68,11 @@ export type Node = | DoctypeNode; export type NodeType = - | typeof DOCUMENT_NODE - | typeof ELEMENT_NODE - | typeof TEXT_NODE - | typeof COMMENT_NODE - | typeof DOCTYPE_NODE; + | DocumentNode["type"] + | ElementNode["type"] + | TextNode["type"] + | CommentNode["type"] + | DoctypeNode["type"]; export interface Location { start: number; @@ -95,39 +95,33 @@ interface ParentNode extends BaseNode { } export interface DocumentNode extends Omit { - type: typeof DOCUMENT_NODE; + type: "document"; attributes: Record; parent: undefined; } export interface ElementNode extends ParentNode { - type: typeof ELEMENT_NODE; + type: "element"; name: string; attributes: Record; } export interface TextNode extends LiteralNode { - type: typeof TEXT_NODE; + type: "text"; } export interface CommentNode extends LiteralNode { - type: typeof COMMENT_NODE; + type: "comment"; } export interface DoctypeNode extends LiteralNode { - type: typeof DOCTYPE_NODE; + type: "doctype"; } //----------------------------------------------------------------------------- // Helpers: Constants //----------------------------------------------------------------------------- -export const DOCUMENT_NODE = 0; -export const ELEMENT_NODE = 1; -export const TEXT_NODE = 2; -export const COMMENT_NODE = 3; -export const DOCTYPE_NODE = 4; - const Fragment = Symbol("Fragment"); const HTMLString = Symbol("HTMLString"); const AttrString = Symbol("AttrString"); @@ -296,7 +290,7 @@ function select( ): Node[] { let nodes: Node[] = []; walkSync(node, (n): void => { - if (n && n.type !== ELEMENT_NODE) return; + if (n && n.type !== "element") return; if (opts.single) throw n; nodes.push(n); }); @@ -317,10 +311,10 @@ function select( * const ast = parse(`

Hello world!

`); * console.log(ast); * // { - * // type: 0, // DOCUMENT_NODE + * // type: "document", * // children: [ * // ... - * // ] + * // ], * // } * ``` */ @@ -338,7 +332,7 @@ export function parse(input: string): any { const tags: Node[] = []; DOM_PARSER_RE.lastIndex = 0; parent = doc = { - type: DOCUMENT_NODE, + type: "document", children: [] as Node[], } as any; @@ -350,7 +344,7 @@ export function parse(input: string): any { ); if (text) { (parent as ParentNode).children.push({ - type: TEXT_NODE, + type: "text", value: text, parent, } as any); @@ -373,7 +367,7 @@ export function parse(input: string): any { continue; } tag = { - type: COMMENT_NODE, + type: "comment", value: bText, parent: parent, loc: [ @@ -392,7 +386,7 @@ export function parse(input: string): any { } else if (bStart === "Hello world!`); * walkSync(ast, (node) => { - * if (node.type === ELEMENT_NODE && node.name === "script") { + * if (node.type === "element" && node.name === "script") { * throw new Error("Found a script!"); * } * }); @@ -537,17 +531,17 @@ export function walkSync( */ export function renderSync(node: Node): string { switch (node.type) { - case DOCUMENT_NODE: + case "document": return node.children .map((child: Node) => renderSync(child)) .join(""); - case ELEMENT_NODE: + case "element": return renderElementSync(node); - case TEXT_NODE: + case "text": return `${node.value}`; - case COMMENT_NODE: + case "comment": return ``; - case DOCTYPE_NODE: + case "doctype": return ``; } } diff --git a/tests/html.test.js b/tests/html.test.js index 20396da1..66ad127c 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -64,7 +64,6 @@ import { renderSync, querySelector, querySelectorAll, - ELEMENT_NODE, } from "../dist/esm/html.js"; //----------------------------------------------------------------------------- @@ -213,7 +212,7 @@ more"">`); let meta = 0; walkSync(parse(input), async (node, parent) => { if ( - node.type === ELEMENT_NODE && + node.type === "element" && node.name === "meta" && parent?.name === "head" ) { From 90a6d2b3bebe50773d47e3405ba7a2cb8f90c009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:19:57 +0900 Subject: [PATCH 37/52] wip: remove `RenderFn` --- src/html.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/html.ts b/src/html.ts index 5e21f2bb..701fb7b7 100644 --- a/src/html.ts +++ b/src/html.ts @@ -125,7 +125,6 @@ export interface DoctypeNode extends LiteralNode { const Fragment = Symbol("Fragment"); const HTMLString = Symbol("HTMLString"); const AttrString = Symbol("AttrString"); -const RenderFn = Symbol("RenderFn"); const VOID_TAGS = new Set([ "area", @@ -145,11 +144,6 @@ const VOID_TAGS = new Set([ "wbr", ]); const RAW_TAGS = new Set(["script", "style"]); -const ESCAPE_CHARS: Record = { - "&": "&", - "<": "<", - ">": ">", -}; const DOM_PARSER_RE = /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; @@ -177,10 +171,6 @@ function canSelfClose(node: Node): boolean { return false; } -function escapeHTML(str: string): string { - return str.replace(/[&<>]/g, c => ESCAPE_CHARS[c] || c); -} - function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { const v = { value: str }; for (const tag of tags) { @@ -198,11 +188,6 @@ function renderElementSync(node: Node): string { const children = node.children .map((child: Node) => renderSync(child)) .join(""); - if (RenderFn in node) { - const value = (node as any)[RenderFn](attributes, mark(children)); - if (value && (value as any)[HTMLString]) return value.value; - return escapeHTML(String(value)); - } if (name === Fragment) return children; const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { From 27cf05e5e47a5a589a1feda5f713a6e9fb21d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:25:47 +0900 Subject: [PATCH 38/52] wip: remove `Fragment` --- src/html.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/html.ts b/src/html.ts index 701fb7b7..18b12233 100644 --- a/src/html.ts +++ b/src/html.ts @@ -122,7 +122,6 @@ export interface DoctypeNode extends LiteralNode { // Helpers: Constants //----------------------------------------------------------------------------- -const Fragment = Symbol("Fragment"); const HTMLString = Symbol("HTMLString"); const AttrString = Symbol("AttrString"); @@ -188,7 +187,6 @@ function renderElementSync(node: Node): string { const children = node.children .map((child: Node) => renderSync(child)) .join(""); - if (name === Fragment) return children; const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { return `<${node.name}${attrs(attributes).value}${ From 2615c0318e06a484b0c31ed99212214b6e4e62fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:28:19 +0900 Subject: [PATCH 39/52] wip: consolidate `renderElementSync` --- src/html.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/html.ts b/src/html.ts index 18b12233..635bed01 100644 --- a/src/html.ts +++ b/src/html.ts @@ -182,20 +182,6 @@ function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { return v; } -function renderElementSync(node: Node): string { - const { name, attributes = {} } = node; - const children = node.children - .map((child: Node) => renderSync(child)) - .join(""); - const isSelfClosing = canSelfClose(node); - if (isSelfClosing || VOID_TAGS.has(name)) { - return `<${node.name}${attrs(attributes).value}${ - isSelfClosing ? " /" : "" - }>`; - } - return `<${node.name}${attrs(attributes).value}>${children}`; -} - function splitAttrs(str?: string) { let obj: Record = {}; if (str) { @@ -518,8 +504,19 @@ export function renderSync(node: Node): string { return node.children .map((child: Node) => renderSync(child)) .join(""); - case "element": - return renderElementSync(node); + case "element": { + const { name, attributes = {} } = node; + const children = node.children + .map((child: Node) => renderSync(child)) + .join(""); + const isSelfClosing = canSelfClose(node); + if (isSelfClosing || VOID_TAGS.has(name)) { + return `<${node.name}${attrs(attributes).value}${ + isSelfClosing ? " /" : "" + }>`; + } + return `<${node.name}${attrs(attributes).value}>${children}`; + } case "text": return `${node.value}`; case "comment": From f3bdc0d28ff7666d30532eaf5f9905c97658c0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:34:01 +0900 Subject: [PATCH 40/52] wip: remove `querySelector` and `select` helper --- src/html.ts | 31 ++++++------------------------- tests/html.test.js | 6 +++--- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/html.ts b/src/html.ts index 635bed01..2c833280 100644 --- a/src/html.ts +++ b/src/html.ts @@ -253,19 +253,6 @@ function splitAttrs(str?: string) { return obj; } -function select( - node: Node, - opts: { single?: boolean } = { single: false }, -): Node[] { - let nodes: Node[] = []; - walkSync(node, (n): void => { - if (n && n.type !== "element") return; - if (opts.single) throw n; - nodes.push(n); - }); - return nodes; -} - //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- @@ -526,17 +513,11 @@ export function renderSync(node: Node): string { } } -export function querySelector(node: Node, selector: string): Node { - try { - return select(node, { single: true })[0]; - } catch (e) { - if (e instanceof Error) { - throw e; - } - return e as Node; - } -} - export function querySelectorAll(node: Node, selector: string): Node[] { - return select(node); + let nodes: Node[] = []; + walkSync(node, (n): void => { + if (n && n.type !== "element") return; + nodes.push(n); + }); + return nodes; } diff --git a/tests/html.test.js b/tests/html.test.js index 66ad127c..733100a7 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -62,7 +62,6 @@ import { parse, walkSync, renderSync, - querySelector, querySelectorAll, } from "../dist/esm/html.js"; @@ -75,7 +74,6 @@ describe("html", () => { assert.strictEqual(typeof parse, "function"); assert.strictEqual(typeof walkSync, "function"); assert.strictEqual(typeof renderSync, "function"); - assert.strictEqual(typeof querySelector, "function"); assert.strictEqual(typeof querySelectorAll, "function"); }); @@ -271,7 +269,9 @@ more"">`); describe("type selector", () => { it("type", async () => { const input = `

Hello world!

`; - const output = renderSync(querySelector(parse(input), "h1")); + const output = renderSync( + querySelectorAll(parse(input), "h1")[0], + ); assert.strictEqual(output, input); }); From 03bf09f1be3dbf0c420119faaa3f26fa4afe4d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:43:28 +0900 Subject: [PATCH 41/52] wip: cleanup --- src/html.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/html.ts b/src/html.ts index 2c833280..70579df4 100644 --- a/src/html.ts +++ b/src/html.ts @@ -278,21 +278,16 @@ export function parse(input: string): any { let str = input; let doc: Node, parent: Node, - token: any, + token: RegExpExecArray | null, text: string, i: number, bStart: string, bText: string, bEnd: string, tag: Node; + let lastIndex = 0; const tags: Node[] = []; - DOM_PARSER_RE.lastIndex = 0; - parent = doc = { - type: "document", - children: [] as Node[], - } as any; - let lastIndex = 0; function commitTextNode() { text = str.substring( lastIndex, @@ -307,6 +302,13 @@ export function parse(input: string): any { } } + DOM_PARSER_RE.lastIndex = 0; + + parent = doc = { + type: "document", + children: [] as Node[], + } as any; + while ((token = DOM_PARSER_RE.exec(str))) { bStart = token[5] || token[8]; bText = token[6] || token[9]; From a64a7dcfbc73b6621f8adcfced96f0158065473c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 16:50:59 +0900 Subject: [PATCH 42/52] wip: add more tests --- tests/html.test.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/html.test.js b/tests/html.test.js index 733100a7..223a9eae 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -81,45 +81,59 @@ describe("html", () => { describe("input === output", () => { it("works for elements", async () => { const input = `

Hello world!

`; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for custom elements", async () => { const input = `Hello world!`; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for comments", async () => { const input = ``; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for text", async () => { const input = `Hmm...`; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for doctype", async () => { const input = ``; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for html:5", async () => { const input = `Document`; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); it("works for long inputs", async () => { const input = '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; - const output = renderSync(parse(input)); + const ast = parse(input); + const output = renderSync(ast); + assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); }); }); From 27af445e1e56d4849de206f05b6f41a899b9be62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 17:02:12 +0900 Subject: [PATCH 43/52] wip: update types --- src/html.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/html.ts b/src/html.ts index 70579df4..cd0edd6d 100644 --- a/src/html.ts +++ b/src/html.ts @@ -324,7 +324,7 @@ export function parse(input: string): any { if (RAW_TAGS.has(parent.name)) { continue; } - tag = { + tag = /** @type {CommentNode} */ { type: "comment", value: bText, parent: parent, @@ -338,12 +338,12 @@ export function parse(input: string): any { end: DOM_PARSER_RE.lastIndex, }, ], - } as any; + }; tags.push(tag); - (tag.parent as any).children.push(tag); + tag.parent.children.push(tag); } else if (bStart === " Date: Wed, 30 Jul 2025 17:16:01 +0900 Subject: [PATCH 44/52] wip: move types into `types.ts` --- src/html.ts | 96 ++++++++++------------------------------------------ src/types.ts | 81 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 79 deletions(-) diff --git a/src/html.ts b/src/html.ts index cd0edd6d..ae9bf712 100644 --- a/src/html.ts +++ b/src/html.ts @@ -56,72 +56,12 @@ // TODO: if (selector.content === "*") return true; // TODO: getElementByTagName, getElementById, getElementsByClassName, getElementByName -//----------------------------------------------------------------------------- -// Type Definitions -//----------------------------------------------------------------------------- - -export type Node = - | DocumentNode - | ElementNode - | TextNode - | CommentNode - | DoctypeNode; - -export type NodeType = - | DocumentNode["type"] - | ElementNode["type"] - | TextNode["type"] - | CommentNode["type"] - | DoctypeNode["type"]; - -export interface Location { - start: number; - end: number; -} - -interface BaseNode { - type: NodeType; - loc: [Location, Location]; - parent: Node; - [key: string]: any; -} - -interface LiteralNode extends BaseNode { - value: string; -} - -interface ParentNode extends BaseNode { - children: Node[]; -} - -export interface DocumentNode extends Omit { - type: "document"; - attributes: Record; - parent: undefined; -} - -export interface ElementNode extends ParentNode { - type: "element"; - name: string; - attributes: Record; -} - -export interface TextNode extends LiteralNode { - type: "text"; -} - -export interface CommentNode extends LiteralNode { - type: "comment"; -} - -export interface DoctypeNode extends LiteralNode { - type: "doctype"; -} - //----------------------------------------------------------------------------- // Helpers: Constants //----------------------------------------------------------------------------- +import type { HtmlNode, HtmlParentNode } from "./types.js"; + const HTMLString = Symbol("HTMLString"); const AttrString = Symbol("AttrString"); @@ -160,9 +100,9 @@ function attrs(attributes: Record) { return mark(attrStr, [HTMLString, AttrString]); } -function canSelfClose(node: Node): boolean { +function canSelfClose(node: HtmlNode): boolean { if (node.children.length === 0) { - let n: Node | undefined = node; + let n: HtmlNode | undefined = node; while ((n = n.parent)) { if (n.name === "svg") return true; } @@ -276,17 +216,17 @@ function splitAttrs(str?: string) { */ export function parse(input: string): any { let str = input; - let doc: Node, - parent: Node, + let doc: HtmlNode, + parent: HtmlNode, token: RegExpExecArray | null, text: string, i: number, bStart: string, bText: string, bEnd: string, - tag: Node; + tag: HtmlNode; let lastIndex = 0; - const tags: Node[] = []; + const tags: HtmlNode[] = []; function commitTextNode() { text = str.substring( @@ -294,7 +234,7 @@ export function parse(input: string): any { DOM_PARSER_RE.lastIndex - token[0].length, ); if (text) { - (parent as ParentNode).children.push({ + (parent as HtmlParentNode).children.push({ type: "text", value: text, parent, @@ -306,7 +246,7 @@ export function parse(input: string): any { parent = doc = { type: "document", - children: [] as Node[], + children: [] as HtmlNode[], } as any; while ((token = DOM_PARSER_RE.exec(str))) { @@ -456,10 +396,10 @@ export function parse(input: string): any { * ``` */ export function walkSync( - node: Node, - callback: (node: Node, parent?: Node, index?: number) => void, + node: HtmlNode, + callback: (node: HtmlNode, parent?: HtmlNode, index?: number) => void, ): void { - function visit(node: Node, parent?: Node, index?: number): void { + function visit(node: HtmlNode, parent?: HtmlNode, index?: number): void { callback(node, parent, index); if (Array.isArray(node.children)) { for (let i = 0; i < node.children.length; i++) { @@ -487,16 +427,16 @@ export function walkSync( * console.log(renderSync(ast)); //

Hello world!

* ``` */ -export function renderSync(node: Node): string { +export function renderSync(node: HtmlNode): string { switch (node.type) { case "document": return node.children - .map((child: Node) => renderSync(child)) + .map((child: HtmlNode) => renderSync(child)) .join(""); case "element": { const { name, attributes = {} } = node; const children = node.children - .map((child: Node) => renderSync(child)) + .map((child: HtmlNode) => renderSync(child)) .join(""); const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { @@ -515,8 +455,8 @@ export function renderSync(node: Node): string { } } -export function querySelectorAll(node: Node, selector: string): Node[] { - let nodes: Node[] = []; +export function querySelectorAll(node: HtmlNode, selector: string): HtmlNode[] { + let nodes: HtmlNode[] = []; walkSync(node, (n): void => { if (n && n.type !== "element") return; nodes.push(n); diff --git a/src/types.ts b/src/types.ts index c7b75adf..0e69a7d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,8 @@ +/** + * @fileoverview Additional types for this package. + * @author Nicholas C. Zakas + */ + //------------------------------------------------------------------------------ // Imports //------------------------------------------------------------------------------ @@ -59,7 +64,7 @@ type WithExit = { }; //------------------------------------------------------------------------------ -// Exports +// Exports: Processor //------------------------------------------------------------------------------ export interface RangeMap { @@ -78,6 +83,72 @@ export interface Block extends Node, BlockBase { meta: string | null; } +//------------------------------------------------------------------------------ +// Exports: HTML +//------------------------------------------------------------------------------ + +export type HtmlNode = + | HtmlDocumentNode + | HtmlElementNode + | HtmlTextNode + | HtmlCommentNode + | HtmlDoctypeNode; + +export type HtmlNodeType = + | HtmlDocumentNode["type"] + | HtmlElementNode["type"] + | HtmlTextNode["type"] + | HtmlCommentNode["type"] + | HtmlDoctypeNode["type"]; + +export interface HtmlLocation { + start: number; + end: number; +} + +interface HtmlBaseNode { + type: HtmlNodeType; + loc: [HtmlLocation, HtmlLocation]; + parent: HtmlNode; + [key: string]: any; +} + +export interface HtmlLiteralNode extends HtmlBaseNode { + value: string; +} + +export interface HtmlParentNode extends HtmlBaseNode { + children: HtmlNode[]; +} + +export interface HtmlDocumentNode extends Omit { + type: "document"; + attributes: Record; + parent: undefined; +} + +export interface HtmlElementNode extends HtmlParentNode { + type: "element"; + name: string; + attributes: Record; +} + +export interface HtmlTextNode extends HtmlLiteralNode { + type: "text"; +} + +export interface HtmlCommentNode extends HtmlLiteralNode { + type: "comment"; +} + +export interface HtmlDoctypeNode extends HtmlLiteralNode { + type: "doctype"; +} + +//------------------------------------------------------------------------------ +// Exports: Front Matter +//------------------------------------------------------------------------------ + /** * Markdown TOML. */ @@ -116,6 +187,10 @@ export interface Json extends Literal { */ export interface JsonData extends Data {} +//------------------------------------------------------------------------------ +// Exports: Markdown Language +//------------------------------------------------------------------------------ + /** * Language options provided for Markdown files. */ @@ -131,6 +206,10 @@ export interface MarkdownLanguageOptions extends LanguageOptions { */ export type MarkdownLanguageContext = LanguageContext; +//------------------------------------------------------------------------------ +// Exports: Markdown Rule Definition +//------------------------------------------------------------------------------ + export interface MarkdownRuleVisitor extends RuleVisitor, WithExit< From 74b46e4e355f0e4d244e7e2b41b0e451284cd246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 17:48:41 +0900 Subject: [PATCH 45/52] wip: migrate to JS from TS --- src/{html.ts => html.js} | 262 ++++++++++++++++++++++++++------------- tests/html.test.js | 7 +- 2 files changed, 179 insertions(+), 90 deletions(-) rename src/{html.ts => html.js} (68%) diff --git a/src/html.ts b/src/html.js similarity index 68% rename from src/html.ts rename to src/html.js index ae9bf712..afbdd3a8 100644 --- a/src/html.ts +++ b/src/html.js @@ -57,15 +57,22 @@ // TODO: getElementByTagName, getElementById, getElementsByClassName, getElementByName //----------------------------------------------------------------------------- -// Helpers: Constants +// Type Definitions //----------------------------------------------------------------------------- -import type { HtmlNode, HtmlParentNode } from "./types.js"; +/** + * @import { HtmlNode, HtmlParentNode, HtmlCommentNode, HtmlDoctypeNode, HtmlDocumentNode } from "./types.js"; + */ + +//----------------------------------------------------------------------------- +// Helpers: Constants +//----------------------------------------------------------------------------- const HTMLString = Symbol("HTMLString"); const AttrString = Symbol("AttrString"); -const VOID_TAGS = new Set([ +/** @type {Set} */ +const VOID_TAGS = new Set([ "area", "base", "br", @@ -82,17 +89,43 @@ const VOID_TAGS = new Set([ "track", "wbr", ]); -const RAW_TAGS = new Set(["script", "style"]); +/** @type {Set} */ +const RAW_TAGS = new Set(["script", "style"]); const DOM_PARSER_RE = + // eslint-disable-next-line require-unicode-regexp, no-useless-escape -- TODO /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; +// eslint-disable-next-line require-unicode-regexp, no-useless-escape -- TODO const ATTR_KEY_IDENTIFIER_RE = /[\@\.a-z0-9_\:\-]/i; //----------------------------------------------------------------------------- // Helpers: Functions //----------------------------------------------------------------------------- -function attrs(attributes: Record) { +/** + * TODO + * @param {string} str TODO + * @param {symbol[]} tags TODO + * @returns {{ value: string }} TODO + */ +function mark(str, tags = [HTMLString]) { + const v = { value: str }; + for (const tag of tags) { + Object.defineProperty(v, tag, { + value: true, + enumerable: false, + writable: false, + }); + } + return v; +} + +/** + * TODO + * @param {Record} attributes TODO + * @returns {{value: string}} TODO + */ +function attrs(attributes) { let attrStr = ""; for (const [key, value] of Object.entries(attributes)) { attrStr += ` ${key}="${value}"`; @@ -100,36 +133,45 @@ function attrs(attributes: Record) { return mark(attrStr, [HTMLString, AttrString]); } -function canSelfClose(node: HtmlNode): boolean { +/** + * TODO + * @param {HtmlNode} node TODO + * @returns {boolean} TODO + */ +function canSelfClose(node) { if (node.children.length === 0) { - let n: HtmlNode | undefined = node; + /** @type {HtmlNode | undefined} */ + let n = node; + while ((n = n.parent)) { - if (n.name === "svg") return true; + if (n.name === "svg") { + return true; + } } } return false; } -function mark(str: string, tags: symbol[] = [HTMLString]): { value: string } { - const v = { value: str }; - for (const tag of tags) { - Object.defineProperty(v, tag, { - value: true, - enumerable: false, - writable: false, - }); - } - return v; -} - -function splitAttrs(str?: string) { - let obj: Record = {}; +/** + * TODO + * @param {string} [str] TODO + * @returns {Record} TODO + */ +function splitAttrs(str) { + /** @type {Record} */ + const obj = {}; if (str) { - let state: "none" | "key" | "value" = "none"; - let currentKey: string | undefined; - let currentValue: string = ""; - let tokenStartIndex: number | undefined; - let valueDelimiter: '"' | "'" | undefined; + /** @type {'none' | 'key' | 'value'} */ + let state = "none"; + /** @type {string | undefined} */ + let currentKey; + /** @type {string} */ + let currentValue = ""; + /** @type {number | undefined} */ + let tokenStartIndex; + /** @type {'"' | "'" | undefined} */ + let valueDelimiter; + for (let currentIndex = 0; currentIndex < str.length; currentIndex++) { const currentChar = str[currentIndex]; @@ -149,7 +191,8 @@ function splitAttrs(str?: string) { } } else if (state === "key") { if (!ATTR_KEY_IDENTIFIER_RE.test(currentChar)) { - currentKey = str.substring(tokenStartIndex!, currentIndex); + // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO + currentKey = str.substring(tokenStartIndex, currentIndex); if (currentChar === "=") { state = "value"; } else { @@ -163,8 +206,9 @@ function splitAttrs(str?: string) { str[currentIndex - 1] !== "\\" ) { if (valueDelimiter) { + // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO currentValue = str.substring( - tokenStartIndex!, + tokenStartIndex, currentIndex, ); valueDelimiter = undefined; @@ -181,9 +225,10 @@ function splitAttrs(str?: string) { } if ( state === "key" && - tokenStartIndex != undefined && + tokenStartIndex !== undefined && tokenStartIndex < str.length ) { + // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO currentKey = str.substring(tokenStartIndex, str.length); } if (currentKey) { @@ -199,7 +244,8 @@ function splitAttrs(str?: string) { /** * The `parse` function takes a string of HTML and returns an AST (Abstract Syntax Tree). - * + * @param {string} input TODO + * @returns {HtmlNode} TODO * @example * ```js * import { parse } from "path/to/html.js"; @@ -213,47 +259,70 @@ function splitAttrs(str?: string) { * // ], * // } * ``` + * */ -export function parse(input: string): any { - let str = input; - let doc: HtmlNode, - parent: HtmlNode, - token: RegExpExecArray | null, - text: string, - i: number, - bStart: string, - bText: string, - bEnd: string, - tag: HtmlNode; +export function parse(input) { + /** @type {HtmlNode} */ + let doc; + /** @type {HtmlNode} */ + let parent; + /** @type {RegExpExecArray | null} */ + let token; + /** @type {string} */ + let text; + /** @type {number} */ + let i; + /** @type {string} */ + let bStart; + /** @type {string} */ + let bText; + /** @type {string} */ + let bEnd; + /** @type {HtmlNode} */ + let tag; + + /** @type {string} */ + const str = input; + /** @type {number} */ let lastIndex = 0; - const tags: HtmlNode[] = []; + /** @type {HtmlNode[]} */ + const tags = []; + + /** + * TODO + * @returns {void} + */ function commitTextNode() { + // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO text = str.substring( lastIndex, DOM_PARSER_RE.lastIndex - token[0].length, ); if (text) { - (parent as HtmlParentNode).children.push({ - type: "text", - value: text, - parent, - } as any); + parent.children.push( + /** @type {any} */ ({ + type: "text", + value: text, + parent, + }), + ); } } DOM_PARSER_RE.lastIndex = 0; - parent = doc = { + parent = doc = /** @type {any} */ ({ type: "document", - children: [] as HtmlNode[], - } as any; + children: [], + }); while ((token = DOM_PARSER_RE.exec(str))) { bStart = token[5] || token[8]; bText = token[6] || token[9]; bEnd = token[7] || token[10]; if (RAW_TAGS.has(parent.name) && token[2] !== parent.name) { + // eslint-disable-next-line no-useless-assignment -- TODO i = DOM_PARSER_RE.lastIndex - token[0].length; if (parent.children.length > 0) { parent.children[0].value += token[0]; @@ -264,10 +333,10 @@ export function parse(input: string): any { if (RAW_TAGS.has(parent.name)) { continue; } - tag = /** @type {CommentNode} */ { + tag = /** @type {HtmlCommentNode} */ { type: "comment", value: bText, - parent: parent, + parent, loc: [ { start: i, @@ -283,10 +352,10 @@ export function parse(input: string): any { tag.parent.children.push(tag); } else if (bStart === " -1) || + // @ts-expect-error -- TODO + (token[4] && token[4].includes("/") > -1) || VOID_TAGS.has(tag.name) ) { tag.loc[1] = tag.loc[0]; @@ -336,13 +406,14 @@ export function parse(input: string): any { } else { commitTextNode(); // Close parent node if end-tag matches - if (token[2] + "" === parent.name) { + if (`${token[2]}` === parent.name) { tag = parent; - parent = tag.parent!; + parent = tag.parent; tag.loc.push({ start: DOM_PARSER_RE.lastIndex - token[0].length, end: DOM_PARSER_RE.lastIndex, }); + // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO text = str.substring(tag.loc[0].end, tag.loc[1].start); if (tag.children.length === 0) { tag.children.push({ @@ -354,10 +425,10 @@ export function parse(input: string): any { } // account for abuse of self-closing tags when an end-tag is also provided: else if ( - token[2] + "" === tags[tags.length - 1].name && - tags[tags.length - 1].isSelfClosingTag === true + `${token[2]}` === tags.at(-1).name && + tags.at(-1).isSelfClosingTag === true ) { - tag = tags[tags.length - 1]; + tag = tags.at(-1); tag.loc.push({ start: DOM_PARSER_RE.lastIndex - token[0].length, end: DOM_PARSER_RE.lastIndex, @@ -382,7 +453,9 @@ export function parse(input: string): any { * * `walkSync` is **synchronous**. This should only be used * when it is guaranteed there are no `async` components in the tree. - * + * @param {HtmlNode} node TODO + * @param {(node: HtmlNode, parent?: HtmlNode, index?: number) => void} callback TODO + * @returns {void} * @example * ```js * import { parse, walkSync } from "path/to/html.js"; @@ -394,17 +467,23 @@ export function parse(input: string): any { * } * }); * ``` + * */ -export function walkSync( - node: HtmlNode, - callback: (node: HtmlNode, parent?: HtmlNode, index?: number) => void, -): void { - function visit(node: HtmlNode, parent?: HtmlNode, index?: number): void { - callback(node, parent, index); - if (Array.isArray(node.children)) { - for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - visit(child, node, i); +export function walkSync(node, callback) { + /** + * TODO + * @param {HtmlNode} n TODO + * @param {HtmlNode} [parent] TODO + * @param {number} [index] TODO + * @returns {void} + */ + function visit(n, parent, index) { + // eslint-disable-next-line n/callback-return -- TODO + callback(n, parent, index); + if (Array.isArray(n.children)) { + for (let i = 0; i < n.children.length; i++) { + const child = n.children[i]; + visit(child, n, i); } } } @@ -416,8 +495,10 @@ export function walkSync( * The `renderSync` function allows you to serialize an AST back into a string. * * - **Note**: By default, `renderSync` will sanitize your markup, - * removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. - * + * removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. + * @param {HtmlNode} node TODO + * @returns {string} TODO + * @throws {Error} TODO * @example * * ```js @@ -426,17 +507,18 @@ export function walkSync( * const ast = parse(`

Hello world!

`); * console.log(renderSync(ast)); //

Hello world!

* ``` + * */ -export function renderSync(node: HtmlNode): string { +export function renderSync(node) { switch (node.type) { case "document": return node.children - .map((child: HtmlNode) => renderSync(child)) + .map((/** @type {HtmlNode} */ child) => renderSync(child)) .join(""); case "element": { const { name, attributes = {} } = node; const children = node.children - .map((child: HtmlNode) => renderSync(child)) + .map((/** @type {HtmlNode} */ child) => renderSync(child)) .join(""); const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { @@ -452,13 +534,25 @@ export function renderSync(node: HtmlNode): string { return ``; case "doctype": return ``; + default: + throw new Error(`Unknown node type: ${node}`); // TODO } } -export function querySelectorAll(node: HtmlNode, selector: string): HtmlNode[] { - let nodes: HtmlNode[] = []; - walkSync(node, (n): void => { - if (n && n.type !== "element") return; +/** + * TODO + * @param {HtmlNode} node TODO + * @param {string} selector TODO + * @returns {HtmlNode[]} TODO + */ // eslint-disable-next-line no-unused-vars -- TODO +export function querySelectorAll(node, selector) { + /** @type {HtmlNode[]} */ + const nodes = []; + + walkSync(node, n => { + if (n && n.type !== "element") { + return; + } nodes.push(n); }); return nodes; diff --git a/tests/html.test.js b/tests/html.test.js index 223a9eae..e7b55d90 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -58,12 +58,7 @@ //----------------------------------------------------------------------------- import assert from "node:assert"; -import { - parse, - walkSync, - renderSync, - querySelectorAll, -} from "../dist/esm/html.js"; +import { parse, walkSync, renderSync, querySelectorAll } from "../src/html.js"; //----------------------------------------------------------------------------- // Tests From 336853f707cc7ea232f37887583dbc7fb2dd10a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 19:32:15 +0900 Subject: [PATCH 46/52] wip: update regex --- src/html.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/html.js b/src/html.js index afbdd3a8..929f6c4b 100644 --- a/src/html.js +++ b/src/html.js @@ -93,10 +93,8 @@ const VOID_TAGS = new Set([ const RAW_TAGS = new Set(["script", "style"]); const DOM_PARSER_RE = - // eslint-disable-next-line require-unicode-regexp, no-useless-escape -- TODO - /(?:<(\/?)([a-zA-Z][a-zA-Z0-9\:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|(<\!\-\-)([\s\S]*?)(\-\->)|(<\!)([\s\S]*?)(>))/gm; -// eslint-disable-next-line require-unicode-regexp, no-useless-escape -- TODO -const ATTR_KEY_IDENTIFIER_RE = /[\@\.a-z0-9_\:\-]/i; + /(?:<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)(?:\s([^>]*?))?((?:\s*\/)?)>|()|())/gmu; +const ATTR_KEY_IDENTIFIER_RE = /[@.a-z0-9_:-]/iu; //----------------------------------------------------------------------------- // Helpers: Functions From 559403fb8a136ff0229a33cc6c992a6c790d8745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 19:47:24 +0900 Subject: [PATCH 47/52] wip: cleanup --- src/html.js | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/html.js b/src/html.js index 929f6c4b..907cdfa3 100644 --- a/src/html.js +++ b/src/html.js @@ -61,7 +61,7 @@ //----------------------------------------------------------------------------- /** - * @import { HtmlNode, HtmlParentNode, HtmlCommentNode, HtmlDoctypeNode, HtmlDocumentNode } from "./types.js"; + * @import { HtmlNode, HtmlParentNode, HtmlTextNode, HtmlCommentNode, HtmlDoctypeNode, HtmlDocumentNode } from "./types.js"; */ //----------------------------------------------------------------------------- @@ -108,6 +108,7 @@ const ATTR_KEY_IDENTIFIER_RE = /[@.a-z0-9_:-]/iu; */ function mark(str, tags = [HTMLString]) { const v = { value: str }; + for (const tag of tags) { Object.defineProperty(v, tag, { value: true, @@ -115,6 +116,7 @@ function mark(str, tags = [HTMLString]) { writable: false, }); } + return v; } @@ -125,9 +127,11 @@ function mark(str, tags = [HTMLString]) { */ function attrs(attributes) { let attrStr = ""; + for (const [key, value] of Object.entries(attributes)) { attrStr += ` ${key}="${value}"`; } + return mark(attrStr, [HTMLString, AttrString]); } @@ -147,6 +151,7 @@ function canSelfClose(node) { } } } + return false; } @@ -158,6 +163,7 @@ function canSelfClose(node) { function splitAttrs(str) { /** @type {Record} */ const obj = {}; + if (str) { /** @type {'none' | 'key' | 'value'} */ let state = "none"; @@ -233,6 +239,7 @@ function splitAttrs(str) { obj[currentKey] = currentValue; } } + return obj; } @@ -278,12 +285,11 @@ export function parse(input) { let bEnd; /** @type {HtmlNode} */ let tag; - - /** @type {string} */ - const str = input; /** @type {number} */ let lastIndex = 0; + /** @type {string} */ + const str = input; /** @type {HtmlNode[]} */ const tags = []; @@ -297,9 +303,10 @@ export function parse(input) { lastIndex, DOM_PARSER_RE.lastIndex - token[0].length, ); + if (text) { parent.children.push( - /** @type {any} */ ({ + /** @type {HtmlTextNode} */ ({ type: "text", value: text, parent, @@ -319,6 +326,7 @@ export function parse(input) { bStart = token[5] || token[8]; bText = token[6] || token[9]; bEnd = token[7] || token[10]; + if (RAW_TAGS.has(parent.name) && token[2] !== parent.name) { // eslint-disable-next-line no-useless-assignment -- TODO i = DOM_PARSER_RE.lastIndex - token[0].length; @@ -365,7 +373,6 @@ export function parse(input) { }, ], }; - // commitTextNode(); tags.push(tag); tag.parent.children.push(tag); } else if (token[1] !== "/") { @@ -433,14 +440,18 @@ export function parse(input) { }); } } + lastIndex = DOM_PARSER_RE.lastIndex; } + text = str.slice(lastIndex); + parent.children.push({ type: "text", value: text, parent, }); + return doc; } @@ -478,6 +489,7 @@ export function walkSync(node, callback) { function visit(n, parent, index) { // eslint-disable-next-line n/callback-return -- TODO callback(n, parent, index); + if (Array.isArray(n.children)) { for (let i = 0; i < n.children.length; i++) { const child = n.children[i]; @@ -491,14 +503,10 @@ export function walkSync(node, callback) { /** * The `renderSync` function allows you to serialize an AST back into a string. - * - * - **Note**: By default, `renderSync` will sanitize your markup, - * removing any `script` tags. Pass `{ sanitize: false }` to disable this behavior. * @param {HtmlNode} node TODO * @returns {string} TODO * @throws {Error} TODO * @example - * * ```js * import { parse, renderSync } from "path/to/html.js"; * @@ -553,5 +561,6 @@ export function querySelectorAll(node, selector) { } nodes.push(n); }); + return nodes; } From e21cfac73a6ff7439a9ed8c33608d432a52a0863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 19:53:03 +0900 Subject: [PATCH 48/52] wip: remove `mark` logic --- src/html.js | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/src/html.js b/src/html.js index 907cdfa3..92a78905 100644 --- a/src/html.js +++ b/src/html.js @@ -68,9 +68,6 @@ // Helpers: Constants //----------------------------------------------------------------------------- -const HTMLString = Symbol("HTMLString"); -const AttrString = Symbol("AttrString"); - /** @type {Set} */ const VOID_TAGS = new Set([ "area", @@ -100,30 +97,10 @@ const ATTR_KEY_IDENTIFIER_RE = /[@.a-z0-9_:-]/iu; // Helpers: Functions //----------------------------------------------------------------------------- -/** - * TODO - * @param {string} str TODO - * @param {symbol[]} tags TODO - * @returns {{ value: string }} TODO - */ -function mark(str, tags = [HTMLString]) { - const v = { value: str }; - - for (const tag of tags) { - Object.defineProperty(v, tag, { - value: true, - enumerable: false, - writable: false, - }); - } - - return v; -} - /** * TODO * @param {Record} attributes TODO - * @returns {{value: string}} TODO + * @returns {string} TODO */ function attrs(attributes) { let attrStr = ""; @@ -132,7 +109,7 @@ function attrs(attributes) { attrStr += ` ${key}="${value}"`; } - return mark(attrStr, [HTMLString, AttrString]); + return attrStr; } /** @@ -528,11 +505,11 @@ export function renderSync(node) { .join(""); const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { - return `<${node.name}${attrs(attributes).value}${ + return `<${node.name}${attrs(attributes)}${ isSelfClosing ? " /" : "" }>`; } - return `<${node.name}${attrs(attributes).value}>${children}`; + return `<${node.name}${attrs(attributes)}>${children}`; } case "text": return `${node.value}`; From 483361aad6fee0b5efd45b6b6244a2bd091964ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 21:05:23 +0900 Subject: [PATCH 49/52] wip: cleanup --- src/html.js | 83 +++++++++++++++++++++++++--------------------- tests/html.test.js | 42 +++++++++++------------ 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/src/html.js b/src/html.js index 92a78905..b93dedfa 100644 --- a/src/html.js +++ b/src/html.js @@ -97,21 +97,6 @@ const ATTR_KEY_IDENTIFIER_RE = /[@.a-z0-9_:-]/iu; // Helpers: Functions //----------------------------------------------------------------------------- -/** - * TODO - * @param {Record} attributes TODO - * @returns {string} TODO - */ -function attrs(attributes) { - let attrStr = ""; - - for (const [key, value] of Object.entries(attributes)) { - attrStr += ` ${key}="${value}"`; - } - - return attrStr; -} - /** * TODO * @param {HtmlNode} node TODO @@ -132,12 +117,25 @@ function canSelfClose(node) { return false; } +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + /** - * TODO - * @param {string} [str] TODO - * @returns {Record} TODO + * Parses a string of HTML attributes into an object. + * @param {string} str The string to parse. + * @returns {Record} The parsed attributes as an object. + * @example + * ```js + * import { parseAttrs } from "path/to/html.js"; + * + * const attrs = parseAttrs('id="my-id" class="my-class" data-custom="value"'); + * console.log(attrs); + * // { class: "my-class", id: "my-id", data-custom: "value" } + * ``` + * */ -function splitAttrs(str) { +export function parseAttrs(str) { /** @type {Record} */ const obj = {}; @@ -220,9 +218,20 @@ function splitAttrs(str) { return obj; } -//----------------------------------------------------------------------------- -// Exports -//----------------------------------------------------------------------------- +/** + * Stringifies an object of HTML attributes into a string. + * @param {Record} attributes The attributes to stringify. + * @returns {string} The stringified attributes. + */ +export function stringifyAttrs(attributes) { + let attrStr = ""; + + for (const [key, value] of Object.entries(attributes)) { + attrStr += ` ${key}="${value}"`; + } + + return attrStr; +} /** * The `parse` function takes a string of HTML and returns an AST (Abstract Syntax Tree). @@ -362,7 +371,7 @@ export function parse(input) { tag = { type: "element", name: `${token[2]}`, - attributes: splitAttrs(token[3]), + attributes: parseAttrs(token[3]), parent, children: [], loc: /** @type {any} */ ([ @@ -433,21 +442,21 @@ export function parse(input) { } /** - * The `walkSync` function provides full control over the AST. + * The `walk` function provides full control over the AST. * It can be used to scan for text, elements, components, * or any other validation you might want to do. * - * `walkSync` is **synchronous**. This should only be used + * `walk` is **synchronous**. This should only be used * when it is guaranteed there are no `async` components in the tree. * @param {HtmlNode} node TODO * @param {(node: HtmlNode, parent?: HtmlNode, index?: number) => void} callback TODO * @returns {void} * @example * ```js - * import { parse, walkSync } from "path/to/html.js"; + * import { parse, walk } from "path/to/html.js"; * * const ast = parse(`

Hello world!

`); - * walkSync(ast, (node) => { + * walk(ast, (node) => { * if (node.type === "element" && node.name === "script") { * throw new Error("Found a script!"); * } @@ -455,7 +464,7 @@ export function parse(input) { * ``` * */ -export function walkSync(node, callback) { +export function walk(node, callback) { /** * TODO * @param {HtmlNode} n TODO @@ -479,37 +488,37 @@ export function walkSync(node, callback) { } /** - * The `renderSync` function allows you to serialize an AST back into a string. + * The `render` function allows you to serialize an AST back into a string. * @param {HtmlNode} node TODO * @returns {string} TODO * @throws {Error} TODO * @example * ```js - * import { parse, renderSync } from "path/to/html.js"; + * import { parse, render } from "path/to/html.js"; * * const ast = parse(`

Hello world!

`); - * console.log(renderSync(ast)); //

Hello world!

+ * console.log(render(ast)); //

Hello world!

* ``` * */ -export function renderSync(node) { +export function render(node) { switch (node.type) { case "document": return node.children - .map((/** @type {HtmlNode} */ child) => renderSync(child)) + .map((/** @type {HtmlNode} */ child) => render(child)) .join(""); case "element": { const { name, attributes = {} } = node; const children = node.children - .map((/** @type {HtmlNode} */ child) => renderSync(child)) + .map((/** @type {HtmlNode} */ child) => render(child)) .join(""); const isSelfClosing = canSelfClose(node); if (isSelfClosing || VOID_TAGS.has(name)) { - return `<${node.name}${attrs(attributes)}${ + return `<${node.name}${stringifyAttrs(attributes)}${ isSelfClosing ? " /" : "" }>`; } - return `<${node.name}${attrs(attributes)}>${children}`; + return `<${node.name}${stringifyAttrs(attributes)}>${children}`; } case "text": return `${node.value}`; @@ -532,7 +541,7 @@ export function querySelectorAll(node, selector) { /** @type {HtmlNode[]} */ const nodes = []; - walkSync(node, n => { + walk(node, n => { if (n && n.type !== "element") { return; } diff --git a/tests/html.test.js b/tests/html.test.js index e7b55d90..6d9808a0 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -58,7 +58,7 @@ //----------------------------------------------------------------------------- import assert from "node:assert"; -import { parse, walkSync, renderSync, querySelectorAll } from "../src/html.js"; +import { parse, walk, render, querySelectorAll } from "../src/html.js"; //----------------------------------------------------------------------------- // Tests @@ -67,8 +67,8 @@ import { parse, walkSync, renderSync, querySelectorAll } from "../src/html.js"; describe("html", () => { it("sanity", () => { assert.strictEqual(typeof parse, "function"); - assert.strictEqual(typeof walkSync, "function"); - assert.strictEqual(typeof renderSync, "function"); + assert.strictEqual(typeof walk, "function"); + assert.strictEqual(typeof render, "function"); assert.strictEqual(typeof querySelectorAll, "function"); }); @@ -77,7 +77,7 @@ describe("html", () => { it("works for elements", async () => { const input = `

Hello world!

`; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -85,7 +85,7 @@ describe("html", () => { it("works for custom elements", async () => { const input = `Hello world!`; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -93,7 +93,7 @@ describe("html", () => { it("works for comments", async () => { const input = ``; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -101,7 +101,7 @@ describe("html", () => { it("works for text", async () => { const input = `Hmm...`; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -109,7 +109,7 @@ describe("html", () => { it("works for doctype", async () => { const input = ``; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -117,7 +117,7 @@ describe("html", () => { it("works for html:5", async () => { const input = `Document`; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -126,7 +126,7 @@ describe("html", () => { const input = '

Token CSS is a new tool that seamlessly integrates Design Tokens into your development workflow. Conceptually, it is similar to tools Tailwind, Styled System, and many CSS-in-JS libraries that provide tokenized constraints for your styles—but there\'s one big difference.

\t

Hello world!

Token CSS embraces .css files and <style> blocks.

'; const ast = parse(input); - const output = renderSync(ast); + const output = render(ast); assert.strictEqual(ast.type, "document"); assert.strictEqual(output, input); @@ -204,20 +204,20 @@ more"">`); describe("script", () => { it("works for elements", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); it("works without quotes", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); it("works with ')HTML Sanitizer API - Web APIs | MDN`; let meta = 0; - walkSync(parse(input), async (node, parent) => { + walk(parse(input), async (node, parent) => { if ( node.type === "element" && node.name === "meta" && @@ -231,19 +231,19 @@ more"">`); }); it("works with `; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); it("works with inside script", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); it("works with <\\/script> inside script", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); @@ -252,13 +252,13 @@ more"">`); describe("style", () => { it("works for elements", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); it("works without quotes", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); @@ -267,7 +267,7 @@ more"">`); describe("svg", () => { it("renderSync as self-closing", async () => { const input = ``; - const output = renderSync(parse(input)); + const output = render(parse(input)); assert.strictEqual(output, input); }); @@ -278,9 +278,7 @@ more"">`); describe("type selector", () => { it("type", async () => { const input = `

Hello world!

`; - const output = renderSync( - querySelectorAll(parse(input), "h1")[0], - ); + const output = render(querySelectorAll(parse(input), "h1")[0]); assert.strictEqual(output, input); }); From dbe38c49f5431d039c2268da06ef1592e6050505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 21:28:17 +0900 Subject: [PATCH 50/52] wip: update `parseAttrs` --- src/html.js | 34 +++++++++++++++------------------- tests/html.test.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/html.js b/src/html.js index b93dedfa..18377e24 100644 --- a/src/html.js +++ b/src/html.js @@ -131,7 +131,7 @@ function canSelfClose(node) { * * const attrs = parseAttrs('id="my-id" class="my-class" data-custom="value"'); * console.log(attrs); - * // { class: "my-class", id: "my-id", data-custom: "value" } + * // { id: "my-id", class: "my-class", data-custom: "value" } * ``` * */ @@ -165,13 +165,13 @@ export function parseAttrs(str) { tokenStartIndex = currentIndex; state = "key"; - } else if (currentChar === "=" && currentKey) { + } else if (currentKey && currentChar === "=") { state = "value"; } } else if (state === "key") { if (!ATTR_KEY_IDENTIFIER_RE.test(currentChar)) { - // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO - currentKey = str.substring(tokenStartIndex, currentIndex); + currentKey = str.slice(tokenStartIndex, currentIndex); + if (currentChar === "=") { state = "value"; } else { @@ -180,36 +180,32 @@ export function parseAttrs(str) { } } else { if ( - currentChar === valueDelimiter && + valueDelimiter && + valueDelimiter === currentChar && currentIndex > 0 && - str[currentIndex - 1] !== "\\" + str[currentIndex - 1] !== "\\" // if not escaped ) { - if (valueDelimiter) { - // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO - currentValue = str.substring( - tokenStartIndex, - currentIndex, - ); - valueDelimiter = undefined; - state = "none"; - } + currentValue = str.slice(tokenStartIndex, currentIndex); + valueDelimiter = undefined; + state = "none"; } else if ( - (currentChar === '"' || currentChar === "'") && - !valueDelimiter + !valueDelimiter && + (currentChar === '"' || currentChar === "'") ) { tokenStartIndex = currentIndex + 1; valueDelimiter = currentChar; } } } + if ( state === "key" && tokenStartIndex !== undefined && tokenStartIndex < str.length ) { - // eslint-disable-next-line unicorn/prefer-string-slice, no-restricted-properties -- TODO - currentKey = str.substring(tokenStartIndex, str.length); + currentKey = str.slice(tokenStartIndex, str.length); } + if (currentKey) { obj[currentKey] = currentValue; } diff --git a/tests/html.test.js b/tests/html.test.js index 6d9808a0..b68ce8b0 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -58,7 +58,13 @@ //----------------------------------------------------------------------------- import assert from "node:assert"; -import { parse, walk, render, querySelectorAll } from "../src/html.js"; +import { + parseAttrs, + parse, + walk, + render, + querySelectorAll, +} from "../src/html.js"; //----------------------------------------------------------------------------- // Tests @@ -66,12 +72,36 @@ import { parse, walk, render, querySelectorAll } from "../src/html.js"; describe("html", () => { it("sanity", () => { + assert.strictEqual(typeof parseAttrs, "function"); assert.strictEqual(typeof parse, "function"); assert.strictEqual(typeof walk, "function"); assert.strictEqual(typeof render, "function"); assert.strictEqual(typeof querySelectorAll, "function"); }); + describe("parseAttrs()", () => { + it("works for empty string", () => { + const attrs = parseAttrs(""); + + assert.deepStrictEqual(attrs, {}); + }); + it('works for `key="value"`', () => { + const attrs = parseAttrs('key="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for ` key="value"`', () => { + const attrs = parseAttrs(' key="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key="value" `', () => { + const attrs = parseAttrs('key="value" '); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + }); + describe("parse(), renderSync()", () => { describe("input === output", () => { it("works for elements", async () => { From 4d5db62963073820b55dc46b27a58003e80dbaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Wed, 30 Jul 2025 21:55:51 +0900 Subject: [PATCH 51/52] wip: add more test cases --- src/html.js | 1 - tests/html.test.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/html.js b/src/html.js index 18377e24..80f37d13 100644 --- a/src/html.js +++ b/src/html.js @@ -180,7 +180,6 @@ export function parseAttrs(str) { } } else { if ( - valueDelimiter && valueDelimiter === currentChar && currentIndex > 0 && str[currentIndex - 1] !== "\\" // if not escaped diff --git a/tests/html.test.js b/tests/html.test.js index b68ce8b0..e7869223 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -80,6 +80,7 @@ describe("html", () => { }); describe("parseAttrs()", () => { + // Basic it("works for empty string", () => { const attrs = parseAttrs(""); @@ -90,14 +91,107 @@ describe("html", () => { assert.deepStrictEqual(attrs, { key: "value" }); }); + it("works for `key='value'`", () => { + const attrs = parseAttrs("key='value'"); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + + // Leading whitespaces it('works for ` key="value"`', () => { const attrs = parseAttrs(' key="value"'); assert.deepStrictEqual(attrs, { key: "value" }); }); + it('works for `\tkey="value"`', () => { + const attrs = parseAttrs('\tkey="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `\nkey="value"`', () => { + const attrs = parseAttrs('\nkey="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `\r\nkey="value"`', () => { + const attrs = parseAttrs('\r\nkey="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + + // Trailing whitespaces it('works for `key="value" `', () => { const attrs = parseAttrs('key="value" '); + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key="value"\t`', () => { + const attrs = parseAttrs('key="value"\t'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key="value"\n`', () => { + const attrs = parseAttrs('key="value"\n'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key="value"\r\n`', () => { + const attrs = parseAttrs('key="value"\r\n'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + + // Leading whitespaces around the equal sign + it('works for `key ="value"`', () => { + const attrs = parseAttrs('key ="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key ="value"`', () => { + const attrs = parseAttrs('key ="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key\t="value"`', () => { + const attrs = parseAttrs('key\t="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key\n="value"`', () => { + const attrs = parseAttrs('key\n="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key\r\n="value"`', () => { + const attrs = parseAttrs('key\r\n="value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + + // Trailing whitespaces around the equal sign + it('works for `key= "value"`', () => { + const attrs = parseAttrs('key= "value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key= "value"`', () => { + const attrs = parseAttrs('key= "value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key=\t"value"`', () => { + const attrs = parseAttrs('key=\t"value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key=\n"value"`', () => { + const attrs = parseAttrs('key=\n"value"'); + + assert.deepStrictEqual(attrs, { key: "value" }); + }); + it('works for `key=\r\n"value"`', () => { + const attrs = parseAttrs('key=\r\n"value"'); + assert.deepStrictEqual(attrs, { key: "value" }); }); }); From f8fe78507d22704916b6c6aa75abb1865753ffdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A3=A8=EB=B0=80LuMir?= Date: Sun, 3 Aug 2025 17:19:58 +0900 Subject: [PATCH 52/52] wip: add more test cases --- tests/html.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/html.test.js b/tests/html.test.js index e7869223..3cc99097 100644 --- a/tests/html.test.js +++ b/tests/html.test.js @@ -86,17 +86,37 @@ describe("html", () => { assert.deepStrictEqual(attrs, {}); }); + it('works for `key=""`', () => { + const attrs = parseAttrs('key=""'); + assert.deepStrictEqual(attrs, { key: "" }); + }); it('works for `key="value"`', () => { const attrs = parseAttrs('key="value"'); assert.deepStrictEqual(attrs, { key: "value" }); }); + it("works for `key=''`", () => { + const attrs = parseAttrs("key=''"); + assert.deepStrictEqual(attrs, { key: "" }); + }); it("works for `key='value'`", () => { const attrs = parseAttrs("key='value'"); assert.deepStrictEqual(attrs, { key: "value" }); }); + // Empty value + it("works for `key`", () => { + const attrs = parseAttrs("key"); + + assert.deepStrictEqual(attrs, { key: "" }); + }); + it("works for `key1 key2`", () => { + const attrs = parseAttrs("key1 key2"); + + assert.deepStrictEqual(attrs, { key1: "", key2: "" }); + }); + // Leading whitespaces it('works for ` key="value"`', () => { const attrs = parseAttrs(' key="value"');