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
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/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/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: '
Link'
- */
-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
Hello world