From f36f87bbe1d23d81bf1e7832a171db6bfa291ed5 Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 14:34:09 +0200 Subject: [PATCH 1/6] feat: render button with href as tag [Button]{href:./page.md} now renders as a styled anchor instead of a non-navigating `; } diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index a7640a1e..fce39b79 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -244,6 +244,22 @@ Spans two }); }); + describe('Button links', () => { + it('should render button with href as tag', () => { + const ast = parse('[Go to Docs]{href:./docs.md}'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(//); + expect(html).not.toContain(' tag', () => { + const ast = parse('[Get Started]*{href:./start.md}'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/ { it('should render a dropdown with options', () => { const input = ` From 5aebefd7fd46c9131a5a66c19b7d5cb6d4eeff46 Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 14:40:00 +0200 Subject: [PATCH 2/6] feat: support [[Button](url)] linked-button syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [[Text](url)] renders as a button-styled anchor. CommonMark parses this as text:"[" + link + text:"]" — the transformer now detects that 3-child pattern and hoists the link url to button.href. Supports variants: [[Label](url)]* for primary, [[Label](url)]{.cls} for attributes. Also fixes {href:url} attribute form which was stored in props.href but renderer only checked node.href. Co-Authored-By: Claude Sonnet 4.6 --- src/parser/transformer.ts | 25 +++++++++++++++++++++++++ tests/parser.test.ts | 37 +++++++++++++++++++++++++++++++++++++ tests/renderer.test.ts | 14 ++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index c51bbda5..8d4f6355 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -424,6 +424,31 @@ function transformParagraph(node: any, _options: ParseOptions, nextNode?: any): child.type === 'strong' || child.type === 'emphasis' || child.type === 'link' || child.type === 'code' || child.type === 'image' ); + // [[Button](url)]* — explicit linked-button syntax. + // CommonMark forbids nested links so remark parses this as: + // text:"[" + link + text:"]*" (or "]{.secondary}", etc.) + if ( + node.children.length === 3 && + node.children[0].type === 'text' && node.children[0].value === '[' && + node.children[1].type === 'link' && + node.children[2].type === 'text' && /^\](\*)?(\s*\{[^}]*\})?$/.test(node.children[2].value) + ) { + const linkNode = node.children[1]; + const suffix: string = node.children[2].value; + const isPrimary = suffix.includes('*'); + const attrMatch = suffix.match(/\{([^}]*)\}/); + const attrs = attrMatch ? parseAttributes(`{${attrMatch[1]}}`) : {}; + return { + type: 'button', + content: extractTextContent(linkNode), + href: linkNode.url || '#', + props: { + ...attrs, + variant: isPrimary ? 'primary' : attrs.variant, + }, + }; + } + // 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 8001cff2..ff921f28 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -518,6 +518,43 @@ Quick }); }); + describe('Button link syntax [[text](url)]', () => { + it('should parse [[Button](url)] as button with href', () => { + const result = parse('[[Go to Docs](./docs.md)]'); + expect(result.children[0]).toMatchObject({ + type: 'button', + content: 'Go to Docs', + href: './docs.md', + }); + }); + + it('should parse [[Button]*(url)] as primary button with href', () => { + const result = parse('[[Get Started](./start.md)]*'); + expect(result.children[0]).toMatchObject({ + type: 'button', + href: './start.md', + props: { variant: 'primary' }, + }); + }); + + it('should parse [[Button](url)] with attributes', () => { + const result = parse('[[Sign Up](./signup.md)]{.secondary}'); + expect(result.children[0]).toMatchObject({ + type: 'button', + href: './signup.md', + props: { classes: ['secondary'] }, + }); + }); + + it('should parse [[Button](url)] with external URL', () => { + const result = parse('[[Google](https://www.google.com)]'); + expect(result.children[0]).toMatchObject({ + type: 'button', + href: 'https://www.google.com', + }); + }); + }); + describe('Grid col-span', () => { it('should hoist col-span class from heading to grid-item', () => { const input = ` diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fce39b79..03818e58 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -258,6 +258,20 @@ Spans two expect(html).toMatch(/ button', () => { + const ast = parse('[[Go to Docs](./docs.md)]'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(//); + expect(html).not.toContain(' button', () => { + const ast = parse('[[Get Started](./start.md)]*'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/ { From 963730ea243830dadb8dc7d6cf865588012e1443 Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 14:46:22 +0200 Subject: [PATCH 3/6] fix: reset link styling on buttons rendered as tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a.wmd-button gets text-decoration:none and color:inherit so button-links look identical to regular buttons — no underline, no browser link color. Applied once in getStyleCSS() so all 7 themes inherit it automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/styles.ts | 29 +++++++++++++---------------- tests/renderer.test.ts | 7 +++++++ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index f6e4820b..b5e4ce75 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -11,24 +11,21 @@ * Get CSS for the specified style */ 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`; + + let themeCSS: string; switch (style) { - case 'sketch': - return getSketchStyle(prefix); - case 'clean': - return getCleanStyle(prefix); - case 'wireframe': - return getWireframeStyle(prefix); - case 'none': - return getNoneStyle(prefix); - case 'tailwind': - return getTailwindStyle(prefix); - case 'material': - return getMaterialStyle(prefix); - case 'brutal': - return getBrutalStyle(prefix); - default: - return getSketchStyle(prefix); + case 'sketch': themeCSS = getSketchStyle(prefix); break; + case 'clean': themeCSS = getCleanStyle(prefix); break; + case 'wireframe': themeCSS = getWireframeStyle(prefix); break; + case 'none': themeCSS = getNoneStyle(prefix); break; + case 'tailwind': themeCSS = getTailwindStyle(prefix); break; + case 'material': themeCSS = getMaterialStyle(prefix); break; + case 'brutal': themeCSS = getBrutalStyle(prefix); break; + default: themeCSS = getSketchStyle(prefix); } + return linkButtonReset + themeCSS; } /** diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 03818e58..341f6a28 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -266,6 +266,13 @@ Spans two expect(html).not.toContain(' tags', () => { + const ast = parse('[[Docs](./docs.md)]'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('text-decoration: none'); + expect(html).toContain('color: inherit'); + }); + it('should render [[Button]*(url)] as primary button', () => { const ast = parse('[[Get Started](./start.md)]*'); const html = renderToHTML(ast, { style: 'sketch' }); From c1d5de0d5ada9353c4c085bbb013f49855848c9d Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 14:54:34 +0200 Subject: [PATCH 4/6] feat: multi-file routing in dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev server now renders any .md file on demand when its URL is requested, enabling button-links like [[Docs](./docs.md)] to navigate between wireframe files in the browser. - startServer() accepts renderFile callback and rootDir - GET /page.md → calls renderFile(path) and serves result - GET /page.html → serves cached HTML or renders from .md - GET / → serves main watched file (unchanged) - GET unknown → 404 - startServer() now returns the server instance (testability) - CLI passes renderFile and rootDir to startServer - Add href? to button node type Co-Authored-By: Claude Sonnet 4.6 --- src/cli/index.ts | 7 +++- src/cli/server.ts | 79 +++++++++++++++++++++++++++++++++----------- src/types.ts | 2 +- tests/server.test.ts | 48 +++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1c9eec1b..7e3885b2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -288,7 +288,12 @@ export function main(): void { // Start dev server if requested if (options.serve) { const port = options.serve; - startServer({ port, outputPath: options.output }); + startServer({ + port, + outputPath: options.output, + renderFile: (mdPath: string) => generateOutput({ ...options, input: mdPath }), + rootDir: dirname(options.input), + }); console.log(''); } diff --git a/src/cli/server.ts b/src/cli/server.ts index 403fdd36..fe7a020d 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -7,12 +7,16 @@ */ import { createServer, IncomingMessage, ServerResponse } from 'http'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; import { createHash } from 'crypto'; +import { dirname, join } from 'path'; interface ServerOptions { port: number; outputPath: string; + renderFile?: (mdPath: string) => string; + /** Root directory for resolving linked .md files. Defaults to dirname(outputPath). */ + rootDir?: string; } const liveReloadScript = ` @@ -356,35 +360,70 @@ const liveReloadScript = ` const wsClients: Set = new Set(); -export function startServer(options: ServerOptions): void { - const { port, outputPath } = options; +export function startServer(options: ServerOptions): ReturnType { + const { port, outputPath, renderFile } = options; + const rootDir = options.rootDir || dirname(outputPath); + + const injectScript = (html: string) => { + const script = liveReloadScript.replace('__PORT__', String(port)); + return html.replace('', `${script}\n`); + }; - // Simple WebSocket implementation without dependencies const server = createServer((req: IncomingMessage, res: ServerResponse) => { - // Handle WebSocket upgrade if (req.url === '/__ws') { res.writeHead(426, { 'Content-Type': 'text/plain' }); res.end('This endpoint requires WebSocket upgrade'); return; } - // Serve the HTML file - try { - let html = readFileSync(outputPath, 'utf-8'); + const urlPath = (req.url || '/').split('?')[0]; + let html: string | null = null; - // Inject live-reload script before - const script = liveReloadScript.replace('__PORT__', String(port)); - html = html.replace('', `${script}\n`); + if (urlPath === '/' || urlPath === '') { + try { + html = readFileSync(outputPath, 'utf-8'); + } catch { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(`Error reading: ${outputPath}`); + return; + } + } else if (renderFile) { + const requestedFile = urlPath.replace(/^\//, ''); + const targetPath = join(rootDir, requestedFile); + + if (targetPath.endsWith('.md') && existsSync(targetPath)) { + try { + html = renderFile(targetPath); + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(`Error rendering ${targetPath}: ${err.message}`); + return; + } + } else if (targetPath.endsWith('.html')) { + if (existsSync(targetPath)) { + try { html = readFileSync(targetPath, 'utf-8'); } catch {} + } + if (!html) { + const mdPath = targetPath.replace(/\.html$/, '.md'); + if (existsSync(mdPath)) { + try { html = renderFile(mdPath); } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(`Error rendering ${mdPath}: ${err.message}`); + return; + } + } + } + } + } - res.writeHead(200, { - 'Content-Type': 'text/html', - 'Cache-Control': 'no-cache, no-store, must-revalidate' - }); - res.end(html); - } catch (error) { - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end(`Error reading file: ${outputPath}`); + if (!html) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end(`Not found: ${urlPath}`); + return; } + + res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate' }); + res.end(injectScript(html)); }); // Handle WebSocket upgrade manually @@ -421,6 +460,8 @@ export function startServer(options: ServerOptions): void { console.log(`📡 Live-reload enabled`); console.log(`Press Ctrl+C to stop`); }); + + return server; } export function notifyReload(): void { diff --git a/src/types.ts b/src/types.ts index 6b040d39..49a9976d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,7 +62,7 @@ export type WiremdNode = | { type: 'grid-item'; props: ComponentProps; children: WiremdNode[]; position?: Location } // Forms - | { type: 'button'; content?: string; children?: WiremdNode[]; props: ComponentProps & { variant?: 'primary' | 'secondary' | 'danger'; type?: 'button' | 'submit' | 'reset' }; position?: Location } + | { type: 'button'; content?: string; children?: WiremdNode[]; href?: string; props: ComponentProps & { variant?: 'primary' | 'secondary' | 'danger'; type?: 'button' | 'submit' | 'reset' }; position?: Location } | { type: 'input'; props: ComponentProps & { inputType?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'number' | 'date' | 'time' | 'datetime-local' | 'search'; placeholder?: string; value?: string; required?: boolean; disabled?: boolean; pattern?: string; min?: number | string; max?: number | string; step?: number | string; width?: number }; position?: Location } | { type: 'textarea'; props: ComponentProps & { placeholder?: string; rows?: number; cols?: number; required?: boolean; disabled?: boolean; value?: string }; position?: Location } | { type: 'select'; props: ComponentProps & { placeholder?: string; required?: boolean; disabled?: boolean; multiple?: boolean; value?: string }; options: Array<{ type: 'option'; value: string; label: string; selected?: boolean; position?: Location }>; position?: Location } diff --git a/tests/server.test.ts b/tests/server.test.ts index 04c4ff0f..b3cd077e 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,6 +7,54 @@ import { createServer } from 'http'; import { readFileSync, writeFileSync, unlinkSync } from 'fs'; import { startServer, notifyReload, notifyError } from '../src/cli/server.js'; +describe('Multi-file routing', () => { + const TEST_PORT = 3457; + const TEST_OUTPUT = './test-main.html'; + const TEST_OTHER_MD = './test-other.md'; + let server: any; + + beforeEach(() => { + writeFileSync(TEST_OUTPUT, 'Main Page', 'utf-8'); + writeFileSync(TEST_OTHER_MD, '# Other Page', 'utf-8'); + }); + + afterEach(async () => { + if (server?.close) await new Promise(r => server.close(() => r())); + try { unlinkSync(TEST_OUTPUT); } catch {} + try { unlinkSync(TEST_OTHER_MD); } catch {} + }); + + it('should return the server instance from startServer', () => { + server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT }); + expect(server).toBeDefined(); + expect(typeof server.close).toBe('function'); + }); + + it('should serve main file at /', async () => { + server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT }); + const res = await fetch(`http://localhost:${TEST_PORT}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('Main Page'); + }); + + it('should call renderFile for .md requests and serve result', async () => { + const renderFile = vi.fn().mockReturnValue('Rendered Other'); + server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT, renderFile, rootDir: '.' }); + const res = await fetch(`http://localhost:${TEST_PORT}/test-other.md`); + expect(res.status).toBe(200); + expect(renderFile).toHaveBeenCalledWith(expect.stringContaining('test-other.md')); + const html = await res.text(); + expect(html).toContain('Rendered Other'); + }); + + it('should return 404 for unknown paths', async () => { + server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT, rootDir: '.' }); + const res = await fetch(`http://localhost:${TEST_PORT}/nonexistent.md`); + expect(res.status).toBe(404); + }); +}); + describe('Dev Server', () => { const TEST_PORT = 3456; const TEST_OUTPUT = './test-output.html'; From 2f109462b38f1cd1949da8966aab7d1a68593903 Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 14:56:14 +0200 Subject: [PATCH 5/6] feat: redirect / to entry file for symmetric navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET / now redirects to /{inputFile} (e.g. /agency-site.md) so there is no special root — every file is addressed by its own path. Links back to the entry file work the same as links to any other file. Fallback: when no inputFile is set (programmatic use), / still serves the pre-rendered outputPath as before. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/index.ts | 3 ++- src/cli/server.ts | 9 ++++++++- tests/server.test.ts | 10 +++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 7e3885b2..99417f21 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,7 +10,7 @@ */ import { readFileSync, writeFileSync, existsSync, statSync } from 'fs'; -import { resolve, dirname, join } from 'path'; +import { resolve, dirname, join, basename } from 'path'; import { pathToFileURL } from 'url'; import { parse } from '../parser/index.js'; import { renderToHTML, renderToJSON } from '../renderer/index.js'; @@ -293,6 +293,7 @@ export function main(): void { outputPath: options.output, renderFile: (mdPath: string) => generateOutput({ ...options, input: mdPath }), rootDir: dirname(options.input), + inputFile: basename(options.input), }); console.log(''); } diff --git a/src/cli/server.ts b/src/cli/server.ts index fe7a020d..827b19ad 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -17,6 +17,8 @@ interface ServerOptions { renderFile?: (mdPath: string) => string; /** Root directory for resolving linked .md files. Defaults to dirname(outputPath). */ rootDir?: string; + /** Entry .md filename (e.g. "index.md"). When set, GET / redirects to /{inputFile}. */ + inputFile?: string; } const liveReloadScript = ` @@ -361,7 +363,7 @@ const liveReloadScript = ` const wsClients: Set = new Set(); export function startServer(options: ServerOptions): ReturnType { - const { port, outputPath, renderFile } = options; + const { port, outputPath, renderFile, inputFile } = options; const rootDir = options.rootDir || dirname(outputPath); const injectScript = (html: string) => { @@ -380,6 +382,11 @@ export function startServer(options: ServerOptions): ReturnType { expect(typeof server.close).toBe('function'); }); - it('should serve main file at /', async () => { + it('should redirect / to the entry file when inputFile is provided', async () => { + const renderFile = vi.fn().mockReturnValue('Main Page'); + server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT, renderFile, rootDir: '.', inputFile: 'test-other.md' }); + const res = await fetch(`http://localhost:${TEST_PORT}/`, { redirect: 'manual' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/test-other.md'); + }); + + it('should serve main file at / when no inputFile provided (fallback)', async () => { server = startServer({ port: TEST_PORT, outputPath: TEST_OUTPUT }); const res = await fetch(`http://localhost:${TEST_PORT}/`); expect(res.status).toBe(200); From 6baa408566235de1cd7d9f8d2644fad2c5abf571 Mon Sep 17 00:00:00 2001 From: teezeit Date: Fri, 17 Apr 2026 18:25:13 +0200 Subject: [PATCH 6/6] feat: nav link support, active state, and multi-button-link inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nav `[[ ]]` items now fully support links and active states: - `[text](url)` inside `[[ ]]` creates a navigable nav item with real href. Previously the link URL was silently dropped (remark parses links as `link` nodes with no `.value`; the inline-containers plugin was using `c.value || ''` which discarded them). Fix: added `serializeChild` to reconstruct `[text](url)` from MDAST link nodes before splitting items on `|`. - `*text*` inside `[[ ]]` is now treated as the active/current-page item (strips asterisks, adds `.active` class with theme-matched styling). Previously rendered as literal `*text*`. - `[text](url)*` in nav renders as a primary-styled button link. - Multiple `[[Btn](url)]` patterns on the same line now render correctly. Previously the second button's URL was dropped and brackets showed as literal text. Root cause: `transformParagraph`'s 3-child detection only handled a single button-link. For two buttons on one line remark produces 5 children (`[`, link, `]* [`, link, `]`). Fix: new `tryParseButtonLinkSequence` generalises detection to n ≥ 1 (replaces the old 3-child check); multiple matches return a `button-group` container. Also adds: - Active nav-item CSS for all 6 themes (`.wmd-nav-item.wmd-active`) - `docs/guide/syntax.md` — Button Links section - `QUICK-REFERENCE.md` — nav link and active-state rows - `examples/gallery/multi-page/` — three-page navigable prototype (home/about/contact sharing a nav with live button-link navigation) Co-Authored-By: Claude Sonnet 4.6 --- QUICK-REFERENCE.md | 14 ++++ docs/guide/syntax.md | 17 +++++ examples/gallery/README.md | 16 +++++ examples/gallery/multi-page/about.md | 45 ++++++++++++ examples/gallery/multi-page/contact.md | 37 ++++++++++ examples/gallery/multi-page/home.md | 38 ++++++++++ src/parser/remark-inline-containers.ts | 12 +++- src/parser/transformer.ts | 98 ++++++++++++++++++++------ src/renderer/html-renderer.ts | 8 ++- src/renderer/styles.ts | 39 ++++++++++ tests/renderer.test.ts | 51 ++++++++++++++ 11 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 examples/gallery/multi-page/about.md create mode 100644 examples/gallery/multi-page/contact.md create mode 100644 examples/gallery/multi-page/home.md diff --git a/QUICK-REFERENCE.md b/QUICK-REFERENCE.md index 0c00357d..92a8cffe 100644 --- a/QUICK-REFERENCE.md +++ b/QUICK-REFERENCE.md @@ -19,7 +19,10 @@ | **Radio** | `- ( )` / `- (*)` | `- (*) Option 1` | | **Icon** | `:name:` | `:home: :user: :gear:` | | **Nav Bar** | `[[ A \| B \| C ]]` | `[[ Home \| About \| [Login] ]]` | +| **Nav Link** | `[[ [Text](url) \| ... ]]` | `[[ [About](./about.md) \| ... ]]` | | **Breadcrumbs** | `[[ A > B > C ]]` | `[[ Home > Products > Item ]]` | +| **Button Link** | `[[Text](url)]` | `[[About](./about.md)]` | +| **Primary Button Link** | `[[Text](url)]*` | `[[Get Started](./start.md)]*` | ## Containers @@ -132,6 +135,17 @@ Message [[ Home > Products > Category > Item ]] ``` +### Multi-file Navigation + +When running `wiremd --serve`, clicking a button link navigates to and renders that `.md` file: + +```markdown +# Shared navbar (paste in each page) +[[ :logo: MyApp | [Home](./home.md) | [About](./about.md) | [Contact](./contact.md)* ]] +``` + +The dev server (`--serve `) redirects `/` to the entry file and renders any `.md` on demand — no build step needed between page navigations. + ## Grid Pattern ```markdown diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 6ad5a81d..56bf060e 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -131,6 +131,23 @@ Enterprise-grade security Grows with your needs ``` +### Button Links + +Wrap a Markdown link inside button brackets to make a clickable button that navigates: + +```markdown +[[Go to Docs](./docs.md)] +[[Get Started](./start.md)]* +``` + +The `*` suffix makes it a primary button. Attributes work too: + +```markdown +[[Sign Up](./signup.md)]{.secondary} +``` + +When using `wiremd --serve`, clicking a button link renders the target `.md` file in the same browser tab — no build step required. This is the recommended way to wire up multi-page navigation in prototypes. + **Column spanning** — `{.col-span-N}` on a child heading spans multiple columns: ```markdown diff --git a/examples/gallery/README.md b/examples/gallery/README.md index 4e640ab5..f53cd291 100644 --- a/examples/gallery/README.md +++ b/examples/gallery/README.md @@ -60,6 +60,22 @@ Application dashboards for data visualization and management. --- +### 🔗 Multi-Page Navigation (1 Example) + +A three-page prototype showing how button links and the dev server work together for real navigation. + +| Example | Description | Key Features | +|---------|-------------|--------------| +| **Multi-Page App** | Home → About → Contact prototype | Shared navbar, button links, live navigation between pages | + +**Run it:** +```bash +wiremd examples/gallery/multi-page/home.md --serve 3001 +# Open http://localhost:3001 and click the nav buttons +``` + +--- + ### 🧩 Components (5 Examples) Reusable UI component patterns and layouts. diff --git a/examples/gallery/multi-page/about.md b/examples/gallery/multi-page/about.md new file mode 100644 index 00000000..435d33b8 --- /dev/null +++ b/examples/gallery/multi-page/about.md @@ -0,0 +1,45 @@ +[[ :logo: MyApp | [Home](./home.md) | *About* | [Contact](./contact.md) ]] + +--- + +## About MyApp + +wiremd is a text-first UI design tool that generates wireframes from Markdown. +Write your UI, see it instantly — no design software required. + +--- + +## The Team {.grid-3 card} + +### :user: Alice Chen +Co-founder & CEO + +### :user: Ben Müller +Co-founder & CTO + +### :user: Sara Kim +Head of Design + +--- + +## Our Story + +::: card + +### From frustration to tool + +We were tired of maintaining both a Figma file and a spec doc. +wiremd collapses those into one artifact: a `.md` file that +*is* the design. + +[Read the full story](./home.md) + +::: + +--- + +::: alert info +Want to get in touch? Hit the **Contact** button in the nav. +::: + +[← Home](./home.md) [Contact →](./contact.md)* diff --git a/examples/gallery/multi-page/contact.md b/examples/gallery/multi-page/contact.md new file mode 100644 index 00000000..f3cc7f32 --- /dev/null +++ b/examples/gallery/multi-page/contact.md @@ -0,0 +1,37 @@ +[[ :logo: MyApp | [Home](./home.md) | [About](./about.md) | *Contact* ]] + +--- + +::: card + +## Contact Us + +Name +[_____________________________]{required} + +Email +[_____________________________]{type:email required} + +Subject +[Select topic_____________v] +- General question +- Bug report +- Feature request +- Partnership + +Message +[Your message...]{rows:5} + +- [ ] Subscribe to updates + +[Send Message]* [Cancel] + +::: + +--- + +::: alert success +We typically respond within one business day. +::: + +[← About](./about.md) diff --git a/examples/gallery/multi-page/home.md b/examples/gallery/multi-page/home.md new file mode 100644 index 00000000..4e25b633 --- /dev/null +++ b/examples/gallery/multi-page/home.md @@ -0,0 +1,38 @@ +[[ :logo: MyApp | *Home* | [About](./about.md) | [Contact](./contact.md) ]] + +--- + +::: hero + +# Welcome to MyApp + +The fastest way to prototype multi-page apps in Markdown. + +[[Get Started](./about.md)]* [[See Features](./about.md)] + +::: + +## Why MyApp {.grid-3 card} + +### :rocket: Fast +From idea to prototype in minutes, not hours. + +### :shield: Reliable +Battle-tested across thousands of real projects. + +### :gear: Flexible +Works with any workflow, any team size. + +--- + +## Latest Updates + +| Feature | Status | Release | +|---------|--------|---------| +| Multi-file navigation | Released | v0.1.5 | +| Button links | Released | v0.1.5 | +| Grid card modifier | Released | v0.1.4 | + +--- + +[About →](./about.md) diff --git a/src/parser/remark-inline-containers.ts b/src/parser/remark-inline-containers.ts index 681523ba..22758554 100644 --- a/src/parser/remark-inline-containers.ts +++ b/src/parser/remark-inline-containers.ts @@ -13,6 +13,16 @@ import type { Plugin } from 'unified'; /** * Remark plugin to parse wiremd inline container directives */ +function serializeChild(c: any): string { + if (c.type === 'link') { + const text = (c.children || []).map((cc: any) => cc.value || '').join(''); + return `[${text}](${c.url})`; + } + if (c.type === 'strong') return `**${(c.children || []).map(serializeChild).join('')}**`; + if (c.type === 'emphasis') return `*${(c.children || []).map(serializeChild).join('')}*`; + return c.value || ''; +} + export const remarkWiremdInlineContainers: Plugin = () => { return (tree: any) => { const newChildren: any[] = []; @@ -24,7 +34,7 @@ export const remarkWiremdInlineContainers: Plugin = () => { node.children && node.children.length > 0 ) { - const text = node.children.map((c: any) => c.value || '').join(''); + const text = node.children.map(serializeChild).join(''); // Check for inline container syntax [[...]] const match = text.match(/^\[\[\s*(.+?)\s*\]\](\{[^}]+\})?$/); diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index 8d4f6355..6e242d24 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -300,6 +300,29 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode for (const item of items) { const trimmed = item.trim(); + // Check if it's an active/emphasized item: *Text* or **Text** + const activeMatch = trimmed.match(/^\*\*?([^*]+)\*\*?$/); + if (activeMatch) { + children.push({ + type: 'nav-item', + content: activeMatch[1], + props: { classes: ['active'] }, + }); + continue; + } + + // Check if it's a link nav-item: [Text](url) or [Text](url)* + const linkMatch = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)(\*)?$/); + if (linkMatch) { + children.push({ + type: 'nav-item', + content: linkMatch[1], + href: linkMatch[2], + props: { variant: linkMatch[3] ? 'primary' : undefined }, + }); + continue; + } + // Check if it's a button: [Text] or [Text]* const buttonMatch = trimmed.match(/^\[([^\]]+)\](\*)?$/); if (buttonMatch) { @@ -414,6 +437,50 @@ function transformHeading(node: any, _options: ParseOptions): WiremdNode { }; } +/** + * Detect one or more [[Text](url)]* patterns in a paragraph's children. + * Remark produces alternating text/link nodes because CommonMark forbids nested links: + * "[", link, "]*[", link, "]" + * Returns button nodes, or null if the children don't match this pattern at all. + */ +function tryParseButtonLinkSequence(children: any[]): WiremdNode[] | null { + if (!children || children.length < 3 || children.length % 2 === 0) return null; + + // Must alternate: text, link, text, link, text, ... + for (let i = 0; i < children.length; i++) { + if (i % 2 === 0 && children[i].type !== 'text') return null; + if (i % 2 === 1 && children[i].type !== 'link') return null; + } + + // First text must be exactly "[" (optionally with leading whitespace) + if (!/^\s*\[$/.test(children[0].value)) return null; + + // Last text must be "]" + optional "*" + optional "{attrs}" + nothing else + const lastText: string = children[children.length - 1].value; + if (!/^\](\*)?\s*(\{[^}]*\})?\s*$/.test(lastText)) return null; + + // Each middle text (between two links) must be "]...[" — closes previous, opens next + for (let i = 2; i <= children.length - 3; i += 2) { + if (!/^\](\*)?\s*(\{[^}]*\})?\s*\[$/.test(children[i].value)) return null; + } + + return children + .filter((_: any, i: number) => i % 2 === 1) // keep only link nodes + .map((linkNode: any, idx: number) => { + const closingText: string = children[idx * 2 + 2].value; + const closeMatch = closingText.match(/^\](\*)?\s*(\{[^}]*\})?/); + const isPrimary = !!(closeMatch && closeMatch[1]); + const attrStr = (closeMatch && closeMatch[2]) || ''; + const attrs = attrStr ? parseAttributes(attrStr) : {}; + return { + type: 'button' as const, + content: extractTextContent(linkNode), + href: linkNode.url || '#', + props: { ...attrs, variant: isPrimary ? 'primary' : (attrs as any).variant }, + }; + }); +} + /** * Transform paragraph node * This is where we'll detect buttons, inputs, etc. @@ -424,28 +491,17 @@ function transformParagraph(node: any, _options: ParseOptions, nextNode?: any): child.type === 'strong' || child.type === 'emphasis' || child.type === 'link' || child.type === 'code' || child.type === 'image' ); - // [[Button](url)]* — explicit linked-button syntax. - // CommonMark forbids nested links so remark parses this as: - // text:"[" + link + text:"]*" (or "]{.secondary}", etc.) - if ( - node.children.length === 3 && - node.children[0].type === 'text' && node.children[0].value === '[' && - node.children[1].type === 'link' && - node.children[2].type === 'text' && /^\](\*)?(\s*\{[^}]*\})?$/.test(node.children[2].value) - ) { - const linkNode = node.children[1]; - const suffix: string = node.children[2].value; - const isPrimary = suffix.includes('*'); - const attrMatch = suffix.match(/\{([^}]*)\}/); - const attrs = attrMatch ? parseAttributes(`{${attrMatch[1]}}`) : {}; + // [[Button](url)]* — one or more linked-button patterns on the same line. + // CommonMark forbids nested links so remark produces alternating text/link children: + // "[", link, "]*", "[", link, "]" for two buttons, etc. + const buttonLinks = tryParseButtonLinkSequence(node.children); + if (buttonLinks !== null) { + if (buttonLinks.length === 1) return buttonLinks[0]; return { - type: 'button', - content: extractTextContent(linkNode), - href: linkNode.url || '#', - props: { - ...attrs, - variant: isPrimary ? 'primary' : attrs.variant, - }, + type: 'container', + containerType: 'button-group', + children: buttonLinks as any, + props: {}, }; } diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index fc650d04..d7ce3cba 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -392,14 +392,18 @@ function renderNav(node: any, context: RenderContext): string { function renderNavItem(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; - const classes = buildClasses(prefix, 'nav-item', node.props); const href = node.href || '#'; - // Handle both content (string) and children (array of nodes) const contentHTML = node.children ? node.children.map((child: any) => renderNode(child, context)).join('') : escapeHtml(node.content); + if (node.props?.variant === 'primary') { + const classes = `${buildClasses(prefix, 'button', node.props)} ${prefix}button-primary`; + return `${contentHTML}`; + } + + const classes = buildClasses(prefix, 'nav-item', node.props); return `${contentHTML}`; } diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index b5e4ce75..41c570f4 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -272,6 +272,13 @@ body.${prefix}root { background: #f8f8f8; } +.${prefix}nav-item.${prefix}active { + background: #000; + color: #fff; + border-color: #000; + transform: rotate(0.3deg); +} + .${prefix}nav .${prefix}button { margin: 0; } @@ -724,6 +731,12 @@ body.${prefix}root { box-shadow: 0 2px 4px rgba(0,0,0,0.1); } +.${prefix}nav-item.${prefix}active { + background: #343a40; + color: #fff; + border-color: #343a40; +} + /* Grid */ .${prefix}grid { display: grid; @@ -1113,6 +1126,12 @@ body.${prefix}root { border-color: #000; } +.${prefix}nav-item.${prefix}active { + background: #000; + color: #fff; + border-color: #000; +} + /* Grid */ .${prefix}grid { display: grid; @@ -1654,6 +1673,12 @@ body { box-shadow: 0 1px 2px rgba(0,0,0,0.05); } +.${prefix}nav-item.${prefix}active { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} + .${prefix}brand { font-weight: 700; font-size: 1.125rem; @@ -2254,6 +2279,12 @@ body { box-shadow: 0 2px 4px rgba(0,0,0,0.2); } +.${prefix}nav-item.${prefix}active { + background: #1565c0; + color: #fff; + border-color: #1565c0; +} + .${prefix}brand { font-weight: 500; font-size: 1.25rem; @@ -2861,6 +2892,14 @@ body { background: #ffffff; } +.${prefix}nav-item.${prefix}active { + background: #000; + color: #fff; + border-color: #000; + transform: translate(4px, 4px); + box-shadow: none; +} + .${prefix}brand { font-weight: 900; font-size: 1.5rem; diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 341f6a28..d24a39c4 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -121,6 +121,36 @@ Content expect(html).toContain('Sign In'); }); + it('should render *active* nav items without literal asterisks', () => { + const input = `[[ Home | *About* | Contact ]]`; + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + + expect(html).not.toContain('*About*'); + expect(html).toContain('About'); + expect(html).toContain('wmd-nav-item'); + }); + + it('should render [Link](url)* nav item as primary button', () => { + const input = `[[ Home | [Get Started](./start.md)* ]]`; + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + + expect(html).toContain('href="./start.md"'); + expect(html).toContain('wmd-button-primary'); + }); + + it('should render nav items with links as tags with href', () => { + const input = `[[ :logo: MyApp | Home | [About](./about.md) | [Contact](./contact.md) ]]`; + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + + expect(html).toContain('href="./about.md"'); + expect(html).toContain('href="./contact.md"'); + expect(html).toContain('About'); + expect(html).toContain('Contact'); + }); + it('should render navigation items with button styling', () => { const input = `[[ Logo | Sign In | Help ]]`; const ast = parse(input); @@ -244,6 +274,27 @@ Spans two }); }); + describe('Multiple inline button-links', () => { + it('should render two [[btn](url)] on one line as button elements (not a paragraph)', () => { + const ast = parse('[[Get Started](./about.md)]* [[See Features](./about.md)]'); + const html = renderToHTML(ast, { style: 'sketch' }); + // Must render as — not as a

wrapping raw links + expect(html).toMatch(/Get Started<\/a>/); + expect(html).toMatch(/See Features<\/a>/); + // Must not wrap in a

paragraph element with literal brackets + expect(html).not.toMatch(/]*wmd-paragraph/); + }); + + it('should render two [[btn](url)] on one line without literal bracket text', () => { + const ast = parse('[[Docs](./docs.md)] [[API](./api.md)]'); + const html = renderToHTML(ast, { style: 'sketch' }); + // No literal ][ in the output (would appear between two paragraph-rendered links) + expect(html).not.toMatch(/\].*\[/); + expect(html).toMatch(/Docs<\/a>/); + expect(html).toMatch(/API<\/a>/); + }); + }); + describe('Button links', () => { it('should render button with href as tag', () => { const ast = parse('[Go to Docs]{href:./docs.md}');