diff --git a/.gitignore b/.gitignore index 1db70c5..c3520d7 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ docs/.vitepress/cache/ # Generated skill zip (built on release, not checked in) wireframe-skill.zip +*.tgz diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cabb1..dc88f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No unreleased changes yet. +### Fixed +- Nested containers that share a paragraph with an outer closer no longer swallow the outer `:::` (tight `::: badge` inside `::: card` without blank lines) +- `[Label](url)*` and `[Label](url){.primary}` now parse as linked primary buttons (same as `[[Label](url)]*`) instead of leaking `*` / `{.primary}` as literal text ## [0.1.4] - 2025-11-24 diff --git a/package.json b/package.json index fa3dbba..d17150a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wiremd", - "version": "0.1.7", + "version": "0.1.8", "description": "Text-first UI design tool - Create wireframes and mockups using Markdown syntax", "type": "module", "sideEffects": false, diff --git a/src/parser/remark-containers.ts b/src/parser/remark-containers.ts index 4e7fbb0..f44ce7d 100644 --- a/src/parser/remark-containers.ts +++ b/src/parser/remark-containers.ts @@ -98,8 +98,11 @@ function collectContainer( ) { const fullText = openerNode.children[0].value as string; const lines = fullText.split('\n'); + // First closer after the opener — not the last. Nested openers often share a + // paragraph with an outer closer ("::: badge\n…\n:::\nText\n:::"); taking the + // last ::: would swallow the outer fence into the inner container's body. let closingIdx = -1; - for (let j = lines.length - 1; j >= 1; j--) { + for (let j = 1; j < lines.length; j++) { if (lines[j].trim() === ':::') { closingIdx = j; break; @@ -107,6 +110,7 @@ function collectContainer( } if (closingIdx > 0) { const contentText = lines.slice(1, closingIdx).join('\n').trim(); + const afterCloser = lines.slice(closingIdx + 1).join('\n'); const children: any[] = []; if (opener.inline) { children.push({ @@ -120,6 +124,14 @@ function collectContainer( children: [{ type: 'text', value: contentText }], }); } + // Remainder after the closer (often the outer container's trailing content + + // its :::) must stay available for the parent collector. + if (afterCloser.trim()) { + nodes.splice(startIdx + 1, 0, { + type: 'paragraph', + children: [{ type: 'text', value: afterCloser }], + }); + } return finishContainer(opener.containerType, opener.attrs, opener.inline, children, startIdx + 1); } } diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index 732f03f..b4c4e29 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -581,6 +581,71 @@ function tryParseButtonLinkSequence(children: any[]): WiremdNode[] | null { }); } +/** + * Detect markdown links trailed by primary markers that would otherwise leak as + * literal text: `[Label](url)*` or `[Label](url){.primary}`. + * Same result as the canonical `[[Label](url)]*` form. + */ +function tryParseMarkdownLinkButtons(children: any[]): WiremdNode[] | null { + if (!children?.length) return null; + + const buttons: WiremdNode[] = []; + let i = 0; + let sawModifier = false; + + while (i < children.length) { + const child = children[i]; + + if (child.type === 'text' && /^\s*$/.test(child.value || '')) { + i++; + continue; + } + + if (child.type !== 'link') return null; + + let isPrimary = false; + let attrs: Record = {}; + const next = children[i + 1]; + + if (next?.type === 'text') { + const mod = (next.value as string).match(/^\s*(\*|(\{[^}]*\}))\s*$/); + if (!mod) return null; + const marker = mod[1]; + if (marker === '*') { + isPrimary = true; + } else { + attrs = parseAttributes(marker); + if ( + (attrs as any).variant === 'primary' || + ((attrs as any).classes || []).includes('primary') + ) { + isPrimary = true; + } + } + sawModifier = true; + i += 2; + } else { + // Trailing unmodified link only allowed after we've already seen a modifier + // on an earlier sibling (e.g. primary + secondary CTA pair). + if (!sawModifier) return null; + i += 1; + } + + buttons.push({ + type: 'button', + content: extractTextContent(child), + href: child.url || '#', + props: { + ...attrs, + variant: isPrimary ? 'primary' : (attrs as any).variant, + }, + }); + } + + if (!buttons.length || !sawModifier) return null; + return buttons; +} + function serializeMdastChildren(children: any[]): string { return (children || []).map((child: any) => { if (child.type === 'link') { @@ -626,6 +691,18 @@ function transformParagraph(node: any, _options: ParseOptions, nextNode?: any): }; } + // [Button](url)* / [Button](url){.primary} — same result without the outer [[…]] + const mdLinkButtons = tryParseMarkdownLinkButtons(node.children); + if (mdLinkButtons !== null) { + if (mdLinkButtons.length === 1) return mdLinkButtons[0]; + return { + type: 'container', + containerType: 'button-group', + children: mdLinkButtons as any, + props: {}, + }; + } + // If it has rich content and is not a special pattern, return as a rich text paragraph if (hasRichContent) { let content = extractTextContent(node); diff --git a/tests/parser.test.ts b/tests/parser.test.ts index c1fa603..c165414 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -400,6 +400,30 @@ Are you sure? expect(result.children[0].children).toHaveLength(3); }); + it('should nest a badge inside a card without blank lines between fences', () => { + const input = `::: card {.buerger} +### Titel +::: badge {.buerger} +Bürger:in +::: +Text hier. +:::`; + const result = parse(input); + const card = result.children[0]; + expect(card).toMatchObject({ type: 'container', containerType: 'card' }); + const badge = (card as any).children.find( + (c: any) => c.type === 'container' && c.containerType === 'badge', + ); + expect(badge).toBeDefined(); + const badgeText = JSON.stringify(badge); + expect(badgeText).toContain('Bürger:in'); + expect(badgeText).not.toContain('Text hier'); + const body = (card as any).children.find( + (c: any) => c.type === 'paragraph' && JSON.stringify(c).includes('Text hier'), + ); + expect(body).toBeDefined(); + }); + it('should nest a container inside another container', () => { const input = ` ::: modal @@ -1052,6 +1076,26 @@ Nav }); }); + it('should parse [text](url)* as primary button with href (no literal asterisk)', () => { + const result = parse('[Geschichten ansehen](./stories.md)*'); + expect(result.children[0]).toMatchObject({ + type: 'button', + content: 'Geschichten ansehen', + href: './stories.md', + props: { variant: 'primary' }, + }); + }); + + it('should parse [text](url){.primary} as primary button with href', () => { + const result = parse('[Geschichten ansehen](./stories.md){.primary}'); + expect(result.children[0]).toMatchObject({ + type: 'button', + content: 'Geschichten ansehen', + href: './stories.md', + props: { variant: 'primary' }, + }); + }); + it('should parse [[Button](url)] with attributes', () => { const result = parse('[[Sign Up](./signup.md)]{.secondary}'); expect(result.children[0]).toMatchObject({