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.
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]*
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 `${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..c14eb36a 100644
--- a/src/renderer/styles.ts
+++ b/src/renderer/styles.ts
@@ -14,6 +14,16 @@ 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; 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;
switch (style) {
case 'sketch': themeCSS = getSketchStyle(prefix); break;
@@ -25,7 +35,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 +555,13 @@ body.${prefix}root {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+
+/* Tabs */
+.${prefix}tabs { margin: 12px 0; }
+.${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; }
`;
}
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(/