boxwood 2.11.0 → 2.13.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.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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",
@@ -15,6 +16,9 @@ function Grid(
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,11 @@ 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 =
36
+ typeof value === "number"
37
+ ? `repeat(${value}, 1fr)`
38
+ : value
34
39
  if (key === "default") {
35
40
  object["grid-template-columns"] = value
36
41
  } else if (typeof key === "string") {
@@ -21,6 +21,11 @@ const {
21
21
  } = require("../..")
22
22
 
23
23
  const { format } = require("./utilities/format")
24
+ const {
25
+ parseCustomTag,
26
+ resolveAttributes,
27
+ } = require("./utilities/parseCustomTag")
28
+ const { replaceVariables } = require("./utilities/replaceVariables")
24
29
 
25
30
  const ORDERED_LIST_REGEXP = /^\d+\.\s/
26
31
  const UNORDERED_MARKERS = ["- ", "— ", "– ", "• "]
@@ -55,6 +60,19 @@ function Markdown(params, children) {
55
60
  return null
56
61
  }
57
62
 
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
+
58
76
  // First pass: detect code blocks before processing lines
59
77
  const allLines = children.trim().split("\n")
60
78
  const items = []
@@ -64,6 +82,68 @@ function Markdown(params, children) {
64
82
  const line = allLines[i]
65
83
  const trimmed = line.trim()
66
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
+
67
147
  // Check for code block start
68
148
  if (trimmed.startsWith("```")) {
69
149
  const language = trimmed.substring(3).trim()
@@ -169,7 +249,8 @@ function Markdown(params, children) {
169
249
  }
170
250
 
171
251
  // Process current item at the correct indent level
172
- const content = [format(item.content, INLINE_COMPONENTS)]
252
+ const processedContent = replaceVariables(item.content, data)
253
+ const content = [format(processedContent, INLINE_COMPONENTS)]
173
254
  currentIndex++
174
255
 
175
256
  // Check if next item is a nested list
@@ -180,11 +261,11 @@ function Markdown(params, children) {
180
261
  currentIndex = nestedResult.nextIndex
181
262
  }
182
263
 
183
- list.push(Li(params, content))
264
+ list.push(Li(htmlParams, content))
184
265
  }
185
266
 
186
267
  const listElement =
187
- parentListType === "ul" ? Ul(params, list) : Ol(params, list)
268
+ parentListType === "ul" ? Ul(htmlParams, list) : Ol(htmlParams, list)
188
269
 
189
270
  return { list: listElement, nextIndex: currentIndex }
190
271
  }
@@ -192,7 +273,20 @@ function Markdown(params, children) {
192
273
  while (i < items.length) {
193
274
  const item = items[i]
194
275
 
195
- if (item.type === "li") {
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") {
196
290
  const result = parseList(i, item.indent)
197
291
  nodes.push(result.list)
198
292
  i = result.nextIndex
@@ -204,26 +298,30 @@ function Markdown(params, children) {
204
298
  i++
205
299
  }
206
300
 
301
+ const content = replaceVariables(lines.join("\n"), data)
207
302
  nodes.push(
208
303
  Blockquote(
209
- params,
210
- P(params, format(lines.join("\n"), INLINE_COMPONENTS)),
304
+ htmlParams,
305
+ P(htmlParams, format(content, INLINE_COMPONENTS)),
211
306
  ),
212
307
  )
213
308
  } else if (item.type === "hr") {
214
- nodes.push(Hr(params))
309
+ nodes.push(Hr(htmlParams))
215
310
  i++
216
311
  } else if (item.type === "pre") {
217
312
  // Code blocks - wrap in <pre><code>
218
313
  const codeParams = item.language
219
314
  ? { class: `language-${item.language}` }
220
315
  : {}
221
- nodes.push(Pre(params, Code(codeParams, item.content)))
316
+ nodes.push(Pre(htmlParams, Code(codeParams, item.content)))
222
317
  i++
223
318
  } else {
224
319
  const { type, content } = item
225
320
  const Component = COMPONENTS[type] || P
226
- nodes.push(Component(params, format(content, INLINE_COMPONENTS)))
321
+ const processedContent = replaceVariables(content, data)
322
+ nodes.push(
323
+ Component(htmlParams, format(processedContent, INLINE_COMPONENTS)),
324
+ )
227
325
  i++
228
326
  }
229
327
  }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Parse a custom component tag from markdown
3
+ * Supports both <Component attr="value"> and <Component attr={variable}>
4
+ * @param {string} line - The line to parse
5
+ * @param {Object} customComponents - Map of component names to functions
6
+ * @returns {Object|null} - Parsed tag info or null if not a custom tag
7
+ */
8
+ function parseCustomTag(line, customComponents) {
9
+ if (!customComponents || typeof customComponents !== "object") {
10
+ return null
11
+ }
12
+
13
+ const trimmed = line.trim()
14
+
15
+ // Check for self-closing tag: <ComponentName ... /> (space before / is optional)
16
+ const selfClosingMatch = trimmed.match(
17
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?\s*\/>\s*$/,
18
+ )
19
+ if (selfClosingMatch) {
20
+ const [, tagName, attributesStr] = selfClosingMatch
21
+ const component = customComponents[tagName]
22
+ if (component) {
23
+ const attributes = parseAttributes(attributesStr || "")
24
+ return {
25
+ type: "custom-component",
26
+ tagName,
27
+ component,
28
+ attributes,
29
+ selfClosing: true,
30
+ }
31
+ }
32
+ }
33
+
34
+ // Check for opening tag: <ComponentName ...>
35
+ const openTagMatch = trimmed.match(
36
+ /^<([A-Za-z][A-Za-z0-9-]*)(\s+[^>]*)?>\s*$/,
37
+ )
38
+ if (openTagMatch) {
39
+ const [, tagName, attributesStr] = openTagMatch
40
+ const component = customComponents[tagName]
41
+ if (component) {
42
+ const attributes = parseAttributes(attributesStr || "")
43
+ return {
44
+ type: "custom-component-open",
45
+ tagName,
46
+ component,
47
+ attributes,
48
+ selfClosing: false,
49
+ }
50
+ }
51
+ }
52
+
53
+ // Check for closing tag: </ComponentName>
54
+ const closeTagMatch = trimmed.match(/^<\/([A-Za-z][A-Za-z0-9-]*)>\s*$/)
55
+ if (closeTagMatch) {
56
+ const [, tagName] = closeTagMatch
57
+ const component = customComponents[tagName]
58
+ if (component) {
59
+ return {
60
+ type: "custom-component-close",
61
+ tagName,
62
+ component,
63
+ }
64
+ }
65
+ }
66
+
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Parse attributes from a tag string
72
+ * Supports: attr="value", attr='value', attr={variable}, attr
73
+ * @param {string} attributesStr - The attributes string
74
+ * @returns {Object} - Parsed attributes
75
+ */
76
+ function parseAttributes(attributesStr) {
77
+ const attributes = {}
78
+ if (!attributesStr || !attributesStr.trim()) {
79
+ return attributes
80
+ }
81
+
82
+ const str = attributesStr.trim()
83
+ let i = 0
84
+
85
+ while (i < str.length) {
86
+ // Skip whitespace
87
+ while (i < str.length && /\s/.test(str[i])) {
88
+ i++
89
+ }
90
+
91
+ if (i >= str.length) break
92
+
93
+ // Parse attribute name
94
+ let nameStart = i
95
+ while (i < str.length && /[a-zA-Z0-9-_:]/.test(str[i])) {
96
+ i++
97
+ }
98
+
99
+ const name = str.substring(nameStart, i)
100
+ if (!name) break
101
+
102
+ // Skip whitespace after name
103
+ while (i < str.length && /\s/.test(str[i])) {
104
+ i++
105
+ }
106
+
107
+ // Check for =
108
+ if (i >= str.length || str[i] !== "=") {
109
+ // Boolean attribute
110
+ attributes[name] = true
111
+ continue
112
+ }
113
+
114
+ i++ // Skip =
115
+
116
+ // Skip whitespace after =
117
+ while (i < str.length && /\s/.test(str[i])) {
118
+ i++
119
+ }
120
+
121
+ if (i >= str.length) break
122
+
123
+ // Parse value
124
+ const quote = str[i]
125
+
126
+ if (quote === '"' || quote === "'") {
127
+ // Quoted string
128
+ i++ // Skip opening quote
129
+ let value = ""
130
+ while (i < str.length && str[i] !== quote) {
131
+ if (str[i] === "\\" && i + 1 < str.length) {
132
+ // Escaped character - add the next character literally
133
+ value += str[i + 1]
134
+ i += 2
135
+ } else {
136
+ value += str[i]
137
+ i++
138
+ }
139
+ }
140
+ attributes[name] = value
141
+ if (i < str.length) i++ // Skip closing quote
142
+ } else if (str[i] === "{") {
143
+ // Variable reference
144
+ i++ // Skip {
145
+ const valueStart = i
146
+ while (i < str.length && str[i] !== "}") {
147
+ i++
148
+ }
149
+ const variableName = str.substring(valueStart, i).trim()
150
+ attributes[name] = { __variable: variableName }
151
+ if (i < str.length) i++ // Skip }
152
+ } else {
153
+ // Unquoted value (until space or end)
154
+ const valueStart = i
155
+ while (i < str.length && !/\s/.test(str[i])) {
156
+ i++
157
+ }
158
+ const value = str.substring(valueStart, i)
159
+ attributes[name] = value
160
+ }
161
+ }
162
+
163
+ return attributes
164
+ }
165
+
166
+ /**
167
+ * Resolve attributes by replacing variable references with actual values
168
+ * @param {Object} attributes - Attributes with possible variable references
169
+ * @param {Object} data - Data object containing variable values
170
+ * @returns {Object} - Resolved attributes
171
+ */
172
+ function resolveAttributes(attributes, data) {
173
+ if (!attributes || typeof attributes !== "object") {
174
+ return {}
175
+ }
176
+
177
+ const resolved = {}
178
+ for (const [key, value] of Object.entries(attributes)) {
179
+ if (value && typeof value === "object" && value.__variable) {
180
+ // Resolve variable
181
+ const variableName = value.__variable
182
+ resolved[key] =
183
+ data && data[variableName] !== undefined
184
+ ? data[variableName]
185
+ : undefined
186
+ } else {
187
+ resolved[key] = value
188
+ }
189
+ }
190
+
191
+ return resolved
192
+ }
193
+
194
+ module.exports = {
195
+ parseCustomTag,
196
+ parseAttributes,
197
+ resolveAttributes,
198
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Replace {variableName} placeholders in text with actual values from data
3
+ * @param {string} text - Text containing variable placeholders
4
+ * @param {Object} data - Data object with variable values
5
+ * @returns {string|Array} - Text with variables replaced, or array if mixed content
6
+ */
7
+ function replaceVariables(text, data) {
8
+ if (!text || typeof text !== "string") {
9
+ return text
10
+ }
11
+
12
+ if (!data || typeof data !== "object") {
13
+ return text
14
+ }
15
+
16
+ // Check if text contains any variables
17
+ if (!text.includes("{") || !text.includes("}")) {
18
+ return text
19
+ }
20
+
21
+ const result = []
22
+ let i = 0
23
+ let lastIndex = 0
24
+
25
+ while (i < text.length) {
26
+ if (text[i] === "\\" && text[i + 1] === "{") {
27
+ // Escaped opening brace
28
+ result.push(text.substring(lastIndex, i))
29
+ result.push("{")
30
+ i += 2
31
+ lastIndex = i
32
+ continue
33
+ }
34
+
35
+ if (text[i] === "{") {
36
+ const closeIndex = text.indexOf("}", i + 1)
37
+
38
+ if (closeIndex !== -1) {
39
+ // Found a variable placeholder
40
+ const variableName = text.substring(i + 1, closeIndex).trim()
41
+
42
+ if (variableName) {
43
+ // Add text before the variable
44
+ if (i > lastIndex) {
45
+ result.push(text.substring(lastIndex, i))
46
+ }
47
+
48
+ // Add the variable value
49
+ const value = data[variableName]
50
+ if (value !== undefined && value !== null) {
51
+ result.push(String(value))
52
+ } else {
53
+ // Variable not found, keep the placeholder
54
+ result.push(text.substring(i, closeIndex + 1))
55
+ }
56
+
57
+ i = closeIndex + 1
58
+ lastIndex = i
59
+ continue
60
+ }
61
+ }
62
+ }
63
+
64
+ i++
65
+ }
66
+
67
+ // Add remaining text
68
+ if (lastIndex < text.length) {
69
+ result.push(text.substring(lastIndex))
70
+ }
71
+
72
+ // If no substitutions were made, return original text
73
+ if (result.length === 0) {
74
+ return text
75
+ }
76
+
77
+ return result.join("")
78
+ }
79
+
80
+ module.exports = { replaceVariables }
package/ui/normalize.js CHANGED
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Converts string numbers to integers
3
+ * @param {string|number} value - The value to convert
4
+ * @returns {number|string} - Integer if string is numeric, otherwise original value
5
+ */
6
+ function toNumber(value) {
7
+ if (typeof value === "string" && /^\d+$/.test(value)) {
8
+ return parseInt(value, 10)
9
+ }
10
+ return value
11
+ }
12
+
13
+ /**
14
+ * Converts number or string number to pixel value
15
+ * @param {string|number} value - The value to convert
16
+ * @returns {string|number} - Value with px suffix if numeric, otherwise original value
17
+ */
18
+ function toPixels(value) {
19
+ value = toNumber(value)
20
+ if (typeof value === "number") {
21
+ return `${value}px`
22
+ }
23
+ return value
24
+ }
25
+
1
26
  function normalizeFlex(align) {
2
27
  switch (align) {
3
28
  case "start":
@@ -22,15 +47,12 @@ function normalizeGap(gap) {
22
47
  if (!gap) {
23
48
  return "1rem"
24
49
  }
25
- if (typeof gap === "number") {
26
- return `${gap}px`
27
- }
28
-
50
+
29
51
  if (GAP_MAP.hasOwnProperty(gap)) {
30
52
  return GAP_MAP[gap]
31
53
  }
32
54
 
33
- return gap
55
+ return toPixels(gap)
34
56
  }
35
57
 
36
58
  const BREAKPOINT_MAP = {
@@ -42,20 +64,14 @@ const BREAKPOINT_MAP = {
42
64
  }
43
65
 
44
66
  function normalizeBreakpoint(breakpoint) {
45
- if (typeof breakpoint === "number") {
46
- return `${breakpoint}px`
47
- }
48
67
  if (BREAKPOINT_MAP.hasOwnProperty(breakpoint)) {
49
68
  return BREAKPOINT_MAP[breakpoint]
50
69
  }
51
- return breakpoint
70
+ return toPixels(breakpoint)
52
71
  }
53
72
 
54
73
  function normalizeWidth(width) {
55
- if (typeof width === "number") {
56
- return `${width}px`
57
- }
58
- return width
74
+ return toPixels(width)
59
75
  }
60
76
 
61
77
  const SPACING_MAP = {
@@ -68,16 +84,15 @@ const SPACING_MAP = {
68
84
  }
69
85
 
70
86
  function normalizeSpacing(spacing) {
71
- if (typeof spacing === "number") {
72
- return `${spacing}px`
73
- }
74
87
  if (SPACING_MAP.hasOwnProperty(spacing)) {
75
88
  return SPACING_MAP[spacing]
76
89
  }
77
- return spacing
90
+ return toPixels(spacing)
78
91
  }
79
92
 
80
93
  module.exports = {
94
+ toNumber,
95
+ toPixels,
81
96
  normalizeFlex,
82
97
  normalizeGap,
83
98
  normalizeBreakpoint,