Skip to content

Commit 3de0854

Browse files
fix(parse): keep attr="true|false" as string (#321)
Co-authored-by: Arash Ari Sheyda <hi@arashsheyda.me>
1 parent 7d9a536 commit 3de0854

8 files changed

Lines changed: 105 additions & 20 deletions

File tree

docs/content/2.syntax/2.components.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,6 @@ Nested components can read the enclosing component's resolved props through the
306306
- Unknown paths resolve to `undefined`: the prop is passed as `undefined` rather than the raw string.
307307
- Only props authored with the `:` prefix participate in data binding. Plain `prop="value"` attributes are always passed as literal strings.
308308

309-
310309
::callout{color="info" icon="i-lucide-replace"}
311310
Need to interpolate a value directly into text rather than a prop? The [Binding plugin](/plugins/built-in/binding) adds a `{{ path || default }}` inline shorthand that resolves against the same render context.
312311
::

packages/comark/SPEC/COMARK/component-block-props-types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Component content
3030
{
3131
"title": "Hello World",
3232
"count": "42",
33-
":enabled": "true",
33+
"enabled": "true",
3434
"hidden": "false",
3535
"tags": [
3636
"markdown",

packages/comark/SPEC/COMARK/component-nested4.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ timeout:
9797
"button",
9898
{
9999
":data-testid": "$doc.snippet.description",
100-
":external": "true",
100+
"external": "true",
101101
":to": "$doc.snippet.link",
102102
"appearance": "primary"
103103
},
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
timeout:
3+
parse: 5ms
4+
html: 5ms
5+
markdown: 5ms
6+
options:
7+
maxInlineAttributes: 5
8+
---
9+
10+
## Input
11+
12+
```md
13+
::my-component{block reverse square="false" disabled="true" count="5"}
14+
My button
15+
::
16+
```
17+
18+
## AST
19+
20+
```json
21+
{
22+
"frontmatter": {},
23+
"meta": {},
24+
"nodes": [
25+
[
26+
"my-component",
27+
{
28+
":block": "true",
29+
":reverse": "true",
30+
"count": "5",
31+
"disabled": "true",
32+
"square": "false"
33+
},
34+
"My button"
35+
]
36+
]
37+
}
38+
```
39+
40+
## HTML
41+
42+
```html
43+
<my-component block reverse square="false" disabled count="5">
44+
My button
45+
</my-component>
46+
```
47+
48+
## Markdown
49+
50+
```md
51+
::my-component{block reverse square="false" disabled="true" count="5"}
52+
My button
53+
::
54+
```

packages/comark/src/internal/parse/syntax/props.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,16 @@ export function searchProps(content: string, index = 0) {
5151
}
5252
const char = content[index]
5353
if (start !== index) {
54-
const key = content.slice(start, index).trim()
54+
let key = content.slice(start, index).trim()
5555
let value = ''
5656
if (char === '=') {
5757
index += 1
5858
value = searchValue()
5959
} else {
60+
key = key[0] === ':' ? key : `:${key}`
6061
value = 'true'
6162
}
62-
if (key.match(/^:?[a-z_][a-z0-9_-]+$/gi)) {
63+
if (key.match(/^:?[a-z_][a-z0-9_-]*$/gi)) {
6364
props.push([key, value])
6465
}
6566
}

packages/comark/src/internal/parse/token-processor.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ function processAttributes(
109109
filterEmpty?: boolean
110110
} = {}
111111
): Record<string, unknown> {
112-
const { handleBoolean = true, handleJSON = true, filterEmpty = false } = options
112+
const { handleJSON = true, filterEmpty = false } = options
113113
const attrs: Record<string, unknown> = {}
114114

115115
if (!attrsArray || !Array.isArray(attrsArray)) {
@@ -126,12 +126,6 @@ function processAttributes(
126126
continue
127127
}
128128

129-
// Handle boolean attributes: {bool} -> {":bool": "true"}
130-
if (handleBoolean && !key.startsWith(':') && !key.startsWith('#') && !key.startsWith('.') && value === 'true') {
131-
attrs[`:${key}`] = 'true'
132-
continue
133-
}
134-
135129
// Handle JSON values
136130
if (handleJSON && typeof value === 'string') {
137131
if (value.startsWith('{') && value.endsWith('}')) {
@@ -412,7 +406,7 @@ function processBlockToken(
412406
if (token.type === 'heading_open') {
413407
const level = Number.parseInt(token.tag.replace('h', ''), 10)
414408
const headingTag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
415-
const userAttrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false })
409+
const userAttrs = processAttributes(token.attrs, { handleJSON: false })
416410
// Process heading children with inHeading flag for Comark component handling
417411
const children = processBlockChildren(
418412
tokens,
@@ -444,7 +438,7 @@ function processBlockToken(
444438

445439
// Handle list items - paragraphs should be unwrapped
446440
if (token.type === 'list_item_open') {
447-
const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false })
441+
const attrs = processAttributes(token.attrs, { handleJSON: false })
448442
const children = processBlockChildren(tokens, startIndex + 1, 'list_item_close', false, false, true, state)
449443
if (children.nodes.length > 0) {
450444
return { node: ['li', attrs, ...children.nodes] as ComarkNode, nextIndex: children.nextIndex + 1 }
@@ -455,7 +449,7 @@ function processBlockToken(
455449
// Handle generic block-level open/close pairs (includes blockquote, lists, tables, etc.)
456450
const tagName = BLOCK_TAG_MAP[token.type]
457451
if (tagName) {
458-
const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false })
452+
const attrs = processAttributes(token.attrs, { handleJSON: false })
459453
const closeType = token.type.replace('_open', '_close')
460454

461455
const isNestedContext = ['td', 'th'].includes(tagName)
@@ -464,7 +458,7 @@ function processBlockToken(
464458
}
465459

466460
const componentName = token.tag || 'component'
467-
const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false })
461+
const attrs = processAttributes(token.attrs, { handleJSON: false })
468462
return { node: [componentName, attrs], nextIndex: startIndex + 1 }
469463
}
470464

@@ -957,7 +951,7 @@ function processInlineToken(
957951
}
958952

959953
if (token.type === 'image') {
960-
const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false, filterEmpty: true })
954+
const attrs = processAttributes(token.attrs, { handleJSON: false, filterEmpty: true })
961955
// Override alt with token.content if available
962956
if (token.content) {
963957
attrs.alt = token.content
@@ -971,7 +965,7 @@ function processInlineToken(
971965
}
972966

973967
if (token.type === 'link_open') {
974-
const attrs = processAttributes(token.attrs, { handleBoolean: false, handleJSON: false })
968+
const attrs = processAttributes(token.attrs, { handleJSON: false })
975969
const children = processInlineChildren(tokens, startIndex + 1, 'link_close', inHeading)
976970

977971
// Check if there's a props token right after the link_close token

packages/comark/test/misc.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,41 @@ describe('misc', () => {
66
const tree = await parse(':Alert{id="1"" }')
77
expect(tree.nodes).toEqual([['alert', { id: '1' }]])
88
})
9+
10+
describe('boolean attribute coercion', () => {
11+
it('promotes bare, explicit-true, and explicit-false attrs to :bindings', async () => {
12+
const tree = await parse('::x{flag other="true" off="false" count="5"}\n::')
13+
expect((tree.nodes[0] as any)[1]).toEqual({
14+
':flag': 'true',
15+
other: 'true',
16+
off: 'false',
17+
count: '5',
18+
})
19+
})
20+
21+
it('promotes unquoted true/false the same way', async () => {
22+
const tree = await parse('::x{on=true off=false}\n::')
23+
expect((tree.nodes[0] as any)[1]).toEqual({
24+
on: 'true',
25+
off: 'false',
26+
})
27+
})
28+
29+
it('leaves explicit :bindings and non-boolean strings alone', async () => {
30+
const tree = await parse('::x{:flag="false" label="falsey" zero="0"}\n::')
31+
expect((tree.nodes[0] as any)[1]).toEqual({
32+
':flag': 'false',
33+
label: 'falsey',
34+
zero: '0',
35+
})
36+
})
37+
38+
it('applies the same rules to inline components', async () => {
39+
const tree = await parse(':badge{disabled="false" active}')
40+
expect((tree.nodes[0] as any)[1]).toEqual({
41+
disabled: 'false',
42+
':active': 'true',
43+
})
44+
})
45+
})
946
})

packages/comark/test/syntax/props.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ describe('parseProps', () => {
1818
"
1919
class=foo
2020
id=my-id
21-
no-border=true
21+
:no-border=true
2222
"
2323
`)
2424

2525
expect(parse('{foo=bar baz}')).toMatchInlineSnapshot(`
2626
"
2727
foo=bar
28-
baz=true
28+
:baz=true
2929
"
3030
`)
3131

0 commit comments

Comments
 (0)