boxwood 2.0.0 → 2.2.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 CHANGED
@@ -114,7 +114,76 @@ console.log(html)
114
114
  // <div><h1>Hello, World!</h1><p>Welcome to Boxwood</p></div>
115
115
  ```
116
116
 
117
- For Express apps, use [express-boxwood](https://www.npmjs.com/package/express-boxwood).
117
+ ### Express Integration
118
+
119
+ Boxwood includes built-in Express support:
120
+
121
+ ```js
122
+ import express from 'express'
123
+ import engine from 'boxwood/adapters/express'
124
+ import crypto from 'crypto'
125
+
126
+ const app = express()
127
+
128
+ // Register Boxwood as template engine
129
+ app.engine('js', engine())
130
+ app.set('views', './views')
131
+ app.set('view engine', 'js')
132
+
133
+ // CSP (Content Security Policy) nonce for inline scripts
134
+ // A nonce is a unique random value generated for each request that allows
135
+ // specific inline scripts to execute while blocking potential XSS attacks
136
+ app.use((req, res, next) => {
137
+ // Generate a cryptographically secure random nonce
138
+ res.locals.nonce = crypto.randomBytes(16).toString('base64')
139
+
140
+ // Set CSP header - only scripts with this exact nonce can execute
141
+ res.setHeader(
142
+ 'Content-Security-Policy',
143
+ `script-src 'nonce-${res.locals.nonce}' 'strict-dynamic';`
144
+ )
145
+ next()
146
+ })
147
+
148
+ // Render templates - nonce is automatically injected into all inline scripts
149
+ app.get('/', (req, res) => {
150
+ res.render('home', { title: 'Welcome' })
151
+ // Boxwood automatically adds nonce="${res.locals.nonce}" to script tags
152
+ })
153
+ ```
154
+
155
+ The Express adapter automatically:
156
+ - Handles template caching in production
157
+ - Hot reloads templates in development
158
+ - Injects CSP nonces from `res.locals.nonce` into all inline scripts and styles
159
+
160
+ #### Understanding CSP Nonces
161
+
162
+ A Content Security Policy (CSP) nonce is a security feature that helps prevent Cross-Site Scripting (XSS) attacks:
163
+
164
+ 1. **Without CSP**: Any injected `<script>` tag can execute, making XSS attacks possible
165
+ 2. **With CSP nonce**: Only scripts with the correct nonce attribute can run
166
+ 3. **How it works**:
167
+ - Server generates a unique random nonce for each request
168
+ - Server adds this nonce to the CSP header: `script-src 'nonce-abc123'`
169
+ - Server adds the same nonce to legitimate scripts: `<script nonce="abc123">`
170
+ - Browser only executes scripts that have the matching nonce
171
+ - Attackers can't guess the nonce, so injected scripts are blocked
172
+
173
+ Example output:
174
+ ```html
175
+ <!-- HTTP Header -->
176
+ Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic';
177
+
178
+ <!-- Generated HTML -->
179
+ <script nonce="rAnd0m123">
180
+ console.log("This legitimate script will execute")
181
+ </script>
182
+
183
+ <script>
184
+ console.log("This injected script will be blocked!")
185
+ </script>
186
+ ```
118
187
 
119
188
  ## Features
120
189
 
@@ -0,0 +1,49 @@
1
+ const NODE_ENV = process.env.NODE_ENV || "development"
2
+ const { compile } = require("../..")
3
+
4
+ function purge(path) {
5
+ const name = require.resolve(path)
6
+ const dependency = require.cache[name]
7
+ if (dependency && dependency.children) {
8
+ dependency.children.forEach((child) => {
9
+ delete require.cache[child.id]
10
+ purge(child.id)
11
+ })
12
+ delete require.cache[name]
13
+ }
14
+ }
15
+
16
+ function engine(options = {}) {
17
+ const env = options.env || NODE_ENV
18
+ const cache = new Map()
19
+ async function compileFile(path) {
20
+ if (env === "development") {
21
+ purge(path)
22
+ const { template } = await compile(path)
23
+ return template
24
+ }
25
+ if (cache.has(path)) return cache.get(path)
26
+ const { template } = await compile(path)
27
+ cache.set(path, template)
28
+ return template
29
+ }
30
+
31
+ async function render(path, options, callback) {
32
+ try {
33
+ const template = await compileFile(path)
34
+ // Automatically inject nonce from res.locals if available
35
+ const templateOptions = options && options._locals && options._locals.nonce
36
+ ? { ...options, nonce: options._locals.nonce }
37
+ : options
38
+ const html = template(templateOptions)
39
+ if (callback) return callback(null, html)
40
+ return html
41
+ } catch (error) {
42
+ if (callback) return callback(error)
43
+ return error.message
44
+ }
45
+ }
46
+ return render
47
+ }
48
+
49
+ module.exports = engine
package/index.js CHANGED
@@ -509,6 +509,27 @@ function decamelize(string) {
509
509
 
510
510
  function stylesheet(input) {
511
511
  const object = { ...input }
512
+ function render(object, selector = "") {
513
+ let result = []
514
+ for (const key in object) {
515
+ const value = object[key]
516
+ if (value && typeof value === "object") {
517
+ if (key.startsWith("@")) {
518
+ result.push(`${key}{${render(value, selector)}}`)
519
+ } else {
520
+ const nextSelector = selector ? `${selector} ${key}` : key
521
+ result.push(render(value, nextSelector))
522
+ }
523
+ } else {
524
+ if (selector) {
525
+ result.push(`${selector}{${decamelize(key)}:${value};}`)
526
+ } else {
527
+ result.push(`${decamelize(key)}:${value};`)
528
+ }
529
+ }
530
+ }
531
+ return result.join("")
532
+ }
512
533
  return {
513
534
  add(item) {
514
535
  for (const key in item) {
@@ -519,14 +540,7 @@ function stylesheet(input) {
519
540
  object[key] = value
520
541
  },
521
542
  toString() {
522
- let result = []
523
- for (const key in object) {
524
- const value = object[key]
525
- if (value) {
526
- result.push(`${decamelize(key)}:${value}`)
527
- }
528
- }
529
- return result.join(";")
543
+ return render(object)
530
544
  },
531
545
  }
532
546
  }
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./adapters/express": "./adapters/express/index.js",
10
+ "./ui": "./ui/index.js"
11
+ },
7
12
  "scripts": {
8
13
  "test": "node --test --test-reporter=dot \"test/**/*.test.js\" \"test/**/*.spec.js\"",
9
14
  "test:debug": "node --test --test-reporter=spec \"test/**/*.test.js\" \"test/**/*.spec.js\"",
@@ -0,0 +1,39 @@
1
+ const { css, component, Div } = require("../..")
2
+ const {
3
+ normalizeGap,
4
+ normalizeFlex,
5
+ normalizeBreakpoint,
6
+ normalizeWidth,
7
+ } = require("../normalize")
8
+
9
+ function Group({ align, breakpoint, justify, gap, width, style }, children) {
10
+ gap = normalizeGap(gap)
11
+ align = normalizeFlex(align)
12
+ justify = normalizeFlex(justify)
13
+ breakpoint = normalizeBreakpoint(breakpoint)
14
+ width = normalizeWidth(width)
15
+
16
+ const styleObject = {
17
+ display: "flex",
18
+ "flex-direction": "row",
19
+ ...(gap && { gap }),
20
+ ...(align && { "align-items": align }),
21
+ ...(justify && { "justify-content": justify }),
22
+ ...(width && { width }),
23
+ ...(breakpoint && {
24
+ [`@media (max-width: ${breakpoint})`]: {
25
+ "flex-direction": "column",
26
+ },
27
+ }),
28
+ }
29
+
30
+ const styles = css`
31
+ .group {
32
+ ${css.create(styleObject).toString()}
33
+ }
34
+ `
35
+
36
+ return [Div({ className: styles.group, style }, children), styles.css]
37
+ }
38
+
39
+ module.exports = component(Group)
@@ -0,0 +1,28 @@
1
+ const { css, component, Div } = require("../..")
2
+ const { normalizeGap, normalizeFlex, normalizeWidth } = require("../normalize")
3
+
4
+ function Stack({ align, justify, gap, width, style }, children) {
5
+ gap = normalizeGap(gap)
6
+ align = normalizeFlex(align)
7
+ justify = normalizeFlex(justify)
8
+ width = normalizeWidth(width)
9
+
10
+ const styleObject = {
11
+ display: "flex",
12
+ "flex-direction": "column",
13
+ ...(gap && { gap }),
14
+ ...(align && { "align-items": align }),
15
+ ...(justify && { "justify-content": justify }),
16
+ ...(width && { width }),
17
+ }
18
+
19
+ const styles = css`
20
+ .stack {
21
+ ${css.create(styleObject).toString()}
22
+ }
23
+ `
24
+
25
+ return [Div({ className: styles.stack, style }, children), styles.css]
26
+ }
27
+
28
+ module.exports = component(Stack)
package/ui/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const Group = require("./Group")
2
+ const Stack = require("./Stack")
3
+
4
+ module.exports = {
5
+ Group,
6
+ Stack,
7
+ }
@@ -0,0 +1,66 @@
1
+ function normalizeFlex(align) {
2
+ switch (align) {
3
+ case "start":
4
+ return "flex-start"
5
+ case "end":
6
+ return "flex-end"
7
+ default:
8
+ return align
9
+ }
10
+ }
11
+
12
+ const GAP_MAP = {
13
+ xs: "0.25rem",
14
+ sm: "0.5rem",
15
+ md: "1rem",
16
+ lg: "2rem",
17
+ xl: "4rem",
18
+ none: null,
19
+ }
20
+
21
+ function normalizeGap(gap) {
22
+ if (!gap) {
23
+ return "1rem"
24
+ }
25
+ if (typeof gap === "number") {
26
+ return `${gap}px`
27
+ }
28
+
29
+ if (GAP_MAP.hasOwnProperty(gap)) {
30
+ return GAP_MAP[gap]
31
+ }
32
+
33
+ return gap
34
+ }
35
+
36
+ const BREAKPOINT_MAP = {
37
+ xs: "575px",
38
+ sm: "767px",
39
+ md: "991px",
40
+ lg: "1199px",
41
+ xl: "1399px",
42
+ }
43
+
44
+ function normalizeBreakpoint(breakpoint) {
45
+ if (typeof breakpoint === "number") {
46
+ return `${breakpoint}px`
47
+ }
48
+ if (BREAKPOINT_MAP.hasOwnProperty(breakpoint)) {
49
+ return BREAKPOINT_MAP[breakpoint]
50
+ }
51
+ return breakpoint
52
+ }
53
+
54
+ function normalizeWidth(width) {
55
+ if (typeof width === "number") {
56
+ return `${width}px`
57
+ }
58
+ return width
59
+ }
60
+
61
+ module.exports = {
62
+ normalizeFlex,
63
+ normalizeGap,
64
+ normalizeBreakpoint,
65
+ normalizeWidth,
66
+ }
@@ -1,10 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node test:*)",
5
- "Bash(mkdir:*)",
6
- "Bash(npm test:*)"
7
- ],
8
- "deny": []
9
- }
10
- }