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/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
- nodes.head.children.push({
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
- nodes.head.children.push({
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
- nodes.body.children.push({
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 ENTITIES = {
94
- "&": "&",
95
- "<": "&lt;",
96
- ">": "&gt;",
97
- "'": "&#39;",
98
- '"': "&quot;",
99
- }
106
+ const escapeHTML = (string) => {
107
+ // Convert to string to handle non-string inputs safely
108
+ string = String(string)
100
109
 
101
- const REGEXP = /[&<>'"]/g
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 escapeHTML = (string) => {
104
- return String.prototype.replace.call(string, REGEXP, function (character) {
105
- return ENTITIES[character]
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 = "&amp;"
132
+ break
133
+ case "<":
134
+ replacement = "&lt;"
135
+ break
136
+ case ">":
137
+ replacement = "&gt;"
138
+ break
139
+ case "'":
140
+ replacement = "&#39;"
141
+ break
142
+ case '"':
143
+ replacement = "&quot;"
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
- const isKeyValid = (key) => /^[a-zA-Z0-9\-_]+$/.test(key)
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.includes(key)) {
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 + "=" + '"' + escapeHTML(content) + '"')
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 isUnescapedTag = (name) => {
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.ignore
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
- if (typeof input === "string") {
316
- if (escape) {
317
- return escapeHTML(input)
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
- if (SELF_CLOSING_TAGS.includes(input.name)) {
329
- if (input.attributes) {
330
- return `<${input.name} ` + attributes(input.attributes) + ">"
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 string = input.attributes ? attributes(input.attributes) : ""
336
- const attrs = string ? " " + string : ""
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
- attrs +
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
- let number = 1
435
- function sequence() {
436
- return number++
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 doctype = node("!DOCTYPE html")
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
- result[name] = node(name)
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.img.load = function (path) {
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.img({ src: base64({ content, path }), ...options })
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.svg.load = function (path, options = {}) {
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
- doctype,
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": "1.1.0",
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\"",