boxwood 0.80.0 → 1.1.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/README.md +11 -1
- package/index.js +306 -32
- package/package.json +8 -14
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
7. i18n support
|
|
17
17
|
8. server side
|
|
18
18
|
9. good for seo
|
|
19
|
-
10. small (1 file,
|
|
19
|
+
10. small (1 file, 890 LOC~)
|
|
20
20
|
11. easy to start, familiar syntax
|
|
21
21
|
12. easy to test
|
|
22
22
|
|
|
@@ -136,6 +136,16 @@ test("banner renders an optional description", async () => {
|
|
|
136
136
|
|
|
137
137
|
You can check the `test` dir for more examples.
|
|
138
138
|
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
By default, boxwood sanitizes all HTML, SVG and i18n content loaded via its API to protect against basic XSS attacks.
|
|
142
|
+
|
|
143
|
+
Disabling sanitization ({ sanitize: false }) is only safe for trusted, developer-controlled files. Never use it with user-generated or untrusted content.
|
|
144
|
+
|
|
145
|
+
All file access is restricted to the project directory and symlinks are not allowed by default to prevent path traversal attacks.
|
|
146
|
+
|
|
147
|
+
That said, the library is pretty small so please review it and suggest improvements if you have any.
|
|
148
|
+
|
|
139
149
|
## Maintainers
|
|
140
150
|
|
|
141
151
|
[@emilos](https://github.com/emilos)
|
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { join } = require("path")
|
|
2
|
-
const { readFileSync } = require("fs")
|
|
1
|
+
const { join, resolve, sep: separator } = require("path")
|
|
2
|
+
const { readFileSync, realpathSync, lstatSync } = require("fs")
|
|
3
3
|
const csstree = require("css-tree")
|
|
4
4
|
|
|
5
5
|
function compile(path) {
|
|
@@ -106,6 +106,86 @@ const escapeHTML = (string) => {
|
|
|
106
106
|
})
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const normalizePath = (path) => path.replace(/\\/g, "/").replace(/\/+$/, "")
|
|
110
|
+
|
|
111
|
+
const ALLOWED_RAW_EXTENSIONS = ["html", "txt"]
|
|
112
|
+
const ALLOWED_CODE_EXTENSIONS = ["js", "css", "json"]
|
|
113
|
+
const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg"]
|
|
114
|
+
const ALLOWED_READ_EXTENSIONS = [
|
|
115
|
+
...ALLOWED_RAW_EXTENSIONS,
|
|
116
|
+
...ALLOWED_IMAGE_EXTENSIONS,
|
|
117
|
+
...ALLOWED_CODE_EXTENSIONS,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
function validateSymlinks(path, base) {
|
|
121
|
+
let relative = path.slice(base.length + 1).split(separator)
|
|
122
|
+
let current = base
|
|
123
|
+
for (const part of relative) {
|
|
124
|
+
if (!part) continue
|
|
125
|
+
current = resolve(current, part)
|
|
126
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
127
|
+
throw new Error(`FileError: symlinks are not allowed ("${current}")`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateFile(path, base) {
|
|
133
|
+
const normalizedPath = normalizePath(path)
|
|
134
|
+
const normalizedBase = normalizePath(base)
|
|
135
|
+
|
|
136
|
+
const type = extension(normalizedPath)
|
|
137
|
+
|
|
138
|
+
if (!type) {
|
|
139
|
+
throw new Error(`FileError: path "${path}" has no extension`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!ALLOWED_READ_EXTENSIONS.includes(type)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`FileError: unsupported file type "${type}" for path "${path}"`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stats = lstatSync(normalizedPath)
|
|
149
|
+
if (!stats.isFile()) {
|
|
150
|
+
throw new Error(`FileError: path "${path}" is not a file`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (stats.isSymbolicLink()) {
|
|
154
|
+
throw new Error(`FileError: path "${path}" is a symbolic link`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (normalizedPath === normalizedBase) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`FileError: path "${path}" is the same as the current working directory "${base}"`
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!normalizedPath.startsWith(normalizedBase + "/")) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`FileError: real path "${realPath}" is not within the current working directory "${realBase}"`
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readFile(path, encoding) {
|
|
171
|
+
try {
|
|
172
|
+
const base = process.cwd()
|
|
173
|
+
const absoluteBase = resolve(base)
|
|
174
|
+
const absolutePath = resolve(path)
|
|
175
|
+
const realBase = realpathSync(absoluteBase)
|
|
176
|
+
const realPath = realpathSync(absolutePath)
|
|
177
|
+
|
|
178
|
+
validateSymlinks(realPath, realBase)
|
|
179
|
+
validateFile(realPath, realBase)
|
|
180
|
+
|
|
181
|
+
return readFileSync(path, encoding)
|
|
182
|
+
} catch (exception) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`FileError: cannot read file "${path}": ${exception.message}`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
109
189
|
const BOOLEAN_ATTRIBUTES = [
|
|
110
190
|
"async",
|
|
111
191
|
"autofocus",
|
|
@@ -150,9 +230,17 @@ const ALIASES = {
|
|
|
150
230
|
htmlFor: "for",
|
|
151
231
|
}
|
|
152
232
|
|
|
233
|
+
const isKeyValid = (key) => /^[a-zA-Z0-9\-_]+$/.test(key)
|
|
234
|
+
|
|
153
235
|
const attributes = (options) => {
|
|
236
|
+
if (!options) {
|
|
237
|
+
return ""
|
|
238
|
+
}
|
|
154
239
|
const result = []
|
|
155
240
|
for (const key in options) {
|
|
241
|
+
if (!isKeyValid(key)) {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
156
244
|
const value = options[key]
|
|
157
245
|
if (
|
|
158
246
|
typeof value === "string" ||
|
|
@@ -166,10 +254,25 @@ const attributes = (options) => {
|
|
|
166
254
|
const name = ALIASES[key] || key
|
|
167
255
|
const value = options[key]
|
|
168
256
|
const content = Array.isArray(value) ? classes(...value) : value
|
|
169
|
-
result.push(name + "=" + '"' + content + '"')
|
|
257
|
+
result.push(name + "=" + '"' + escapeHTML(content) + '"')
|
|
258
|
+
}
|
|
259
|
+
} else if (key === "style" && typeof value === "object") {
|
|
260
|
+
const styles = []
|
|
261
|
+
for (const param in value) {
|
|
262
|
+
if (!isKeyValid(param)) {
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const result = value[param]
|
|
266
|
+
if (result) {
|
|
267
|
+
styles.push(`${decamelize(param)}:${escapeHTML(result)}`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (styles.length > 0) {
|
|
271
|
+
result.push(`style="${styles.join(";")}"`)
|
|
170
272
|
}
|
|
171
273
|
}
|
|
172
274
|
}
|
|
275
|
+
|
|
173
276
|
return result.join(" ")
|
|
174
277
|
}
|
|
175
278
|
|
|
@@ -229,9 +332,12 @@ const render = (input, escape = true) => {
|
|
|
229
332
|
return `<${input.name}>`
|
|
230
333
|
}
|
|
231
334
|
|
|
335
|
+
const string = input.attributes ? attributes(input.attributes) : ""
|
|
336
|
+
const attrs = string ? " " + string : ""
|
|
337
|
+
|
|
232
338
|
return (
|
|
233
339
|
`<${input.name}` +
|
|
234
|
-
|
|
340
|
+
attrs +
|
|
235
341
|
">" +
|
|
236
342
|
render(input.children, isUnescapedTag(input.name)) +
|
|
237
343
|
`</${input.name}>`
|
|
@@ -242,9 +348,67 @@ const raw = (children) => {
|
|
|
242
348
|
return { name: "raw", children }
|
|
243
349
|
}
|
|
244
350
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Never trust HTML files from untrusted sources.
|
|
353
|
+
*
|
|
354
|
+
* This function is a basic sanitization of HTML content in case
|
|
355
|
+
* you've accidentally included a "trusted", but malicious HTML file that was downloaded
|
|
356
|
+
* from the internet or other untrusted sources.
|
|
357
|
+
*
|
|
358
|
+
* This function removes script and style tags, inline event handlers,
|
|
359
|
+
* and any href attributes that use JavaScript. It does not
|
|
360
|
+
* guarantee complete security, but it helps to mitigate some common
|
|
361
|
+
* XSS attacks that can be embedded in HTML files.
|
|
362
|
+
*
|
|
363
|
+
* It is recommended to check all HTML files before using them
|
|
364
|
+
* in your application.
|
|
365
|
+
*
|
|
366
|
+
* Never trust user-generated content.
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
const sanitizeHTML = (content) => {
|
|
370
|
+
return content
|
|
371
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
372
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
373
|
+
.replace(/\son\w+="[^"]*"/gi, "")
|
|
374
|
+
.replace(/\son\w+='[^']*'/gi, "")
|
|
375
|
+
.replace(/(href|xlink:href)\s*=\s*(['"])javascript:[^'"]*\2/gi, "")
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/*
|
|
379
|
+
* Raw content is a special case where we want to allow
|
|
380
|
+
* unescaped HTML content to be rendered directly.
|
|
381
|
+
* This is useful for cases where we want to
|
|
382
|
+
* include HTML fragments or templates that are
|
|
383
|
+
* not meant to be escaped, like large blocks of HTML,
|
|
384
|
+
* or when integrating with third-party libraries
|
|
385
|
+
* that require raw HTML.
|
|
386
|
+
*
|
|
387
|
+
* Please note that this should be used with caution,
|
|
388
|
+
* as it can lead to XSS vulnerabilities if the content
|
|
389
|
+
* is not properly sanitized.
|
|
390
|
+
*
|
|
391
|
+
* It should only be used for trusted content
|
|
392
|
+
* or in controlled environments.
|
|
393
|
+
*
|
|
394
|
+
* Should not be used for user-generated content.
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
raw.load = function (path, options = {}) {
|
|
398
|
+
const type = extension(path)
|
|
399
|
+
if (!ALLOWED_RAW_EXTENSIONS.includes(type)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`RawError: unsupported raw type "${type}" for path "${path}"`
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let content = readFile(path, "utf8")
|
|
406
|
+
if (type === "html" && options.sanitize !== false) {
|
|
407
|
+
content = sanitizeHTML(content)
|
|
408
|
+
} else if (type === "txt" && options.escape !== false) {
|
|
409
|
+
content = escapeHTML(content)
|
|
410
|
+
}
|
|
411
|
+
|
|
248
412
|
return raw(content)
|
|
249
413
|
}
|
|
250
414
|
|
|
@@ -272,6 +436,34 @@ function sequence() {
|
|
|
272
436
|
return number++
|
|
273
437
|
}
|
|
274
438
|
|
|
439
|
+
function decamelize(string) {
|
|
440
|
+
return string.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function stylesheet(input) {
|
|
444
|
+
const object = { ...input }
|
|
445
|
+
return {
|
|
446
|
+
add(item) {
|
|
447
|
+
for (const key in item) {
|
|
448
|
+
object[key] = item[key]
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
set(key, value) {
|
|
452
|
+
object[key] = value
|
|
453
|
+
},
|
|
454
|
+
toString() {
|
|
455
|
+
let result = []
|
|
456
|
+
for (const key in object) {
|
|
457
|
+
const value = object[key]
|
|
458
|
+
if (value) {
|
|
459
|
+
result.push(`${decamelize(key)}:${value}`)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return result.join(";")
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
275
467
|
function css(inputs) {
|
|
276
468
|
let result = ""
|
|
277
469
|
for (let i = 0, ilen = inputs.length; i < ilen; i += 1) {
|
|
@@ -301,15 +493,22 @@ function css(inputs) {
|
|
|
301
493
|
}
|
|
302
494
|
}
|
|
303
495
|
|
|
304
|
-
css.load = function () {
|
|
305
|
-
const path = join(...arguments)
|
|
496
|
+
css.load = function (path) {
|
|
306
497
|
const file = path.endsWith(".css") ? path : join(path, "index.css")
|
|
307
|
-
const content =
|
|
498
|
+
const content = readFile(file, "utf8")
|
|
308
499
|
return css`
|
|
309
500
|
${content}
|
|
310
501
|
`
|
|
311
502
|
}
|
|
312
503
|
|
|
504
|
+
css.create = function (object) {
|
|
505
|
+
return stylesheet(object)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
css.inline = function (object) {
|
|
509
|
+
return stylesheet(object).toString()
|
|
510
|
+
}
|
|
511
|
+
|
|
313
512
|
function js(inputs) {
|
|
314
513
|
let result = ""
|
|
315
514
|
for (let i = 0, ilen = inputs.length; i < ilen; i += 1) {
|
|
@@ -326,18 +525,23 @@ function js(inputs) {
|
|
|
326
525
|
}
|
|
327
526
|
}
|
|
328
527
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
528
|
+
/*
|
|
529
|
+
* Load a JavaScript file and return a script tag.
|
|
530
|
+
*
|
|
531
|
+
* Please note that this should be used with caution,
|
|
532
|
+
* as it can lead to XSS vulnerabilities if the content
|
|
533
|
+
* is not properly sanitized.
|
|
534
|
+
*
|
|
535
|
+
* It should only be used for trusted content
|
|
536
|
+
* or in controlled environments.
|
|
537
|
+
*
|
|
538
|
+
* Should not be used for user-generated content.
|
|
539
|
+
*/
|
|
540
|
+
|
|
541
|
+
js.load = function (path, options = {}) {
|
|
337
542
|
const file = path.endsWith(".js") ? path : join(path, "index.js")
|
|
338
|
-
const content =
|
|
543
|
+
const content = readFile(file, "utf8")
|
|
339
544
|
|
|
340
|
-
const options = arguments[arguments.length - 1]
|
|
341
545
|
const attributes = options.target ? { target: options.target } : {}
|
|
342
546
|
if (options && options.transform) {
|
|
343
547
|
return {
|
|
@@ -483,17 +687,73 @@ function base64({ content, path }) {
|
|
|
483
687
|
return `data:${media(path)};base64,${content}`
|
|
484
688
|
}
|
|
485
689
|
|
|
486
|
-
nodes.img.load = function () {
|
|
487
|
-
const
|
|
488
|
-
|
|
690
|
+
nodes.img.load = function (path) {
|
|
691
|
+
const type = extension(path)
|
|
692
|
+
if (!ALLOWED_IMAGE_EXTENSIONS.includes(type)) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`ImageError: unsupported image type "${type}" for path "${path}"`
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
const content = readFile(path, "base64")
|
|
489
698
|
return (options) => {
|
|
490
699
|
return nodes.img({ src: base64({ content, path }), ...options })
|
|
491
700
|
}
|
|
492
701
|
}
|
|
493
702
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
703
|
+
/*
|
|
704
|
+
Never trust SVG files from untrusted sources.
|
|
705
|
+
This function is a basic sanitization of SVG content in case
|
|
706
|
+
you've accidentally included a "trusted", but malicious SVG file that was downloaded
|
|
707
|
+
from the internet or other untrusted sources.
|
|
708
|
+
|
|
709
|
+
This function removes script and style tags, inline event handlers,
|
|
710
|
+
and any href attributes that use JavaScript. It does not
|
|
711
|
+
guarantee complete security, but it helps to mitigate some common
|
|
712
|
+
XSS attacks that can be embedded in SVG files.
|
|
713
|
+
|
|
714
|
+
It is recommended to check all SVG files before using them
|
|
715
|
+
in your application.
|
|
716
|
+
*/
|
|
717
|
+
const sanitizeSVG = (content) => {
|
|
718
|
+
return content
|
|
719
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
720
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
721
|
+
.replace(/\son\w+="[^"]*"/gi, "")
|
|
722
|
+
.replace(/\son\w+='[^']*'/gi, "")
|
|
723
|
+
.replace(/(href|xlink:href)\s*=\s*(['"])javascript:[^'"]*\2/gi, "")
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/*
|
|
727
|
+
* SVG files are a special case where we want to allow
|
|
728
|
+
* unescaped SVG content to be rendered directly.
|
|
729
|
+
* This is useful for cases where we want to
|
|
730
|
+
* include SVG fragments or templates that are
|
|
731
|
+
* not meant to be escaped, like large blocks of SVG,
|
|
732
|
+
* or when integrating with third-party libraries
|
|
733
|
+
* that require raw SVG.
|
|
734
|
+
*
|
|
735
|
+
* Please note that this should be used with caution,
|
|
736
|
+
* as it can lead to XSS vulnerabilities if the content
|
|
737
|
+
* is not properly sanitized.
|
|
738
|
+
*
|
|
739
|
+
* It should only be used for trusted content
|
|
740
|
+
* or in controlled environments.
|
|
741
|
+
*
|
|
742
|
+
* Should not be used for user-generated content.
|
|
743
|
+
*/
|
|
744
|
+
|
|
745
|
+
nodes.svg.load = function (path, options = {}) {
|
|
746
|
+
const type = extension(path)
|
|
747
|
+
if (type !== "svg") {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`SVGError: unsupported SVG type "${type}" for path "${path}"`
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
let content = readFile(path, "utf8")
|
|
753
|
+
if (options.sanitize !== false) {
|
|
754
|
+
content = sanitizeSVG(content)
|
|
755
|
+
}
|
|
756
|
+
|
|
497
757
|
return raw(content)
|
|
498
758
|
}
|
|
499
759
|
|
|
@@ -521,11 +781,16 @@ function classes() {
|
|
|
521
781
|
}
|
|
522
782
|
|
|
523
783
|
const json = {
|
|
524
|
-
load() {
|
|
525
|
-
const path = join(...arguments)
|
|
784
|
+
load(path) {
|
|
526
785
|
const file = path.endsWith(".json") ? path : join(path, "index.json")
|
|
527
|
-
const content =
|
|
528
|
-
|
|
786
|
+
const content = readFile(file, "utf8")
|
|
787
|
+
try {
|
|
788
|
+
return JSON.parse(content)
|
|
789
|
+
} catch (exception) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`JSONError: cannot parse file "${file}": ${exception.message}`
|
|
792
|
+
)
|
|
793
|
+
}
|
|
529
794
|
},
|
|
530
795
|
}
|
|
531
796
|
|
|
@@ -535,9 +800,18 @@ function i18n(translations) {
|
|
|
535
800
|
}
|
|
536
801
|
}
|
|
537
802
|
|
|
538
|
-
i18n.load = function () {
|
|
539
|
-
const path = join(...arguments)
|
|
803
|
+
i18n.load = function (path, options = {}) {
|
|
540
804
|
const data = json.load(path)
|
|
805
|
+
if (options.sanitize !== false) {
|
|
806
|
+
for (const key in data) {
|
|
807
|
+
for (const lang in data[key]) {
|
|
808
|
+
if (typeof data[key][lang] === "string") {
|
|
809
|
+
data[key][lang] = sanitizeHTML(data[key][lang])
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
541
815
|
return function translate(language, key) {
|
|
542
816
|
if (!language) {
|
|
543
817
|
throw new Error(`TranslationError: language is undefined`)
|
package/package.json
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "boxwood",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Compile HTML templates into JS",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test --test-reporter=dot",
|
|
8
|
-
"test:debug": "node --test --test-reporter=spec",
|
|
7
|
+
"test": "node --test --test-reporter=dot \"test/**/*.test.js\" \"test/**/*.spec.js\"",
|
|
8
|
+
"test:debug": "node --test --test-reporter=spec \"test/**/*.test.js\" \"test/**/*.spec.js\"",
|
|
9
9
|
"coverage": "c8 npm test",
|
|
10
10
|
"benchmark": "node --test benchmark/index.js",
|
|
11
11
|
"watch": "npm test -- --watch",
|
|
12
12
|
"prepush": "npm test"
|
|
13
13
|
},
|
|
14
|
-
"ava": {
|
|
15
|
-
"files": [
|
|
16
|
-
"test/spec/**/*.js",
|
|
17
|
-
"**/*.spec.js"
|
|
18
|
-
]
|
|
19
|
-
},
|
|
20
14
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
15
|
+
"node": ">= 24.1.0"
|
|
22
16
|
},
|
|
23
17
|
"repository": {
|
|
24
18
|
"type": "git",
|
|
@@ -47,10 +41,10 @@
|
|
|
47
41
|
"homepage": "https://github.com/buxlabs/boxwood#readme",
|
|
48
42
|
"devDependencies": {
|
|
49
43
|
"benchmark": "2.1.4",
|
|
50
|
-
"c8": "^10.1.
|
|
51
|
-
"express": "^
|
|
44
|
+
"c8": "^10.1.3",
|
|
45
|
+
"express": "^5.1.0",
|
|
52
46
|
"handlebars": "^4.7.8",
|
|
53
|
-
"jsdom": "^
|
|
47
|
+
"jsdom": "^26.1.0",
|
|
54
48
|
"mustache": "^4.2.0",
|
|
55
49
|
"underscore": "^1.13.7"
|
|
56
50
|
},
|
|
@@ -62,7 +56,7 @@
|
|
|
62
56
|
]
|
|
63
57
|
},
|
|
64
58
|
"dependencies": {
|
|
65
|
-
"css-tree": "^3.
|
|
59
|
+
"css-tree": "^3.1.0"
|
|
66
60
|
},
|
|
67
61
|
"prettier": {
|
|
68
62
|
"semi": false
|