diff --git a/FAQ.md b/FAQ.md index d5039f14..5abf63c4 100644 --- a/FAQ.md +++ b/FAQ.md @@ -105,13 +105,13 @@ Three ways: **Check these:** -1. Grid class is on the parent heading: `## Title {.grid-3}` +1. Grid class is on the parent heading: `## Title {.grid-3 card}` 2. Grid items are `###` headings (one level deeper) 3. Each grid item starts with `###` ```markdown ✅ Correct: -## Features {.grid-3} +## Features {.grid-3 card} ### Feature 1 Content @@ -120,7 +120,7 @@ Content Content ❌ Wrong (missing ### for items): -## Features {.grid-3} +## Features {.grid-3 card} Feature 1 Feature 2 ``` @@ -273,9 +273,9 @@ Use state attributes: ### What grid sizes are supported? Built-in grid classes: -- `{.grid-2}` - 2 columns -- `{.grid-3}` - 3 columns -- `{.grid-4}` - 4 columns +- `{.grid-2 card}` - 2 columns +- `{.grid-3 card}` - 3 columns +- `{.grid-4 card}` - 4 columns - `{.grid-auto}` - Auto-fit columns ### Can I nest containers? diff --git a/QUICK-REFERENCE.md b/QUICK-REFERENCE.md index bfe5d1a3..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 @@ -35,14 +38,18 @@ ## Layouts -| Layout | Syntax | Example | -|--------|--------|---------| -| **2-Column Grid** | `## Title {.grid-2}` | Section with 2 columns | -| **3-Column Grid** | `## Title {.grid-3}` | Section with 3 columns | -| **4-Column Grid** | `## Title {.grid-4}` | Section with 4 columns | -| **Auto Grid** | `## Title {.grid-auto}` | Auto-fit columns | +| Layout | Syntax | Notes | +|--------|--------|-------| +| **Grid (layout only)** | `## Title {.grid-3}` | Equal columns, no styling on items | +| **Grid (card chrome)** | `## Title {.grid-3 card}` | Items rendered as styled cards | +| **2-Column Grid** | `## Title {.grid-2}` | | +| **3-Column Grid** | `## Title {.grid-3}` | | +| **4-Column Grid** | `## Title {.grid-4}` | | +| **Col span** | `### Item {.col-span-2}` | Item spans 2 columns | -Grid items are defined by `###` headings under the grid heading. +Grid items are defined by `###` headings under the grid heading. The grid heading label (e.g., "Features") is a **declaration-only** author comment — it is never rendered in the output. + +`{.grid-N}` is pure layout — use it for form columns, multi-column text, etc. Add `card` when items should have card chrome (features, pricing, team members). ## Attributes @@ -128,10 +135,21 @@ 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 -## Features {.grid-3} +## Features {.grid-3 card} ### Feature 1 Description here @@ -180,7 +198,7 @@ Password ### Stats Grid ```markdown -## Metrics {.grid-4} +## Metrics {.grid-4 card} ### Users 10,000+ diff --git a/README.md b/README.md index 5727ca56..f3c9628a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This renders into a styled HTML wireframe with a form, inputs, and buttons. Create a `features.md` file: ```markdown -## Product Features {.grid-3} +## Product Features {.grid-3 card} ### :rocket: Fast Lightning quick performance diff --git a/SYNTAX-SPEC-v0.1.md b/SYNTAX-SPEC-v0.1.md index 86623286..3ccd209b 100644 --- a/SYNTAX-SPEC-v0.1.md +++ b/SYNTAX-SPEC-v0.1.md @@ -191,7 +191,7 @@ Content goes here ### 4.1 Grid Layouts ```markdown -## Features {.grid-3} +## Features {.grid-3 card} ### Feature One Content @@ -489,7 +489,7 @@ We couldn't load this page | Class | `{.class}` | `{.primary}` | | Attribute | `{key:value}` | `{type:email}` | | State | `{:state}` | `{:disabled}` | -| Grid | `{.grid-N}` | `{.grid-3}` | +| Grid | `{.grid-N}` | `{.grid-3 card}` | --- @@ -715,7 +715,7 @@ Email **Input:** ```markdown -## Features {.grid-3} +## Features {.grid-3 card} ### :rocket: Fast Quick rendering diff --git a/docs/examples/index.md b/docs/examples/index.md index fbae7922..9ec0c6df 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -60,7 +60,7 @@ Message ### Dashboard Layout ```markdown -## Dashboard {.grid-3} +## Dashboard {.grid-3 card} ### Active Users **1,234** @@ -201,7 +201,7 @@ The best tool for modern teams to collaborate and build amazing products. ### Pricing Table ```markdown -## Pricing {.grid-3} +## Pricing {.grid-3 card} ### Basic **$9/month** diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 7fd0d3b8..56bf060e 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -100,10 +100,26 @@ Try our product today ### Grid Layouts -Create grids with heading modifiers: +`{.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. + +**Pure layout grid** — no visual styling on items, useful for form columns or multi-column text: ```markdown -## Features {.grid-3} +## Contact {.grid-2} + +### Details +Name +[_____________________________]{required} + +### Address +Street +[_____________________________]{required} +``` + +**Card grid** — add `card` to render items with card chrome: + +```markdown +## Features {.grid-3 card} ### Fast Lightning quick performance @@ -115,6 +131,35 @@ 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 +## Pricing {.grid-3 card} + +### Starter {.col-span-1} +$9/mo + +### Pro {.col-span-2} +$29/mo — most popular, spans two columns +``` + ## Component Examples ### Forms diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index fc1ff085..f4a1ccc7 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -78,14 +78,14 @@ try { **Problem**: Grid syntax doesn't create columns ```markdown -## Features {.grid-3} +## Features {.grid-3 card} Content here... ``` **Solution**: Ensure proper structure with level-3 headings: ```markdown -## Features {.grid-3} +## Features {.grid-3 card} ### Column 1 Content for column 1 @@ -448,8 +448,8 @@ Common validation errors and fixes: // Error: INVALID_GRID_COLUMNS // Fix: Use 1 or more columns -{.grid-3} // ✓ Valid -{.grid-0} // ✗ Invalid +{.grid-3 card} // ✓ Valid +{.grid-0 card} // ✗ Invalid ``` ## Performance Issues diff --git a/examples/figma-test.md b/examples/figma-test.md index ccca67a7..9b7b2e88 100644 --- a/examples/figma-test.md +++ b/examples/figma-test.md @@ -16,7 +16,7 @@ This mockup demonstrates all wiremd features that can be imported to Figma. --- -## Features Grid {.grid-3} +## Features Grid {.grid-3 card} ### ⚡ Fast Lightning-quick wireframing with markdown syntax 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/components/card-layouts.md b/examples/gallery/components/card-layouts.md index 0dabcf8f..5f2ed86d 100644 --- a/examples/gallery/components/card-layouts.md +++ b/examples/gallery/components/card-layouts.md @@ -65,7 +65,7 @@ Cards can include images, text, and buttons for rich content presentation. ### Three Column Feature Grid ```markdown -## Our Features {.grid-3} +## Our Features {.grid-3 card} ### :rocket: Fast Performance Lightning-quick loading times and optimized rendering for the best user experience. @@ -87,7 +87,7 @@ Flexible configuration options and extensive API for complete control. **Rendered:** -## Our Features {.grid-3} +## Our Features {.grid-3 card} ### :rocket: Fast Performance Lightning-quick loading times and optimized rendering for the best user experience. @@ -113,7 +113,7 @@ Flexible configuration options and extensive API for complete control. ### Pricing Tiers ```markdown -## Choose Your Plan {.grid-3} +## Choose Your Plan {.grid-3 card} ### Starter **$9** /month @@ -160,7 +160,7 @@ For large organizations **Rendered:** -## Choose Your Plan {.grid-3} +## Choose Your Plan {.grid-3 card} ### Starter **$9** /month @@ -211,7 +211,7 @@ For large organizations ### Product Grid ```markdown -## Featured Products {.grid-4} +## Featured Products {.grid-4 card} ### Wireless Headphones ![Product](https://via.placeholder.com/250x250) @@ -254,7 +254,7 @@ For large organizations **Rendered:** -## Featured Products {.grid-4} +## Featured Products {.grid-4 card} ### Wireless Headphones ![Product](https://via.placeholder.com/250x250) @@ -301,7 +301,7 @@ For large organizations ### Team Member Grid ```markdown -## Meet Our Team {.grid-4} +## Meet Our Team {.grid-4 card} ### Sarah Johnson ![Profile](https://via.placeholder.com/200x200) @@ -344,7 +344,7 @@ For large organizations **Rendered:** -## Meet Our Team {.grid-4} +## Meet Our Team {.grid-4 card} ### Sarah Johnson ![Profile](https://via.placeholder.com/200x200) @@ -391,7 +391,7 @@ For large organizations ### Blog Post Grid ```markdown -## Latest Articles {.grid-3} +## Latest Articles {.grid-3 card} ### Getting Started with Wireframing ![Blog Post](https://via.placeholder.com/350x200) @@ -425,7 +425,7 @@ Make your designs inclusive and accessible to all users with these proven techni **Rendered:** -## Latest Articles {.grid-3} +## Latest Articles {.grid-3 card} ### Getting Started with Wireframing ![Blog Post](https://via.placeholder.com/350x200) @@ -463,7 +463,7 @@ Make your designs inclusive and accessible to all users with these proven techni ### Customer Testimonials ```markdown -## What Our Customers Say {.grid-3} +## What Our Customers Say {.grid-3 card} ### :star::star::star::star::star: *"This product completely transformed how we work. Best decision we made this year!"* @@ -488,7 +488,7 @@ Operations Director **Rendered:** -## What Our Customers Say {.grid-3} +## What Our Customers Say {.grid-3 card} ### :star::star::star::star::star: *"This product completely transformed how we work. Best decision we made this year!"* @@ -517,7 +517,7 @@ Operations Director ### Key Metrics Dashboard ```markdown -## Performance Metrics {.grid-4} +## Performance Metrics {.grid-4 card} ### Total Users **124,567** @@ -540,7 +540,7 @@ Operations Director **Rendered:** -## Performance Metrics {.grid-4} +## Performance Metrics {.grid-4 card} ### Total Users **124,567** @@ -567,7 +567,7 @@ Operations Director ### Card States ```markdown -## Card States {.grid-2} +## Card States {.grid-2 card} ### Normal Card ::: card @@ -590,7 +590,7 @@ This card has special emphasis **Rendered:** -## Card States {.grid-2} +## Card States {.grid-2 card} ### Normal Card ::: card @@ -716,6 +716,106 @@ Expected Delivery: Mar 20, 2025 --- +## 12. Grid Layout vs Card Grid + +### Pure Layout Grid (no card chrome) + +Use `{.grid-N}` alone when items are form fields, text columns, or anything that shouldn't have card borders/backgrounds. + +```markdown +## Contact {.grid-2} + +### Your Details +Name +[_____________________________]{required} + +Email +[_____________________________]{type:email required} + +### Message +Subject +[_____________________________]{required} + +[Send Message]* +``` + +**Rendered:** + +## Contact {.grid-2} + +### Your Details +Name +[_____________________________]{required} + +Email +[_____________________________]{type:email required} + +### Message +Subject +[_____________________________]{required} + +[Send Message]* + +--- + +### Card Grid (with card chrome) + +Add `card` to apply card borders and backgrounds to each item. + +```markdown +## Why Choose Us {.grid-3 card} + +### :rocket: Fast +Renders in milliseconds. + +### :shield: Secure +Enterprise-grade by default. + +### :gear: Flexible +Works with any stack. +``` + +**Rendered:** + +## Why Choose Us {.grid-3 card} + +### :rocket: Fast +Renders in milliseconds. + +### :shield: Secure +Enterprise-grade by default. + +### :gear: Flexible +Works with any stack. + +--- + +### Column Spanning + +Add `{.col-span-N}` to a child heading to span multiple columns. + +```markdown +## Pricing {.grid-3 card} + +### Starter +$9/month — up to 3 projects. + +### Pro {.col-span-2} +$29/month — unlimited projects, priority support, spans two columns. +``` + +**Rendered:** + +## Pricing {.grid-3 card} + +### Starter +$9/month — up to 3 projects. + +### Pro {.col-span-2} +$29/month — unlimited projects, priority support, spans two columns. + +--- + ## Best Practices ::: card diff --git a/examples/gallery/components/navigation-patterns.md b/examples/gallery/components/navigation-patterns.md index 690726d9..7806fbf2 100644 --- a/examples/gallery/components/navigation-patterns.md +++ b/examples/gallery/components/navigation-patterns.md @@ -310,7 +310,7 @@ Email ```markdown ::: footer -## Links {.grid-4} +## Links {.grid-4 card} ### Product - Features @@ -351,7 +351,7 @@ Email ::: footer -## Links {.grid-4} +## Links {.grid-4 card} ### Product - Features diff --git a/examples/gallery/dashboards/admin-panel.md b/examples/gallery/dashboards/admin-panel.md index 80766f13..26ebb870 100644 --- a/examples/gallery/dashboards/admin-panel.md +++ b/examples/gallery/dashboards/admin-panel.md @@ -42,7 +42,7 @@ --- -## System Stats {.grid-5} +## System Stats {.grid-5 card} ### Total Users **24,567** @@ -68,7 +68,7 @@ Normal --- -## Recent Activity {.grid-2} +## Recent Activity {.grid-2 card} ### Latest Users @@ -137,7 +137,7 @@ Showing 1-5 of 24,567 users --- -## Content Management {.grid-2} +## Content Management {.grid-2 card} ### Published Content @@ -263,7 +263,7 @@ Event Type: [All Events...v] | User: [All Users...v] | Date: [Today...v] --- -## Database & Storage {.grid-2} +## Database & Storage {.grid-2 card} ### Database Stats @@ -299,7 +299,7 @@ Event Type: [All Events...v] | User: [All Users...v] | Date: [Today...v] --- -## Support Tickets {.grid-3} +## Support Tickets {.grid-3 card} ### Open Tickets **23** new @@ -333,7 +333,7 @@ Customer satisfaction: ## Quick Actions -## Administrative Tools {.grid-4} +## Administrative Tools {.grid-4 card} ### :user: Create User Add new user account diff --git a/examples/gallery/dashboards/analytics-dashboard.md b/examples/gallery/dashboards/analytics-dashboard.md index 002b3e20..1f029d8c 100644 --- a/examples/gallery/dashboards/analytics-dashboard.md +++ b/examples/gallery/dashboards/analytics-dashboard.md @@ -16,7 +16,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] --- -## Key Metrics {.grid-4} +## Key Metrics {.grid-4 card} ### Total Visitors **458,234** @@ -38,7 +38,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] --- -## Traffic Overview {.grid-2} +## Traffic Overview {.grid-2 card} ### Visitors Over Time @@ -96,7 +96,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] --- -## Geographic Distribution {.grid-2} +## Geographic Distribution {.grid-2 card} ### Top Countries @@ -124,7 +124,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] --- -## Device & Browser Stats {.grid-2} +## Device & Browser Stats {.grid-2 card} ### Device Types @@ -168,7 +168,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] --- -## Conversion Funnel {.grid-3} +## Conversion Funnel {.grid-3 card} ### Step 1: Landing **100,000** visitors @@ -196,7 +196,7 @@ Date Range: [Last 30 days...v] [Custom Date Range] [Refresh] ## Goals & Events -## Tracking Performance {.grid-4} +## Tracking Performance {.grid-4 card} ### Newsletter Signups **8,234** diff --git a/examples/gallery/dashboards/ecommerce-dashboard.md b/examples/gallery/dashboards/ecommerce-dashboard.md index 87f18e01..93723ff5 100644 --- a/examples/gallery/dashboards/ecommerce-dashboard.md +++ b/examples/gallery/dashboards/ecommerce-dashboard.md @@ -16,7 +16,7 @@ --- -## Key Metrics {.grid-4} +## Key Metrics {.grid-4 card} ### Total Revenue **$124,567** @@ -38,7 +38,7 @@ --- -## Sales Overview {.grid-2} +## Sales Overview {.grid-2 card} ### Revenue Chart (30 Days) @@ -89,7 +89,7 @@ Filter: [All Statuses...v] | Search: [Order # or customer...___________] --- -## Inventory Management {.grid-2} +## Inventory Management {.grid-2 card} ### Low Stock Alerts @@ -123,7 +123,7 @@ Filter: [All Statuses...v] | Search: [Order # or customer...___________] --- -## Customer Insights {.grid-3} +## Customer Insights {.grid-3 card} ### Total Customers **8,234** @@ -160,7 +160,7 @@ Based on 1,234 reviews ## Order Status Overview -## Current Orders {.grid-5} +## Current Orders {.grid-5 card} ### Pending **45** orders @@ -211,7 +211,7 @@ Based on 1,234 reviews --- -## Marketing Performance {.grid-2} +## Marketing Performance {.grid-2 card} ### Active Campaigns @@ -281,7 +281,7 @@ Based on 1,234 reviews --- -## Shipping & Fulfillment {.grid-2} +## Shipping & Fulfillment {.grid-2 card} ### Shipping Methods @@ -335,7 +335,7 @@ Based on 1,234 reviews ## Customer Support -## Support Metrics {.grid-4} +## Support Metrics {.grid-4 card} ### Open Tickets **23** diff --git a/examples/gallery/dashboards/project-management.md b/examples/gallery/dashboards/project-management.md index 80362a30..0ff2c732 100644 --- a/examples/gallery/dashboards/project-management.md +++ b/examples/gallery/dashboards/project-management.md @@ -48,7 +48,7 @@ --- -## Sprint Progress {.grid-4} +## Sprint Progress {.grid-4 card} ### Total Tasks **48** tasks @@ -227,7 +227,7 @@ Needs attention --- -## Team Performance {.grid-3} +## Team Performance {.grid-3 card} ### Task Completion @@ -343,7 +343,7 @@ I can help with the Redis setup if needed. ## Milestones & Deadlines -## Project Timeline {.grid-3} +## Project Timeline {.grid-3 card} ### Milestone 1: Alpha Release ✅ **Completed:** Mar 1, 2025 @@ -441,7 +441,7 @@ I can help with the Redis setup if needed. ## Team Members -## Project Team {.grid-5} +## Project Team {.grid-5 card} ### Jane Doe ![Avatar](https://via.placeholder.com/100) @@ -489,7 +489,7 @@ I can help with the Redis setup if needed. ## Reports & Analytics -## Sprint Metrics {.grid-4} +## Sprint Metrics {.grid-4 card} ### Completed Story Points **46** / 50 points diff --git a/examples/gallery/dashboards/social-media-dashboard.md b/examples/gallery/dashboards/social-media-dashboard.md index 8a45e13a..0449a0d2 100644 --- a/examples/gallery/dashboards/social-media-dashboard.md +++ b/examples/gallery/dashboards/social-media-dashboard.md @@ -22,7 +22,7 @@ --- -## Performance Summary {.grid-5} +## Performance Summary {.grid-5 card} ### Total Followers **458,234** @@ -48,7 +48,7 @@ --- -## Platform Performance {.grid-3} +## Platform Performance {.grid-3 card} ### :instagram: Instagram **Followers:** 234,567 (+2,345) @@ -109,7 +109,7 @@ ## Content Calendar -## This Week's Schedule {.grid-7} +## This Week's Schedule {.grid-7 card} ### Mon, Mar 17 **2 posts** @@ -179,7 +179,7 @@ Weekly recap ## Engagement Analytics -## Engagement Breakdown {.grid-2} +## Engagement Breakdown {.grid-2 card} ### Engagement Over Time @@ -205,7 +205,7 @@ Weekly recap --- -## Audience Insights {.grid-3} +## Audience Insights {.grid-3 card} ### Demographics @@ -314,7 +314,7 @@ Weekly recap ## Messages & Mentions -## Inbox Overview {.grid-4} +## Inbox Overview {.grid-4 card} ### Unread Messages **45** @@ -388,7 +388,7 @@ Auto-filtered ## Competitor Analysis -## Competitive Insights {.grid-3} +## Competitive Insights {.grid-3 card} ### Competitor A **Followers:** 567K diff --git a/examples/gallery/forms/registration-form.md b/examples/gallery/forms/registration-form.md index d2d21015..90a74aaf 100644 --- a/examples/gallery/forms/registration-form.md +++ b/examples/gallery/forms/registration-form.md @@ -43,7 +43,7 @@ Phone Number --- -## Choose Your Plan {.grid-3} +## Choose Your Plan {.grid-3 card} ### Free **$0** /month diff --git a/examples/gallery/landing-pages/agency-site.md b/examples/gallery/landing-pages/agency-site.md index 5b273903..8d35747a 100644 --- a/examples/gallery/landing-pages/agency-site.md +++ b/examples/gallery/landing-pages/agency-site.md @@ -17,6 +17,9 @@ Award-winning creative agency specializing in branding, web design, and digital ::: +[View Our Work ↓] [Start a Project]* [Schedule Call] "hi there" | hello [button]* + + --- ## Trusted by Industry Leaders @@ -27,7 +30,7 @@ Award-winning creative agency specializing in branding, web design, and digital --- -## Our Services {.grid-3} +## Our Services {.grid-3 card} ### :art: Brand Strategy & Design We craft compelling brand identities that resonate with your audience and stand out in the market. @@ -71,7 +74,7 @@ Data-driven marketing strategies that drive growth and measurable results. ## Featured Work -## Recent Projects {.grid-2} +## Recent Projects {.grid-2 card} ### TechStart - Brand Redesign ![Case Study](https://via.placeholder.com/600x400) @@ -109,7 +112,7 @@ Sustainable e-commerce platform serving 50K+ customers with 4.9★ rating. ## Our Process -## How We Work {.grid-4} +## How We Work {.grid-4 card} ### 1. Discovery **Week 1** @@ -151,7 +154,7 @@ Sustainable e-commerce platform serving 50K+ customers with 4.9★ rating. ::: card -## Results That Speak for Themselves {.grid-4} +## Results That Speak for Themselves {.grid-4 card} ### 250+ Projects Delivered @@ -173,7 +176,7 @@ In Business ## Client Testimonials -## What Our Clients Say {.grid-3} +## What Our Clients Say {.grid-3 card} ### :star::star::star::star::star: *"CreativeCo transformed our brand. Their strategic approach and creative execution exceeded all expectations."* @@ -199,7 +202,7 @@ Founder, EcoMarket ## Our Team -## Meet the Creative Minds {.grid-4} +## Meet the Creative Minds {.grid-4 card} ### John Doe ![Team Member](https://via.placeholder.com/250x250) @@ -237,7 +240,7 @@ Founder, EcoMarket ## Recognition & Awards -## Industry Recognition {.grid-6} +## Industry Recognition {.grid-6 card} ### Awwwards 5x Site of the Day @@ -263,7 +266,7 @@ Innovation Award ## Our Expertise -## Technologies We Love {.grid-3} +## Technologies We Love {.grid-3 card} ### Design - Figma @@ -386,7 +389,7 @@ Sunday: Closed ## Latest from Our Blog -## Recent Articles {.grid-3} +## Recent Articles {.grid-3 card} ### The Future of Web Design ![Blog Post](https://via.placeholder.com/350x200) @@ -423,7 +426,7 @@ Latest strategies to improve your search rankings and organic traffic. ::: footer -## Links {.grid-4} +## Links {.grid-4 card} ### Services - Brand Strategy diff --git a/examples/gallery/landing-pages/app-landing.md b/examples/gallery/landing-pages/app-landing.md index b4319bfa..1b0c79d6 100644 --- a/examples/gallery/landing-pages/app-landing.md +++ b/examples/gallery/landing-pages/app-landing.md @@ -29,7 +29,7 @@ Stay organized, focused, and on top of your tasks with the #1 rated productivity --- -## Everything You Need to Stay Productive {.grid-3} +## Everything You Need to Stay Productive {.grid-3 card} ### :check: Smart Task Management Create, organize, and prioritize tasks with intelligent categorization and due dates. @@ -87,7 +87,7 @@ TaskMaster combines elegant design with powerful productivity features. Manage y ## Loved by Millions -## User Testimonials {.grid-2} +## User Testimonials {.grid-2 card} ### :star::star::star::star::star: *"This app changed my life. I've never been this organized. The natural language input is a game changer!"* @@ -117,7 +117,7 @@ TaskMaster combines elegant design with powerful productivity features. Manage y --- -## Stats That Matter {.grid-4} +## Stats That Matter {.grid-4 card} ### 5M+ Downloads @@ -135,7 +135,7 @@ Uptime --- -## Pricing Plans {.grid-3} +## Pricing Plans {.grid-3 card} ### Free **$0** forever @@ -250,7 +250,7 @@ Join 5 million users who trust TaskMaster for their daily productivity. ::: footer -## Links {.grid-4} +## Links {.grid-4 card} ### Product - Features diff --git a/examples/gallery/landing-pages/ecommerce-home.md b/examples/gallery/landing-pages/ecommerce-home.md index c07c1db0..e9b1d05b 100644 --- a/examples/gallery/landing-pages/ecommerce-home.md +++ b/examples/gallery/landing-pages/ecommerce-home.md @@ -27,7 +27,7 @@ Discover our latest styles for the season --- -## Shop by Category {.grid-6} +## Shop by Category {.grid-6 card} ### :tshirt: Clothing 2,450 items @@ -39,7 +39,6 @@ Discover our latest styles for the season 1,230 items ### :watch: Watches -340 items ### :briefcase: Bags 560 items @@ -51,7 +50,7 @@ Discover our latest styles for the season --- -## Featured Deals {.grid-4} +## Featured Deals {.grid-4 card} ### Summer Dress ![Product](https://via.placeholder.com/250x300) @@ -97,7 +96,7 @@ Discover our latest styles for the season ## New Arrivals -## Just In {.grid-3} +## Just In {.grid-3 card} ### Designer Handbag ![Product](https://via.placeholder.com/300x350) @@ -144,7 +143,7 @@ Fitness tracking & notifications --- -## Why Shop with Us? {.grid-4} +## Why Shop with Us? {.grid-4 card} ### :truck: Free Shipping On orders over $50 @@ -164,7 +163,7 @@ Always here to help ## Customer Reviews -## What Our Customers Say {.grid-3} +## What Our Customers Say {.grid-3 card} ### :star::star::star::star::star: *"Best online shopping experience! Fast shipping and quality products."* @@ -203,7 +202,7 @@ Verified Buyer ## Special Offers -## Current Promotions {.grid-2} +## Current Promotions {.grid-2 card} ### :gift: Summer Sale **Up to 50% Off** @@ -260,7 +259,7 @@ Stay connected for daily inspiration ## Shop by Price -## Budget-Friendly Options {.grid-4} +## Budget-Friendly Options {.grid-4 card} ### Under $25 1,234 items @@ -307,7 +306,7 @@ Stay connected for daily inspiration ::: footer -## Quick Links {.grid-5} +## Quick Links {.grid-5 card} ### Shop - Women diff --git a/examples/gallery/landing-pages/portfolio.md b/examples/gallery/landing-pages/portfolio.md index e3351c62..4414fc46 100644 --- a/examples/gallery/landing-pages/portfolio.md +++ b/examples/gallery/landing-pages/portfolio.md @@ -27,7 +27,7 @@ I design beautiful, functional digital experiences that users love. ## Featured Work -## Recent Projects {.grid-2} +## Recent Projects {.grid-2 card} ### E-Commerce Redesign ![Project Screenshot](https://via.placeholder.com/500x350) @@ -94,7 +94,7 @@ Secure, accessible banking app serving 500K+ users with 4.8★ rating. ## Skills & Expertise -## Design {.grid-3} +## Design {.grid-3 card} ### UI/UX Design - User Research @@ -159,7 +159,7 @@ I'm a product designer with 8+ years of experience creating digital experiences ## Recognition & Awards -## Achievements {.grid-4} +## Achievements {.grid-4 card} ### Awwwards 3x Site of the Day @@ -231,7 +231,7 @@ Message ::: footer -## Connect {.grid-3} +## Connect {.grid-3 card} ### Work - [Portfolio](https://janedoe.design) diff --git a/examples/gallery/landing-pages/saas-product.md b/examples/gallery/landing-pages/saas-product.md index 95fc43e5..5d4a44f7 100644 --- a/examples/gallery/landing-pages/saas-product.md +++ b/examples/gallery/landing-pages/saas-product.md @@ -29,7 +29,7 @@ The all-in-one platform for modern teams to collaborate, automate, and scale the --- -## Why CloudFlow? {.grid-3} +## Why CloudFlow? {.grid-3 card} ### :rocket: Lightning Fast Deploy in seconds, not hours. Our infrastructure handles millions of requests with 99.99% uptime. @@ -53,7 +53,7 @@ Expert support team available around the clock via chat, email, and phone. --- -## Core Features {.grid-2} +## Core Features {.grid-2 card} ### Automation That Just Works @@ -83,7 +83,7 @@ Expert support team available around the clock via chat, email, and phone. --- -## Simple, Transparent Pricing {.grid-3} +## Simple, Transparent Pricing {.grid-3 card} ### Starter **$29** /month @@ -135,7 +135,7 @@ For large organizations ## What Our Customers Say -## Testimonials {.grid-3} +## Testimonials {.grid-3 card} ### :star::star::star::star::star: *"CloudFlow transformed how our team works. We've saved 20 hours per week on manual tasks."* @@ -210,7 +210,7 @@ Start your free trial today. No credit card required. ::: footer -## Quick Links {.grid-4} +## Quick Links {.grid-4 card} ### Product - Features 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/examples/icons-test.md b/examples/icons-test.md index a00f4158..0e5eb336 100644 --- a/examples/icons-test.md +++ b/examples/icons-test.md @@ -22,7 +22,7 @@ - :error: Failed task - :info: Information item -## Icon Grid {.grid-4} +## Icon Grid {.grid-4 card} ### :chart: Analytics diff --git a/examples/showcase.md b/examples/showcase.md index 7f70874d..af760434 100644 --- a/examples/showcase.md +++ b/examples/showcase.md @@ -546,7 +546,7 @@ Are you sure you want to delete this item? ### 2-Column Grid ```markdown -## Pricing {.grid-2} +## Pricing {.grid-2 card} ### Free Plan - 10 GB Storage @@ -565,7 +565,7 @@ Are you sure you want to delete this item? **Rendered:** -## Pricing {.grid-2} +## Pricing {.grid-2 card} ### Free Plan - 10 GB Storage @@ -584,7 +584,7 @@ Are you sure you want to delete this item? ### 3-Column Grid ```markdown -## Features {.grid-3} +## Features {.grid-3 card} ### :rocket: Fast Lightning quick performance @@ -598,7 +598,7 @@ Fully customizable **Rendered:** -## Features {.grid-3} +## Features {.grid-3 card} ### :rocket: Fast Lightning quick performance @@ -614,7 +614,7 @@ Fully customizable ### 4-Column Grid ```markdown -## Stats {.grid-4} +## Stats {.grid-4 card} ### Users 10,000+ @@ -631,7 +631,7 @@ Fully customizable **Rendered:** -## Stats {.grid-4} +## Stats {.grid-4 card} ### Users 10,000+ @@ -847,7 +847,7 @@ Terms [[ :logo: Dashboard | Overview | *Analytics* | Reports | Settings | :user: User ]] -## Quick Stats {.grid-4} +## Quick Stats {.grid-4 card} ### Revenue $45,231 @@ -885,7 +885,7 @@ $45,231 > > [New Project]* [Import] [Export] -## Project Stats {.grid-4} +## Project Stats {.grid-4 card} ### Total 42 @@ -914,7 +914,7 @@ Sprint 23 Progress [View All Tasks] -## Quick Actions {.grid-3} +## Quick Actions {.grid-3 card} ### :plus: Create New Task diff --git a/examples/sidebar-layout.md b/examples/sidebar-layout.md new file mode 100644 index 00000000..05548ede --- /dev/null +++ b/examples/sidebar-layout.md @@ -0,0 +1,55 @@ +::: layout {.sidebar-main} + +## Sidebar {.sidebar} + +:folder: **Projects** + +#### Workspace +[[Overview](./overview.html)]* +[[Projects](./projects.html)] +[[Tasks](./tasks.html)] +[[Calendar](./calendar.html)] + +#### Team +[[Members](./members.html)] +[[Roles](./roles.html)] +[[Activity](./activity.html)] + +#### Settings +[[General](./settings.html)] +[[Billing](./billing.html)] +[[Integrations](./integrations.html)] + +--- + +[[+ New Project](./new-project.html)]* + +## Main {.main} + +## Overview + +A summary of your workspace activity and open tasks. + +## Stats {.grid-3 card} + +### :check: Tasks Done +**48** this week + +### :clock: In Progress +**12** open + +### :bell: Upcoming +**5** due today + +## Recent Projects + +| Project | Owner | Status | Due Date | Progress | +|---------|-------|--------|----------|----------| +| Website Redesign | Alice M. | In Progress | Dec 15 | 64% | +| Mobile App v2 | Bob K. | In Progress | Jan 10 | 31% | +| API Migration | Clara T. | Review | Dec 20 | 88% | +| Design System | Dan W. | Planning | Feb 1 | 12% | + +[[View All Projects →](./projects.html)] + +::: diff --git a/scripts/migrate-v0.2.sh b/scripts/migrate-v0.2.sh new file mode 100755 index 00000000..9d929057 --- /dev/null +++ b/scripts/migrate-v0.2.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# wiremd v0.2 migration: adds 'card' modifier to {.grid-N} headings +# +# Before: ## Features {.grid-3} (used to render with card chrome) +# After: ## Features {.grid-3 card} (explicit card chrome) +# ## Features {.grid-3} (now pure layout grid) +# +# Usage: +# ./scripts/migrate-v0.2.sh # migrate all .md files in current dir +# ./scripts/migrate-v0.2.sh src/ # migrate a specific directory +# ./scripts/migrate-v0.2.sh file.md # migrate a single file +# +# After running, review any {.grid-N card} that are pure layout grids +# (e.g. form field columns) and remove 'card' from those manually. + +TARGET=${1:-.} +COUNT=0 + +while IFS= read -r -d '' file; do + if grep -q '\.grid-[0-9]' "$file"; then + perl -pi -e 's/(\{[^}]*\.grid-\d+[^}]*?)(\})/ + my ($inner, $close) = ($1, $2); + $inner =~ m|\bcard\b| ? "$inner$close" : "$inner card$close" + /gex' "$file" + echo "migrated: $file" + COUNT=$((COUNT + 1)) + fi +done < <(find "$TARGET" -name "*.md" -not -path "*/node_modules/*" -print0) + +echo "" +echo "$COUNT file(s) updated." +echo "" +echo "Next: review any {.grid-N card} that should be pure layout (e.g. form columns)" +echo "and remove the 'card' modifier from those manually." 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 0365ecd7..51859580 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -29,125 +29,11 @@ export function transformToWiremdAST( theme: 'sketch', }; - const children: WiremdNode[] = []; - - // Visit all nodes in the MDAST with context for dropdown options and grid layouts - let i = 0; - while (i < mdast.children.length) { - const node = mdast.children[i]; - const nextNode = mdast.children[i + 1]; - - // Check if this is a heading with grid class - if (node.type === 'heading') { - const content = extractTextContent(node); - const gridMatch = content.match(/\{[^}]*\.grid-(\d+)[^}]*\}/); - - if (gridMatch) { - const columns = parseInt(gridMatch[1], 10); - const gridHeadingLevel = node.depth; - - // This is a grid container - collect grid items - const gridItems: WiremdNode[] = []; - const headingTransformed = transformHeading(node, options); - - i++; // Move to next node - - // Collect child headings as grid items - while (i < mdast.children.length) { - const childNode = mdast.children[i]; - - // Grid items are headings one level deeper - if ( - childNode.type === 'heading' && - childNode.depth === gridHeadingLevel + 1 - ) { - const gridItem: WiremdNode[] = []; - - // Add the heading - const childNextNode = mdast.children[i + 1]; - const headingNode = transformNode(childNode, options, childNextNode); - if (headingNode) { - gridItem.push(headingNode); - } - - i++; - - // Collect content until next heading at same or higher level - while (i < mdast.children.length) { - const contentNode = mdast.children[i]; - - if ( - contentNode.type === 'heading' && - contentNode.depth <= gridHeadingLevel + 1 - ) { - break; // Stop at next grid item or parent level - } - - const contentNextNode = mdast.children[i + 1]; - const contentTransformed = transformNode(contentNode, options, contentNextNode); - if (contentTransformed) { - gridItem.push(contentTransformed); - - // Skip consumed nodes - if (contentTransformed.type === 'select' && contentNextNode?.type === 'list') { - i++; - } - } - - i++; - } - - // Add as grid item - gridItems.push({ - type: 'grid-item', - props: {}, - children: gridItem, - }); - } else { - // Not a grid item heading, stop collecting - break; - } - } - - // Create grid node - children.push({ - type: 'grid', - columns, - props: (headingTransformed as any).props || {}, - children: gridItems, - }); - - continue; - } - } - - const transformed = transformNode(node, options, nextNode); - if (transformed) { - children.push(transformed); - - // If this was a select node and we consumed the next list, skip it - if (transformed.type === 'select' && nextNode && nextNode.type === 'list') { - i++; // Skip the next node (list) as it was consumed - } - // Also check if it's a container with a select child that has consumed the list - if (transformed.type === 'container' && nextNode && nextNode.type === 'list') { - const hasSelectWithOptions = (transformed.children || []).some((child: any) => - child.type === 'select' && child.options && child.options.length > 0 - ); - if (hasSelectWithOptions) { - i++; // Skip the next node (list) as it was consumed by the select - } - } - } - - i++; - } - return { type: 'document', version: SYNTAX_VERSION, meta, - children, + children: processNodeList(mdast.children as any[], options), }; } @@ -238,34 +124,106 @@ function transformNode( } /** - * Transform container node (:::) + * Process a list of MDAST nodes into wiremd nodes, detecting grid layouts. + * Shared by both the top-level document pass and container children. */ -function transformContainer(node: any, options: ParseOptions): WiremdNode { - const children: WiremdNode[] = []; - const nodeChildren = node.children || []; +function processNodeList(nodeChildren: any[], options: ParseOptions): WiremdNode[] { + const result: WiremdNode[] = []; + let i = 0; - for (let i = 0; i < nodeChildren.length; i++) { - const child = nodeChildren[i]; - const nextChild = nodeChildren[i + 1]; - const transformed = transformNode(child, options, nextChild); + while (i < nodeChildren.length) { + const node = nodeChildren[i]; + const nextNode = nodeChildren[i + 1]; - if (transformed) { - children.push(transformed); + if (node.type === 'heading') { + const content = extractTextContent(node); + const gridMatch = content.match(/\{[^}]*\.grid-(\d+)[^}]*\}/); + + if (gridMatch) { + const columns = parseInt(gridMatch[1], 10); + const gridHeadingLevel = node.depth; + const gridItems: WiremdNode[] = []; + const headingTransformed = transformHeading(node, options); - // Skip next node if it was consumed (dropdown options) - if (transformed.type === 'select' && nextChild && nextChild.type === 'list') { i++; + + while (i < nodeChildren.length) { + const childNode = nodeChildren[i]; + + if (childNode.type === 'heading' && childNode.depth === gridHeadingLevel + 1) { + const gridItem: WiremdNode[] = []; + const childNextNode = nodeChildren[i + 1]; + const headingNode = transformNode(childNode, options, childNextNode); + if (headingNode) gridItem.push(headingNode); + i++; + + while (i < nodeChildren.length) { + const contentNode = nodeChildren[i]; + if (contentNode.type === 'heading' && contentNode.depth <= gridHeadingLevel + 1) break; + const contentNextNode = nodeChildren[i + 1]; + const contentTransformed = transformNode(contentNode, options, contentNextNode); + if (contentTransformed) { + gridItem.push(contentTransformed); + if (contentTransformed.type === 'select' && contentNextNode?.type === 'list') i++; + } + i++; + } + + const headingContent = extractTextContent(childNode); + const colSpanMatch = headingContent.match(/\{[^}]*\.col-span-(\d+)[^}]*\}/); + const gridItemProps: any = { classes: [] }; + if (colSpanMatch) gridItemProps.classes.push(`col-span-${colSpanMatch[1]}`); + + gridItems.push({ type: 'grid-item', props: gridItemProps, children: gridItem }); + } else if (childNode.type === 'heading' && childNode.depth <= gridHeadingLevel) { + break; + } else if (gridItems.length === 0) { + i++; + continue; + } else { + break; + } + } + + result.push({ + type: 'grid', + columns, + props: (headingTransformed as any).props || {}, + children: gridItems, + }); + continue; + } + } + + const transformed = transformNode(node, options, nextNode); + if (transformed) { + result.push(transformed); + if (transformed.type === 'select' && nextNode && nextNode.type === 'list') i++; + if (transformed.type === 'container' && nextNode && nextNode.type === 'list') { + const hasSelectWithOptions = (transformed.children || []).some((child: any) => + child.type === 'select' && child.options && child.options.length > 0 + ); + if (hasSelectWithOptions) i++; } } + i++; } + return result; +} + +/** + * Transform container node (:::) + */ +function transformContainer(node: any, options: ParseOptions): WiremdNode { const props = parseAttributes(node.attributes || ''); + const containerType: string = (node.containerType || '').trim(); return { type: 'container', - containerType: node.containerType as any, + containerType: containerType as any, props, - children, + children: processNodeList(node.children || [], options) as any, }; } @@ -281,6 +239,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) { @@ -395,6 +376,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. @@ -405,6 +430,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 0641e818..eb64b90e 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -63,6 +63,7 @@ export function renderNode(node: WiremdNode, context: RenderContext): string { case 'grid-item': return renderGridItem(node, context); + case 'heading': return renderHeading(node, context); @@ -121,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 ``; } @@ -365,6 +371,12 @@ function renderIcon(node: any, context: RenderContext): string { function renderContainer(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, `container-${node.containerType}`, node.props); + + const nodeClasses: string[] = node.props?.classes || []; + if (node.containerType === 'layout' && nodeClasses.includes('sidebar-main')) { + return renderSidebarMainLayout(node, context, classes); + } + const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n '); return `
@@ -372,6 +384,36 @@ function renderContainer(node: any, context: RenderContext): string {
`; } +function renderSidebarMainLayout(node: any, context: RenderContext, classes: string): string { + const { classPrefix: prefix } = context; + const children: any[] = node.children || []; + + const sections: { name: string; nodes: any[] }[] = []; + let current: { name: string; nodes: any[] } | null = null; + + for (const child of children) { + const childClasses: string[] = child.props?.classes || []; + if (child.type === 'heading' && (childClasses.includes('sidebar') || childClasses.includes('main'))) { + if (current) sections.push(current); + current = { name: childClasses.includes('sidebar') ? 'sidebar' : 'main', nodes: [] }; + } else if (current) { + current.nodes.push(child); + } + } + if (current) sections.push(current); + + const sectionsHTML = sections.map((s) => { + const contentHTML = s.nodes.map((child: any) => renderNode(child, context)).join('\n '); + return `
+ ${contentHTML} +
`; + }).join('\n'); + + return `
+${sectionsHTML} +
`; +} + function renderNav(node: any, context: RenderContext): string { const { classPrefix: prefix } = context; const classes = buildClasses(prefix, 'nav', node.props); @@ -386,14 +428,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}`; } @@ -410,16 +456,19 @@ function renderGrid(node: any, context: RenderContext): string { const classes = buildClasses(prefix, 'grid', node.props); const columns = node.columns || 3; const gridClass = `${classes} ${prefix}grid-${columns}`; - const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n '); + const isCard = !!node.props?.card; + const childrenHTML = (node.children || []).map((child: any) => renderGridItem(child, context, isCard)).join('\n '); return `
${childrenHTML}
`; } -function renderGridItem(node: any, context: RenderContext): string { +function renderGridItem(node: any, context: RenderContext, isCard = false): string { const { classPrefix: prefix } = context; - const classes = buildClasses(prefix, 'grid-item', node.props); + const extraClasses = isCard ? [...(node.props?.classes || []), 'grid-item-card'] : (node.props?.classes || []); + const itemProps = { ...node.props, classes: extraClasses }; + const classes = buildClasses(prefix, 'grid-item', itemProps); const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n '); return `
diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index 52aefc2c..8087c99a 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,10 +272,65 @@ 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; } +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #f0f8ff; + border: 2px solid #4682B4; + border-radius: 8px; + box-shadow: 3px 3px 0 rgba(0,0,0,0.15); +} +.${prefix}container-sidebar .${prefix}button { + display: block; + width: 100%; + text-align: left; + margin: 0; +} +.${prefix}container-sidebar .${prefix}h4 { + margin: 12px 0 4px; + font-size: 0.85em; + opacity: 0.6; + text-transform: uppercase; +} +.${prefix}container-sidebar .${prefix}separator { + margin: 8px 0; +} + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { + min-width: 0; +} + /* Grid */ .${prefix}grid { display: grid; @@ -291,7 +343,14 @@ body.${prefix}root { .${prefix}grid-3 { grid-template-columns: repeat(3, 1fr); } .${prefix}grid-4 { grid-template-columns: repeat(4, 1fr); } -.${prefix}grid-item { +.${prefix}grid-item { min-width: 0; } + +.${prefix}col-span-1 { grid-column: span 1; } +.${prefix}col-span-2 { grid-column: span 2; } +.${prefix}col-span-3 { grid-column: span 3; } +.${prefix}col-span-4 { grid-column: span 4; } + +.${prefix}grid-item-card { background: #fff; border: 2px solid #666; border-radius: 8px; @@ -299,10 +358,7 @@ body.${prefix}root { box-shadow: 2px 2px 0 rgba(0,0,0,0.1); transform: rotate(0.5deg); } - -.${prefix}grid-item:nth-child(even) { - transform: rotate(-0.5deg); -} +.${prefix}grid-item-card:nth-child(even) { transform: rotate(-0.5deg); } /* Lists */ .${prefix}list { @@ -449,6 +505,13 @@ body.${prefix}root { grid-template-columns: 1fr !important; } + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } + .${prefix}nav-content { flex-direction: column; align-items: stretch; @@ -716,6 +779,45 @@ 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; +} + +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #f0f8ff; + border: 2px solid #4682B4; + border-radius: 8px; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}container-sidebar .${prefix}separator { margin: 8px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { min-width: 0; } + /* Grid */ .${prefix}grid { display: grid; @@ -724,15 +826,21 @@ body.${prefix}root { margin: 32px 0; } -.${prefix}grid-item { +.${prefix}grid-item { min-width: 0; } + +.${prefix}col-span-1 { grid-column: span 1; } +.${prefix}col-span-2 { grid-column: span 2; } +.${prefix}col-span-3 { grid-column: span 3; } +.${prefix}col-span-4 { grid-column: span 4; } + +.${prefix}grid-item-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; transition: box-shadow 0.2s; } - -.${prefix}grid-item:hover { +.${prefix}grid-item-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); } @@ -856,6 +964,13 @@ body.${prefix}root { grid-template-columns: 1fr !important; } + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } + .${prefix}nav-content { flex-direction: column; align-items: stretch; @@ -1092,6 +1207,44 @@ body.${prefix}root { border-color: #000; } +.${prefix}nav-item.${prefix}active { + background: #000; + color: #fff; + border-color: #000; +} + +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #f5f5f5; + border: 1px solid #aaa; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}container-sidebar .${prefix}separator { margin: 8px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { min-width: 0; } + /* Grid */ .${prefix}grid { display: grid; @@ -1100,7 +1253,14 @@ body.${prefix}root { margin: 20px 0; } -.${prefix}grid-item { +.${prefix}grid-item { min-width: 0; } + +.${prefix}col-span-1 { grid-column: span 1; } +.${prefix}col-span-2 { grid-column: span 2; } +.${prefix}col-span-3 { grid-column: span 3; } +.${prefix}col-span-4 { grid-column: span 4; } + +.${prefix}grid-item-card { background: #fff; border: 2px solid #000; padding: 16px; @@ -1226,6 +1386,13 @@ body.${prefix}root { .${prefix}grid { grid-template-columns: 1fr !important; } + + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } } @keyframes spin { @@ -1266,6 +1433,36 @@ body.${prefix}root { margin: 4px 0; } +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 8px; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 8px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}container-sidebar .${prefix}separator { margin: 4px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 8px 0 4px; font-size: 0.85em; opacity: 0.6; text-transform: uppercase; } +.${prefix}layout-sidebar .${prefix}separator { margin: 4px 0; } +.${prefix}layout-main { min-width: 0; } + .${prefix}grid { display: grid; grid-template-columns: repeat(var(--grid-columns, 3), 1fr); @@ -1297,6 +1494,13 @@ body.${prefix}root { .${prefix}grid { grid-template-columns: 1fr !important; } + + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } } `; } @@ -1612,6 +1816,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; @@ -1639,6 +1849,38 @@ body { border-bottom: none; } +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #f9fafb; + border-right: 1px solid #e5e7eb; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.75em; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; } +.${prefix}container-sidebar .${prefix}separator { margin: 8px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.75em; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { min-width: 0; } + /* Grid */ .${prefix}grid { display: grid; @@ -1823,6 +2065,13 @@ body { grid-template-columns: 1fr; } + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } + .${prefix}nav-content { flex-direction: column; align-items: flex-start; @@ -2205,6 +2454,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; @@ -2242,6 +2497,38 @@ body { border-bottom: none; } +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #fafafa; + border-right: 1px solid #e0e0e0; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.75em; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(0,0,0,0.54); } +.${prefix}container-sidebar .${prefix}separator { margin: 8px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.75em; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(0,0,0,0.54); } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { min-width: 0; } + /* Material Grid */ .${prefix}grid { display: grid; @@ -2432,6 +2719,13 @@ body { grid-template-columns: 1fr; } + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } + .${prefix}nav-content { flex-direction: column; align-items: flex-start; @@ -2805,6 +3099,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; @@ -2844,6 +3146,39 @@ body { border-bottom: none; } +/* Sidebar */ +.${prefix}container-sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 180px; + padding: 12px; + background: #fff; + border: 3px solid #000; + box-shadow: 4px 4px 0 #000; +} +.${prefix}container-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 0; } +.${prefix}container-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; font-weight: 900; text-transform: uppercase; } +.${prefix}container-sidebar .${prefix}separator { margin: 8px 0; } + +/* Layout: sidebar-main */ +.${prefix}container-layout.${prefix}sidebar-main { + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; + align-items: start; +} +.${prefix}layout-sidebar { + display: flex; + flex-direction: column; + gap: 4px; +} +.${prefix}layout-sidebar .${prefix}container-sidebar { width: 100%; } +.${prefix}layout-sidebar .${prefix}button { display: block; width: 100%; text-align: left; margin: 2px 0; } +.${prefix}layout-sidebar .${prefix}h4 { margin: 12px 0 4px; font-size: 0.85em; font-weight: 900; text-transform: uppercase; } +.${prefix}layout-sidebar .${prefix}separator { margin: 8px 0; } +.${prefix}layout-main { min-width: 0; } + /* Brutal Grid */ .${prefix}grid { display: grid; @@ -3072,6 +3407,13 @@ body { grid-template-columns: 1fr; } + .${prefix}col-span-1, + .${prefix}col-span-2, + .${prefix}col-span-3, + .${prefix}col-span-4 { + grid-column: span 1; + } + .${prefix}nav-content { flex-direction: column; align-items: flex-start; 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 8e1d60ae..94efa9ad 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -485,5 +485,206 @@ Advanced features included }); expect(result.children[0].children).toHaveLength(4); }); + + it('should set card prop on grid node when card modifier is present', () => { + const input = ` +## Features {.grid-3 card} + +### Fast +Quick + +### Secure +Safe + +### Scalable +Grows + `.trim(); + + const result = parse(input); + expect(result.children[0]).toMatchObject({ type: 'grid', columns: 3 }); + expect((result.children[0] as any).props.card).toBe(true); + }); + + it('should not set card prop on grid node without card modifier', () => { + const input = ` +## Features {.grid-3} + +### Fast +Quick + `.trim(); + + const result = parse(input); + expect((result.children[0] as any).props.card).toBeFalsy(); + }); + + it('should parse a grid nested inside a container as a grid node', () => { + const input = ` +::: card + +## Features {.grid-3} + +### Fast +Quick + +### Secure +Safe + +### Powerful +Strong + +::: + `.trim(); + + const result = parse(input); + const card = result.children[0] as any; + expect(card.type).toBe('container'); + expect(card.containerType).toBe('card'); + // The grid must be a grid node, not a plain heading + const grid = card.children[0]; + expect(grid.type).toBe('grid'); + expect(grid.columns).toBe(3); + expect(grid.children).toHaveLength(3); + expect(grid.children[0].type).toBe('grid-item'); + }); + }); + + describe('Sidebar layout', () => { + it('should parse :::layout {.sidebar-main} as a layout container', () => { + const input = ` +::: layout {.sidebar-main} + +## Sidebar {.sidebar} +Nav here + +## Main {.main} +Content here + +::: + `.trim(); + + const result = parse(input); + const layout = result.children[0] as any; + expect(layout.type).toBe('container'); + expect(layout.containerType).toBe('layout'); + expect(layout.props.classes).toContain('sidebar-main'); + }); + + it('should parse grid inside sidebar-main layout as a grid node', () => { + const input = ` +::: layout {.sidebar-main} + +## Sidebar {.sidebar} +Nav + +## Main {.main} + +## Stats {.grid-3} + +### Done +48 + +### Active +12 + +### Pending +5 + +::: + `.trim(); + + const result = parse(input); + const layout = result.children[0] as any; + // Find the grid in the layout's children + const grid = layout.children.find((c: any) => c.type === 'grid'); + expect(grid).toBeDefined(); + expect(grid.columns).toBe(3); + expect(grid.children).toHaveLength(3); + }); + }); + + 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 = ` +## Pricing {.grid-3} + +### Starter {.col-span-1} +$9/mo + +### Pro {.col-span-2} +$29/mo + `.trim(); + + const result = parse(input); + const grid = result.children[0] as any; + expect(grid.type).toBe('grid'); + expect(grid.children[0].props.classes).toContain('col-span-1'); + expect(grid.children[1].props.classes).toContain('col-span-2'); + }); + + it('should leave grid-item without col-span when not specified', () => { + const input = ` +## Layout {.grid-3} + +### Item One +Content + `.trim(); + + const result = parse(input); + const item = (result.children[0] as any).children[0]; + expect(item.props.classes).not.toContain('col-span-1'); + expect(item.props.classes).not.toContain('col-span-2'); + }); + + it('should not render grid heading label text as a child node', () => { + const input = ` +## Features {.grid-3} + +### Fast +Quick + `.trim(); + + const result = parse(input); + const grid = result.children[0] as any; + const types = grid.children.map((c: any) => c.type); + expect(types).not.toContain('heading'); + expect(types.every((t: string) => t === 'grid-item')).toBe(true); + }); }); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 9f69c39d..fce5bd32 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); @@ -167,6 +197,139 @@ Powerful expect(html).toContain('Feature Two'); expect(html).toContain('Feature Three'); }); + + it('should render wmd-grid-item-card class when card modifier is set', () => { + const input = ` +## Features {.grid-3 card} + +### Fast +Quick + +### Secure +Safe + `.trim(); + + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/class="[^"]*wmd-grid-item-card/); + }); + + it('should NOT render wmd-grid-item-card class without card modifier', () => { + const input = ` +## Features {.grid-3} + +### Fast +Quick + `.trim(); + + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).not.toMatch(/class="[^"]*wmd-grid-item-card/); + }); + + it('should NOT render grid heading label text in output', () => { + const input = ` +## MyGridLabel {.grid-3} + +### Item One +Content + `.trim(); + + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).not.toContain('MyGridLabel'); + }); + + it('should render col-span class on grid item', () => { + const input = ` +## Pricing {.grid-3} + +### Wide {.col-span-2} +Spans two columns + `.trim(); + + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/class="[^"]*wmd-col-span-2/); + }); + + it('should include col-span mobile reset in CSS', () => { + const ast = parse('## G {.grid-3}\n\n### Item\nContent'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/max-width:\s*768px[\s\S]*?col-span-2[\s\S]*?grid-column:\s*span 1/); + }); + + it('should render col-span combined with card modifier', () => { + const input = ` +## Pricing {.grid-3 card} + +### Wide {.col-span-2} +Spans two + `.trim(); + + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/class="[^"]*wmd-grid-item-card/); + expect(html).toMatch(/class="[^"]*wmd-col-span-2/); + }); + }); + + 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(/ { @@ -253,6 +416,141 @@ Email }); }); + describe('Grid inside container', () => { + it('should render a grid nested inside a card container', () => { + const input = ` +::: card + +## Features {.grid-3} + +### Fast +Quick + +### Secure +Safe + +### Powerful +Strong + +::: + `.trim(); + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + // Must produce an actual grid div, not just CSS class strings + expect(html).toMatch(/

/); + expect(html).toContain('Fast'); + expect(html).toContain('Secure'); + expect(html).toContain('Powerful'); + }); + + it('should render a grid nested inside sidebar-main layout main section', () => { + const input = ` +::: layout {.sidebar-main} + +## Sidebar {.sidebar} +Nav + +## Main {.main} + +## Stats {.grid-3} + +### Done +48 + +### Active +12 + +### Pending +5 + +::: + `.trim(); + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/
/); + expect(html).toContain('Done'); + expect(html).toContain('Active'); + expect(html).toContain('Pending'); + }); + }); + + describe('Sidebar Layout', () => { + const sidebarInput = ` +::: layout {.sidebar-main} + +## Sidebar {.sidebar} +- [Home](#) +- [Settings](#) + +## Main {.main} +### Dashboard +Content here + +::: + `.trim(); + + it('should render sidebar-main layout with correct wrapper class', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('wmd-container-layout'); + expect(html).toContain('wmd-sidebar-main'); + }); + + it('should render sidebar and main sections as separate divs', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('wmd-layout-sidebar'); + expect(html).toContain('wmd-layout-main'); + }); + + it('should NOT render the section heading labels as visible text', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + // Heading text "Sidebar" and "Main" used as structural markers should not appear in output + expect(html).not.toMatch(/]*>[^<]*\bSidebar\b/); + expect(html).not.toMatch(/]*>[^<]*\bMain\b/); + }); + + it('should place sidebar content inside layout-sidebar div', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + // Extract just the body content (after ) to avoid CSS false positives + const bodyStart = html.indexOf(''); + const body = html.slice(bodyStart); + const sidebarDivPos = body.indexOf('class="wmd-layout-sidebar"'); + const mainDivPos = body.indexOf('class="wmd-layout-main"'); + const homePos = body.indexOf('>Home<'); + expect(sidebarDivPos).toBeGreaterThan(-1); + expect(homePos).toBeGreaterThan(sidebarDivPos); + expect(homePos).toBeLessThan(mainDivPos); + }); + + it('should place main content inside layout-main div', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + const bodyStart = html.indexOf(''); + const body = html.slice(bodyStart); + const mainDivPos = body.indexOf('class="wmd-layout-main"'); + const dashPos = body.indexOf('Dashboard'); + expect(dashPos).toBeGreaterThan(mainDivPos); + }); + + it('should include sidebar-main grid CSS', () => { + const ast = parse(sidebarInput); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/grid-template-columns:\s*\d+px\s+1fr/); + }); + + it('should include container-sidebar CSS', () => { + const ast = parse('::: sidebar\n- [Home](#)\n:::\n'); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('wmd-container-sidebar'); + expect(html).toMatch(/\.wmd-container-sidebar\s*\{/); + }); + }); + describe('JSON Renderer', () => { it('should render to JSON', () => { const ast = parse('[Button]'); 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';