Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ docs/.vitepress/cache/

# Generated skill zip (built on release, not checked in)
wireframe-skill.zip
*.tgz
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
14 changes: 13 additions & 1 deletion src/parser/remark-containers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,19 @@ 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;
}
}
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({
Expand All @@ -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);
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/parser/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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') {
Expand Down Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down