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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2018 - 2022 buxlabs
3
+ Copyright (c) 2018 - 2024 buxlabs
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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 fragment([
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 { fragment, div, span } = require("../../..")
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 fragment(accounts.map((account) => div(description(account))))
20
+ return accounts.map((account) => div(description(account)))
21
21
  }
@@ -1,18 +1,16 @@
1
- const { fragment, div, ul, li, p } = require("../../..")
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
- fragment([
10
- div({ class: "title" }, result.title),
11
- div({ class: "description" }, result.description),
12
- result.featured && div({ class: "highlight" }, "Featured!"),
13
- result.sizes.length && ul(result.sizes.map((size) => li(size))),
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, fragment } = require("../../..")
1
+ const { h1, h2, ul, li } = require("../../..")
2
2
 
3
3
  module.exports = function ({ title, subtitle, todos }) {
4
- return fragment([
4
+ return [
5
5
  h1(title),
6
6
  h2(subtitle),
7
7
  ul(todos.map((todo) => li(todo.description))),
8
- ])
8
+ ]
9
9
  }
@@ -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 = template(source3)
38
- const fn4 = handlebars.compile(source4)
39
- const fn5 = (data) => mustache.render(source5, data)
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
- fn6(data)
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
- fn4(data)
66
+ fn3(data)
73
67
  })
74
68
  .add("mustache[mst]", function () {
75
- fn5(data)
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
- return escape ? escapeHTML(input) : input
192
- }
193
- if (input.name === "fragment") {
194
- return render(input.children)
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
- const fragment = (children) => {
230
- return { name: "fragment", children }
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 path = join(...arguments)
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 = path.endsWith(".yaml") ? yaml.load(path) : json.load(path)
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
- const translation = data[key][language]
533
- if (!translation) {
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}]: translation is undefined`
515
+ `TranslationError: translation [${key}][${language}] is undefined`
536
516
  )
537
517
  }
538
- return translation
518
+ return data[key][language]
539
519
  }
540
520
  }
541
521
 
542
- function component(fn, { styles, i18n, code } = {}) {
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[${key}][${language}]: key is undefined for component:\n${fn.toString()}`
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}]: language is undefined for component:\n${fn.toString()}`
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
- if (styles && code) {
575
- if (Array.isArray(tree)) {
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
- if (Array.isArray(tree)) {
582
- return tree.concat(styles.css)
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
- if (code) {
587
- if (Array.isArray(tree)) {
588
- return tree.concat(code.js)
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
- return tree
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.74.1",
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
  }