Skip to content
Merged
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
55 changes: 55 additions & 0 deletions examples/sidebar-layout.md
Original file line number Diff line number Diff line change
@@ -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)]

:::
231 changes: 85 additions & 146 deletions src/parser/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,143 +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++;
}

// Hoist col-span from heading text to grid-item wrapper
const headingContent = extractTextContent(childNode);
const colSpanMatch = headingContent.match(/\{[^}]*\.col-span-(\d+)[^}]*\}/);
const gridItemProps: any = { classes: [] };
if (colSpanMatch) {
gridItemProps.classes.push(`col-span-${colSpanMatch[1]}`);
}

// Add as grid item
gridItems.push({
type: 'grid-item',
props: gridItemProps,
children: gridItem,
});
} else if (
childNode.type === 'heading' &&
childNode.depth <= gridHeadingLevel
) {
// Same or higher level heading — end of grid section
break;
} else if (gridItems.length === 0) {
// Non-heading content before any items — silently skip
i++;
continue;
} else {
// Non-heading content after items — end of grid section
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),
};
}

Expand Down Expand Up @@ -256,35 +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: containerType as any,
props,
children,
children: processNodeList(node.children || [], options) as any,
};
}

Expand Down
36 changes: 36 additions & 0 deletions src/renderer/html-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,49 @@ 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 `<div class="${classes}">
${childrenHTML}
</div>`;
}

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 ` <div class="${prefix}layout-${s.name}">
${contentHTML}
</div>`;
}).join('\n');

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

function renderNav(node: any, context: RenderContext): string {
const { classPrefix: prefix } = context;
const classes = buildClasses(prefix, 'nav', node.props);
Expand Down
Loading