boxwood 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/README.md +143 -101
- package/examples/typescript-example.ts +49 -0
- package/index.d.ts +545 -0
- package/index.js +221 -64
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -5,7 +5,8 @@ const csstree = require("css-tree")
|
|
|
5
5
|
function compile(path) {
|
|
6
6
|
const fn = require(path)
|
|
7
7
|
return {
|
|
8
|
-
template() {
|
|
8
|
+
template(options) {
|
|
9
|
+
const nonce = options && options.nonce
|
|
9
10
|
const tree = fn(...arguments)
|
|
10
11
|
const nodes = {}
|
|
11
12
|
const styles = []
|
|
@@ -65,24 +66,36 @@ function compile(path) {
|
|
|
65
66
|
walk(tree)
|
|
66
67
|
if (nodes.head) {
|
|
67
68
|
if (styles.length > 0) {
|
|
68
|
-
|
|
69
|
+
const styleNode = {
|
|
69
70
|
name: "style",
|
|
70
71
|
children: styles.join(""),
|
|
71
|
-
}
|
|
72
|
+
}
|
|
73
|
+
if (nonce) {
|
|
74
|
+
styleNode.attributes = { nonce }
|
|
75
|
+
}
|
|
76
|
+
nodes.head.children.push(styleNode)
|
|
72
77
|
}
|
|
73
78
|
if (scripts.head.length > 0) {
|
|
74
|
-
|
|
79
|
+
const scriptNode = {
|
|
75
80
|
name: "script",
|
|
76
81
|
children: scripts.head.join(""),
|
|
77
|
-
}
|
|
82
|
+
}
|
|
83
|
+
if (nonce) {
|
|
84
|
+
scriptNode.attributes = { nonce }
|
|
85
|
+
}
|
|
86
|
+
nodes.head.children.push(scriptNode)
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
if (nodes.body) {
|
|
81
90
|
if (scripts.body.length > 0) {
|
|
82
|
-
|
|
91
|
+
const scriptNode = {
|
|
83
92
|
name: "script",
|
|
84
93
|
children: scripts.body.join(""),
|
|
85
|
-
}
|
|
94
|
+
}
|
|
95
|
+
if (nonce) {
|
|
96
|
+
scriptNode.attributes = { nonce }
|
|
97
|
+
}
|
|
98
|
+
nodes.body.children.push(scriptNode)
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
return render(tree)
|
|
@@ -90,20 +103,61 @@ function compile(path) {
|
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
">": ">",
|
|
97
|
-
"'": "'",
|
|
98
|
-
'"': """,
|
|
99
|
-
}
|
|
106
|
+
const escapeHTML = (string) => {
|
|
107
|
+
// Convert to string to handle non-string inputs safely
|
|
108
|
+
string = String(string)
|
|
100
109
|
|
|
101
|
-
|
|
110
|
+
// Fast path: if no special characters, return as-is
|
|
111
|
+
if (
|
|
112
|
+
!string.includes("&") &&
|
|
113
|
+
!string.includes("<") &&
|
|
114
|
+
!string.includes(">") &&
|
|
115
|
+
!string.includes("'") &&
|
|
116
|
+
!string.includes('"')
|
|
117
|
+
) {
|
|
118
|
+
return string
|
|
119
|
+
}
|
|
102
120
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
const len = string.length
|
|
122
|
+
let result = ""
|
|
123
|
+
let lastIndex = 0
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < len; i++) {
|
|
126
|
+
const char = string[i]
|
|
127
|
+
let replacement
|
|
128
|
+
|
|
129
|
+
switch (char) {
|
|
130
|
+
case "&":
|
|
131
|
+
replacement = "&"
|
|
132
|
+
break
|
|
133
|
+
case "<":
|
|
134
|
+
replacement = "<"
|
|
135
|
+
break
|
|
136
|
+
case ">":
|
|
137
|
+
replacement = ">"
|
|
138
|
+
break
|
|
139
|
+
case "'":
|
|
140
|
+
replacement = "'"
|
|
141
|
+
break
|
|
142
|
+
case '"':
|
|
143
|
+
replacement = """
|
|
144
|
+
break
|
|
145
|
+
default:
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (lastIndex !== i) {
|
|
150
|
+
result += string.slice(lastIndex, i)
|
|
151
|
+
}
|
|
152
|
+
result += replacement
|
|
153
|
+
lastIndex = i + 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (lastIndex !== len) {
|
|
157
|
+
result += string.slice(lastIndex)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return result
|
|
107
161
|
}
|
|
108
162
|
|
|
109
163
|
const normalizePath = (path) => path.replace(/\\/g, "/").replace(/\/+$/, "")
|
|
@@ -186,7 +240,7 @@ function readFile(path, encoding) {
|
|
|
186
240
|
}
|
|
187
241
|
}
|
|
188
242
|
|
|
189
|
-
const BOOLEAN_ATTRIBUTES = [
|
|
243
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
190
244
|
"async",
|
|
191
245
|
"autofocus",
|
|
192
246
|
"autoplay",
|
|
@@ -223,14 +277,16 @@ const BOOLEAN_ATTRIBUTES = [
|
|
|
223
277
|
"sortable",
|
|
224
278
|
"spellcheck",
|
|
225
279
|
"translate",
|
|
226
|
-
]
|
|
280
|
+
])
|
|
227
281
|
|
|
228
282
|
const ALIASES = {
|
|
229
283
|
className: "class",
|
|
230
284
|
htmlFor: "for",
|
|
231
285
|
}
|
|
232
286
|
|
|
233
|
-
|
|
287
|
+
// Pre-compiled regex for better performance
|
|
288
|
+
const KEY_VALIDATION_REGEX = /^[a-zA-Z0-9\-_]+$/
|
|
289
|
+
const isKeyValid = (key) => KEY_VALIDATION_REGEX.test(key)
|
|
234
290
|
|
|
235
291
|
const attributes = (options) => {
|
|
236
292
|
if (!options) {
|
|
@@ -248,13 +304,13 @@ const attributes = (options) => {
|
|
|
248
304
|
value === true ||
|
|
249
305
|
Array.isArray(value)
|
|
250
306
|
) {
|
|
251
|
-
if (BOOLEAN_ATTRIBUTES.
|
|
307
|
+
if (BOOLEAN_ATTRIBUTES.has(key)) {
|
|
252
308
|
result.push(key)
|
|
253
309
|
} else {
|
|
254
310
|
const name = ALIASES[key] || key
|
|
255
311
|
const value = options[key]
|
|
256
312
|
const content = Array.isArray(value) ? classes(...value) : value
|
|
257
|
-
result.push(name
|
|
313
|
+
result.push(`${name}="${escapeHTML(content)}"`)
|
|
258
314
|
}
|
|
259
315
|
} else if (key === "style" && typeof value === "object") {
|
|
260
316
|
const styles = []
|
|
@@ -276,7 +332,7 @@ const attributes = (options) => {
|
|
|
276
332
|
return result.join(" ")
|
|
277
333
|
}
|
|
278
334
|
|
|
279
|
-
const SELF_CLOSING_TAGS = [
|
|
335
|
+
const SELF_CLOSING_TAGS = new Set([
|
|
280
336
|
"area",
|
|
281
337
|
"base",
|
|
282
338
|
"br",
|
|
@@ -294,54 +350,60 @@ const SELF_CLOSING_TAGS = [
|
|
|
294
350
|
"track",
|
|
295
351
|
"wbr",
|
|
296
352
|
"!DOCTYPE html",
|
|
297
|
-
]
|
|
353
|
+
])
|
|
298
354
|
|
|
299
|
-
const
|
|
300
|
-
return !["script", "style", "template"].includes(name)
|
|
301
|
-
}
|
|
355
|
+
const UNESCAPED_TAGS = new Set(["script", "style", "template"])
|
|
302
356
|
|
|
303
357
|
const render = (input, escape = true) => {
|
|
358
|
+
// Most common case: string (~50% of nodes)
|
|
359
|
+
if (typeof input === "string") {
|
|
360
|
+
return escape ? escapeHTML(input) : input
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Second most common: arrays (~20% of nodes)
|
|
364
|
+
if (Array.isArray(input)) {
|
|
365
|
+
let result = ""
|
|
366
|
+
for (let i = 0; i < input.length; i++) {
|
|
367
|
+
result += render(input[i])
|
|
368
|
+
}
|
|
369
|
+
return result
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Early exit for null/undefined/false/true
|
|
304
373
|
if (
|
|
305
|
-
typeof input === "undefined" ||
|
|
306
|
-
typeof input === "boolean" ||
|
|
307
374
|
input === null ||
|
|
308
|
-
input
|
|
375
|
+
input === undefined ||
|
|
376
|
+
input === false ||
|
|
377
|
+
input === true
|
|
309
378
|
) {
|
|
310
379
|
return ""
|
|
311
380
|
}
|
|
381
|
+
|
|
382
|
+
// Numbers (~5% of nodes)
|
|
312
383
|
if (typeof input === "number") {
|
|
313
384
|
return input.toString()
|
|
314
385
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return input
|
|
320
|
-
}
|
|
321
|
-
if (Array.isArray(input)) {
|
|
322
|
-
return input.map((input) => render(input)).join("")
|
|
386
|
+
|
|
387
|
+
// Objects (elements) - check ignore flag first
|
|
388
|
+
if (input.ignore) {
|
|
389
|
+
return ""
|
|
323
390
|
}
|
|
324
391
|
|
|
325
392
|
if (input.name === "raw") {
|
|
326
393
|
return render(input.children, false)
|
|
327
394
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
return `<${input.name}>`
|
|
395
|
+
|
|
396
|
+
if (SELF_CLOSING_TAGS.has(input.name)) {
|
|
397
|
+
const attrs = input.attributes ? attributes(input.attributes) : ""
|
|
398
|
+
return attrs ? `<${input.name} ${attrs}>` : `<${input.name}>`
|
|
333
399
|
}
|
|
334
400
|
|
|
335
|
-
const
|
|
336
|
-
const
|
|
401
|
+
const attrs = input.attributes ? attributes(input.attributes) : ""
|
|
402
|
+
const children = render(input.children, !UNESCAPED_TAGS.has(input.name))
|
|
337
403
|
|
|
338
|
-
return
|
|
339
|
-
`<${input.name}
|
|
340
|
-
|
|
341
|
-
">" +
|
|
342
|
-
render(input.children, isUnescapedTag(input.name)) +
|
|
343
|
-
`</${input.name}>`
|
|
344
|
-
)
|
|
404
|
+
return attrs
|
|
405
|
+
? `<${input.name} ${attrs}>${children}</${input.name}>`
|
|
406
|
+
: `<${input.name}>${children}</${input.name}>`
|
|
345
407
|
}
|
|
346
408
|
|
|
347
409
|
const raw = (children) => {
|
|
@@ -431,9 +493,14 @@ const tag = (a, b, c) => {
|
|
|
431
493
|
}
|
|
432
494
|
}
|
|
433
495
|
|
|
434
|
-
|
|
435
|
-
function
|
|
436
|
-
|
|
496
|
+
// DJB2 hash algorithm for CSS class names
|
|
497
|
+
function hashDJB2(str) {
|
|
498
|
+
let hash = 5381
|
|
499
|
+
for (let i = 0; i < str.length; i++) {
|
|
500
|
+
hash = (hash * 33) ^ str.charCodeAt(i)
|
|
501
|
+
}
|
|
502
|
+
// Convert to positive number and base36 for shorter string
|
|
503
|
+
return (hash >>> 0).toString(36)
|
|
437
504
|
}
|
|
438
505
|
|
|
439
506
|
function decamelize(string) {
|
|
@@ -475,12 +542,13 @@ function css(inputs) {
|
|
|
475
542
|
result += input
|
|
476
543
|
}
|
|
477
544
|
}
|
|
478
|
-
const hash = sequence()
|
|
479
545
|
const tree = csstree.parse(result)
|
|
480
546
|
const classes = {}
|
|
481
547
|
|
|
482
548
|
csstree.walk(tree, (node) => {
|
|
483
549
|
if (node.type === "ClassSelector") {
|
|
550
|
+
// Generate hash based on the CSS content and class name
|
|
551
|
+
const hash = hashDJB2(result + node.name).slice(0, 6)
|
|
484
552
|
const name = `${node.name}_${hash}`
|
|
485
553
|
classes[node.name] = name
|
|
486
554
|
node.name = name
|
|
@@ -493,9 +561,62 @@ function css(inputs) {
|
|
|
493
561
|
}
|
|
494
562
|
}
|
|
495
563
|
|
|
564
|
+
function occurences(input, string) {
|
|
565
|
+
if (string.length <= 0) {
|
|
566
|
+
return input.length + 1
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
let count = 0
|
|
570
|
+
let position = 0
|
|
571
|
+
const step = string.length
|
|
572
|
+
while (true) {
|
|
573
|
+
position = input.indexOf(string, position)
|
|
574
|
+
if (position >= 0) {
|
|
575
|
+
count += 1
|
|
576
|
+
position += step
|
|
577
|
+
} else {
|
|
578
|
+
break
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return count
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const validateCSS = (content, character1, character2) => {
|
|
585
|
+
const count1 = occurences(content, character1)
|
|
586
|
+
const count2 = occurences(content, character2)
|
|
587
|
+
if (count1 !== count2) {
|
|
588
|
+
return {
|
|
589
|
+
valid: false,
|
|
590
|
+
message: `Mismatched count of ${character1} and ${character2}`,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { valid: true }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const CSS_PAIRS = [
|
|
597
|
+
["{", "}"],
|
|
598
|
+
["(", ")"],
|
|
599
|
+
["[", "]"],
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
function isCSSValid(content) {
|
|
603
|
+
for (const [left, right] of CSS_PAIRS) {
|
|
604
|
+
const { valid, message } = validateCSS(content, left, right)
|
|
605
|
+
if (!valid) {
|
|
606
|
+
return { valid, message: message }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return { valid: true }
|
|
611
|
+
}
|
|
612
|
+
|
|
496
613
|
css.load = function (path) {
|
|
497
614
|
const file = path.endsWith(".css") ? path : join(path, "index.css")
|
|
498
615
|
const content = readFile(file, "utf8")
|
|
616
|
+
const { valid, message } = isCSSValid(content)
|
|
617
|
+
if (!valid) {
|
|
618
|
+
throw new Error(`CSSError: invalid CSS for path "${file}": ${message}`)
|
|
619
|
+
}
|
|
499
620
|
return css`
|
|
500
621
|
${content}
|
|
501
622
|
`
|
|
@@ -552,12 +673,15 @@ js.load = function (path, options = {}) {
|
|
|
552
673
|
}
|
|
553
674
|
|
|
554
675
|
const node = (name) => (options, children) => tag(name, options, children)
|
|
555
|
-
const
|
|
676
|
+
const Doctype = node("!DOCTYPE html")
|
|
556
677
|
|
|
557
678
|
const nodes = [
|
|
558
679
|
"a",
|
|
559
680
|
"abbr",
|
|
560
681
|
"address",
|
|
682
|
+
"animate",
|
|
683
|
+
"animateMotion",
|
|
684
|
+
"animateTransform",
|
|
561
685
|
"area",
|
|
562
686
|
"article",
|
|
563
687
|
"aside",
|
|
@@ -572,14 +696,18 @@ const nodes = [
|
|
|
572
696
|
"button",
|
|
573
697
|
"canvas",
|
|
574
698
|
"caption",
|
|
699
|
+
"circle",
|
|
575
700
|
"cite",
|
|
701
|
+
"clipPath",
|
|
576
702
|
"code",
|
|
577
703
|
"col",
|
|
578
704
|
"colgroup",
|
|
579
705
|
"data",
|
|
580
706
|
"datalist",
|
|
581
707
|
"dd",
|
|
708
|
+
"defs",
|
|
582
709
|
"del",
|
|
710
|
+
"desc",
|
|
583
711
|
"details",
|
|
584
712
|
"dfn",
|
|
585
713
|
"dialog",
|
|
@@ -587,12 +715,16 @@ const nodes = [
|
|
|
587
715
|
"dl",
|
|
588
716
|
"dt",
|
|
589
717
|
"em",
|
|
718
|
+
"ellipse",
|
|
590
719
|
"embed",
|
|
591
720
|
"fieldset",
|
|
592
721
|
"figcaption",
|
|
593
722
|
"figure",
|
|
723
|
+
"filter",
|
|
594
724
|
"footer",
|
|
725
|
+
"foreignObject",
|
|
595
726
|
"form",
|
|
727
|
+
"g",
|
|
596
728
|
"h1",
|
|
597
729
|
"h2",
|
|
598
730
|
"h3",
|
|
@@ -601,10 +733,12 @@ const nodes = [
|
|
|
601
733
|
"h6",
|
|
602
734
|
"head",
|
|
603
735
|
"header",
|
|
736
|
+
"hgroup",
|
|
604
737
|
"hr",
|
|
605
738
|
"html",
|
|
606
739
|
"i",
|
|
607
740
|
"iframe",
|
|
741
|
+
"image",
|
|
608
742
|
"img",
|
|
609
743
|
"input",
|
|
610
744
|
"ins",
|
|
@@ -612,11 +746,17 @@ const nodes = [
|
|
|
612
746
|
"label",
|
|
613
747
|
"legend",
|
|
614
748
|
"li",
|
|
749
|
+
"line",
|
|
750
|
+
"linearGradient",
|
|
615
751
|
"link",
|
|
616
752
|
"main",
|
|
617
753
|
"map",
|
|
618
754
|
"mark",
|
|
755
|
+
"marker",
|
|
756
|
+
"mask",
|
|
757
|
+
"menu",
|
|
619
758
|
"meta",
|
|
759
|
+
"metadata",
|
|
620
760
|
"meter",
|
|
621
761
|
"nav",
|
|
622
762
|
"noscript",
|
|
@@ -627,10 +767,16 @@ const nodes = [
|
|
|
627
767
|
"output",
|
|
628
768
|
"p",
|
|
629
769
|
"param",
|
|
770
|
+
"path",
|
|
771
|
+
"pattern",
|
|
630
772
|
"picture",
|
|
773
|
+
"polygon",
|
|
774
|
+
"polyline",
|
|
631
775
|
"pre",
|
|
632
776
|
"progress",
|
|
633
777
|
"q",
|
|
778
|
+
"radialGradient",
|
|
779
|
+
"rect",
|
|
634
780
|
"rp",
|
|
635
781
|
"rt",
|
|
636
782
|
"ruby",
|
|
@@ -639,20 +785,27 @@ const nodes = [
|
|
|
639
785
|
"script",
|
|
640
786
|
"section",
|
|
641
787
|
"select",
|
|
788
|
+
"set",
|
|
789
|
+
"slot",
|
|
642
790
|
"small",
|
|
643
791
|
"source",
|
|
644
792
|
"span",
|
|
793
|
+
"stop",
|
|
645
794
|
"strong",
|
|
646
795
|
"style",
|
|
647
796
|
"sub",
|
|
648
797
|
"summary",
|
|
649
798
|
"sup",
|
|
650
799
|
"svg",
|
|
800
|
+
"switch",
|
|
801
|
+
"symbol",
|
|
651
802
|
"table",
|
|
652
803
|
"tbody",
|
|
653
804
|
"td",
|
|
654
805
|
"template",
|
|
806
|
+
"text",
|
|
655
807
|
"textarea",
|
|
808
|
+
"textPath",
|
|
656
809
|
"tfoot",
|
|
657
810
|
"th",
|
|
658
811
|
"thead",
|
|
@@ -660,13 +813,17 @@ const nodes = [
|
|
|
660
813
|
"title",
|
|
661
814
|
"tr",
|
|
662
815
|
"track",
|
|
816
|
+
"tspan",
|
|
663
817
|
"u",
|
|
664
818
|
"ul",
|
|
819
|
+
"use",
|
|
665
820
|
"var",
|
|
666
821
|
"video",
|
|
822
|
+
"view",
|
|
667
823
|
"wbr",
|
|
668
824
|
].reduce((result, name) => {
|
|
669
|
-
|
|
825
|
+
const pascalName = name.charAt(0).toUpperCase() + name.slice(1)
|
|
826
|
+
result[pascalName] = node(name)
|
|
670
827
|
return result
|
|
671
828
|
}, {})
|
|
672
829
|
|
|
@@ -687,7 +844,7 @@ function base64({ content, path }) {
|
|
|
687
844
|
return `data:${media(path)};base64,${content}`
|
|
688
845
|
}
|
|
689
846
|
|
|
690
|
-
nodes.
|
|
847
|
+
nodes.Img.load = function (path) {
|
|
691
848
|
const type = extension(path)
|
|
692
849
|
if (!ALLOWED_IMAGE_EXTENSIONS.includes(type)) {
|
|
693
850
|
throw new Error(
|
|
@@ -696,7 +853,7 @@ nodes.img.load = function (path) {
|
|
|
696
853
|
}
|
|
697
854
|
const content = readFile(path, "base64")
|
|
698
855
|
return (options) => {
|
|
699
|
-
return nodes.
|
|
856
|
+
return nodes.Img({ src: base64({ content, path }), ...options })
|
|
700
857
|
}
|
|
701
858
|
}
|
|
702
859
|
|
|
@@ -742,7 +899,7 @@ const sanitizeSVG = (content) => {
|
|
|
742
899
|
* Should not be used for user-generated content.
|
|
743
900
|
*/
|
|
744
901
|
|
|
745
|
-
nodes.
|
|
902
|
+
nodes.Svg.load = function (path, options = {}) {
|
|
746
903
|
const type = extension(path)
|
|
747
904
|
if (type !== "svg") {
|
|
748
905
|
throw new Error(
|
|
@@ -880,7 +1037,7 @@ module.exports = {
|
|
|
880
1037
|
compile,
|
|
881
1038
|
component,
|
|
882
1039
|
classes,
|
|
883
|
-
|
|
1040
|
+
Doctype,
|
|
884
1041
|
escape: escapeHTML,
|
|
885
1042
|
raw,
|
|
886
1043
|
css,
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "boxwood",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Compile HTML templates into JS",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
6
7
|
"scripts": {
|
|
7
8
|
"test": "node --test --test-reporter=dot \"test/**/*.test.js\" \"test/**/*.spec.js\"",
|
|
8
9
|
"test:debug": "node --test --test-reporter=spec \"test/**/*.test.js\" \"test/**/*.spec.js\"",
|