boxwood 0.74.1 → 0.76.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/LICENSE +1 -1
- package/README.md +111 -0
- package/benchmark/fixtures/friends/boxwood.js +2 -3
- package/benchmark/fixtures/if/boxwood.js +2 -2
- package/benchmark/fixtures/search/boxwood.js +7 -9
- package/benchmark/fixtures/todos/boxwood.js +3 -3
- package/benchmark/index.js +6 -12
- package/index.js +51 -79
- package/package.json +4 -6
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -5,9 +5,28 @@
|
|
|
5
5
|
|
|
6
6
|
> Server side templating engine written in JavaScript
|
|
7
7
|
|
|
8
|
+
[boxwood](https://github.com/buxlabs/boxwood) was created to achieve the following design goals:
|
|
9
|
+
|
|
10
|
+
1. templates can be split into components
|
|
11
|
+
2. css is hashed per component
|
|
12
|
+
3. css is automatically minified
|
|
13
|
+
4. critical css is inlined
|
|
14
|
+
5. templates can import other dependencies
|
|
15
|
+
6. inline images or svgs
|
|
16
|
+
7. i18n support
|
|
17
|
+
8. server side
|
|
18
|
+
9. good for seo
|
|
19
|
+
10. small (1 file, 700 LOC~)
|
|
20
|
+
11. easy to start, familiar syntax
|
|
21
|
+
12. easy to test
|
|
22
|
+
|
|
23
|
+
The template starts with a standard js file, which builds a tree of nodes, that get rendered to html.
|
|
24
|
+
|
|
8
25
|
## Table of Contents
|
|
9
26
|
|
|
10
27
|
- [Install](#install)
|
|
28
|
+
- [Usage](#usage)
|
|
29
|
+
- [Syntax](#syntax)
|
|
11
30
|
- [Maintainers](#maintainers)
|
|
12
31
|
- [Contributing](#contributing)
|
|
13
32
|
- [License](#license)
|
|
@@ -16,6 +35,98 @@
|
|
|
16
35
|
|
|
17
36
|
`npm install boxwood`
|
|
18
37
|
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const { compile } = require("boxwood")
|
|
42
|
+
const { join } = require("path")
|
|
43
|
+
// ...
|
|
44
|
+
const path = join(__dirname, "index.js")
|
|
45
|
+
const { template } = await compile(path)
|
|
46
|
+
// ...
|
|
47
|
+
const html = template({ foo: "bar" })
|
|
48
|
+
console.log(html)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Syntax
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// example/index.js
|
|
55
|
+
const layout = require("./layout")
|
|
56
|
+
const banner = require("./banner")
|
|
57
|
+
|
|
58
|
+
module.exports = () => {
|
|
59
|
+
return layout([
|
|
60
|
+
banner({
|
|
61
|
+
title: "Hello, world!",
|
|
62
|
+
description: "Lorem ipsum dolor sit amet",
|
|
63
|
+
}),
|
|
64
|
+
])
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
// example/layout/index.js
|
|
70
|
+
|
|
71
|
+
const { component, css, html, head, body } = require("boxwood")
|
|
72
|
+
const head = require("./head")
|
|
73
|
+
|
|
74
|
+
const styles = css.load(__dirname)
|
|
75
|
+
|
|
76
|
+
module.exports = component(
|
|
77
|
+
(children) => {
|
|
78
|
+
return html([head(), body({ className: styles.layout }, children)])
|
|
79
|
+
},
|
|
80
|
+
{ styles }
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
// example/head/index.js
|
|
86
|
+
const { head, title } = require("boxwood")
|
|
87
|
+
|
|
88
|
+
module.exports = () => {
|
|
89
|
+
return head([title("example")])
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// example/banner/index.js
|
|
95
|
+
const { component, css, h1, p, section } = require("boxwood")
|
|
96
|
+
|
|
97
|
+
const styles = css.load(__dirname)
|
|
98
|
+
|
|
99
|
+
module.exports = component(
|
|
100
|
+
({ title, description }) => {
|
|
101
|
+
return section({ className: styles.banner }, [
|
|
102
|
+
h1(title),
|
|
103
|
+
description && p(description),
|
|
104
|
+
])
|
|
105
|
+
},
|
|
106
|
+
{ styles }
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
// example/banner/index.test.js
|
|
112
|
+
const test = require("node:test")
|
|
113
|
+
const assert = require("node:assert")
|
|
114
|
+
const { compile } = require("boxwood")
|
|
115
|
+
|
|
116
|
+
test("banner renders a title", async () => {
|
|
117
|
+
const { template } = await compile(__dirname)
|
|
118
|
+
const html = template({ title: "foo" })
|
|
119
|
+
assert(html.includes("<h1>foo</h1>"))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("banner renders an optional description", async () => {
|
|
123
|
+
const { template } = await compile(__dirname)
|
|
124
|
+
const html = template({ title: "foo", description: "bar" })
|
|
125
|
+
assert(html.includes("<h1>foo</h1>"))
|
|
126
|
+
assert(html.includes("<p>bar</p>"))
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
19
130
|
## Maintainers
|
|
20
131
|
|
|
21
132
|
[@emilos](https://github.com/emilos)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const {
|
|
2
|
-
fragment,
|
|
3
2
|
doctype,
|
|
4
3
|
html,
|
|
5
4
|
head,
|
|
@@ -14,7 +13,7 @@ const {
|
|
|
14
13
|
} = require("../../..")
|
|
15
14
|
|
|
16
15
|
module.exports = function ({ friends }) {
|
|
17
|
-
return
|
|
16
|
+
return [
|
|
18
17
|
doctype(),
|
|
19
18
|
html({ lang: "en" }, [
|
|
20
19
|
head([meta({ charset: "UTF-8" }), title("Friends")]),
|
|
@@ -52,5 +51,5 @@ module.exports = function ({ friends }) {
|
|
|
52
51
|
),
|
|
53
52
|
]),
|
|
54
53
|
]),
|
|
55
|
-
]
|
|
54
|
+
]
|
|
56
55
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { div, span } = require("../../..")
|
|
2
2
|
|
|
3
3
|
function description(account) {
|
|
4
4
|
if (account.status === "closed") {
|
|
@@ -17,5 +17,5 @@ function description(account) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
module.exports = function ({ accounts }) {
|
|
20
|
-
return
|
|
20
|
+
return accounts.map((account) => div(description(account)))
|
|
21
21
|
}
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { div, ul, li, p } = require("../../..")
|
|
2
2
|
|
|
3
3
|
module.exports = function ({ count, results }) {
|
|
4
4
|
return div({ class: "search" }, [
|
|
5
5
|
div({ class: "loader" }, "Loading..."),
|
|
6
6
|
div({ class: "results" }, [
|
|
7
7
|
p(`${count} results`),
|
|
8
|
-
...results.map((result) =>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
])
|
|
15
|
-
),
|
|
8
|
+
...results.map((result) => [
|
|
9
|
+
div({ class: "title" }, result.title),
|
|
10
|
+
div({ class: "description" }, result.description),
|
|
11
|
+
result.featured && div({ class: "highlight" }, "Featured!"),
|
|
12
|
+
result.sizes.length && ul(result.sizes.map((size) => li(size))),
|
|
13
|
+
]),
|
|
16
14
|
]),
|
|
17
15
|
])
|
|
18
16
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
const { h1, h2, ul, li
|
|
1
|
+
const { h1, h2, ul, li } = require("../../..")
|
|
2
2
|
|
|
3
3
|
module.exports = function ({ title, subtitle, todos }) {
|
|
4
|
-
return
|
|
4
|
+
return [
|
|
5
5
|
h1(title),
|
|
6
6
|
h2(subtitle),
|
|
7
7
|
ul(todos.map((todo) => li(todo.description))),
|
|
8
|
-
]
|
|
8
|
+
]
|
|
9
9
|
}
|
package/benchmark/index.js
CHANGED
|
@@ -6,7 +6,6 @@ const {
|
|
|
6
6
|
} = require("fs")
|
|
7
7
|
const { Suite } = require("benchmark")
|
|
8
8
|
const underscore = require("underscore")
|
|
9
|
-
const template = require("lodash.template")
|
|
10
9
|
const handlebars = require("handlebars")
|
|
11
10
|
const mustache = require("mustache")
|
|
12
11
|
const { compile } = require("..")
|
|
@@ -34,10 +33,9 @@ async function benchmark(dir) {
|
|
|
34
33
|
path.join(__dirname, `./fixtures/${dir}/boxwood.js`)
|
|
35
34
|
)
|
|
36
35
|
const fn2 = underscore.template(source2)
|
|
37
|
-
const fn3 =
|
|
38
|
-
const fn4 =
|
|
39
|
-
const fn5 = (
|
|
40
|
-
const fn6 = require(path.join(__dirname, `./fixtures/${dir}/vanilla.js`))
|
|
36
|
+
const fn3 = handlebars.compile(source4)
|
|
37
|
+
const fn4 = (data) => mustache.render(source5, data)
|
|
38
|
+
const fn5 = require(path.join(__dirname, `./fixtures/${dir}/vanilla.js`))
|
|
41
39
|
mustache.parse(source5)
|
|
42
40
|
|
|
43
41
|
const data = require(path.join(__dirname, `./fixtures/${dir}/data.json`))
|
|
@@ -52,12 +50,11 @@ async function benchmark(dir) {
|
|
|
52
50
|
assert.deepEqual(result, normalize(fn3(data)))
|
|
53
51
|
assert.deepEqual(result, normalize(fn4(data)))
|
|
54
52
|
assert.deepEqual(result, normalize(fn5(data)))
|
|
55
|
-
assert.deepEqual(result, normalize(fn6(data)))
|
|
56
53
|
|
|
57
54
|
await new Promise((resolve) => {
|
|
58
55
|
suite
|
|
59
56
|
.add("vanilla[js]", function () {
|
|
60
|
-
|
|
57
|
+
fn5(data)
|
|
61
58
|
})
|
|
62
59
|
.add("boxwood[js]", function () {
|
|
63
60
|
fn1(data)
|
|
@@ -65,14 +62,11 @@ async function benchmark(dir) {
|
|
|
65
62
|
.add("underscore[ejs]", function () {
|
|
66
63
|
fn2(data)
|
|
67
64
|
})
|
|
68
|
-
.add("lodash[ejs]", function () {
|
|
69
|
-
fn3(data)
|
|
70
|
-
})
|
|
71
65
|
.add("handlebars[hbs]", function () {
|
|
72
|
-
|
|
66
|
+
fn3(data)
|
|
73
67
|
})
|
|
74
68
|
.add("mustache[mst]", function () {
|
|
75
|
-
|
|
69
|
+
fn4(data)
|
|
76
70
|
})
|
|
77
71
|
.on("cycle", function (event) {
|
|
78
72
|
console.log(`${dir}: ${String(event.target)}`)
|
package/index.js
CHANGED
|
@@ -2,7 +2,6 @@ const { join } = require("path")
|
|
|
2
2
|
const { readFileSync } = require("fs")
|
|
3
3
|
const csstree = require("css-tree")
|
|
4
4
|
const toHash = require("string-hash")
|
|
5
|
-
const YAML = require("yaml")
|
|
6
5
|
|
|
7
6
|
async function compile(path) {
|
|
8
7
|
const fn = require(path)
|
|
@@ -175,7 +174,7 @@ const isUnescapedTag = (name) => {
|
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
const render = (input, escape = true) => {
|
|
178
|
-
if (input.ignore) {
|
|
177
|
+
if (!input || input.ignore) {
|
|
179
178
|
return ""
|
|
180
179
|
}
|
|
181
180
|
if (Array.isArray(input)) {
|
|
@@ -188,10 +187,10 @@ const render = (input, escape = true) => {
|
|
|
188
187
|
return input.toString()
|
|
189
188
|
}
|
|
190
189
|
if (typeof input === "string") {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return
|
|
190
|
+
if (escape) {
|
|
191
|
+
return escapeHTML(input)
|
|
192
|
+
}
|
|
193
|
+
return input
|
|
195
194
|
}
|
|
196
195
|
if (input.name === "raw") {
|
|
197
196
|
return render(input.children, false)
|
|
@@ -202,32 +201,14 @@ const render = (input, escape = true) => {
|
|
|
202
201
|
}
|
|
203
202
|
return `<${input.name}>`
|
|
204
203
|
}
|
|
205
|
-
if (input.attributes && input.children) {
|
|
206
|
-
return (
|
|
207
|
-
`<${input.name} ` +
|
|
208
|
-
attributes(input.attributes) +
|
|
209
|
-
">" +
|
|
210
|
-
render(input.children, isUnescapedTag(input.name)) +
|
|
211
|
-
`</${input.name}>`
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
if (input.attributes) {
|
|
215
|
-
return (
|
|
216
|
-
`<${input.name} ` + attributes(input.attributes) + `></${input.name}>`
|
|
217
|
-
)
|
|
218
|
-
}
|
|
219
|
-
if (input.children) {
|
|
220
|
-
return (
|
|
221
|
-
`<${input.name}>` +
|
|
222
|
-
render(input.children, isUnescapedTag(input.name)) +
|
|
223
|
-
`</${input.name}>`
|
|
224
|
-
)
|
|
225
|
-
}
|
|
226
|
-
return `<${input.name}></${input.name}>`
|
|
227
|
-
}
|
|
228
204
|
|
|
229
|
-
|
|
230
|
-
|
|
205
|
+
return (
|
|
206
|
+
`<${input.name}` +
|
|
207
|
+
(input.attributes ? " " + attributes(input.attributes) : "") +
|
|
208
|
+
">" +
|
|
209
|
+
render(input.children, isUnescapedTag(input.name)) +
|
|
210
|
+
`</${input.name}>`
|
|
211
|
+
)
|
|
231
212
|
}
|
|
232
213
|
|
|
233
214
|
const raw = (children) => {
|
|
@@ -314,9 +295,20 @@ function js(inputs) {
|
|
|
314
295
|
}
|
|
315
296
|
|
|
316
297
|
js.load = function () {
|
|
317
|
-
const
|
|
298
|
+
const parts = []
|
|
299
|
+
for (const param of arguments) {
|
|
300
|
+
if (typeof param === "string") {
|
|
301
|
+
parts.push(param)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const path = join(...parts)
|
|
318
305
|
const file = path.endsWith(".js") ? path : join(path, "index.js")
|
|
319
306
|
const content = readFileSync(file, "utf8")
|
|
307
|
+
|
|
308
|
+
const options = arguments[arguments.length - 1]
|
|
309
|
+
if (options && options.transform) {
|
|
310
|
+
return js`${options.transform(content)}`
|
|
311
|
+
}
|
|
320
312
|
return js`${content}`
|
|
321
313
|
}
|
|
322
314
|
|
|
@@ -493,15 +485,6 @@ function classes() {
|
|
|
493
485
|
return array.join(" ")
|
|
494
486
|
}
|
|
495
487
|
|
|
496
|
-
const yaml = {
|
|
497
|
-
load() {
|
|
498
|
-
const path = join(...arguments)
|
|
499
|
-
const file = path.endsWith(".yaml") ? path : join(path, "index.yaml")
|
|
500
|
-
const content = readFileSync(file, "utf8")
|
|
501
|
-
return YAML.parse(content)
|
|
502
|
-
},
|
|
503
|
-
}
|
|
504
|
-
|
|
505
488
|
const json = {
|
|
506
489
|
load() {
|
|
507
490
|
const path = join(...arguments)
|
|
@@ -519,50 +502,47 @@ function i18n(translations) {
|
|
|
519
502
|
|
|
520
503
|
i18n.load = function () {
|
|
521
504
|
const path = join(...arguments)
|
|
522
|
-
const data =
|
|
505
|
+
const data = json.load(path)
|
|
523
506
|
return function translate(language, key) {
|
|
524
|
-
if (!key) {
|
|
525
|
-
throw new Error(`TranslationError[${key}][${language}]: key is undefined`)
|
|
526
|
-
}
|
|
527
507
|
if (!language) {
|
|
528
|
-
throw new Error(
|
|
529
|
-
`TranslationError[${key}][${language}]: language is undefined`
|
|
530
|
-
)
|
|
508
|
+
throw new Error(`TranslationError: language is undefined`)
|
|
531
509
|
}
|
|
532
|
-
|
|
533
|
-
|
|
510
|
+
if (!key) {
|
|
511
|
+
throw new Error(`TranslationError: key is undefined`)
|
|
512
|
+
}
|
|
513
|
+
if (!data[key] || !data[key][language]) {
|
|
534
514
|
throw new Error(
|
|
535
|
-
`TranslationError[${key}][${language}]
|
|
515
|
+
`TranslationError: translation [${key}][${language}] is undefined`
|
|
536
516
|
)
|
|
537
517
|
}
|
|
538
|
-
return
|
|
518
|
+
return data[key][language]
|
|
539
519
|
}
|
|
540
520
|
}
|
|
541
521
|
|
|
542
|
-
function component(fn, { styles, i18n,
|
|
522
|
+
function component(fn, { styles, i18n, scripts } = {}) {
|
|
543
523
|
function execute(a, b) {
|
|
544
524
|
if (typeof a === "string" || typeof a === "number" || Array.isArray(a)) {
|
|
545
525
|
return fn({}, a)
|
|
546
526
|
}
|
|
547
527
|
if (i18n) {
|
|
528
|
+
if (!a || !a.language) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`TranslationError: language is undefined for component:\n${fn.toString()}`
|
|
531
|
+
)
|
|
532
|
+
}
|
|
548
533
|
const { language } = a
|
|
549
534
|
function translate(key) {
|
|
550
535
|
if (!key) {
|
|
551
536
|
throw new Error(
|
|
552
|
-
`TranslationError
|
|
537
|
+
`TranslationError: key is undefined for component:\n${fn.toString()}`
|
|
553
538
|
)
|
|
554
539
|
}
|
|
555
|
-
if (!language) {
|
|
540
|
+
if (!i18n[key] || !i18n[key][language]) {
|
|
556
541
|
throw new Error(
|
|
557
|
-
`TranslationError[${key}][${language}]
|
|
542
|
+
`TranslationError: translation [${key}][${language}] is undefined for component:\n${fn.toString()}`
|
|
558
543
|
)
|
|
559
544
|
}
|
|
560
545
|
const translation = i18n[key][language]
|
|
561
|
-
if (!translation) {
|
|
562
|
-
throw new Error(
|
|
563
|
-
`TranslationError[${key}][${language}]: translation is undefined for component:\n${fn.toString()}`
|
|
564
|
-
)
|
|
565
|
-
}
|
|
566
546
|
return translation
|
|
567
547
|
}
|
|
568
548
|
return fn({ ...a, translate }, b || [])
|
|
@@ -571,25 +551,19 @@ function component(fn, { styles, i18n, code } = {}) {
|
|
|
571
551
|
}
|
|
572
552
|
return function (a, b) {
|
|
573
553
|
const tree = execute(a, b)
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
return tree.concat(styles.css, code.js)
|
|
577
|
-
}
|
|
578
|
-
return [tree, styles.css, code.js]
|
|
579
|
-
}
|
|
554
|
+
let nodes = Array.isArray(tree) ? tree : [tree]
|
|
555
|
+
|
|
580
556
|
if (styles) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
return [tree, styles.css]
|
|
557
|
+
const data = Array.isArray(styles) ? styles : [styles]
|
|
558
|
+
nodes = nodes.concat(data.map((style) => style.css))
|
|
585
559
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
return [tree, code.js]
|
|
560
|
+
|
|
561
|
+
if (scripts) {
|
|
562
|
+
const data = Array.isArray(scripts) ? scripts : [scripts]
|
|
563
|
+
nodes = nodes.concat(data.map((script) => script.js))
|
|
591
564
|
}
|
|
592
|
-
|
|
565
|
+
|
|
566
|
+
return nodes
|
|
593
567
|
}
|
|
594
568
|
}
|
|
595
569
|
|
|
@@ -599,11 +573,9 @@ module.exports = {
|
|
|
599
573
|
classes,
|
|
600
574
|
doctype,
|
|
601
575
|
escape: escapeHTML,
|
|
602
|
-
fragment,
|
|
603
576
|
raw,
|
|
604
577
|
css,
|
|
605
578
|
js,
|
|
606
|
-
yaml,
|
|
607
579
|
json,
|
|
608
580
|
tag,
|
|
609
581
|
i18n,
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "boxwood",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "Compile HTML templates into JS",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test",
|
|
7
|
+
"test": "node --test --test-reporter=dot",
|
|
8
|
+
"test:debug": "node --test --test-reporter=spec",
|
|
8
9
|
"coverage": "c8 npm test",
|
|
9
10
|
"benchmark": "node --test benchmark/index.js",
|
|
10
11
|
"watch": "npm test -- --watch",
|
|
@@ -45,13 +46,11 @@
|
|
|
45
46
|
},
|
|
46
47
|
"homepage": "https://github.com/buxlabs/boxwood#readme",
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"ava": "^6.1.2",
|
|
49
49
|
"benchmark": "2.1.4",
|
|
50
50
|
"c8": "^9.1.0",
|
|
51
51
|
"express": "^4.19.2",
|
|
52
52
|
"handlebars": "^4.7.8",
|
|
53
53
|
"jsdom": "^24.0.0",
|
|
54
|
-
"lodash.template": "4.5.0",
|
|
55
54
|
"mustache": "^4.2.0",
|
|
56
55
|
"underscore": "^1.13.6"
|
|
57
56
|
},
|
|
@@ -64,7 +63,6 @@
|
|
|
64
63
|
},
|
|
65
64
|
"dependencies": {
|
|
66
65
|
"css-tree": "^2.3.1",
|
|
67
|
-
"string-hash": "^1.1.3"
|
|
68
|
-
"yaml": "^2.4.1"
|
|
66
|
+
"string-hash": "^1.1.3"
|
|
69
67
|
}
|
|
70
68
|
}
|