boxwood 2.12.0 → 2.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "scripts": {
13
13
  "test": "node --test --test-reporter=dot \"**/*.test.js\" \"**/*.spec.js\"",
14
14
  "test:debug": "node --test --test-reporter=spec \"**/*.test.js\" \"**/*.spec.js\"",
15
- "coverage": "c8 npm test",
15
+ "coverage": "node --test --experimental-test-coverage \"**/*.test.js\" \"**/*.spec.js\"",
16
16
  "benchmark": "node --test benchmark/index.js",
17
17
  "watch": "npm test -- --watch",
18
18
  "prepush": "npm test"
@@ -47,7 +47,6 @@
47
47
  "homepage": "https://github.com/buxlabs/boxwood#readme",
48
48
  "devDependencies": {
49
49
  "benchmark": "2.1.4",
50
- "c8": "^11.0.0",
51
50
  "express": "^5.2.1",
52
51
  "handlebars": "^4.7.9",
53
52
  "jsdom": "^29.1.1",
package/ui/grid/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const { component, css, Div } = require("../..")
2
2
  const { normalizeGap, normalizeBreakpoint } = require("../normalize")
3
+ const { toNumber } = require("../normalize")
3
4
 
4
5
  const BREAKPOINTS = {
5
6
  xl: "1199px",
@@ -10,11 +11,14 @@ const BREAKPOINTS = {
10
11
 
11
12
  function Grid(
12
13
  { className, columns = 3, gap, id, breakpoint, style },
13
- children
14
+ children,
14
15
  ) {
15
16
  gap = normalizeGap(gap)
16
17
  breakpoint = normalizeBreakpoint(breakpoint)
17
18
 
19
+ // Normalize columns: convert string numbers to numbers
20
+ columns = toNumber(columns)
21
+
18
22
  const styleObject = {
19
23
  "box-sizing": "border-box",
20
24
  display: "grid",
@@ -27,10 +31,8 @@ function Grid(
27
31
  }),
28
32
  ...(typeof columns === "object" &&
29
33
  Object.keys(columns).reduce((object, key) => {
30
- const value =
31
- typeof columns[key] === "number"
32
- ? `repeat(${columns[key]}, 1fr)`
33
- : columns[key]
34
+ let value = toNumber(columns[key])
35
+ value = typeof value === "number" ? `repeat(${value}, 1fr)` : value
34
36
  if (key === "default") {
35
37
  object["grid-template-columns"] = value
36
38
  } else if (typeof key === "string") {
package/ui/index.js CHANGED
@@ -4,6 +4,7 @@ const Grid = require("./grid")
4
4
  const Group = require("./group")
5
5
  const Stack = require("./stack")
6
6
  const Markdown = require("./markdown")
7
+ const Prose = require("./prose")
7
8
 
8
9
  module.exports = {
9
10
  Center,
@@ -12,4 +13,5 @@ module.exports = {
12
13
  Group,
13
14
  Stack,
14
15
  Markdown,
16
+ Prose,
15
17
  }
@@ -0,0 +1,48 @@
1
+ # Markdown Component
2
+
3
+ The `Markdown` component provides pure markdown rendering without any templating features. It's a simple, focused component for rendering static markdown content.
4
+
5
+ ## Features
6
+
7
+ - Headings (`#`, `##`, etc.)
8
+ - Paragraphs
9
+ - Lists (ordered and unordered)
10
+ - Links and images
11
+ - Code blocks with syntax highlighting support
12
+ - Bold and italic text
13
+ - Inline code
14
+ - Blockquotes
15
+ - Horizontal rules
16
+
17
+ ## Usage
18
+
19
+ ```javascript
20
+ const { Markdown } = require("boxwood/ui")
21
+
22
+ Markdown(`
23
+ # Hello World
24
+
25
+ This is **bold** and this is *italic*.
26
+
27
+ - First item
28
+ - Second item
29
+ - Third item
30
+
31
+ [Link text](https://example.com)
32
+
33
+ \`\`\`javascript
34
+ console.log('Hello')
35
+ \`\`\`
36
+ `)
37
+ ```
38
+
39
+ ## When to Use
40
+
41
+ Use `Markdown` when you need:
42
+
43
+ - Simple, static markdown rendering
44
+ - No variables or templating
45
+ - Fast, predictable output
46
+ - Standard markdown behavior
47
+
48
+ For dynamic content with variables and custom components, use the `Prose` component instead.
@@ -1,332 +1,63 @@
1
- const {
2
- component,
3
- H1,
4
- H2,
5
- H3,
6
- H4,
7
- H5,
8
- H6,
9
- P,
10
- Blockquote,
11
- Ul,
12
- Ol,
13
- Li,
14
- Strong,
15
- Em,
16
- Code,
17
- A,
18
- Hr,
19
- Img,
20
- Pre,
21
- } = require("../..")
1
+ const nodes = require("../..")
2
+ const { component } = nodes
22
3
 
23
- const { format } = require("./utilities/format")
24
- const {
25
- parseCustomTag,
26
- resolveAttributes,
27
- } = require("./utilities/parseCustomTag")
28
- const { replaceVariables } = require("./utilities/replaceVariables")
4
+ const { extractHtmlParams } = require("./utilities/params")
5
+ const { parseMarkdownLines } = require("./utilities/parseBlock")
6
+ const { convertItemsToNodes } = require("./utilities/convertNodes")
29
7
 
30
- const ORDERED_LIST_REGEXP = /^\d+\.\s/
31
- const UNORDERED_MARKERS = ["- ", "— ", "– ", "• "]
32
- const HORIZONTAL_RULE_REGEXP = /^(?:\*\s*\*\s*\*+|-\s*-\s*-+|_\s*_\s*_+)\s*$/
33
- const HEADINGS = [
34
- { prefix: "###### ", type: "h6" },
35
- { prefix: "##### ", type: "h5" },
36
- { prefix: "#### ", type: "h4" },
37
- { prefix: "### ", type: "h3" },
38
- { prefix: "## ", type: "h2" },
39
- { prefix: "# ", type: "h1" },
40
- ]
41
8
  const COMPONENTS = {
42
- h1: H1,
43
- h2: H2,
44
- h3: H3,
45
- h4: H4,
46
- h5: H5,
47
- h6: H6,
48
- blockquote: Blockquote,
49
- hr: Hr,
9
+ h1: nodes.H1,
10
+ h2: nodes.H2,
11
+ h3: nodes.H3,
12
+ h4: nodes.H4,
13
+ h5: nodes.H5,
14
+ h6: nodes.H6,
15
+ blockquote: nodes.Blockquote,
16
+ hr: nodes.Hr,
50
17
  }
51
18
 
52
- const INLINE_COMPONENTS = { A, Img, Code, Strong, Em }
19
+ // Basic HTML components needed for markdown formatting (links, images, code, etc.)
20
+ const BASIC_HTML_COMPONENTS = {
21
+ a: nodes.A,
22
+ img: nodes.Img,
23
+ code: nodes.Code,
24
+ strong: nodes.Strong,
25
+ em: nodes.Em,
26
+ p: nodes.P,
27
+ ul: nodes.Ul,
28
+ ol: nodes.Ol,
29
+ li: nodes.Li,
30
+ pre: nodes.Pre,
31
+ blockquote: nodes.Blockquote,
32
+ hr: nodes.Hr,
33
+ }
53
34
 
54
35
  function Markdown(params, children) {
36
+ // Handle array of children recursively
55
37
  if (Array.isArray(children)) {
56
38
  return children.map((child) => Markdown(params, child))
57
39
  }
58
40
 
41
+ // Only process string content
59
42
  if (typeof children !== "string") {
60
43
  return null
61
44
  }
62
45
 
63
- const customComponents = params && params.components
64
- const data = params && params.data
65
-
66
- // Filter out special params that shouldn't be passed to HTML elements
67
- const htmlParams = params
68
- ? Object.keys(params).reduce((acc, key) => {
69
- if (key !== "components" && key !== "data") {
70
- acc[key] = params[key]
71
- }
72
- return acc
73
- }, {})
74
- : {}
75
-
76
- // First pass: detect code blocks before processing lines
77
- const allLines = children.trim().split("\n")
78
- const items = []
79
- let i = 0
80
-
81
- while (i < allLines.length) {
82
- const line = allLines[i]
83
- const trimmed = line.trim()
84
-
85
- // Check for custom component tags first
86
- const customTag = parseCustomTag(line, customComponents)
87
- if (customTag) {
88
- if (customTag.type === "custom-component" && customTag.selfClosing) {
89
- // Self-closing custom component
90
- items.push({
91
- type: "custom-component",
92
- tagName: customTag.tagName,
93
- component: customTag.component,
94
- attributes: customTag.attributes,
95
- selfClosing: true,
96
- indent: line.length - line.trimStart().length,
97
- })
98
- i++
99
- continue
100
- } else if (customTag.type === "custom-component-open") {
101
- // Opening tag of a custom component
102
- const openIndent = line.length - line.trimStart().length
103
- const tagName = customTag.tagName
104
- const componentFn = customTag.component
105
- const attributes = customTag.attributes
106
- const contentLines = []
107
- i++ // Move past opening tag
108
-
109
- // Collect content until closing tag
110
- let depth = 1
111
- while (i < allLines.length && depth > 0) {
112
- const contentLine = allLines[i]
113
- const contentTag = parseCustomTag(contentLine, customComponents)
114
-
115
- if (contentTag && contentTag.tagName === tagName) {
116
- if (contentTag.type === "custom-component-open") {
117
- depth++
118
- contentLines.push(contentLine)
119
- } else if (contentTag.type === "custom-component-close") {
120
- depth--
121
- if (depth === 0) {
122
- // Found matching closing tag
123
- i++
124
- break
125
- }
126
- contentLines.push(contentLine)
127
- }
128
- } else {
129
- contentLines.push(contentLine)
130
- }
131
- i++
132
- }
133
-
134
- items.push({
135
- type: "custom-component",
136
- tagName,
137
- component: componentFn,
138
- attributes,
139
- content: contentLines.join("\n"),
140
- selfClosing: false,
141
- indent: openIndent,
142
- })
143
- continue
144
- }
145
- }
146
-
147
- // Check for code block start
148
- if (trimmed.startsWith("```")) {
149
- const language = trimmed.substring(3).trim()
150
- const codeLines = []
151
- i++ // Move past the opening ```
152
-
153
- // Collect code block lines until closing ```
154
- while (i < allLines.length) {
155
- const codeLine = allLines[i]
156
- if (codeLine.trim().startsWith("```")) {
157
- // Found closing ```, don't include it
158
- i++
159
- break
160
- }
161
- codeLines.push(codeLine)
162
- i++
163
- }
164
-
165
- items.push({
166
- type: "pre",
167
- content: codeLines.join("\n"),
168
- language: language || undefined,
169
- indent: 0,
170
- })
171
- continue
172
- }
173
-
174
- // Skip empty lines
175
- if (trimmed.length === 0) {
176
- i++
177
- continue
178
- }
179
-
180
- const leadingSpaces = line.length - line.trimStart().length
181
-
182
- // Check for other block types
183
- if (HORIZONTAL_RULE_REGEXP.test(trimmed)) {
184
- items.push({ type: "hr", indent: leadingSpaces })
185
- } else if (UNORDERED_MARKERS.some((marker) => trimmed.startsWith(marker))) {
186
- const content = trimmed.substring(2)
187
- if (content) {
188
- items.push({ type: "li", list: "ul", content, indent: leadingSpaces })
189
- }
190
- } else if (ORDERED_LIST_REGEXP.test(trimmed)) {
191
- const content = trimmed.replace(ORDERED_LIST_REGEXP, "")
192
- if (content) {
193
- items.push({ type: "li", list: "ol", content, indent: leadingSpaces })
194
- }
195
- } else {
196
- let matched = false
197
- for (const { prefix, type } of HEADINGS) {
198
- if (trimmed.startsWith(prefix)) {
199
- items.push({
200
- type,
201
- content: trimmed.substring(prefix.length),
202
- indent: leadingSpaces,
203
- })
204
- matched = true
205
- break
206
- }
207
- }
208
-
209
- if (!matched) {
210
- if (trimmed.startsWith("> ")) {
211
- items.push({
212
- type: "blockquote",
213
- content: trimmed.substring(2),
214
- indent: leadingSpaces,
215
- })
216
- } else {
217
- items.push({ type: "p", content: trimmed, indent: leadingSpaces })
218
- }
219
- }
220
- }
221
-
222
- i++
223
- }
224
-
225
- const nodes = []
226
- i = 0
227
-
228
- function parseList(startIndex, parentIndent) {
229
- const list = []
230
- let currentIndex = startIndex
231
- const parentListType = items[startIndex].list
232
-
233
- while (currentIndex < items.length) {
234
- const item = items[currentIndex]
235
-
236
- // Stop if not a list item or indent is less than expected
237
- if (item.type !== "li" || item.indent < parentIndent) {
238
- break
239
- }
240
-
241
- // Stop if indent matches but list type differs
242
- if (item.indent === parentIndent && item.list !== parentListType) {
243
- break
244
- }
245
-
246
- // Skip items with greater indent (they belong to nested lists)
247
- if (item.indent > parentIndent) {
248
- break
249
- }
250
-
251
- // Process current item at the correct indent level
252
- const processedContent = replaceVariables(item.content, data)
253
- const content = [format(processedContent, INLINE_COMPONENTS)]
254
- currentIndex++
255
-
256
- // Check if next item is a nested list
257
- const nextItem = items[currentIndex]
258
- if (nextItem?.type === "li" && nextItem.indent > item.indent) {
259
- const nestedResult = parseList(currentIndex, nextItem.indent)
260
- content.push(nestedResult.list)
261
- currentIndex = nestedResult.nextIndex
262
- }
263
-
264
- list.push(Li(htmlParams, content))
265
- }
266
-
267
- const listElement =
268
- parentListType === "ul" ? Ul(htmlParams, list) : Ol(htmlParams, list)
269
-
270
- return { list: listElement, nextIndex: currentIndex }
271
- }
272
-
273
- while (i < items.length) {
274
- const item = items[i]
275
-
276
- if (item.type === "custom-component") {
277
- // Handle custom component
278
- const resolvedAttrs = resolveAttributes(item.attributes, data)
279
-
280
- if (item.selfClosing) {
281
- // Self-closing component
282
- nodes.push(item.component(resolvedAttrs))
283
- } else {
284
- // Component with children - recursively process markdown content
285
- const childContent = item.content ? Markdown(params, item.content) : []
286
- nodes.push(item.component(resolvedAttrs, childContent))
287
- }
288
- i++
289
- } else if (item.type === "li") {
290
- const result = parseList(i, item.indent)
291
- nodes.push(result.list)
292
- i = result.nextIndex
293
- } else if (item.type === "blockquote") {
294
- const lines = []
295
-
296
- while (i < items.length && items[i].type === "blockquote") {
297
- lines.push(items[i].content)
298
- i++
299
- }
300
-
301
- const content = replaceVariables(lines.join("\n"), data)
302
- nodes.push(
303
- Blockquote(
304
- htmlParams,
305
- P(htmlParams, format(content, INLINE_COMPONENTS)),
306
- ),
307
- )
308
- } else if (item.type === "hr") {
309
- nodes.push(Hr(htmlParams))
310
- i++
311
- } else if (item.type === "pre") {
312
- // Code blocks - wrap in <pre><code>
313
- const codeParams = item.language
314
- ? { class: `language-${item.language}` }
315
- : {}
316
- nodes.push(Pre(htmlParams, Code(codeParams, item.content)))
317
- i++
318
- } else {
319
- const { type, content } = item
320
- const Component = COMPONENTS[type] || P
321
- const processedContent = replaceVariables(content, data)
322
- nodes.push(
323
- Component(htmlParams, format(processedContent, INLINE_COMPONENTS)),
324
- )
325
- i++
326
- }
327
- }
328
-
329
- return nodes
46
+ const htmlParams = extractHtmlParams(params)
47
+
48
+ // Parse all markdown lines into structured items (no custom components, no data)
49
+ const items = parseMarkdownLines(children, null, null)
50
+
51
+ // Convert parsed items into final node tree
52
+ return convertItemsToNodes(
53
+ items,
54
+ params,
55
+ htmlParams,
56
+ null, // no data
57
+ BASIC_HTML_COMPONENTS, // basic HTML components for formatting
58
+ COMPONENTS,
59
+ Markdown,
60
+ )
330
61
  }
331
62
 
332
63
  module.exports = component(Markdown)
@@ -0,0 +1,194 @@
1
+ const { resolveAttributes } = require("./parseCustomTag")
2
+ const { replaceVariables } = require("./replaceVariables")
3
+ const { format } = require("./format")
4
+
5
+ /**
6
+ * Recursively parses nested lists with proper indentation handling
7
+ */
8
+ function parseList(
9
+ items,
10
+ startIndex,
11
+ parentIndent,
12
+ htmlParams,
13
+ data,
14
+ allComponents,
15
+ ) {
16
+ const list = []
17
+ let currentIndex = startIndex
18
+ const parentListType = items[startIndex].list
19
+
20
+ while (currentIndex < items.length) {
21
+ const item = items[currentIndex]
22
+
23
+ // Stop conditions
24
+ if (item.type !== "li" || item.indent < parentIndent) {
25
+ break
26
+ }
27
+
28
+ if (item.indent === parentIndent && item.list !== parentListType) {
29
+ break
30
+ }
31
+
32
+ if (item.indent > parentIndent) {
33
+ break
34
+ }
35
+
36
+ // Process current list item
37
+ const processedContent = replaceVariables(item.content, data)
38
+ const content = [format(processedContent, allComponents)]
39
+ currentIndex++
40
+
41
+ // Check for nested lists
42
+ const nextItem = items[currentIndex]
43
+ if (nextItem?.type === "li" && nextItem.indent > item.indent) {
44
+ const nestedResult = parseList(
45
+ items,
46
+ currentIndex,
47
+ nextItem.indent,
48
+ htmlParams,
49
+ data,
50
+ allComponents,
51
+ )
52
+ content.push(nestedResult.list)
53
+ currentIndex = nestedResult.nextIndex
54
+ }
55
+
56
+ list.push(allComponents.li(htmlParams, content))
57
+ }
58
+
59
+ const listElement =
60
+ parentListType === "ul"
61
+ ? allComponents.ul(htmlParams, list)
62
+ : allComponents.ol(htmlParams, list)
63
+
64
+ return { list: listElement, nextIndex: currentIndex }
65
+ }
66
+
67
+ /**
68
+ * Processes a custom component item into a node
69
+ */
70
+ function processCustomComponentItem(item, params, data, markdownRenderer) {
71
+ const resolvedAttrs = resolveAttributes(item.attributes, data)
72
+
73
+ if (item.selfClosing) {
74
+ return item.component(resolvedAttrs)
75
+ }
76
+
77
+ if (item.singleLine) {
78
+ return item.component(resolvedAttrs, item.content)
79
+ }
80
+
81
+ // Multi-line component with children - recursively process markdown
82
+ const childContent = item.content
83
+ ? markdownRenderer(params, item.content)
84
+ : []
85
+ return item.component(resolvedAttrs, childContent)
86
+ }
87
+
88
+ /**
89
+ * Processes blockquote items into a single blockquote node
90
+ */
91
+ function processBlockquotes(
92
+ items,
93
+ startIndex,
94
+ htmlParams,
95
+ data,
96
+ allComponents,
97
+ ) {
98
+ const lines = []
99
+ let i = startIndex
100
+
101
+ while (i < items.length && items[i].type === "blockquote") {
102
+ lines.push(items[i].content)
103
+ i++
104
+ }
105
+
106
+ const content = replaceVariables(lines.join("\n"), data)
107
+ const blockquote = allComponents.blockquote(
108
+ htmlParams,
109
+ allComponents.p(htmlParams, format(content, allComponents)),
110
+ )
111
+
112
+ return { node: blockquote, nextIndex: i }
113
+ }
114
+
115
+ /**
116
+ * Converts parsed items into final node tree
117
+ */
118
+ function convertItemsToNodes(
119
+ items,
120
+ params,
121
+ htmlParams,
122
+ data,
123
+ allComponents,
124
+ components,
125
+ markdownRenderer,
126
+ ) {
127
+ const result = []
128
+ let i = 0
129
+
130
+ while (i < items.length) {
131
+ const item = items[i]
132
+
133
+ if (item.type === "custom-component") {
134
+ result.push(
135
+ processCustomComponentItem(item, params, data, markdownRenderer),
136
+ )
137
+ i++
138
+ } else if (item.type === "li") {
139
+ const listResult = parseList(
140
+ items,
141
+ i,
142
+ item.indent,
143
+ htmlParams,
144
+ data,
145
+ allComponents,
146
+ )
147
+ result.push(listResult.list)
148
+ i = listResult.nextIndex
149
+ } else if (item.type === "blockquote") {
150
+ const { node, nextIndex } = processBlockquotes(
151
+ items,
152
+ i,
153
+ htmlParams,
154
+ data,
155
+ allComponents,
156
+ )
157
+ result.push(node)
158
+ i = nextIndex
159
+ } else if (item.type === "hr") {
160
+ result.push(allComponents.hr(htmlParams))
161
+ i++
162
+ } else if (item.type === "pre") {
163
+ // Code blocks - wrap in <pre><code>
164
+ const codeParams = item.language
165
+ ? { class: `language-${item.language}` }
166
+ : {}
167
+ result.push(
168
+ allComponents.pre(
169
+ htmlParams,
170
+ allComponents.code(codeParams, item.content),
171
+ ),
172
+ )
173
+ i++
174
+ } else {
175
+ // Regular block elements (h1-h6, p, etc.)
176
+ const { type, content } = item
177
+ const Component = components[type] || allComponents.p
178
+ const processedContent = replaceVariables(content, data)
179
+ result.push(
180
+ Component(htmlParams, format(processedContent, allComponents)),
181
+ )
182
+ i++
183
+ }
184
+ }
185
+
186
+ return result
187
+ }
188
+
189
+ module.exports = {
190
+ parseList,
191
+ processCustomComponentItem,
192
+ processBlockquotes,
193
+ convertItemsToNodes,
194
+ }