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/cli/index.ts b/src/cli/index.ts index 1c9eec1b..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'; @@ -288,7 +288,13 @@ 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), + inputFile: basename(options.input), + }); console.log(''); } diff --git a/src/cli/server.ts b/src/cli/server.ts index 403fdd36..827b19ad 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -7,12 +7,18 @@ */ 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; + /** Entry .md filename (e.g. "index.md"). When set, GET / redirects to /{inputFile}. */ + inputFile?: string; } const liveReloadScript = ` @@ -356,35 +362,75 @@ 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, inputFile } = 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 === '') { + if (inputFile) { + res.writeHead(302, { Location: `/${inputFile}` }); + res.end(); + return; + } + 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 +467,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/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 c51bbda5..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,6 +491,20 @@ 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)]* β€” 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: 'container', + containerType: 'button-group', + children: buttonLinks 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/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index 1a5deaf1..d7ce3cba 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -122,6 +122,11 @@ function renderButton(node: any, context: RenderContext): string { ? node.children.map((child: any) => renderNode(child, context)).join('') : escapeHtml(node.content); + const href = node.href || node.props?.href; + if (href) { + return `${contentHTML}`; + } + return ``; } @@ -387,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 f6e4820b..41c570f4 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; } /** @@ -275,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; } @@ -727,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; @@ -1116,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; @@ -1657,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; @@ -2257,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; @@ -2864,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/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/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 a7640a1e..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,64 @@ 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}'); + 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(/ button', () => { + const ast = parse('[[Go to Docs](./docs.md)]'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(//); + 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' }); + expect(html).toMatch(/ { it('should render a dropdown with options', () => { const input = ` diff --git a/tests/server.test.ts b/tests/server.test.ts index 04c4ff0f..b7695a55 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -7,6 +7,62 @@ 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 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); + 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';