Skip to content

Commit f54694a

Browse files
committed
feat(src): add el helper and expose via default export 📐
- Add Types El, ElTagNames, TagFn, Schema2UIDefaultExport - Add Constant allowedNodeKeys, allowedPropsKeys, containerTags, tagAliases - Add Helper.ts with el.root, el.node, and tag aliases (el.div, el.header, etc.) - Refactor index to class Schema2UI with static create, render, el and callable defaultExport - Rename Constant.svgNs to svgNamespace and Render templateTag/svgTag to templateTagName/svgTagName - Move allowedNodeKeys from Validator to Constant and use in Validator - Export el and type namespace Types; default export returns create, render, el
1 parent 6c884a1 commit f54694a

File tree

6 files changed

+446
-73
lines changed

6 files changed

+446
-73
lines changed

src/Constant.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,62 @@
11
/**
22
* Schema2UI constants.
3-
* @description Void HTML tags and SVG namespace for renderer.
3+
* @description Void tags, SVG namespace, allowed keys, tag lists.
44
*/
55
export default class Constant {
6+
/** Allowed keys for schema node (validation, helper). */
7+
static readonly allowedNodeKeys: Set<string> = new Set<string>([
8+
'type',
9+
'id',
10+
'layout',
11+
'style',
12+
'attrs',
13+
'content',
14+
'src',
15+
'alt',
16+
'children'
17+
])
18+
/** Allowed keys in node props (no type, children). */
19+
static readonly allowedPropsKeys: Set<string> = new Set(
20+
[...Constant.allowedNodeKeys].filter((key) => {
21+
return key !== 'type' && key !== 'children'
22+
})
23+
)
24+
/** Container tag names for el.* (non-void). */
25+
static readonly containerTags: readonly string[] = [
26+
'div',
27+
'span',
28+
'main',
29+
'header',
30+
'footer',
31+
'section',
32+
'article',
33+
'aside',
34+
'nav',
35+
'p',
36+
'h1',
37+
'h2',
38+
'h3',
39+
'h4',
40+
'h5',
41+
'h6',
42+
'a',
43+
'ul',
44+
'ol',
45+
'li',
46+
'table',
47+
'thead',
48+
'tbody',
49+
'tr',
50+
'th',
51+
'td',
52+
'form',
53+
'label',
54+
'button',
55+
'template'
56+
]
657
/** SVG namespace URI for createElementNS. */
7-
static readonly svgNs: string = 'http://www.w3.org/2000/svg'
8-
/** Set of void HTML tag names (no children). */
58+
static readonly svgNamespace: string = 'http://www.w3.org/2000/svg'
59+
/** Void HTML tag names (no children). */
960
static readonly voidTags: Set<string> = new Set<string>([
1061
'area',
1162
'base',
@@ -22,4 +73,9 @@ export default class Constant {
2273
'track',
2374
'wbr'
2475
])
76+
/** All tag names for el.* aliases (container + void). */
77+
static readonly tagAliases: readonly string[] = [
78+
...Constant.containerTags,
79+
...Constant.voidTags
80+
]
2581
}

src/Helper.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import type * as Types from '@app/Types.ts'
2+
import Constant from '@app/Constant.ts'
3+
4+
/**
5+
* Element helper for Definition/Node building.
6+
* @description Class namespace; use single instance via Helper.el.
7+
*/
8+
export default class Helper {
9+
/** Singleton element helper instance. */
10+
private static elInstance: Types.El | null = null
11+
12+
/** Element helper namespace (root, node, tag aliases). */
13+
static get el(): Types.El {
14+
if (Helper.elInstance === null) {
15+
Helper.elInstance = new Helper() as Types.El
16+
}
17+
return Helper.elInstance
18+
}
19+
20+
/**
21+
* Initialize tag-alias methods on instance.
22+
* @description Binds each tag in tagAliases to a function calling this.node.
23+
*/
24+
constructor() {
25+
for (const tag of Constant.tagAliases) {
26+
;(this as unknown as Record<string, Types.TagFn>)[tag] = (...args: unknown[]) => {
27+
return this.node(
28+
tag,
29+
...(args as [Types.UnknownRecord?, ...(Types.Node | string | Types.Node[])[]])
30+
)
31+
}
32+
}
33+
}
34+
/**
35+
* Build single node (static).
36+
* @description Delegates to Helper.el.node; allowed keys only.
37+
* @param type - Tag name (e.g. div, span)
38+
* @param propsOrContent - Optional props, text, or first child
39+
* @param rest - Additional children or content (no mix)
40+
* @returns Valid Node for schema
41+
* @throws When void tag has content/children or content mixed with children
42+
*/
43+
static node(
44+
type: string,
45+
propsOrContent?: Types.UnknownRecord | string | Types.Node,
46+
...rest: (Types.Node | string | Types.Node[])[]
47+
): Types.Node {
48+
return Helper.el.node(type, propsOrContent, ...rest)
49+
}
50+
51+
/**
52+
* Build single node (instance).
53+
* @description Factory for Node; allowed keys copied from props.
54+
* @param type - Tag name (e.g. div, span)
55+
* @param propsOrContent - Optional props, text, or first child
56+
* @param rest - Additional children or content (no mix)
57+
* @returns Valid Node for schema
58+
* @throws When void tag has content/children or content mixed with children
59+
*/
60+
node(
61+
type: string,
62+
propsOrContent?: Types.UnknownRecord | string | Types.Node,
63+
...rest: (Types.Node | string | Types.Node[])[]
64+
): Types.Node {
65+
const tag = Helper.normalizeTag(type)
66+
const isVoid = Constant.voidTags.has(tag)
67+
if (isVoid) {
68+
if (
69+
rest.length > 0 ||
70+
typeof propsOrContent === 'string' ||
71+
(typeof propsOrContent === 'object' &&
72+
propsOrContent !== null &&
73+
Helper.isNodeLike(propsOrContent))
74+
) {
75+
throw new Error(`el.node: void tag "${tag}" must not have content or children`)
76+
}
77+
if (propsOrContent === undefined) {
78+
return { type: tag }
79+
}
80+
const props = Helper.pickProps(propsOrContent as Types.UnknownRecord)
81+
return { type: tag, ...props } as Types.Node
82+
}
83+
if (propsOrContent === undefined) {
84+
return { type: tag }
85+
}
86+
if (typeof propsOrContent === 'string') {
87+
if (rest.length > 0) {
88+
throw new Error('el.node: content (string) and children cannot be mixed')
89+
}
90+
return { type: tag, content: propsOrContent }
91+
}
92+
if (Helper.isNodeLike(propsOrContent)) {
93+
let children: Types.Node[]
94+
if (rest.length === 1 && Array.isArray(rest[0])) {
95+
children = rest[0] as Types.Node[]
96+
} else {
97+
children = rest as Types.Node[]
98+
}
99+
return { type: tag, children: [propsOrContent, ...children] }
100+
}
101+
const props = Helper.pickProps(propsOrContent as Types.UnknownRecord)
102+
if (rest.length === 0) {
103+
return { type: tag, ...props } as Types.Node
104+
}
105+
if (rest.length === 1 && typeof rest[0] === 'string') {
106+
return { type: tag, ...props, content: rest[0] } as Types.Node
107+
}
108+
const childList = rest.flatMap((item) => {
109+
if (Array.isArray(item)) {
110+
return item
111+
}
112+
return [item]
113+
}) as Types.Node[]
114+
return { type: tag, ...props, children: childList } as Types.Node
115+
}
116+
117+
/**
118+
* Build definition root from nodes (static).
119+
* @description Delegates to Helper.el.root; root(), root(n1,n2), root([n1,n2]).
120+
* @param nodeArgs - Zero or more nodes, or single array of nodes
121+
* @returns Definition ready for create()
122+
*/
123+
static root(...nodeArgs: (Types.Node | Types.Node[])[]): Types.Definition {
124+
return Helper.el.root(...nodeArgs)
125+
}
126+
127+
/**
128+
* Build definition root from nodes (instance).
129+
* @description Supports root(), root(n1, n2) or root([n1, n2]).
130+
* @param nodeArgs - Zero or more nodes, or single array of nodes
131+
* @returns Definition ready for create()
132+
*/
133+
root(...nodeArgs: (Types.Node | Types.Node[])[]): Types.Definition {
134+
if (nodeArgs.length === 0) {
135+
return { root: [] }
136+
}
137+
let nodes: Types.Node[]
138+
if (nodeArgs.length === 1 && Array.isArray(nodeArgs[0])) {
139+
nodes = nodeArgs[0] as Types.Node[]
140+
} else {
141+
nodes = nodeArgs as Types.Node[]
142+
}
143+
return { root: nodes }
144+
}
145+
146+
/**
147+
* Type guard for Node-like value.
148+
* @description Returns true when value is object with type key.
149+
* @param value - Value to check
150+
* @returns True when value is Node-like
151+
*/
152+
private static isNodeLike(value: unknown): value is Types.Node {
153+
return typeof value === 'object' && value !== null && 'type' in (value as object)
154+
}
155+
156+
/**
157+
* Lowercase tag name for HTML.
158+
* @description Normalises tag string for comparison.
159+
* @param tag - Tag name (e.g. DIV, span)
160+
* @returns Lowercase tag string
161+
*/
162+
private static normalizeTag(tag: string): string {
163+
return tag.toLowerCase()
164+
}
165+
166+
/**
167+
* Copy allowed props from record (whitelist keys).
168+
* @description Picks only Constant.allowedPropsKeys from propsRecord.
169+
* @param propsRecord - Raw props object
170+
* @returns Filtered props for Node
171+
*/
172+
private static pickProps(
173+
propsRecord: Types.UnknownRecord
174+
): Partial<Omit<Types.Node, 'type' | 'children'>> {
175+
const pickedProps: Record<string, unknown> = {}
176+
for (const key of Object.keys(propsRecord)) {
177+
if (Constant.allowedPropsKeys.has(key)) {
178+
pickedProps[key] = (propsRecord as Record<string, unknown>)[key]
179+
}
180+
}
181+
return pickedProps as Partial<Omit<Types.Node, 'type' | 'children'>>
182+
}
183+
}

src/Render.ts

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import Constant from '@app/Constant.ts'
33

44
/**
55
* Renders schema to DOM.
6-
* @description Builds elements from nodes, applies layout and style.
6+
* @description Builds elements from nodes; applies layout and style.
77
*/
88
export default class Render {
9-
/** HTML tag name for template elements. */
10-
static readonly templateTag: string = 'template'
119
/** HTML tag name for SVG root. */
12-
static readonly svgTag: string = 'svg'
10+
static readonly svgTagName: string = 'svg'
11+
/** HTML tag name for template elements. */
12+
static readonly templateTagName: string = 'template'
1313

1414
/**
1515
* Apply attrs map to element.
@@ -62,32 +62,6 @@ export default class Render {
6262
}
6363
}
6464

65-
/**
66-
* Append inline CSS string to style.
67-
* @description Parses prop: value pairs and sets each via setProperty.
68-
* @param targetStyle - Target CSS style declaration
69-
* @param inlineCssString - Inline CSS declarations (e.g. color: red; margin: 8px)
70-
*/
71-
static applyStyleString(targetStyle: CSSStyleDeclaration, inlineCssString: string): void {
72-
const declarations = inlineCssString.split(';')
73-
for (const declarationPart of declarations) {
74-
const colonIndex = declarationPart.indexOf(':')
75-
if (colonIndex < 0) {
76-
continue
77-
}
78-
const propertyName = declarationPart.slice(0, colonIndex).trim()
79-
const propertyValue = declarationPart.slice(colonIndex + 1).trim()
80-
if (propertyName === '') {
81-
continue
82-
}
83-
try {
84-
targetStyle.setProperty(propertyName, propertyValue)
85-
} catch {
86-
// ignore invalid property names
87-
}
88-
}
89-
}
90-
9165
/**
9266
* Apply layout to element style.
9367
* @description Sets width, height, left, top, flex, gap.
@@ -170,6 +144,32 @@ export default class Render {
170144
}
171145
}
172146

147+
/**
148+
* Append inline CSS string to style.
149+
* @description Parses prop: value pairs; sets each via setProperty.
150+
* @param targetStyle - Target CSS style declaration
151+
* @param inlineCssString - Inline CSS (e.g. color: red; margin: 8px)
152+
*/
153+
static applyStyleString(targetStyle: CSSStyleDeclaration, inlineCssString: string): void {
154+
const declarations = inlineCssString.split(';')
155+
for (const declarationPart of declarations) {
156+
const colonIndex = declarationPart.indexOf(':')
157+
if (colonIndex < 0) {
158+
continue
159+
}
160+
const propertyName = declarationPart.slice(0, colonIndex).trim()
161+
const propertyValue = declarationPart.slice(colonIndex + 1).trim()
162+
if (propertyName === '') {
163+
continue
164+
}
165+
try {
166+
targetStyle.setProperty(propertyName, propertyValue)
167+
} catch {
168+
// ignore invalid property names
169+
}
170+
}
171+
}
172+
173173
/**
174174
* Create element for node (HTML or SVG).
175175
* @description Uses createElementNS for SVG, createElement otherwise.
@@ -180,11 +180,11 @@ export default class Render {
180180
*/
181181
static createElementForNode(node: Types.Node, doc: Document, isSvg: boolean): Element {
182182
const tag = node.type
183-
if (tag === Render.templateTag) {
183+
if (tag === Render.templateTagName) {
184184
return doc.createElement('template')
185185
}
186-
if (isSvg || tag === Render.svgTag) {
187-
return doc.createElementNS(Constant.svgNs, tag)
186+
if (isSvg || tag === Render.svgTagName) {
187+
return doc.createElementNS(Constant.svgNamespace, tag)
188188
}
189189
return doc.createElement(tag)
190190
}
@@ -211,7 +211,7 @@ export default class Render {
211211

212212
/**
213213
* Render single node to element or fragment.
214-
* @description Creates element, applies attrs/layout/style, appends children.
214+
* @description Creates element; applies attrs/layout/style; appends children.
215215
* @param node - Schema node
216216
* @param doc - Document
217217
* @param parentIsSvg - Whether parent is SVG
@@ -223,7 +223,7 @@ export default class Render {
223223
parentIsSvg: boolean
224224
): Element | DocumentFragment {
225225
const tag = node.type
226-
const isSvg = parentIsSvg || tag === Render.svgTag
226+
const isSvg = parentIsSvg || tag === Render.svgTagName
227227
const element = Render.createElementForNode(node, doc, isSvg)
228228
if (node.id && element instanceof HTMLElement) {
229229
element.id = node.id
@@ -253,7 +253,12 @@ export default class Render {
253253
}
254254
}
255255
if (!Constant.voidTags.has(tag) && node.children && node.children.length > 0) {
256-
const target = element instanceof HTMLTemplateElement ? element.content : element
256+
let target: DocumentFragment | Element
257+
if (element instanceof HTMLTemplateElement) {
258+
target = element.content
259+
} else {
260+
target = element
261+
}
257262
for (const child of node.children) {
258263
const childResult = Render.renderNode(child, doc, isSvg)
259264
if (childResult instanceof DocumentFragment) {
@@ -270,7 +275,7 @@ export default class Render {
270275

271276
/**
272277
* Convert layout value to CSS string.
273-
* @description Numbers become "Npx", strings returned as-is.
278+
* @description Numbers become "Npx"; strings as-is.
274279
* @param layoutValue - Number or string
275280
* @returns CSS value string
276281
*/

0 commit comments

Comments
 (0)