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
2 changes: 2 additions & 0 deletions packages/merman/src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js"
import { isMermaidFlowchartDiagram } from "./flowchart/parser.js"
import { isMermaidSequenceDiagram } from "./sequence/parser.js"
import { isMermaidStateDiagram } from "./state/parser.js"
import { isMermaidTimelineDiagram } from "./timeline/parser.js"

export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined {
if (isMermaidFlowchartDiagram(content)) return "flowchart"
if (isMermaidSequenceDiagram(content)) return "sequence"
if (isMermaidStateDiagram(content)) return "state"
if (isMermaidTimelineDiagram(content)) return "timeline"
return undefined
}
2 changes: 1 addition & 1 deletion packages/merman/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type MermaidDiagramKind = "flowchart" | "sequence" | "state"
export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline"

/** An otherwise valid diagram contains syntax that this renderer does not support. */
export class MermaidSyntaxError extends Error {
Expand Down
23 changes: 23 additions & 0 deletions packages/merman/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js"
import { parseMermaidStateDiagram } from "./state/parser.js"
import { renderStateGridStyledText } from "./state/render-grid.js"
import { resolveStateStyleColors } from "./state/style.js"
import { drawTimelineDiagramGrid } from "./timeline/drawing.js"
import { parseMermaidTimelineDiagram } from "./timeline/parser.js"
import { renderTimelineGridStyledText } from "./timeline/render-grid.js"
import { resolveTimelineStyleColors } from "./timeline/style.js"

type DiagramKind = NonNullable<ReturnType<typeof detectMermaidDiagram>>

Expand Down Expand Up @@ -180,6 +184,25 @@ function prepareDiagram(
height: size.height,
}
}
case "timeline": {
const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source))
const size = grid.getTextSize({ trimBottom: true })
return {
kind,
source,
text: renderTimelineGridStyledText(
grid,
resolveTimelineStyleColors({
title: color(colors.text),
section: color(colors.secondary),
period: color(colors.warning),
spine: color(colors.muted),
event: color(colors.primary),
}),
),
height: size.height,
}
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/merman/src/test/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js"
import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js"
import { parseMermaidSequenceDiagram } from "../sequence/parser.js"
import { parseMermaidStateDiagram } from "../state/parser.js"
import { renderTimelineDiagram } from "../timeline/diagram.js"
import { renderSequenceDiagram } from "../sequence/diagram.js"

describe("parser diagnostics", () => {
Expand Down Expand Up @@ -104,6 +105,12 @@ describe("parser diagnostics", () => {
).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"')
})

test("reports malformed timeline continuations with timeline diagnostics", () => {
expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})

test("does not attach else through an unclosed nested sequence block", () => {
expect(() =>
parseMermaidSequenceDiagram(`sequenceDiagram
Expand Down
28 changes: 28 additions & 0 deletions packages/merman/src/test/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,31 @@ stateDiagram-v2
expect(frame).toContain("Idle")
expect(frame).not.toContain("stateDiagram-v2")
})

test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-timeline",
content: `\`\`\`mermaid
timeline
title Product history
section Foundation
2024 : Prototype
: First release
\`\`\``,
syntaxStyle,
treeSitterClient,
renderNode: createMermaidMarkdownRenderer(renderer),
})

renderer.root.add(markdown)
await renderMarkdown(markdown, renderOnce)

const frame = captureCharFrame()
expect(frame).toContain("Product history")
expect(frame).toContain("Foundation")
expect(frame).toContain("First release")
expect(frame).not.toContain("timeline")
})
188 changes: 188 additions & 0 deletions packages/merman/src/timeline/diagram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, expect, test } from "bun:test"
import { renderTimelineDiagram } from "./diagram.js"
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import { resolveTimelineStyleColors } from "./style.js"

describe("TimelineDiagram", () => {
test("detects and parses titles, sections, periods, inline events, and continuations", () => {
const diagram = parseMermaidTimelineDiagram(`
%% product history
timeline LR
title Product &amp;<br/>Platform

section Foundation
2024 : Prototype : First release
: Public beta
section Growth
2025 : "Scale: &#x2265; 10k"
`)

expect(diagram.direction).toBe("LR")
expect(diagram.title).toBe("Product &<br/>Platform")
expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }])
expect(diagram.periods).toEqual([
{ period: "2024", events: ["Prototype", "First release", "Public beta"] },
{ period: "2025", events: ["Scale: ≥ 10k"] },
])
expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"])
})

test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => {
const output = renderTimelineDiagram(`timeline
title Product &amp;<br/>Platform
section Foundation<br/>phase
2024 : Prototype<br/>ready : First release
: Scale &#x2265; 10k`)

expect(output).toBe(
[
" Product &",
" Platform",
"",
"Foundation ───┐",
" phase │",
" │",
" 2024 ───● Prototype",
" │ ready",
" │ First release",
" │ Scale ≥ 10k",
" │",
].join("\n"),
)
})

test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => {
const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`)
const lines = output.split("\n")

expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan(
lines.findIndex((line) => line.includes("2025")),
)
expect(output).toContain("│")
expect(output).toContain("●")
})

test("preserves Mermaid direction semantics while using vertical terminal layout", () => {
expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR")
expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD")
})

test("keeps ordinary colons in event text", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024 : https://example.com : event:detail : next event`)

expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"])
})

test("does not treat apostrophes in event prose as quotes", () => {
const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta")

expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"])
})

test("supports standalone periods followed by continuation events", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
2024
: First release
: Public beta`)

expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }])
})

test("ignores timeline comments and accessibility directives", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
# product history
accTitle: Product timeline
accDescr Product release history
2024 : Prototype %% internal note`)

expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }])
})

test("ignores multiline accessibility descriptions", () => {
const diagram = parseMermaidTimelineDiagram(`timeline
accDescr {
Product milestones by year.
Includes launch and growth.
}
2024 : Ship`)

expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }])
})

test("rejects a continuation without a period with source diagnostics", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow(
'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"',
)
})

test("rejects unsupported and empty syntax", () => {
expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty")
expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period")
})

test("draws semantic styles for every timeline role", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean)))

expect(styles).toEqual(
new Set([
"title",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"event",
]),
)
expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([
"event",
"period",
"periodFade1",
"periodFade2",
"periodFade3",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
"title",
])
expect(renderTimelineGridText(grid)).toBe(
renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"),
)
})

test("uses section starts and joins with ordered color ramps", () => {
const grid = drawTimelineDiagramGrid(
parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"),
)
const text = renderTimelineGridText(grid)

expect(text).toContain("Morning ───┐")
expect(text).toContain("Midday ───┤")
expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([
"section",
"section",
"section",
"section",
"section",
"section",
"section",
"sectionFade1",
"sectionFade2",
"sectionFade3",
"spine",
])
})
})
8 changes: 8 additions & 0 deletions packages/merman/src/timeline/diagram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { drawTimelineDiagramGrid } from "./drawing.js"
import { parseMermaidTimelineDiagram } from "./parser.js"
import { renderTimelineGridText } from "./render-grid.js"
import type { TimelineDiagramRenderOptions } from "./types.js"

export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string {
return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options))
}
Loading
Loading