Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions docs/components/icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ Use `:name:` inline anywhere — in headings, buttons, nav bars, or plain text.

| Name | Renders as |
|------|------------|
| `:home:` | 🏠 |
| `:user:` | 👤 |
| `:settings:` | ⚙️ |
| `:home:` / `:house:` | 🏠 |
| `:user:` / `:person:` | 👤 |
| `:settings:` / `:gear:` | ⚙️ |
| `:search:` | 🔍 |
| `:star:` | ⭐ |
| `:heart:` | ❤️ |
Expand All @@ -43,11 +43,11 @@ Use `:name:` inline anywhere — in headings, buttons, nav bars, or plain text.
| `:download:` | ⬇️ |
| `:upload:` | ⬆️ |
| `:edit:` | ✏️ |
| `:delete:` | 🗑️ |
| `:delete:` / `:trash:` | 🗑️ |
| `:plus:` | ➕ |
| `:minus:` | ➖ |
| `:check:` | ✓ |
| `:close:` | ✕ |
| `:close:` / `:x:` | ✕ |
| `:menu:` | ☰ |
| `:more:` | ⋯ |
| `:info:` | ℹ️ |
Expand Down Expand Up @@ -104,6 +104,24 @@ Use `:name:` inline anywhere — in headings, buttons, nav bars, or plain text.
| `:cloud:` | ☁️ |
| `:wifi:` | 📶 |

### Domain / climate

| Name | Renders as |
|------|------------|
| `:tree:` | 🌳 |
| `:plant:` | 🌱 |
| `:sun:` | ☀️ |
| `:thermometer:` / `:heat:` | 🌡️ |
| `:drop:` / `:water:` | 💧 |
| `:rain:` | 🌧️ |
| `:wind:` | 💨 |
| `:map:` | 🗺️ |
| `:building:` | 🏢 |
| `:shop:` | 🏪 |
| `:tools:` | 🛠️ |
| `:megaphone:` | 📢 |
| `:handshake:` | 🤝 |

### Activities

| Name | Renders as |
Expand Down
11 changes: 4 additions & 7 deletions docs/components/not-implemented-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Same as `[Select___v]` + list — the `v` suffix and following list already expr

Segmented control. Used for view-mode switches (list/grid), alignment, and filters.

A `::: row` with a class modifier — row already creates a flex button container, `.toggle-group` styles it as a segmented control:
**Implemented.** A `::: row` with a class modifier — row already creates a flex button container, `.toggle-group` styles it as a segmented control:

```
::: row {.toggle-group}
Expand Down Expand Up @@ -280,14 +280,11 @@ New container using `###` items — same boundary convention as `::: grid-N`:

Mobile tab bar fixed to the bottom. Used in mobile-first apps.

New container type — button links with icons follow the existing `[[:icon: Label]]` pattern:
**Implemented.** Nest a nav row inside `::: bottom-nav`. Trailing `*` on an item marks it active:

```
::: bottom-nav
[[:home: Home]*]
[[:search: Explore]]
[[:bell: Alerts]]
[[:user: Profile]]
[[ :home: Feed* | :search: Explore | :bell: Alerts | :user: Profile ]]
:::
```

Expand Down Expand Up @@ -332,7 +329,7 @@ Extends image syntax — `![alt](src)` already works; avatar is an image with a

Compact, optionally removable label. Distinct from badge — chips are interactive and can be dismissed. Used for tags, filters, selections.

Extends badge `|...|` syntax — static chip is a badge with `.chip` class; removable adds `×`:
**Implemented.** Extends badge `|...|` syntax — static chip is a badge with `.chip` class; removable adds `×`:

```
|React|{.chip} <!-- static chip -->
Expand Down
54 changes: 44 additions & 10 deletions figma-plugin/src/lib/content-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,50 @@ export async function createIcon(

// Icon mapping - using Unicode symbols and emoji
const iconMap: Record<string, string> = {
'home': '🏠', 'user': '👤', 'settings': '⚙️', 'search': '🔍',
'star': '⭐', 'heart': '❤️', 'mail': '✉️', 'phone': '📞',
'calendar': '📅', 'clock': '🕐', 'location': '📍', 'link': '🔗',
'download': '⬇️', 'upload': '⬆️', 'edit': '✏️', 'delete': '🗑️',
'plus': '➕', 'minus': '➖', 'check': '✓', 'close': '✕',
'menu': '☰', 'more': '⋯', 'info': 'ℹ️', 'warning': '⚠️',
'error': '❌', 'success': '✅',
'arrow-up': '↑', 'arrow-down': '↓', 'arrow-left': '←', 'arrow-right': '→',
'twitter': '𝕏', 'github': '⊙', 'linkedin': 'in',
'default': '●'
home: '🏠',
user: '👤',
settings: '⚙️',
search: '🔍',
star: '⭐',
heart: '❤️',
mail: '✉️',
phone: '📞',
calendar: '📅',
clock: '🕐',
location: '📍',
link: '🔗',
download: '⬇️',
upload: '⬆️',
edit: '✏️',
delete: '🗑️',
plus: '➕',
minus: '➖',
check: '✓',
close: '✕',
menu: '☰',
more: '⋯',
info: 'ℹ️',
warning: '⚠️',
error: '❌',
success: '✅',
'arrow-up': '↑',
'arrow-down': '↓',
'arrow-left': '←',
'arrow-right': '→',
twitter: '𝕏',
github: '⊙',
linkedin: 'in',
// Aliases + domain
house: '🏠',
person: '👤',
gear: '⚙️',
trash: '🗑️',
x: '✕',
tree: '🌳',
map: '🗺️',
thermometer: '🌡️',
heat: '🌡️',
default: '●',
};

const iconName = node.props.name || 'default';
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wiremd",
"version": "0.1.8",
"version": "0.1.9",
"description": "Text-first UI design tool - Create wireframes and mockups using Markdown syntax",
"type": "module",
"sideEffects": false,
Expand Down
2 changes: 1 addition & 1 deletion src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function validate(ast: DocumentNode): ValidationError[] {
code: 'MISSING_CONTAINER_TYPE',
});
} else {
const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group'];
const validContainerTypes = ['hero', 'card', 'modal', 'sidebar', 'footer', 'alert', 'grid', 'layout', 'section', 'form-group', 'button-group', 'bottom-nav'];
if (!validContainerTypes.includes(node.containerType)) {
errors.push({
message: `Invalid containerType: "${node.containerType}". Must be one of: ${validContainerTypes.join(', ')}`,
Expand Down
13 changes: 9 additions & 4 deletions src/parser/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,16 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode
continue;
}

// Check if it starts with icon: :icon: Text
const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+)$/);
// Check if it starts with icon: :icon: Text (trailing * marks active)
const iconTextMatch = trimmed.match(/^:([a-z-]+):\s*(.+?)(\*)?$/);
if (iconTextMatch) {
const iconName = iconTextMatch[1];
const text = iconTextMatch[2];
let text = iconTextMatch[2].trim();
const isActive = Boolean(iconTextMatch[3]) || /^\*\*?(.+?)\*\*?$/.test(text);
if (isActive) {
const inner = text.match(/^\*\*?(.+?)\*\*?$/);
if (inner) text = inner[1];
}

// Create a brand node for :logo:, otherwise nav-item
const nodeType = iconName === 'logo' ? 'brand' : 'nav-item';
Expand All @@ -452,7 +457,7 @@ function transformInlineContainer(node: any, _options: ParseOptions): WiremdNode
{ type: 'icon', props: { name: iconName } },
{ type: 'text', content: text },
],
props: {},
props: isActive && nodeType === 'nav-item' ? { classes: ['active'] } : {},
});
continue;
}
Expand Down
159 changes: 64 additions & 95 deletions src/renderer/html-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type { WiremdNode } from '../types.js';
import { resolveIcon } from './icon-map.js';

export interface RenderContext {
style: string;
Expand Down Expand Up @@ -152,7 +153,15 @@ function renderButton(node: any, context: RenderContext): string {
function renderBadge(node: any, context: RenderContext): string {
const { classPrefix: prefix } = context;
const classes = buildClasses(prefix, 'badge', node.props);
return `<span class="${classes}">${escapeHtml(node.content)}</span>`;
const raw = String(node.content ?? '');
const isChip = (node.props?.classes || []).includes('chip');
// Removable chip: "|Label ×|{.chip}" — keep the × as a dismiss affordance.
const removable = isChip && /[×x]\s*$/u.test(raw);
if (removable) {
const label = raw.replace(/\s*[×x]\s*$/u, '').trim();
return `<span class="${classes} ${prefix}chip-removable" role="listitem">${escapeHtml(label)}<button type="button" class="${prefix}chip-dismiss" aria-label="Remove ${escapeHtml(label)}">×</button></span>`;
}
return `<span class="${classes}">${escapeHtml(raw)}</span>`;
}

function renderInput(node: any, context: RenderContext): string {
Expand Down Expand Up @@ -289,100 +298,7 @@ function renderIcon(node: any, context: RenderContext): string {
const { classPrefix: prefix } = context;
const classes = buildClasses(prefix, 'icon', node.props);
const iconName = node.props.name || 'default';

// Icon mapping - using Unicode symbols and emoji
const iconMap: Record<string, string> = {
// Social media
'twitter': '𝕏', // Twitter/X logo approximation
'github': '⊙', // GitHub-like symbol
'linkedin': 'in', // LinkedIn text representation
'facebook': 'f',
'instagram': '◉',
'youtube': '▶',

// Common UI icons
'home': '🏠',
'user': '👤',
'settings': '⚙️',
'search': '🔍',
'star': '⭐',
'heart': '❤️',
'mail': '✉️',
'phone': '📞',
'calendar': '📅',
'clock': '🕐',
'location': '📍',
'link': '🔗',
'download': '⬇️',
'upload': '⬆️',
'edit': '✏️',
'delete': '🗑️',
'plus': '➕',
'minus': '➖',
'check': '✓',
'close': '✕',
'menu': '☰',
'more': '⋯',
'info': 'ℹ️',
'warning': '⚠️',
'error': '❌',
'success': '✅',

// Arrows
'arrow-up': '↑',
'arrow-down': '↓',
'arrow-left': '←',
'arrow-right': '→',

// Business/Finance
'chart': '📊',
'dollar': '$',
'euro': '€',
'pound': '£',

// Tech
'code': '</>',
'database': '🗄️',
'cloud': '☁️',
'wifi': '📶',

// Communication
'chat': '💬',
'video': '🎥',
'microphone': '🎤',
'bell': '🔔',

// Files
'file': '📄',
'folder': '📁',
'image': '🖼️',
'document': '📃',
'pdf': '📑',

// Brand placeholders
'logo': '◈',
'brand': '◆',

// Activities
'rocket': '🚀',
'bulb': '💡',
'shield': '🛡️',
'lock': '🔒',
'unlock': '🔓',
'key': '🔑',
'gift': '🎁',
'trophy': '🏆',
'flag': '🚩',
'bookmark': '🔖',
'tag': '🏷️',
'cart': '🛒',
'credit-card': '💳',

// Default
'default': '●'
};

const iconContent = iconMap[iconName] || iconMap['default'];
const iconContent = resolveIcon(iconName);

// For social media icons, wrap in a styled span to make them look more icon-like
const socialIcons = ['twitter', 'github', 'linkedin', 'facebook', 'instagram', 'youtube'];
Expand All @@ -402,13 +318,66 @@ function renderContainer(node: any, context: RenderContext): string {
return renderSidebarMainLayout(node, context, classes);
}

if (node.containerType === 'bottom-nav') {
return renderBottomNav(node, context, classes);
}

const childrenHTML = (node.children || []).map((child: any) => renderNode(child, context)).join('\n ');

return `<div class="${classes}">
${childrenHTML}
</div>`;
}

/**
* Mobile tab bar. Prefer a nested `[[ :icon: Label | ... ]]` nav; otherwise
* render button / nav-item children directly.
*/
function renderBottomNav(node: any, context: RenderContext, classes: string): string {
const { classPrefix: prefix } = context;
const kids: any[] = node.children || [];
let items: any[] = [];
if (kids.length === 1 && kids[0].type === 'nav') {
items = kids[0].children || [];
} else {
for (const child of kids) {
if (child.type === 'nav') {
items.push(...(child.children || []));
} else if (child.type === 'nav-item' || child.type === 'button') {
items.push(child);
} else if (child.type === 'container' && child.containerType === 'button-group') {
items.push(...(child.children || []));
} else if (child.type === 'container' && child.containerType === 'section') {
for (const nested of child.children || []) {
if (nested.type === 'nav-item' || nested.type === 'button') items.push(nested);
else if (nested.type === 'nav') items.push(...(nested.children || []));
}
}
}
}

const itemsHTML = items
.map((item: any) => {
if (item.type === 'nav-item') return renderNavItem(item, context);
if (item.type === 'button') {
const btn = renderButton(item, context);
// Promote primary button to active tab affordance in the bar.
if ((item.props?.variant === 'primary') && !btn.includes(`${prefix}active`)) {
return btn.replace(`class="`, `class="${prefix}active `);
}
return btn;
}
return renderNode(item, context);
})
.join('\n ');

return `<nav class="${classes}" aria-label="Bottom navigation">
<div class="${prefix}bottom-nav-content">
${itemsHTML}
</div>
</nav>`;
}

function renderSidebarMainLayout(node: any, context: RenderContext, classes: string): string {
const { classPrefix: prefix } = context;
const children: any[] = node.children || [];
Expand Down
Loading