From e6452e196b6ac5cdc6e09984ce93d35f0ff20c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 14:19:51 +0000 Subject: [PATCH 1/5] feat(tabs): interactive tabs via '## Title {.tabs}' heading class Mirrors the grid pattern: heading with .tabs class declares the tabs container; direct child headings (depth+1) become tab panels. First tab active by default; {.active} on a sub-heading overrides. - Parser: detect .tabs class in processNodeList, emit tabs/tab nodes - Renderer: tabs case emits header row + panel divs, injects one-time click-delegated switcher script (idempotent via window.__wmdTabsInit) - Styles: structural rules in getStyleCSS wrapper (hidden panels), sketch-themed pill headers matching nav-item aesthetic - Tests: 7 parser + 6 renderer tests (TDD) --- package-lock.json | 6 +-- src/parser/transformer.ts | 66 ++++++++++++++++++++++++++++++++ src/renderer/html-renderer.ts | 43 +++++++++++++++++++++ src/renderer/styles.ts | 48 ++++++++++++++++++++++- tests/parser.test.ts | 71 +++++++++++++++++++++++++++++++++++ tests/renderer.test.ts | 60 +++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3393762b..731aa1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wiremd", - "version": "0.1.1", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wiremd", - "version": "0.1.1", + "version": "0.1.5", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -20,7 +20,7 @@ "unist-util-visit": "^5.0.0" }, "bin": { - "wiremd": "dist/cli/index.js" + "wiremd": "bin/wiremd.js" }, "devDependencies": { "@types/node": "^20.19.24", diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index 51859580..6cff91bb 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -138,6 +138,72 @@ function processNodeList(nodeChildren: any[], options: ParseOptions): WiremdNode if (node.type === 'heading') { const content = extractTextContent(node); const gridMatch = content.match(/\{[^}]*\.grid-(\d+)[^}]*\}/); + const tabsMatch = !gridMatch && /\{[^}]*\.tabs\b[^}]*\}/.test(content); + + if (tabsMatch) { + const tabsHeadingLevel = node.depth; + const tabs: WiremdNode[] = []; + const headingTransformed = transformHeading(node, options); + + i++; + + while (i < nodeChildren.length) { + const childNode = nodeChildren[i]; + + if (childNode.type === 'heading' && childNode.depth === tabsHeadingLevel + 1) { + const tabPanelChildren: WiremdNode[] = []; + const tabHeadingContent = extractTextContent(childNode); + const tabAttrMatch = tabHeadingContent.match(/^(.+?)(\{[^}]+\})$/); + let tabLabel = tabHeadingContent; + let tabProps: any = { classes: [] }; + if (tabAttrMatch) { + tabLabel = tabAttrMatch[1].trim(); + tabProps = parseAttributes(tabAttrMatch[2]); + } + const isActive = (tabProps.classes || []).includes('active'); + + i++; + + while (i < nodeChildren.length) { + const contentNode = nodeChildren[i]; + if (contentNode.type === 'heading' && contentNode.depth <= tabsHeadingLevel + 1) break; + const contentNextNode = nodeChildren[i + 1]; + const contentTransformed = transformNode(contentNode, options, contentNextNode); + if (contentTransformed) { + tabPanelChildren.push(contentTransformed); + if (contentTransformed.type === 'select' && contentNextNode?.type === 'list') i++; + } + i++; + } + + tabs.push({ + type: 'tab', + label: tabLabel, + active: isActive, + props: tabProps, + children: tabPanelChildren as any, + }); + } else if (childNode.type === 'heading' && childNode.depth <= tabsHeadingLevel) { + break; + } else if (tabs.length === 0) { + i++; + continue; + } else { + break; + } + } + + if (tabs.length > 0 && !tabs.some((t: any) => t.active)) { + (tabs[0] as any).active = true; + } + + result.push({ + type: 'tabs', + props: (headingTransformed as any).props || {}, + children: tabs as any, + }); + continue; + } if (gridMatch) { const columns = parseInt(gridMatch[1], 10); diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index eb64b90e..3e2a8d87 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -106,6 +106,12 @@ export function renderNode(node: WiremdNode, context: RenderContext): string { case 'separator': return renderSeparator(node, context); + case 'tabs': + return renderTabs(node, context); + + case 'tab': + return renderTab(node, context); + default: return ``; } @@ -649,6 +655,43 @@ function renderSeparator(node: any, context: RenderContext): string { return `
`; } +function renderTabs(node: any, context: RenderContext): string { + const { classPrefix: prefix } = context; + const classes = buildClasses(prefix, 'tabs', node.props); + const tabs: any[] = node.children || []; + + const headers = tabs.map((tab: any, i: number) => { + const activeClass = tab.active ? ` ${prefix}active` : ''; + return ``; + }).join(''); + + const panels = tabs.map((tab: any, i: number) => { + const panelChildren = (tab.children || []).map((c: any) => renderNode(c, context)).join('\n '); + const hidden = tab.active ? '' : ' hidden'; + return `
+ ${panelChildren} +
`; + }).join('\n '); + + return `
+
${headers}
+
+ ${panels} +
+
${getTabsScript(prefix)}`; +} + +function renderTab(node: any, context: RenderContext): string { + const { classPrefix: prefix } = context; + const hidden = node.active ? '' : ' hidden'; + const childrenHTML = (node.children || []).map((c: any) => renderNode(c, context)).join(''); + return `
${childrenHTML}
`; +} + +function getTabsScript(prefix: string): string { + return ``; +} + /** * Build CSS classes string from prefix, base class, and props */ diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index 919c8806..aa04cadb 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -14,6 +14,13 @@ export function getStyleCSS(style: string, prefix: string): string { // Reset browser link defaults for buttons rendered as tags via [[Text](url)] syntax const linkButtonReset = `a.${prefix}button { text-decoration: none; color: inherit; }\n`; + // Structural rules shared across themes — required for tabs visibility toggling + const tabsStructural = ` +.${prefix}tab-headers { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; } +.${prefix}tab-header { cursor: pointer; font-family: inherit; } +.${prefix}tab-panel[hidden] { display: none; } +`; + let themeCSS: string; switch (style) { case 'sketch': themeCSS = getSketchStyle(prefix); break; @@ -25,7 +32,7 @@ export function getStyleCSS(style: string, prefix: string): string { case 'brutal': themeCSS = getBrutalStyle(prefix); break; default: themeCSS = getSketchStyle(prefix); } - return linkButtonReset + themeCSS; + return linkButtonReset + tabsStructural + themeCSS; } /** @@ -545,6 +552,45 @@ body.${prefix}root { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +/* Tabs */ +.${prefix}tabs { + margin: 12px 0; +} + +.${prefix}tab-header { + display: inline-block; + padding: 6px 14px; + background: #fff; + color: #000; + border: 2px solid #000; + border-radius: 6px; + font-weight: bold; + font-size: 14px; + box-shadow: 2px 2px 0 rgba(0,0,0,0.15); + transform: rotate(-0.2deg); + transition: all 0.1s; +} + +.${prefix}tab-header:hover { + transform: rotate(-0.2deg) translateY(-1px); + box-shadow: 2px 3px 0 rgba(0,0,0,0.15); + background: #f8f8f8; +} + +.${prefix}tab-header.${prefix}active { + background: #000; + color: #fff; + border-color: #000; +} + +.${prefix}tab-panels { + padding: 12px; + border: 2px solid #000; + border-radius: 8px; + background: #fff; + box-shadow: 3px 3px 0 rgba(0,0,0,0.1); +} `; } diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 42a05103..c7107a9d 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -727,4 +727,75 @@ Quick expect(types.every((t: string) => t === 'grid-item')).toBe(true); }); }); + + describe('Tabs Syntax', () => { + const input = ` +## Product {.tabs} + +### Overview +Overview panel text. + +### Details +[Buy Now]* + +### Reviews +Review content + `.trim(); + + it('should parse heading with .tabs class into a tabs node', () => { + const result = parse(input); + expect(result.children).toHaveLength(1); + expect(result.children[0].type).toBe('tabs'); + }); + + it('should produce one tab child per sub-heading', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children).toHaveLength(3); + expect(tabs.children.every((c: any) => c.type === 'tab')).toBe(true); + }); + + it('should use sub-heading text as the tab label', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children.map((t: any) => t.label)).toEqual(['Overview', 'Details', 'Reviews']); + }); + + it('should mark the first tab active by default', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children[0].active).toBe(true); + expect(tabs.children[1].active).toBe(false); + expect(tabs.children[2].active).toBe(false); + }); + + it('should let {.active} on a sub-heading override the default active tab', () => { + const result = parse(` +## Product {.tabs} + +### Overview +a + +### Details {.active} +b + `.trim()); + const tabs = result.children[0] as any; + expect(tabs.children[0].active).toBe(false); + expect(tabs.children[1].active).toBe(true); + }); + + it('should put panel content as tab children', () => { + const result = parse(input); + const tabs = result.children[0] as any; + const detailsTab = tabs.children[1]; + const types = detailsTab.children.map((c: any) => c.type); + expect(types).toContain('button'); + }); + + it('should not emit the parent heading as a separate node', () => { + const result = parse(input); + const topTypes = result.children.map((c: any) => c.type); + expect(topTypes).not.toContain('heading'); + }); + }); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fce5bd32..bd29df8b 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -551,6 +551,66 @@ Content here }); }); + describe('Tabs', () => { + const input = ` +## Product {.tabs} + +### Overview +Overview panel + +### Details +Details panel + `.trim(); + + it('should render a tabs container with headers and panels', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('wmd-tabs'); + expect(html).toContain('wmd-tab-header'); + expect(html).toContain('wmd-tab-panel'); + }); + + it('should render tab labels as header buttons', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/]*wmd-tab-header[^>]*>Overview<\/button>/); + expect(html).toMatch(/]*wmd-tab-header[^>]*>Details<\/button>/); + }); + + it('should mark the active header with wmd-active', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + const headerMatches = [...html.matchAll(/]*class="([^"]*)"[^>]*>(Overview|Details)<\/button>/g)]; + expect(headerMatches).toHaveLength(2); + const overviewClasses = headerMatches.find((m) => m[2] === 'Overview')![1]; + const detailsClasses = headerMatches.find((m) => m[2] === 'Details')![1]; + expect(overviewClasses).toContain('wmd-active'); + expect(detailsClasses).not.toContain('wmd-active'); + }); + + it('should hide inactive panels via the hidden attribute', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + const panels = [...html.matchAll(/]*data-wmd-tab-panel="\d+"[^>]*>/g)].map((m) => m[0]); + expect(panels).toHaveLength(2); + expect(panels[0]).not.toContain('hidden'); + expect(panels[1]).toContain('hidden'); + }); + + it('should render panel children', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('Overview panel'); + expect(html).toContain('Details panel'); + }); + + it('should inject a tabs interactivity script', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/ +
+
+
+
+
+ Username + +
+
+ Password + +
+ +
+ + +
+
+ + \ No newline at end of file diff --git a/examples/tabs-demo.md b/examples/tabs-demo.md new file mode 100644 index 00000000..2ac68fe3 --- /dev/null +++ b/examples/tabs-demo.md @@ -0,0 +1,72 @@ +# Tabs Demo + +A showcase of the `{.tabs}` heading-class syntax — interactive, JS-switched tab panels. + +--- + +## Product Details {.tabs} + +### Overview +This is the overview panel. Tap/click the other tab headers above to switch. + +**Key points:** +- Available in three sizes +- Ships worldwide +- 30-day returns + +[Buy Now]* [Add to Cart] + +### Specifications +| Spec | Value | +|------|-------| +| Weight | 250g | +| Dimensions | 10 × 5 × 3 cm | +| Material | Aluminium | +| Warranty | 2 years | + +### Reviews {.active} +> :star: :star: :star: :star: :star: +> +> "Fantastic build quality — exactly as described." +> — Satisfied Customer + +> :star: :star: :star: :star: +> +> "Works as advertised, shipping was fast." +> — Another Buyer + +### FAQ +**Q: Does it come with a charger?** +A: Yes, a USB-C cable is included. + +**Q: Is there a mobile app?** +A: Yes — available on iOS and Android. + +--- + +## Another Example {.tabs} + +### Login +Username +[_______________________] + +Password +[***********************] + +[Sign In]* + +### Register +Email +[Email___________________] + +Password +[***********************] + +[Create Account]* + +### Forgot? +Enter your email and we'll send a reset link. + +[Email___________________] + +[Send Reset Link]* From 1aba53ba65e36a8eae873a740fd63b399d5a5deb Mon Sep 17 00:00:00 2001 From: teezeit Date: Sun, 19 Apr 2026 21:59:48 +0200 Subject: [PATCH 3/5] style(tabs): replace button look with underline tab style Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/styles.ts | 49 +++++++++--------------------------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index aa04cadb..afcfbc79 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -16,9 +16,12 @@ export function getStyleCSS(style: string, prefix: string): string { // Structural rules shared across themes — required for tabs visibility toggling const tabsStructural = ` -.${prefix}tab-headers { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; } -.${prefix}tab-header { cursor: pointer; font-family: inherit; } +.${prefix}tab-headers { display: flex; gap: 0; border-bottom: 2px solid #e0e0e0; margin-bottom: 12px; } +.${prefix}tab-header { display: inline-block; padding: 8px 16px; border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 14px; font-weight: 500; color: #888; background: transparent; cursor: pointer; font-family: inherit; transition: color 0.15s; } +.${prefix}tab-header:hover { color: #333; } +.${prefix}tab-header.${prefix}active { border-bottom-color: currentColor; color: #333; font-weight: 600; } .${prefix}tab-panel[hidden] { display: none; } +.${prefix}tab-panels { padding: 12px 0; } `; let themeCSS: string; @@ -554,43 +557,11 @@ body.${prefix}root { } /* Tabs */ -.${prefix}tabs { - margin: 12px 0; -} - -.${prefix}tab-header { - display: inline-block; - padding: 6px 14px; - background: #fff; - color: #000; - border: 2px solid #000; - border-radius: 6px; - font-weight: bold; - font-size: 14px; - box-shadow: 2px 2px 0 rgba(0,0,0,0.15); - transform: rotate(-0.2deg); - transition: all 0.1s; -} - -.${prefix}tab-header:hover { - transform: rotate(-0.2deg) translateY(-1px); - box-shadow: 2px 3px 0 rgba(0,0,0,0.15); - background: #f8f8f8; -} - -.${prefix}tab-header.${prefix}active { - background: #000; - color: #fff; - border-color: #000; -} - -.${prefix}tab-panels { - padding: 12px; - border: 2px solid #000; - border-radius: 8px; - background: #fff; - box-shadow: 3px 3px 0 rgba(0,0,0,0.1); -} +.${prefix}tabs { margin: 12px 0; } +.${prefix}tab-headers { border-bottom-color: #000; } +.${prefix}tab-header { color: #666; } +.${prefix}tab-header:hover { color: #000; } +.${prefix}tab-header.${prefix}active { border-bottom-color: #000; color: #000; } `; } From 82c8fad4febe4f1dbf64ff372b41f694c42c6ae9 Mon Sep 17 00:00:00 2001 From: teezeit Date: Sun, 19 Apr 2026 22:15:10 +0200 Subject: [PATCH 4/5] docs: add tabs syntax to guide and spec Co-Authored-By: Claude Sonnet 4.6 --- SYNTAX-SPEC-v0.1.md | 21 +++++++++++++++------ docs/guide/syntax.md | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/SYNTAX-SPEC-v0.1.md b/SYNTAX-SPEC-v0.1.md index 3ccd209b..4f0cc6cd 100644 --- a/SYNTAX-SPEC-v0.1.md +++ b/SYNTAX-SPEC-v0.1.md @@ -400,16 +400,25 @@ Home > Products > Category > Current Page ### 7.3 Tabs ```markdown -[Overview]* | Details | Reviews | FAQ +## Settings {.tabs} -Content for Overview tab... +### Profile +Name +[_____________________________]{required} + +### Notifications +[ ] Email alerts +[ ] SMS alerts + +### Security {.active} +[Change Password] ``` **Parser Rules:** -- Pipe-separated button-like elements -- `*` suffix indicates active tab -- Following content belongs to active tab -- Each H2 can start a new tab content section +- `{.tabs}` class on a heading declares a tabs container +- Child headings one level deeper become tab panels (label = heading text) +- First tab is active by default; add `{.active}` to a child heading to override +- Any wiremd content is valid inside a tab panel ### 7.4 Badges/Pills diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 6b31bce7..7012552e 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -124,6 +124,25 @@ Place inline content directly on the opener line to inject it as the first child ::: ``` +### Tabs + +`{.tabs}` on a heading creates a tabbed panel. Child headings one level deeper become tab labels; their content becomes the panel body. The first tab is active by default — add `{.active}` to a child heading to override. + +```markdown +## Settings {.tabs} + +### Profile +Name +[_____________________________]{required} + +### Notifications +[ ] Email alerts +[ ] SMS alerts + +### Security {.active} +[Change Password] +``` + ### Grid Layouts `{.grid-N}` on a heading creates an N-column layout. Child `###` headings become grid items. The heading label itself is **declaration-only** — it is never rendered in the output; it only names the grid for the author. From c91ac5b22bb178bd6aad4fe1c633969b06e87815 Mon Sep 17 00:00:00 2001 From: teezeit Date: Sun, 19 Apr 2026 22:22:34 +0200 Subject: [PATCH 5/5] style(tabs): update tab header border color for improved visibility --- docs/tabs-demo.html | 670 ----------------------------------------- src/renderer/styles.ts | 2 +- 2 files changed, 1 insertion(+), 671 deletions(-) delete mode 100644 docs/tabs-demo.html diff --git a/docs/tabs-demo.html b/docs/tabs-demo.html deleted file mode 100644 index 9706d725..00000000 --- a/docs/tabs-demo.html +++ /dev/null @@ -1,670 +0,0 @@ - - - - - - wiremd Mockup - - - -

Tabs Demo

-

A showcase of the {.tabs} heading-class syntax — interactive, JS-switched tab panels.

-
-
-
-
- - -
-
-

-

"Fantastic build quality — exactly as described." -— Satisfied Customer

-
-
-

-

"Works as advertised, shipping was fast." -— Another Buyer

-
-
- -
-
-
-
-
-
-
- Username - -
-
- Password - -
- -
- - -
-
- - \ No newline at end of file diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index afcfbc79..c14eb36a 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -558,7 +558,7 @@ body.${prefix}root { /* Tabs */ .${prefix}tabs { margin: 12px 0; } -.${prefix}tab-headers { border-bottom-color: #000; } +.${prefix}tab-headers { border-bottom-color: #666; } .${prefix}tab-header { color: #666; } .${prefix}tab-header:hover { color: #000; } .${prefix}tab-header.${prefix}active { border-bottom-color: #000; color: #000; }