boxwood 2.5.0 → 2.7.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
@@ -3,6 +3,70 @@ const { readFileSync, realpathSync, lstatSync } = require("fs")
3
3
  const csstree = require("css-tree")
4
4
  const { createHash } = require("./utilities/hash")
5
5
 
6
+ class TranslationError extends Error {
7
+ constructor(message) {
8
+ super(message)
9
+ this.name = "TranslationError"
10
+ Error.captureStackTrace(this, this.constructor)
11
+ }
12
+ }
13
+
14
+ class FileError extends Error {
15
+ constructor(message) {
16
+ super(message)
17
+ this.name = "FileError"
18
+ Error.captureStackTrace(this, this.constructor)
19
+ }
20
+ }
21
+
22
+ class RawError extends Error {
23
+ constructor(message) {
24
+ super(message)
25
+ this.name = "RawError"
26
+ Error.captureStackTrace(this, this.constructor)
27
+ }
28
+ }
29
+
30
+ class CSSError extends Error {
31
+ constructor(message) {
32
+ super(message)
33
+ this.name = "CSSError"
34
+ Error.captureStackTrace(this, this.constructor)
35
+ }
36
+ }
37
+
38
+ class ImageError extends Error {
39
+ constructor(message) {
40
+ super(message)
41
+ this.name = "ImageError"
42
+ Error.captureStackTrace(this, this.constructor)
43
+ }
44
+ }
45
+
46
+ class SVGError extends Error {
47
+ constructor(message) {
48
+ super(message)
49
+ this.name = "SVGError"
50
+ Error.captureStackTrace(this, this.constructor)
51
+ }
52
+ }
53
+
54
+ class JSONError extends Error {
55
+ constructor(message) {
56
+ super(message)
57
+ this.name = "JSONError"
58
+ Error.captureStackTrace(this, this.constructor)
59
+ }
60
+ }
61
+
62
+ class ComponentError extends Error {
63
+ constructor(message) {
64
+ super(message)
65
+ this.name = "ComponentError"
66
+ Error.captureStackTrace(this, this.constructor)
67
+ }
68
+ }
69
+
6
70
  function compile(path) {
7
71
  const fn = require(path)
8
72
  return {
@@ -37,7 +101,7 @@ function compile(path) {
37
101
  if (
38
102
  attributes.src ||
39
103
  ["application/json", "application/ld+json"].includes(
40
- attributes.type
104
+ attributes.type,
41
105
  )
42
106
  ) {
43
107
  node.ignore = false
@@ -179,7 +243,7 @@ function validateSymlinks(path, base) {
179
243
  if (!part) continue
180
244
  current = resolve(current, part)
181
245
  if (lstatSync(current).isSymbolicLink()) {
182
- throw new Error(`FileError: symlinks are not allowed ("${current}")`)
246
+ throw new FileError(`symlinks are not allowed ("${current}")`)
183
247
  }
184
248
  }
185
249
  }
@@ -191,33 +255,31 @@ function validateFile(path, base) {
191
255
  const type = extension(normalizedPath)
192
256
 
193
257
  if (!type) {
194
- throw new Error(`FileError: path "${path}" has no extension`)
258
+ throw new FileError(`path "${path}" has no extension`)
195
259
  }
196
260
 
197
261
  if (!ALLOWED_READ_EXTENSIONS.includes(type)) {
198
- throw new Error(
199
- `FileError: unsupported file type "${type}" for path "${path}"`
200
- )
262
+ throw new FileError(`unsupported file type "${type}" for path "${path}"`)
201
263
  }
202
264
 
203
265
  const stats = lstatSync(normalizedPath)
204
266
  if (!stats.isFile()) {
205
- throw new Error(`FileError: path "${path}" is not a file`)
267
+ throw new FileError(`path "${path}" is not a file`)
206
268
  }
207
269
 
208
270
  if (stats.isSymbolicLink()) {
209
- throw new Error(`FileError: path "${path}" is a symbolic link`)
271
+ throw new FileError(`path "${path}" is a symbolic link`)
210
272
  }
211
273
 
212
274
  if (normalizedPath === normalizedBase) {
213
- throw new Error(
214
- `FileError: path "${path}" is the same as the current working directory "${base}"`
275
+ throw new FileError(
276
+ `path "${path}" is the same as the current working directory "${base}"`,
215
277
  )
216
278
  }
217
279
 
218
280
  if (!normalizedPath.startsWith(normalizedBase + "/")) {
219
- throw new Error(
220
- `FileError: real path "${realPath}" is not within the current working directory "${realBase}"`
281
+ throw new FileError(
282
+ `real path "${normalizedPath}" is not within the current working directory "${normalizedBase}"`,
221
283
  )
222
284
  }
223
285
  }
@@ -235,9 +297,7 @@ function readFile(path, encoding) {
235
297
 
236
298
  return readFileSync(path, encoding)
237
299
  } catch (exception) {
238
- throw new Error(
239
- `FileError: cannot read file "${path}": ${exception.message}`
240
- )
300
+ throw new FileError(`cannot read file "${path}": ${exception.message}`)
241
301
  }
242
302
  }
243
303
 
@@ -286,7 +346,7 @@ const ALIASES = {
286
346
  }
287
347
 
288
348
  // Pre-compiled regex for better performance
289
- const KEY_VALIDATION_REGEX = /^[a-zA-Z0-9\-_]+$/
349
+ const KEY_VALIDATION_REGEX = /^[a-zA-Z0-9\-_:]+$/
290
350
  const isKeyValid = (key) => KEY_VALIDATION_REGEX.test(key)
291
351
 
292
352
  const attributes = (options) => {
@@ -309,7 +369,6 @@ const attributes = (options) => {
309
369
  result.push(key)
310
370
  } else {
311
371
  const name = ALIASES[key] || key
312
- const value = options[key]
313
372
  const content = Array.isArray(value) ? classes(...value) : value
314
373
  result.push(`${name}="${escapeHTML(content)}"`)
315
374
  }
@@ -330,8 +389,8 @@ const attributes = (options) => {
330
389
  const left = result.left || "0"
331
390
  styles.push(
332
391
  `${decamelize(param)}:${escapeHTML(
333
- `${top} ${right} ${bottom} ${left}`
334
- )}`
392
+ `${top} ${right} ${bottom} ${left}`,
393
+ )}`,
335
394
  )
336
395
  } else if (typeof result === "string" || typeof result === "number") {
337
396
  styles.push(`${decamelize(param)}:${escapeHTML(result)}`)
@@ -377,8 +436,8 @@ const render = (input, escape = true) => {
377
436
  // Second most common: arrays (~20% of nodes)
378
437
  if (Array.isArray(input)) {
379
438
  let result = ""
380
- for (let i = 0; i < input.length; i++) {
381
- result += render(input[i])
439
+ for (let i = 0, ilen = input.length; i < ilen; i++) {
440
+ result += render(input[i], escape)
382
441
  }
383
442
  return result
384
443
  }
@@ -393,11 +452,6 @@ const render = (input, escape = true) => {
393
452
  return ""
394
453
  }
395
454
 
396
- // Numbers (~5% of nodes)
397
- if (typeof input === "number") {
398
- return input.toString()
399
- }
400
-
401
455
  // Objects (elements) - check ignore flag first
402
456
  if (input.ignore) {
403
457
  return ""
@@ -412,12 +466,24 @@ const render = (input, escape = true) => {
412
466
  return attrs ? `<${input.name} ${attrs}>` : `<${input.name}>`
413
467
  }
414
468
 
415
- const attrs = input.attributes ? attributes(input.attributes) : ""
416
- const children = render(input.children, !UNESCAPED_TAGS.has(input.name))
469
+ if (input.name) {
470
+ const attrs = input.attributes ? attributes(input.attributes) : ""
471
+ const children = render(input.children, !UNESCAPED_TAGS.has(input.name))
417
472
 
418
- return attrs
419
- ? `<${input.name} ${attrs}>${children}</${input.name}>`
420
- : `<${input.name}>${children}</${input.name}>`
473
+ return attrs
474
+ ? `<${input.name} ${attrs}>${children}</${input.name}>`
475
+ : `<${input.name}>${children}</${input.name}>`
476
+ }
477
+
478
+ if (typeof input === "number") {
479
+ return input.toString()
480
+ }
481
+
482
+ if (typeof input === "object" && input instanceof Date) {
483
+ return input.toString()
484
+ }
485
+
486
+ return ""
421
487
  }
422
488
 
423
489
  const raw = (children) => {
@@ -473,9 +539,7 @@ const sanitizeHTML = (content) => {
473
539
  raw.load = function (path, options = {}) {
474
540
  const type = extension(path)
475
541
  if (!ALLOWED_RAW_EXTENSIONS.includes(type)) {
476
- throw new Error(
477
- `RawError: unsupported raw type "${type}" for path "${path}"`
478
- )
542
+ throw new RawError(`unsupported raw type "${type}" for path "${path}"`)
479
543
  }
480
544
 
481
545
  let content = readFile(path, "utf8")
@@ -488,22 +552,48 @@ raw.load = function (path, options = {}) {
488
552
  return raw(content)
489
553
  }
490
554
 
491
- const tag = (a, b, c) => {
492
- if (typeof b === "string" || typeof b === "number" || Array.isArray(b)) {
493
- const name = a
494
- const children = b
555
+ const tag = (tagName, attrsOrChildren, ...restChildren) => {
556
+ // Check if second argument is children (not attributes)
557
+ const isChildrenNotAttributes =
558
+ typeof attrsOrChildren === "string" ||
559
+ typeof attrsOrChildren === "number" ||
560
+ Array.isArray(attrsOrChildren) ||
561
+ (attrsOrChildren &&
562
+ typeof attrsOrChildren === "object" &&
563
+ "name" in attrsOrChildren &&
564
+ "children" in attrsOrChildren)
565
+
566
+ // If we have rest arguments, they must be additional children
567
+ if (restChildren.length > 0) {
568
+ if (isChildrenNotAttributes) {
569
+ // tagName is name, attrsOrChildren is first child, restChildren are more children
570
+ return {
571
+ name: tagName,
572
+ children: [attrsOrChildren, ...restChildren],
573
+ }
574
+ } else {
575
+ // tagName is name, attrsOrChildren is attributes, restChildren are children
576
+ return {
577
+ name: tagName,
578
+ attributes: attrsOrChildren,
579
+ children: restChildren,
580
+ }
581
+ }
582
+ }
583
+
584
+ // Original two-argument logic
585
+ if (isChildrenNotAttributes) {
495
586
  return {
496
- name,
497
- children: children,
587
+ name: tagName,
588
+ children: attrsOrChildren,
498
589
  }
499
590
  }
500
- const name = a
501
- const attributes = b
502
- const children = typeof c === "number" ? c : c || []
591
+
592
+ // attrsOrChildren is attributes, no children provided
503
593
  return {
504
- name,
505
- children,
506
- attributes,
594
+ name: tagName,
595
+ children: [],
596
+ attributes: attrsOrChildren,
507
597
  }
508
598
  }
509
599
 
@@ -578,7 +668,7 @@ function css(inputs) {
578
668
  }
579
669
  }
580
670
 
581
- function occurences(input, string) {
671
+ function occurrences(input, string) {
582
672
  if (string.length <= 0) {
583
673
  return input.length + 1
584
674
  }
@@ -599,8 +689,8 @@ function occurences(input, string) {
599
689
  }
600
690
 
601
691
  const validateCSS = (content, character1, character2) => {
602
- const count1 = occurences(content, character1)
603
- const count2 = occurences(content, character2)
692
+ const count1 = occurrences(content, character1)
693
+ const count2 = occurrences(content, character2)
604
694
  if (count1 !== count2) {
605
695
  return {
606
696
  valid: false,
@@ -632,7 +722,7 @@ css.load = function (path) {
632
722
  const content = readFile(file, "utf8")
633
723
  const { valid, message } = isCSSValid(content)
634
724
  if (!valid) {
635
- throw new Error(`CSSError: invalid CSS for path "${file}": ${message}`)
725
+ throw new CSSError(`invalid CSS for path "${file}": ${message}`)
636
726
  }
637
727
  return css`
638
728
  ${content}
@@ -689,7 +779,10 @@ js.load = function (path, options = {}) {
689
779
  return { js: tag("script", attributes, content) }
690
780
  }
691
781
 
692
- const node = (name) => (options, children) => tag(name, options, children)
782
+ const node =
783
+ (name) =>
784
+ (options, ...children) =>
785
+ tag(name, options, ...children)
693
786
  const Doctype = node("!DOCTYPE html")
694
787
 
695
788
  const nodes = [
@@ -864,9 +957,7 @@ function base64({ content, path }) {
864
957
  nodes.Img.load = function (path) {
865
958
  const type = extension(path)
866
959
  if (!ALLOWED_IMAGE_EXTENSIONS.includes(type)) {
867
- throw new Error(
868
- `ImageError: unsupported image type "${type}" for path "${path}"`
869
- )
960
+ throw new ImageError(`unsupported image type "${type}" for path "${path}"`)
870
961
  }
871
962
  const content = readFile(path, "base64")
872
963
  return (options) => {
@@ -919,9 +1010,7 @@ const sanitizeSVG = (content) => {
919
1010
  nodes.Svg.load = function (path, options = {}) {
920
1011
  const type = extension(path)
921
1012
  if (type !== "svg") {
922
- throw new Error(
923
- `SVGError: unsupported SVG type "${type}" for path "${path}"`
924
- )
1013
+ throw new SVGError(`unsupported SVG type "${type}" for path "${path}"`)
925
1014
  }
926
1015
  let content = readFile(path, "utf8")
927
1016
  if (options.sanitize !== false) {
@@ -961,15 +1050,29 @@ const json = {
961
1050
  try {
962
1051
  return JSON.parse(content)
963
1052
  } catch (exception) {
964
- throw new Error(
965
- `JSONError: cannot parse file "${file}": ${exception.message}`
966
- )
1053
+ throw new JSONError(`cannot parse file "${file}": ${exception.message}`)
967
1054
  }
968
1055
  },
969
1056
  }
970
1057
 
971
1058
  function i18n(translations) {
972
1059
  return function translate(language, key) {
1060
+ if (key === undefined) {
1061
+ throw new TranslationError("key is undefined")
1062
+ }
1063
+ if (language === undefined) {
1064
+ throw new TranslationError("language is undefined")
1065
+ }
1066
+ if (translations[key] === undefined) {
1067
+ throw new TranslationError(
1068
+ `translation [${key}][${language}] is undefined`,
1069
+ )
1070
+ }
1071
+ if (translations[key][language] === undefined) {
1072
+ throw new TranslationError(
1073
+ `translation [${key}][${language}] is undefined`,
1074
+ )
1075
+ }
973
1076
  return translations[key][language]
974
1077
  }
975
1078
  }
@@ -988,14 +1091,14 @@ i18n.load = function (path, options = {}) {
988
1091
 
989
1092
  return function translate(language, key) {
990
1093
  if (!language) {
991
- throw new Error(`TranslationError: language is undefined`)
1094
+ throw new TranslationError(`language is undefined`)
992
1095
  }
993
1096
  if (!key) {
994
- throw new Error(`TranslationError: key is undefined`)
1097
+ throw new TranslationError(`key is undefined`)
995
1098
  }
996
1099
  if (!data[key] || !data[key][language]) {
997
- throw new Error(
998
- `TranslationError: translation [${key}][${language}] is undefined`
1100
+ throw new TranslationError(
1101
+ `translation [${key}][${language}] is undefined`,
999
1102
  )
1000
1103
  }
1001
1104
  return data[key][language]
@@ -1009,20 +1112,20 @@ function component(fn, { styles, i18n, scripts } = {}) {
1009
1112
  }
1010
1113
  if (i18n) {
1011
1114
  if (!a || !a.language) {
1012
- throw new Error(
1013
- `TranslationError: language is undefined for component:\n${fn.toString()}`
1115
+ throw new TranslationError(
1116
+ `language is undefined for component:\n${fn.toString()}`,
1014
1117
  )
1015
1118
  }
1016
1119
  const { language } = a
1017
1120
  function translate(key) {
1018
1121
  if (!key) {
1019
- throw new Error(
1020
- `TranslationError: key is undefined for component:\n${fn.toString()}`
1122
+ throw new TranslationError(
1123
+ `key is undefined for component:\n${fn.toString()}`,
1021
1124
  )
1022
1125
  }
1023
1126
  if (!i18n[key] || !i18n[key][language]) {
1024
- throw new Error(
1025
- `TranslationError: translation [${key}][${language}] is undefined for component:\n${fn.toString()}`
1127
+ throw new TranslationError(
1128
+ `translation [${key}][${language}] is undefined for component:\n${fn.toString()}`,
1026
1129
  )
1027
1130
  }
1028
1131
  const translation = i18n[key][language]
@@ -1038,11 +1141,27 @@ function component(fn, { styles, i18n, scripts } = {}) {
1038
1141
 
1039
1142
  if (styles) {
1040
1143
  const data = Array.isArray(styles) ? styles : [styles]
1144
+ data.forEach((style, index) => {
1145
+ if (!style || typeof style !== "object" || !style.css) {
1146
+ throw new ComponentError(
1147
+ `Invalid style object at index ${index}: missing .css property. ` +
1148
+ `Styles must be created using the css\`...\` template tag or css.load() function.`,
1149
+ )
1150
+ }
1151
+ })
1041
1152
  nodes = nodes.concat(data.map((style) => style.css))
1042
1153
  }
1043
1154
 
1044
1155
  if (scripts) {
1045
1156
  const data = Array.isArray(scripts) ? scripts : [scripts]
1157
+ data.forEach((script, index) => {
1158
+ if (!script || typeof script !== "object" || !script.js) {
1159
+ throw new ComponentError(
1160
+ `Invalid script object at index ${index}: missing .js property. ` +
1161
+ `Scripts must be created using the js\`...\` template tag or js.load() function.`,
1162
+ )
1163
+ }
1164
+ })
1046
1165
  nodes = nodes.concat(data.map((script) => script.js))
1047
1166
  }
1048
1167
 
@@ -1062,5 +1181,13 @@ module.exports = {
1062
1181
  json,
1063
1182
  tag,
1064
1183
  i18n,
1184
+ TranslationError,
1185
+ ComponentError,
1186
+ FileError,
1187
+ RawError,
1188
+ CSSError,
1189
+ ImageError,
1190
+ SVGError,
1191
+ JSONError,
1065
1192
  ...nodes,
1066
1193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -47,12 +47,12 @@
47
47
  "homepage": "https://github.com/buxlabs/boxwood#readme",
48
48
  "devDependencies": {
49
49
  "benchmark": "2.1.4",
50
- "c8": "^10.1.3",
50
+ "c8": "^11.0.0",
51
51
  "express": "^5.2.1",
52
52
  "handlebars": "^4.7.8",
53
- "jsdom": "^27.2.0",
53
+ "jsdom": "^28.1.0",
54
54
  "mustache": "^4.2.0",
55
- "underscore": "^1.13.7"
55
+ "underscore": "^1.13.8"
56
56
  },
57
57
  "standard": {
58
58
  "ignore": [
@@ -62,7 +62,7 @@
62
62
  ]
63
63
  },
64
64
  "dependencies": {
65
- "css-tree": "^3.1.0"
65
+ "css-tree": "^3.2.1"
66
66
  },
67
67
  "prettier": {
68
68
  "semi": false
@@ -1,6 +1,6 @@
1
1
  const { css, component, Div } = require("../..")
2
2
 
3
- function Center({ className, style, height, width } = {}, children) {
3
+ function Center({ className, id, style, height, width } = {}, children) {
4
4
  const styleObject = {
5
5
  display: "flex",
6
6
  "justify-content": "center",
@@ -17,7 +17,7 @@ function Center({ className, style, height, width } = {}, children) {
17
17
  `
18
18
 
19
19
  return [
20
- Div({ className: [className, styles.center], style }, children),
20
+ Div({ className: [className, styles.center], id, style }, children),
21
21
  styles.css,
22
22
  ]
23
23
  }
@@ -16,7 +16,7 @@ const normalizeValue = (value) => {
16
16
  }
17
17
 
18
18
  function Container(
19
- { className, style, width = 1200, padding = 16 } = {},
19
+ { className, style, id, width = 1200, padding = 16 } = {},
20
20
  children
21
21
  ) {
22
22
  width = normalizeValue(width)
@@ -39,7 +39,7 @@ function Container(
39
39
  }
40
40
  `
41
41
  return [
42
- Div({ className: [styles.container, className], style }, children),
42
+ Div({ className: [styles.container, className], id, style }, children),
43
43
  styles.css,
44
44
  ]
45
45
  }
package/ui/grid/index.js CHANGED
@@ -8,7 +8,10 @@ const BREAKPOINTS = {
8
8
  sm: "575px",
9
9
  }
10
10
 
11
- function Grid({ className, columns = 3, gap, breakpoint, style }, children) {
11
+ function Grid(
12
+ { className, columns = 3, gap, id, breakpoint, style },
13
+ children
14
+ ) {
12
15
  gap = normalizeGap(gap)
13
16
  breakpoint = normalizeBreakpoint(breakpoint)
14
17
 
@@ -55,7 +58,7 @@ function Grid({ className, columns = 3, gap, breakpoint, style }, children) {
55
58
  `
56
59
 
57
60
  return [
58
- Div({ className: [styles.grid, className], style }, children),
61
+ Div({ className: [styles.grid, className], id, style }, children),
59
62
  styles.css,
60
63
  ]
61
64
  }
package/ui/group/index.js CHANGED
@@ -8,7 +8,18 @@ const {
8
8
  } = require("../normalize")
9
9
 
10
10
  function Group(
11
- { align, className, breakpoint, justify, gap, width, margin, padding, style },
11
+ {
12
+ align,
13
+ className,
14
+ breakpoint,
15
+ id,
16
+ justify,
17
+ gap,
18
+ width,
19
+ margin,
20
+ padding,
21
+ style,
22
+ },
12
23
  children
13
24
  ) {
14
25
  gap = normalizeGap(gap)
@@ -43,7 +54,7 @@ function Group(
43
54
  `
44
55
 
45
56
  return [
46
- Div({ className: [styles.group, className], style }, children),
57
+ Div({ className: [styles.group, className], id, style }, children),
47
58
  styles.css,
48
59
  ]
49
60
  }
@@ -11,29 +11,31 @@ function Markdown(params, children) {
11
11
  return lines.map((line) => {
12
12
  if (line.startsWith("# ")) {
13
13
  const text = line.substring(2)
14
- return H1(text)
14
+ return H1(params, text)
15
15
  } else if (line.startsWith("## ")) {
16
16
  const text = line.substring(3)
17
- return H2(text)
17
+ return H2(params, text)
18
18
  } else if (line.startsWith("### ")) {
19
19
  const text = line.substring(4)
20
- return H3(text)
20
+ return H3(params, text)
21
21
  } else if (line.startsWith("#### ")) {
22
22
  const text = line.substring(5)
23
- return H4(text)
23
+ return H4(params, text)
24
24
  } else if (line.startsWith("##### ")) {
25
25
  const text = line.substring(6)
26
- return H5(text)
26
+ return H5(params, text)
27
27
  } else if (line.startsWith("###### ")) {
28
28
  const text = line.substring(7)
29
- return H6(text)
29
+ return H6(params, text)
30
30
  } else if (line.startsWith("> ")) {
31
31
  const text = line.substring(2)
32
- return Blockquote(text)
32
+ return Blockquote(params, text)
33
33
  }
34
34
 
35
- return P(line)
35
+ return P(params, line)
36
36
  })
37
+ } else {
38
+ return null
37
39
  }
38
40
  }
39
41
 
package/ui/stack/index.js CHANGED
@@ -7,7 +7,7 @@ const {
7
7
  } = require("../normalize")
8
8
 
9
9
  function Stack(
10
- { align, className, justify, gap, width, margin, padding, style },
10
+ { align, className, id, justify, gap, width, margin, padding, style },
11
11
  children
12
12
  ) {
13
13
  gap = normalizeGap(gap)
@@ -36,7 +36,7 @@ function Stack(
36
36
  `
37
37
 
38
38
  return [
39
- Div({ className: [styles.stack, className], style }, children),
39
+ Div({ className: [styles.stack, className], id, style }, children),
40
40
  styles.css,
41
41
  ]
42
42
  }