From d5ef5a21ac5b8783a30dd12ee8890f6d547516cf Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Fri, 13 Feb 2026 22:03:10 +0800 Subject: [PATCH 1/3] feat(parser): add YAHTML AST parser API and bump to 0.0.5 --- README.md | 28 ++ index.cjs | 12 +- index.d.ts | 4 +- index.js | 6 +- package.json | 2 +- src/constants.js | 16 ++ src/parser.js | 622 ++++++++++++++++++++++++++++++++++++++++++++ src/yahtml.d.ts | 26 +- src/yahtml.js | 15 +- test/parser.test.js | 83 ++++++ 10 files changed, 785 insertions(+), 29 deletions(-) create mode 100644 src/constants.js create mode 100644 src/parser.js create mode 100644 test/parser.test.js diff --git a/README.md b/README.md index 00e810c..6e186ad 100644 --- a/README.md +++ b/README.md @@ -250,3 +250,31 @@ Converts a YAHTML array to an HTML string. - `TypeError`: If yahtmlContent is not an array - `Error`: If element structure is malformed +#### `parseElementKey(key)` + +Parses a YAHTML element declaration key (selector + attrs) into structured metadata. + +**Parameters:** +- `key` (string): Element key like `div#main.card data-id=42` + +**Returns:** +- Parsed selector object with: + - `tag`, `id`, `classes`, `attributes` + - `ranges` for tag/id/class offsets within the key + +#### `parseYahtmlAst(yahtmlContent)` + +Builds an AST from YAHTML input without rendering HTML. + +**Parameters:** +- `yahtmlContent` (Array): YAHTML content array + +**Returns:** +- Root AST node: + - `{ type: "root", children: [...] }` +- Child node kinds: + - `element`, `text`, `rawHtml`, `doctype`, `fragment` + +**Throws:** +- `TypeError`: If `yahtmlContent` is not an array +- `Error`: If an element declaration is malformed diff --git a/index.cjs b/index.cjs index b83b04b..db2f6bf 100644 --- a/index.cjs +++ b/index.cjs @@ -9,7 +9,13 @@ module.exports = { convertToHtml: (...args) => { throw new Error('yahtml requires async initialization in CommonJS. Use: const yahtml = await require("yahtml")'); }, - SELF_CLOSING_TAGS: [] + SELF_CLOSING_TAGS: [], + parseElementKey: (...args) => { + throw new Error('yahtml requires async initialization in CommonJS. Use: const yahtml = await require("yahtml")'); + }, + parseYahtmlAst: (...args) => { + throw new Error('yahtml requires async initialization in CommonJS. Use: const yahtml = await require("yahtml")'); + } }; // Async initialization for CommonJS @@ -18,6 +24,8 @@ module.exports = (async () => { return { convertToHtml: mod.convertToHtml, SELF_CLOSING_TAGS: mod.SELF_CLOSING_TAGS, + parseElementKey: mod.parseElementKey, + parseYahtmlAst: mod.parseYahtmlAst, default: mod.convertToHtml }; -})(); \ No newline at end of file +})(); diff --git a/index.d.ts b/index.d.ts index bc98876..40cb5ce 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,3 @@ -export { convertToHtml, SELF_CLOSING_TAGS } from "./src/yahtml"; +export { convertToHtml, SELF_CLOSING_TAGS, parseElementKey, parseYahtmlAst } from "./src/yahtml"; export default convertToHtml; -declare const convertToHtml: typeof import("./src/yahtml").convertToHtml; \ No newline at end of file +declare const convertToHtml: typeof import("./src/yahtml").convertToHtml; diff --git a/index.js b/index.js index 0bddb06..5f13ce7 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,10 @@ /** * @module yahtml * @description YAHTML (YAML HTML) - Write HTML as valid YAML - * @version 0.0.1 + * @version 0.0.5 * @license MIT */ -export { convertToHtml, SELF_CLOSING_TAGS } from './src/yahtml.js'; +export { convertToHtml, SELF_CLOSING_TAGS, parseElementKey, parseYahtmlAst } from './src/yahtml.js'; import { convertToHtml } from './src/yahtml.js'; -export default convertToHtml; \ No newline at end of file +export default convertToHtml; diff --git a/package.json b/package.json index 9f89cc2..bd75542 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yahtml", - "version": "0.0.4", + "version": "0.0.5", "description": "YAHTML (YAML HTML) - Write HTML as valid YAML with clean, concise syntax", "keywords": [ "html", diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..1aec65a --- /dev/null +++ b/src/constants.js @@ -0,0 +1,16 @@ +export const SELF_CLOSING_TAGS = [ + "br", + "hr", + "img", + "input", + "meta", + "area", + "base", + "col", + "embed", + "link", + "param", + "source", + "track", + "wbr", +]; diff --git a/src/parser.js b/src/parser.js new file mode 100644 index 0000000..7b96cc6 --- /dev/null +++ b/src/parser.js @@ -0,0 +1,622 @@ +import { SELF_CLOSING_TAGS } from "./constants.js"; + +const ROOT_ARRAY_ERROR = "YAHTML content must be an array. YAHTML documents always start with an array at the root level."; + +const parseStringDeclaration = (source = "") => { + if (source.endsWith(":")) { + return { + key: source.slice(0, -1).trim(), + content: null, + }; + } + + let colonIndex = -1; + const doubleQuotePattern = ': "'; + const singleQuotePattern = ": '"; + + const dqIndex = source.indexOf(doubleQuotePattern); + const sqIndex = source.indexOf(singleQuotePattern); + + if (dqIndex >= 0 && (sqIndex < 0 || dqIndex < sqIndex)) { + colonIndex = dqIndex; + } else if (sqIndex >= 0) { + colonIndex = sqIndex; + } else { + const simpleMatch = source.match(/^([^:]+?):\s+(.*)$/); + if (simpleMatch && !simpleMatch[1].includes("//")) { + return { + key: simpleMatch[1], + content: simpleMatch[2], + }; + } + } + + if (colonIndex < 0) { + return null; + } + + const key = source.substring(0, colonIndex); + const contentStr = source.substring(colonIndex + 2); + + let content = contentStr; + if ( + (contentStr.startsWith("\"") && contentStr.endsWith("\"")) + || (contentStr.startsWith("'") && contentStr.endsWith("'")) + ) { + content = contentStr.slice(1, -1); + content = content.replace(/\\"/g, "\"").replace(/\\'/g, "'"); + } + + return { + key, + content: content === "\"\"" || content === "''" ? "" : content, + }; +}; + +const normalizeElementKey = (key = "") => key.replace(/:$/, ""); + +const resolveKeyToken = (token, fallbackStart) => { + const tokenMatch = token.match(/^([^\s]+)(\s+.*)?$/); + if (!tokenMatch) { + return { + selectorPart: "", + attrsPart: "", + attrsStartOffset: fallbackStart, + }; + } + + const selectorPart = tokenMatch[1]; + const attrsPart = tokenMatch[2] || ""; + const leadingWhitespace = attrsPart.match(/^\s*/)?.[0].length || 0; + const trimmedAttrs = attrsPart.trim(); + + return { + selectorPart, + attrsPart: trimmedAttrs, + attrsStartOffset: selectorPart.length + leadingWhitespace, + }; +}; + +const parseAttributeEntries = ({ attrsPart, attrsStartOffset }) => { + const attributes = []; + let pos = 0; + + while (pos < attrsPart.length) { + while (pos < attrsPart.length && /\s/u.test(attrsPart[pos])) { + pos += 1; + } + if (pos >= attrsPart.length) { + break; + } + + const nameStart = pos; + while (pos < attrsPart.length && /[a-zA-Z-]/u.test(attrsPart[pos])) { + pos += 1; + } + const name = attrsPart.substring(nameStart, pos); + if (!name) { + break; + } + + const nameRange = { + start: attrsStartOffset + nameStart, + end: attrsStartOffset + pos, + }; + + if (attrsPart[pos] !== "=") { + attributes.push({ + name, + value: true, + kind: "boolean", + range: nameRange, + nameRange, + }); + continue; + } + + pos += 1; + let value = ""; + let quote = null; + let valueStart = pos; + let rawValueStart = pos; + let rawValueEnd = pos; + + if (attrsPart[pos] === "\"") { + quote = "\""; + pos += 1; + valueStart = pos; + rawValueStart = pos - 1; + while (pos < attrsPart.length && attrsPart[pos] !== "\"") { + pos += 1; + } + value = attrsPart.substring(valueStart, pos); + if (attrsPart[pos] === "\"") { + pos += 1; + } + rawValueEnd = pos; + } else if (attrsPart[pos] === "'") { + quote = "'"; + pos += 1; + valueStart = pos; + rawValueStart = pos - 1; + while (pos < attrsPart.length && attrsPart[pos] !== "'") { + pos += 1; + } + value = attrsPart.substring(valueStart, pos); + if (attrsPart[pos] === "'") { + pos += 1; + } + rawValueEnd = pos; + } else { + valueStart = pos; + rawValueStart = pos; + while (pos < attrsPart.length && !/\s/u.test(attrsPart[pos])) { + if (attrsPart[pos] === ":" && pos === attrsPart.length - 1) { + break; + } + pos += 1; + } + value = attrsPart.substring(valueStart, pos); + rawValueEnd = pos; + } + + attributes.push({ + name, + value, + kind: "value", + quote, + range: { + start: nameRange.start, + end: attrsStartOffset + rawValueEnd, + }, + nameRange, + valueRange: { + start: attrsStartOffset + valueStart, + end: attrsStartOffset + valueStart + value.length, + }, + rawValueRange: { + start: attrsStartOffset + rawValueStart, + end: attrsStartOffset + rawValueEnd, + }, + }); + } + + return attributes; +}; + +export const parseElementKey = (key = "") => { + const source = typeof key === "string" ? key : String(key ?? ""); + const normalizedKey = normalizeElementKey(source); + + const tokenMatch = normalizedKey.match(/^[^\s]+/u); + if (!tokenMatch) { + return { + raw: source, + normalized: normalizedKey, + tag: "", + id: "", + classes: [], + attributes: [], + ranges: { + tag: null, + id: null, + classes: [], + }, + }; + } + + const { + selectorPart, + attrsPart, + attrsStartOffset, + } = resolveKeyToken(normalizedKey, tokenMatch[0].length); + + const attributes = parseAttributeEntries({ attrsPart, attrsStartOffset }); + + let tag = ""; + let id = ""; + const classes = []; + const classRanges = []; + let remainingSelector = selectorPart; + + if (remainingSelector.includes("=") && !remainingSelector.match(/^[a-zA-Z0-9-]+[#.]/u)) { + tag = ""; + } else { + const tagMatch = remainingSelector.match(/^([a-zA-Z0-9-]+)/u); + if (tagMatch) { + tag = tagMatch[1]; + remainingSelector = remainingSelector.substring(tag.length); + } + } + + const idMatch = remainingSelector.match(/#([a-zA-Z0-9-]+)/u); + let idRange = null; + if (idMatch) { + id = idMatch[1]; + const hashIndex = idMatch.index ?? -1; + if (hashIndex >= 0) { + idRange = { + start: tag.length + hashIndex + 1, + end: tag.length + hashIndex + 1 + id.length, + }; + } + } + + const classRegex = /\.([a-zA-Z0-9-]+)/gu; + let classMatch = classRegex.exec(remainingSelector); + while (classMatch) { + const className = classMatch[1]; + const classIndex = classMatch.index ?? -1; + classes.push(className); + if (classIndex >= 0) { + classRanges.push({ + start: tag.length + classIndex + 1, + end: tag.length + classIndex + 1 + className.length, + }); + } + classMatch = classRegex.exec(remainingSelector); + } + + return { + raw: source, + normalized: normalizedKey, + tag, + id, + classes, + attributes, + ranges: { + tag: tag + ? { + start: 0, + end: tag.length, + } + : null, + id: idRange, + classes: classRanges, + }, + }; +}; + +const toRawHtmlNode = ({ value, path }) => ({ + type: "rawHtml", + value: String(value ?? ""), + path, +}); + +const toTextNode = ({ value, path }) => ({ + type: "text", + value: String(value), + path, +}); + +const pushOrReplaceIdAttribute = ({ attributes, idValue, source }) => { + if (typeof idValue !== "string" || idValue.length === 0) { + return; + } + + const existingIndex = attributes.findIndex((attr) => attr.name === "id"); + if (existingIndex >= 0) { + attributes.splice(existingIndex, 1); + } + + attributes.push({ + name: "id", + value: idValue, + source, + }); +}; + +const pushOrReplaceClassAttribute = ({ attributes, classValue, source }) => { + if (typeof classValue !== "string" || classValue.length === 0) { + return; + } + + const existingIndex = attributes.findIndex((attr) => attr.name === "class"); + if (existingIndex >= 0) { + attributes.splice(existingIndex, 1); + } + + attributes.push({ + name: "class", + value: classValue, + source, + }); +}; + +const buildElementAttributes = ({ keyMeta, objectValue }) => { + const mergedAttributes = []; + + const inlineAttributes = keyMeta.attributes.map((attr) => ({ + name: attr.name, + value: attr.value, + kind: attr.kind, + source: "inline", + })); + + const classAttr = inlineAttributes.find((attr) => attr.name === "class" && typeof attr.value === "string"); + const classAttrValues = classAttr + ? classAttr.value.split(" ").filter(Boolean) + : []; + const allClasses = [...keyMeta.classes, ...classAttrValues]; + + if (keyMeta.id) { + pushOrReplaceIdAttribute({ + attributes: mergedAttributes, + idValue: keyMeta.id, + source: "selector", + }); + } + if (allClasses.length > 0) { + pushOrReplaceClassAttribute({ + attributes: mergedAttributes, + classValue: allClasses.join(" "), + source: classAttr ? "selector+inline" : "selector", + }); + } + + inlineAttributes.forEach((attr) => { + if (attr.name === "class") { + return; + } + if (attr.name === "id") { + pushOrReplaceIdAttribute({ + attributes: mergedAttributes, + idValue: String(attr.value ?? ""), + source: "inline", + }); + return; + } + mergedAttributes.push(attr); + }); + + if (!(objectValue && typeof objectValue === "object" && !Array.isArray(objectValue) && "children" in objectValue)) { + return mergedAttributes; + } + + Object.entries(objectValue).forEach(([attrName, attrValue]) => { + if (attrName === "children") { + return; + } + + if (mergedAttributes.some((existing) => existing.name === attrName)) { + return; + } + + if (attrValue === true) { + mergedAttributes.push({ + name: attrName, + value: true, + kind: "boolean", + source: "object", + }); + return; + } + + if (attrValue === "") { + mergedAttributes.push({ + name: attrName, + value: "", + kind: "value", + source: "object", + }); + return; + } + + mergedAttributes.push({ + name: attrName, + value: String(attrValue), + kind: "value", + source: "object", + }); + }); + + return mergedAttributes; +}; + +const parseChildArray = ({ content, path, parseElement }) => { + const children = []; + + content.forEach((child, index) => { + if (Array.isArray(child)) { + children.push(...parseChildArray({ + content: child, + path: [...path, index], + parseElement, + })); + return; + } + + const parsedChild = parseElement({ element: child, path: [...path, index] }); + if (parsedChild) { + children.push(parsedChild); + } + }); + + return children; +}; + +const parseObjectElement = ({ element, path, parseElement }) => { + if (element instanceof Date) { + throw new TypeError("Date objects cannot be used as content. Convert to string first (e.g., date.toISOString() or date.toLocaleDateString())"); + } + + if ("__html" in element) { + return toRawHtmlNode({ + value: element.__html, + path, + }); + } + + const key = Object.keys(element)[0]; + if (!key) { + throw new Error("Malformed YAHTML element: empty element key"); + } + + const value = element[key]; + if (value instanceof Date) { + throw new TypeError("Date objects cannot be used as element content. Convert to string first (e.g., date.toISOString() or date.toLocaleDateString())"); + } + + if (key.startsWith("\"!DOCTYPE") || key.startsWith("!DOCTYPE")) { + return { + type: "doctype", + value: "html", + path, + rawKey: key, + }; + } + + const keyMeta = parseElementKey(key); + if (!keyMeta.tag) { + throw new Error(`Malformed YAHTML element: "${key}" - element must have a valid tag name`); + } + + const attributes = buildElementAttributes({ + keyMeta, + objectValue: value, + }); + + const node = { + type: "element", + path, + rawKey: key, + key: keyMeta, + tag: keyMeta.tag, + selfClosing: SELF_CLOSING_TAGS.includes(keyMeta.tag), + attributes, + children: [], + }; + + if (node.selfClosing) { + return node; + } + + if (Array.isArray(value)) { + node.children = parseChildArray({ + content: value, + path: [...path, "children"], + parseElement, + }); + return node; + } + + if (typeof value === "object" && value !== null && "children" in value) { + const children = value.children; + + if (Array.isArray(children)) { + node.children = parseChildArray({ + content: children, + path: [...path, "children"], + parseElement, + }); + return node; + } + + if (children !== null && children !== undefined && children !== "") { + if (children instanceof Date) { + throw new TypeError("Date objects cannot be used as element content. Convert to string first (e.g., date.toISOString() or date.toLocaleDateString())"); + } + if (typeof children === "object" && children !== null && "__html" in children) { + node.children = [toRawHtmlNode({ + value: children.__html, + path: [...path, "children", 0], + })]; + } else { + node.children = [toTextNode({ + value: children, + path: [...path, "children", 0], + })]; + } + } + + return node; + } + + if (value !== null && value !== undefined && value !== "") { + if (typeof value === "object" && "__html" in value) { + node.children = [toRawHtmlNode({ + value: value.__html, + path: [...path, "children", 0], + })]; + } else { + node.children = [toTextNode({ + value, + path: [...path, "children", 0], + })]; + } + } + + return node; +}; + +export const parseYahtmlAst = (yahtmlContent) => { + if (!Array.isArray(yahtmlContent)) { + throw new TypeError(ROOT_ARRAY_ERROR); + } + + const parseElement = ({ element, path }) => { + if (element === null || element === undefined) { + return null; + } + + if (typeof element === "string") { + const declaration = parseStringDeclaration(element); + if (declaration) { + return parseObjectElement({ + element: { [declaration.key]: declaration.content }, + path, + parseElement, + }); + } + return toTextNode({ + value: element, + path, + }); + } + + if (typeof element === "number" || typeof element === "boolean") { + return toTextNode({ + value: element, + path, + }); + } + + if (Array.isArray(element)) { + return { + type: "fragment", + path, + children: parseChildArray({ + content: element, + path, + parseElement, + }), + }; + } + + if (typeof element === "object") { + return parseObjectElement({ + element, + path, + parseElement, + }); + } + + return null; + }; + + const children = []; + yahtmlContent.forEach((element, index) => { + const node = parseElement({ + element, + path: [index], + }); + if (node) { + children.push(node); + } + }); + + return { + type: "root", + children, + }; +}; diff --git a/src/yahtml.d.ts b/src/yahtml.d.ts index 7e086c0..fc9de4f 100644 --- a/src/yahtml.d.ts +++ b/src/yahtml.d.ts @@ -30,15 +30,21 @@ * 'a href="https://example.com": "Link"' * ]) * // Returns: 'PhotoLink' - */ -export function convertToHtml(yahtmlContent: any[]): string; -/** - * List of HTML5 void elements (self-closing tags) - * @constant {string[]} + * * @example - * // Check if a tag is self-closing - * if (SELF_CLOSING_TAGS.includes('br')) { - * // handle self-closing tag - * } + * // Object notation with attributes and children + * convertToHtml([ + * { a: { href: '/', class: 'nav-link', children: ['Home'] }}, + * { div: { + * class: 'container', + * children: [ + * { h1: { id: 'title', children: ['Welcome'] }}, + * { p: { children: ['Hello world'] }} + * ] + * }} + * ]) + * // Returns: 'Home

Welcome

Hello world

' */ -export const SELF_CLOSING_TAGS: string[]; +export function convertToHtml(yahtmlContent: any[]): string; +export { SELF_CLOSING_TAGS } from "./constants.js"; +export { parseElementKey, parseYahtmlAst } from "./parser.js"; diff --git a/src/yahtml.js b/src/yahtml.js index ebb7049..9837ef2 100644 --- a/src/yahtml.js +++ b/src/yahtml.js @@ -1,13 +1,6 @@ -/** - * List of HTML5 void elements (self-closing tags) - * @constant {string[]} - * @example - * // Check if a tag is self-closing - * if (SELF_CLOSING_TAGS.includes('br')) { - * // handle self-closing tag - * } - */ -export const SELF_CLOSING_TAGS = ['br', 'hr', 'img', 'input', 'meta', 'area', 'base', 'col', 'embed', 'link', 'param', 'source', 'track', 'wbr']; +import { SELF_CLOSING_TAGS } from "./constants.js"; +export { SELF_CLOSING_TAGS } from "./constants.js"; +export { parseElementKey, parseYahtmlAst } from "./parser.js"; /** * Convert YAHTML array to HTML string @@ -533,4 +526,4 @@ function escapeAttribute(text) { }; return text.replace(/[&<>"]/g, char => escapeMap[char]); -} \ No newline at end of file +} diff --git a/test/parser.test.js b/test/parser.test.js new file mode 100644 index 0000000..cca70ef --- /dev/null +++ b/test/parser.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { convertToHtml, parseElementKey, parseYahtmlAst } from "../index.js"; + +describe("parser api", () => { + it("parses element keys with selector parts, attrs, and ranges", () => { + const parsed = parseElementKey("div#main.card.active class=\"extra one\" data-id=123 required:"); + + expect(parsed.tag).toBe("div"); + expect(parsed.id).toBe("main"); + expect(parsed.classes).toEqual(["card", "active"]); + expect(parsed.ranges.tag).toEqual({ start: 0, end: 3 }); + expect(parsed.ranges.id).toEqual({ start: 4, end: 8 }); + expect(parsed.ranges.classes).toEqual([ + { start: 9, end: 13 }, + { start: 14, end: 20 }, + ]); + expect(parsed.attributes.map((attr) => [attr.name, attr.value])).toEqual([ + ["class", "extra one"], + ["data-id", "123"], + ["required", true], + ]); + }); + + it("builds ast nodes for nested YAHTML structures", () => { + const ast = parseYahtmlAst([ + { + "div#home.card class=\"extra\"": [ + "h1.title: \"Welcome\"", + { p: "Body" }, + { span: { children: { __html: "raw" } } }, + ], + }, + ]); + + expect(ast.type).toBe("root"); + expect(ast.children).toHaveLength(1); + + const root = ast.children[0]; + expect(root.type).toBe("element"); + expect(root.tag).toBe("div"); + expect(root.attributes).toEqual([ + { name: "id", value: "home", source: "selector" }, + { name: "class", value: "card extra", source: "selector+inline" }, + ]); + expect(root.children).toHaveLength(3); + + const titleNode = root.children[0]; + expect(titleNode.type).toBe("element"); + expect(titleNode.tag).toBe("h1"); + expect(titleNode.children[0]).toMatchObject({ + type: "text", + value: "Welcome", + }); + + const rawNode = root.children[2].children[0]; + expect(rawNode).toEqual({ + type: "rawHtml", + value: "raw", + path: [0, "children", 2, "children", 0], + }); + }); + + it("keeps converter behavior unchanged for existing YAHTML", () => { + const html = convertToHtml([ + "h1: \"Title\"", + { div: ["p: \"Hello\""] }, + ]); + + expect(html).toBe("

Title

Hello

"); + }); + + it("throws the same root-array validation error", () => { + expect(() => parseYahtmlAst({ div: "x" })).toThrow( + "YAHTML content must be an array. YAHTML documents always start with an array at the root level.", + ); + }); + + it("throws malformed element errors for invalid selectors", () => { + expect(() => parseYahtmlAst(["#id-only.class-only: \"content\""])).toThrow( + "Malformed YAHTML element: \"#id-only.class-only\" - element must have a valid tag name", + ); + }); +}); From c12b703a62c334538d414b60d3d351ef992dae0f Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Fri, 13 Feb 2026 22:08:37 +0800 Subject: [PATCH 2/3] test(parser): move parser coverage to puty yaml spec suite --- spec/parser.spec.yaml | 753 ++++++++++++++++++++++++++++++++++++++++++ test/parser.test.js | 83 ----- 2 files changed, 753 insertions(+), 83 deletions(-) create mode 100644 spec/parser.spec.yaml delete mode 100644 test/parser.test.js diff --git a/spec/parser.spec.yaml b/spec/parser.spec.yaml new file mode 100644 index 0000000..7be4ab4 --- /dev/null +++ b/spec/parser.spec.yaml @@ -0,0 +1,753 @@ +file: ../src/parser.js +group: yahtml-parser +suites: + - parseElementKey + - parseYahtmlAst +--- +suite: parseElementKey +exportName: parseElementKey +--- +case: parses basic tag with trailing colon +in: + - 'br:' +out: + raw: 'br:' + normalized: br + tag: br + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 2 + id: null + classes: [] +--- +case: parses selector with id classes attrs and ranges +in: + - 'div#main.card.active class="extra one" data-id=123 required:' +out: + raw: 'div#main.card.active class="extra one" data-id=123 required:' + normalized: div#main.card.active class="extra one" data-id=123 required + tag: div + id: main + classes: + - card + - active + attributes: + - name: class + value: extra one + kind: value + quote: '"' + range: + start: 21 + end: 38 + nameRange: + start: 21 + end: 26 + valueRange: + start: 28 + end: 37 + rawValueRange: + start: 27 + end: 38 + - name: data-id + value: '123' + kind: value + quote: null + range: + start: 39 + end: 50 + nameRange: + start: 39 + end: 46 + valueRange: + start: 47 + end: 50 + rawValueRange: + start: 47 + end: 50 + - name: required + value: true + kind: boolean + range: + start: 51 + end: 59 + nameRange: + start: 51 + end: 59 + ranges: + tag: + start: 0 + end: 3 + id: + start: 4 + end: 8 + classes: + - start: 9 + end: 13 + - start: 14 + end: 20 +--- +case: parses url style attribute values with colons +in: + - 'img src=https://example.com/a.png alt="Hero" loading=lazy:' +out: + raw: 'img src=https://example.com/a.png alt="Hero" loading=lazy:' + normalized: img src=https://example.com/a.png alt="Hero" loading=lazy + tag: img + id: '' + classes: [] + attributes: + - name: src + value: https://example.com/a.png + kind: value + quote: null + range: + start: 4 + end: 33 + nameRange: + start: 4 + end: 7 + valueRange: + start: 8 + end: 33 + rawValueRange: + start: 8 + end: 33 + - name: alt + value: Hero + kind: value + quote: '"' + range: + start: 34 + end: 44 + nameRange: + start: 34 + end: 37 + valueRange: + start: 39 + end: 43 + rawValueRange: + start: 38 + end: 44 + - name: loading + value: lazy + kind: value + quote: null + range: + start: 45 + end: 57 + nameRange: + start: 45 + end: 52 + valueRange: + start: 53 + end: 57 + rawValueRange: + start: 53 + end: 57 + ranges: + tag: + start: 0 + end: 3 + id: null + classes: [] +--- +case: parses single-quoted attribute values +in: + - 'a href=''/x y'' target=_blank:' +out: + raw: 'a href=''/x y'' target=_blank:' + normalized: a href='/x y' target=_blank + tag: a + id: '' + classes: [] + attributes: + - name: href + value: /x y + kind: value + quote: '''' + range: + start: 2 + end: 13 + nameRange: + start: 2 + end: 6 + valueRange: + start: 8 + end: 12 + rawValueRange: + start: 7 + end: 13 + - name: target + value: _blank + kind: value + quote: null + range: + start: 14 + end: 27 + nameRange: + start: 14 + end: 20 + valueRange: + start: 21 + end: 27 + rawValueRange: + start: 21 + end: 27 + ranges: + tag: + start: 0 + end: 1 + id: null + classes: [] +--- +case: parses boolean attributes +in: + - 'input required disabled:' +out: + raw: 'input required disabled:' + normalized: input required disabled + tag: input + id: '' + classes: [] + attributes: + - name: required + value: true + kind: boolean + range: + start: 6 + end: 14 + nameRange: + start: 6 + end: 14 + - name: disabled + value: true + kind: boolean + range: + start: 15 + end: 23 + nameRange: + start: 15 + end: 23 + ranges: + tag: + start: 0 + end: 5 + id: null + classes: [] +--- +case: handles empty selector key +in: + - ' ' +out: + raw: ' ' + normalized: ' ' + tag: '' + id: '' + classes: [] + attributes: [] + ranges: + tag: null + id: null + classes: [] +--- +case: returns empty tag for attribute-only declaration +in: + - 'src=image.jpg alt=test:' +out: + raw: 'src=image.jpg alt=test:' + normalized: src=image.jpg alt=test + tag: '' + id: '' + classes: + - jpg + attributes: + - name: alt + value: test + kind: value + quote: null + range: + start: 14 + end: 22 + nameRange: + start: 14 + end: 17 + valueRange: + start: 18 + end: 22 + rawValueRange: + start: 18 + end: 22 + ranges: + tag: null + id: null + classes: + - start: 10 + end: 13 +--- +case: keeps both selector id and inline id metadata for downstream merge logic +in: + - div#first id=second class="x" +out: + raw: div#first id=second class="x" + normalized: div#first id=second class="x" + tag: div + id: first + classes: [] + attributes: + - name: id + value: second + kind: value + quote: null + range: + start: 10 + end: 19 + nameRange: + start: 10 + end: 12 + valueRange: + start: 13 + end: 19 + rawValueRange: + start: 13 + end: 19 + - name: class + value: x + kind: value + quote: '"' + range: + start: 20 + end: 29 + nameRange: + start: 20 + end: 25 + valueRange: + start: 27 + end: 28 + rawValueRange: + start: 26 + end: 29 + ranges: + tag: + start: 0 + end: 3 + id: + start: 4 + end: 9 + classes: [] +--- +suite: parseYahtmlAst +exportName: parseYahtmlAst +--- +case: builds text node root +in: + - - plain text +out: + type: root + children: + - type: text + value: plain text + path: + - 0 +--- +case: builds element node from string declaration +in: + - - 'h1: "Title"' +out: + type: root + children: + - type: element + path: + - 0 + rawKey: h1 + key: + raw: h1 + normalized: h1 + tag: h1 + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 2 + id: null + classes: [] + tag: h1 + selfClosing: false + attributes: [] + children: + - type: text + value: Title + path: + - 0 + - children + - 0 +--- +case: builds standalone raw html node +in: + - - __html: raw +out: + type: root + children: + - type: rawHtml + value: raw + path: + - 0 +--- +case: builds doctype node +in: + - - '!DOCTYPE': html5 +out: + type: root + children: + - type: doctype + value: html + path: + - 0 + rawKey: '!DOCTYPE' +--- +case: builds fragment node for nested root arrays +in: + - - - 'span: "a"' + - 'span: "b"' +out: + type: root + children: + - type: fragment + path: + - 0 + children: + - type: element + path: + - 0 + - 0 + rawKey: span + key: + raw: span + normalized: span + tag: span + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 4 + id: null + classes: [] + tag: span + selfClosing: false + attributes: [] + children: + - type: text + value: a + path: + - 0 + - 0 + - children + - 0 + - type: element + path: + - 0 + - 1 + rawKey: span + key: + raw: span + normalized: span + tag: span + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 4 + id: null + classes: [] + tag: span + selfClosing: false + attributes: [] + children: + - type: text + value: b + path: + - 0 + - 1 + - children + - 0 +--- +case: keeps self closing tags without children +in: + - - 'img src=photo.jpg: "ignored"' +out: + type: root + children: + - type: element + path: + - 0 + rawKey: img src=photo.jpg + key: + raw: img src=photo.jpg + normalized: img src=photo.jpg + tag: img + id: '' + classes: [] + attributes: + - name: src + value: photo.jpg + kind: value + quote: null + range: + start: 4 + end: 17 + nameRange: + start: 4 + end: 7 + valueRange: + start: 8 + end: 17 + rawValueRange: + start: 8 + end: 17 + ranges: + tag: + start: 0 + end: 3 + id: null + classes: [] + tag: img + selfClosing: true + attributes: + - name: src + value: photo.jpg + kind: value + source: inline + children: [] +--- +case: builds object notation attributes and children +in: + - - section: + role: main + hidden: true + children: + - 'p: "A"' +out: + type: root + children: + - type: element + path: + - 0 + rawKey: section + key: + raw: section + normalized: section + tag: section + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 7 + id: null + classes: [] + tag: section + selfClosing: false + attributes: + - name: role + value: main + kind: value + source: object + - name: hidden + value: true + kind: boolean + source: object + children: + - type: element + path: + - 0 + - children + - 0 + rawKey: p + key: + raw: p + normalized: p + tag: p + id: '' + classes: [] + attributes: [] + ranges: + tag: + start: 0 + end: 1 + id: null + classes: [] + tag: p + selfClosing: false + attributes: [] + children: + - type: text + value: A + path: + - 0 + - children + - 0 + - children + - 0 +--- +case: merges selector and inline class attributes in ast attributes +in: + - - div#home.card class="extra" data-id=1: null +out: + type: root + children: + - type: element + path: + - 0 + rawKey: div#home.card class="extra" data-id=1 + key: + raw: div#home.card class="extra" data-id=1 + normalized: div#home.card class="extra" data-id=1 + tag: div + id: home + classes: + - card + attributes: + - name: class + value: extra + kind: value + quote: '"' + range: + start: 14 + end: 27 + nameRange: + start: 14 + end: 19 + valueRange: + start: 21 + end: 26 + rawValueRange: + start: 20 + end: 27 + - name: data-id + value: '1' + kind: value + quote: null + range: + start: 28 + end: 37 + nameRange: + start: 28 + end: 35 + valueRange: + start: 36 + end: 37 + rawValueRange: + start: 36 + end: 37 + ranges: + tag: + start: 0 + end: 3 + id: + start: 4 + end: 8 + classes: + - start: 9 + end: 13 + tag: div + selfClosing: false + attributes: + - name: id + value: home + source: selector + - name: class + value: card extra + source: selector+inline + - name: data-id + value: '1' + kind: value + source: inline + children: [] +--- +case: inline id attribute overrides selector id in ast attributes +in: + - - div#first id=second class="x": null +out: + type: root + children: + - type: element + path: + - 0 + rawKey: div#first id=second class="x" + key: + raw: div#first id=second class="x" + normalized: div#first id=second class="x" + tag: div + id: first + classes: [] + attributes: + - name: id + value: second + kind: value + quote: null + range: + start: 10 + end: 19 + nameRange: + start: 10 + end: 12 + valueRange: + start: 13 + end: 19 + rawValueRange: + start: 13 + end: 19 + - name: class + value: x + kind: value + quote: '"' + range: + start: 20 + end: 29 + nameRange: + start: 20 + end: 25 + valueRange: + start: 27 + end: 28 + rawValueRange: + start: 26 + end: 29 + ranges: + tag: + start: 0 + end: 3 + id: + start: 4 + end: 9 + classes: [] + tag: div + selfClosing: false + attributes: + - name: class + value: x + source: selector+inline + - name: id + value: second + source: inline + children: [] +--- +case: throws for malformed selector without tag +in: + - - '#id-only.class-only: "content"' +throws: 'Malformed YAHTML element: "#id-only.class-only" - element must have a valid tag name' +--- +case: throws for non-array root input +in: + - div: x +throws: YAHTML content must be an array. YAHTML documents always start with an array at the root level. diff --git a/test/parser.test.js b/test/parser.test.js deleted file mode 100644 index cca70ef..0000000 --- a/test/parser.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { convertToHtml, parseElementKey, parseYahtmlAst } from "../index.js"; - -describe("parser api", () => { - it("parses element keys with selector parts, attrs, and ranges", () => { - const parsed = parseElementKey("div#main.card.active class=\"extra one\" data-id=123 required:"); - - expect(parsed.tag).toBe("div"); - expect(parsed.id).toBe("main"); - expect(parsed.classes).toEqual(["card", "active"]); - expect(parsed.ranges.tag).toEqual({ start: 0, end: 3 }); - expect(parsed.ranges.id).toEqual({ start: 4, end: 8 }); - expect(parsed.ranges.classes).toEqual([ - { start: 9, end: 13 }, - { start: 14, end: 20 }, - ]); - expect(parsed.attributes.map((attr) => [attr.name, attr.value])).toEqual([ - ["class", "extra one"], - ["data-id", "123"], - ["required", true], - ]); - }); - - it("builds ast nodes for nested YAHTML structures", () => { - const ast = parseYahtmlAst([ - { - "div#home.card class=\"extra\"": [ - "h1.title: \"Welcome\"", - { p: "Body" }, - { span: { children: { __html: "raw" } } }, - ], - }, - ]); - - expect(ast.type).toBe("root"); - expect(ast.children).toHaveLength(1); - - const root = ast.children[0]; - expect(root.type).toBe("element"); - expect(root.tag).toBe("div"); - expect(root.attributes).toEqual([ - { name: "id", value: "home", source: "selector" }, - { name: "class", value: "card extra", source: "selector+inline" }, - ]); - expect(root.children).toHaveLength(3); - - const titleNode = root.children[0]; - expect(titleNode.type).toBe("element"); - expect(titleNode.tag).toBe("h1"); - expect(titleNode.children[0]).toMatchObject({ - type: "text", - value: "Welcome", - }); - - const rawNode = root.children[2].children[0]; - expect(rawNode).toEqual({ - type: "rawHtml", - value: "raw", - path: [0, "children", 2, "children", 0], - }); - }); - - it("keeps converter behavior unchanged for existing YAHTML", () => { - const html = convertToHtml([ - "h1: \"Title\"", - { div: ["p: \"Hello\""] }, - ]); - - expect(html).toBe("

Title

Hello

"); - }); - - it("throws the same root-array validation error", () => { - expect(() => parseYahtmlAst({ div: "x" })).toThrow( - "YAHTML content must be an array. YAHTML documents always start with an array at the root level.", - ); - }); - - it("throws malformed element errors for invalid selectors", () => { - expect(() => parseYahtmlAst(["#id-only.class-only: \"content\""])).toThrow( - "Malformed YAHTML element: \"#id-only.class-only\" - element must have a valid tag name", - ); - }); -}); From 929535fb7ab675009f48280eabaae07f5e49aa99 Mon Sep 17 00:00:00 2001 From: Luciano Hanyon Wu Date: Fri, 13 Feb 2026 22:15:04 +0800 Subject: [PATCH 3/3] ci: run tests on pull requests and main pushes --- .github/workflows/ci.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..34cddb9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests + run: bun run test