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 6271dbd4a..4ac8c27d8 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 000000000..2386115b1 --- /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 000000000..3b699548c --- /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 000000000..e704f555c --- /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 000000000..020b5608f --- /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 000000000..2e5def6bf --- /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 000000000..7a0a13fdd --- /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 000000000..6a9992c0b --- /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 000000000..48d803fcc --- /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 000000000..ba558ae3a --- /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 000000000..fdd7ffa0d --- /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 000000000..689e34478 --- /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 000000000..f06c21e13 --- /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 000000000..0f13c5937 --- /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 000000000..72e400f8e --- /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 000000000..f205f69b3 --- /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 000000000..8c0a31d52 --- /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 000000000..3cdddab57 --- /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 000000000..0a5963f27 --- /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 9cd89cf81..feb52517a 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 020b5608f..791078ebc 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 ba558ae3a..3aec9be05 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 f205f69b3..b5d4f47ab 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 3cdddab57..87cdbc7a8 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 6a9992c0b..000000000 --- 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 8c0a31d52..000000000 --- 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 e704f555c..28bfcf6bd 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 7a0a13fdd..000000000 --- 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 b5d4f47ab..000000000 --- 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 3aec9be05..000000000 --- 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 0a5963f27..000000000 --- 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 2386115b1..000000000 --- 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 791078ebc..9c0367d9b 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 28bfcf6bd..ac0ed8044 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 48d803fcc..000000000 --- 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 87cdbc7a8..000000000 --- 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 9c0367d9b..39267a8b5 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 39267a8b5..43618cf8a 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 0d6a5879c..dcc6cb399 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 43618cf8a..d998e2c16 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 d998e2c16..8e1026280 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 fdd7ffa0d..5af85eca2 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 f06c21e13..b181f305b 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 dcc6cb399..eb9a0ed02 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 0f13c5937..f68c2d004 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 72e400f8e..3c02c64c2 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 8e1026280..0d47ef03f 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 689e34478..000000000 --- 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 3b699548c..f149da1f5 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 0d47ef03f..9b0627cfe 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 2e5def6bf..60426d413 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 f68c2d004..d94b09557 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 60426d413..0b5bc311a 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 ac0ed8044..121f42edd 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 b181f305b..2e31d1fa8 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 2e31d1fa8..000000000 --- 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 eb9a0ed02..000000000 --- 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 3d6b51a17..000000000 --- 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 3c02c64c2..000000000 --- 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 acfde6b43..52e6c6772 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 feb52517a..9cd89cf81 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 784684a65..d1879bd11 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 d1879bd11..10b25d354 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 10b25d354..eb86a5b7d 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 52e6c6772..3bd54bff4 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 3bd54bff4..8c2e53c31 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 8c2e53c31..c8aae29ff 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 c8aae29ff..78f190381 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 4ac8c27d8..6271dbd4a 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 36c09f534..000000000 --- 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 78f190381..20396da16 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 f149da1f5..000000000 --- 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 eb86a5b7d..f894c562c 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 f894c562c..f40c2e2aa 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 f40c2e2aa..6af153d2a 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 6af153d2a..d9747ecfd 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 d9747ecfd..5e21f2bb1 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 20396da16..66ad127ce 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 5e21f2bb1..701fb7b74 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 701fb7b74..18b12233e 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 18b12233e..635bed01e 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 635bed01e..2c8332804 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 66ad127ce..733100a7c 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 2c8332804..70579df42 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 733100a7c..223a9eae1 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 70579df42..cd0edd6da 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 cd0edd6da..ae9bf7123 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 c7b75adf3..0e69a7d93 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 ae9bf7123..afbdd3a84 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 223a9eae1..e7b55d905 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 afbdd3a84..929f6c4b8 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 929f6c4b8..907cdfa36 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 907cdfa36..92a789050 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 92a789050..b93dedfa8 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 e7b55d905..6d9808a0a 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 b93dedfa8..18377e241 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 6d9808a0a..b68ce8b09 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 18377e241..80f37d133 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 b68ce8b09..e78692237 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 e78692237..3cc990971 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"');