opentui-markdown-render 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +223 -2
  4. package/bin/otmd.tsx +99 -0
  5. package/native/Cargo.lock +3708 -0
  6. package/native/Cargo.toml +28 -0
  7. package/native/build.rs +3 -0
  8. package/native/src/lib.rs +880 -0
  9. package/package.json +67 -4
  10. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  11. package/src/app.tsx +180 -0
  12. package/src/cli.ts +63 -0
  13. package/src/config/index.ts +46 -0
  14. package/src/config/load.ts +337 -0
  15. package/src/config/resolve.ts +36 -0
  16. package/src/config/theme.ts +110 -0
  17. package/src/config/types.ts +105 -0
  18. package/src/core/document.ts +44 -0
  19. package/src/core/frame.ts +144 -0
  20. package/src/core/image.ts +9 -0
  21. package/src/core/index.ts +6 -0
  22. package/src/core/layout.ts +27 -0
  23. package/src/core/math.ts +170 -0
  24. package/src/core/parse.ts +208 -0
  25. package/src/core/tables.ts +58 -0
  26. package/src/graphics.ts +85 -0
  27. package/src/headings.ts +77 -0
  28. package/src/index.ts +107 -0
  29. package/src/math/index.ts +86 -0
  30. package/src/math/native.ts +39 -0
  31. package/src/math/renderer.ts +84 -0
  32. package/src/math/validate.ts +44 -0
  33. package/src/media.tsx +267 -0
  34. package/src/merman/class/dagre-layout.ts +171 -0
  35. package/src/merman/class/diagram.ts +8 -0
  36. package/src/merman/class/drawing.ts +343 -0
  37. package/src/merman/class/labels.ts +38 -0
  38. package/src/merman/class/layout.ts +531 -0
  39. package/src/merman/class/parser.ts +308 -0
  40. package/src/merman/class/render-grid.ts +18 -0
  41. package/src/merman/class/style.ts +27 -0
  42. package/src/merman/class/types.ts +57 -0
  43. package/src/merman/core/canvas.ts +228 -0
  44. package/src/merman/core/color/style.ts +56 -0
  45. package/src/merman/core/drawing.ts +307 -0
  46. package/src/merman/core/geometry.ts +347 -0
  47. package/src/merman/core/mermaid.ts +38 -0
  48. package/src/merman/core/render-grid.ts +51 -0
  49. package/src/merman/core/spatial.ts +243 -0
  50. package/src/merman/core/text-lines.ts +32 -0
  51. package/src/merman/core/text.ts +44 -0
  52. package/src/merman/detect.ts +19 -0
  53. package/src/merman/diagnostics.ts +16 -0
  54. package/src/merman/diagram.ts +268 -0
  55. package/src/merman/flowchart/drawing.ts +433 -0
  56. package/src/merman/flowchart/labels.ts +144 -0
  57. package/src/merman/flowchart/layout.ts +1057 -0
  58. package/src/merman/flowchart/options.ts +12 -0
  59. package/src/merman/flowchart/parser.ts +477 -0
  60. package/src/merman/flowchart/render.ts +10 -0
  61. package/src/merman/flowchart/routing.ts +1429 -0
  62. package/src/merman/flowchart/style.ts +79 -0
  63. package/src/merman/flowchart/types.ts +63 -0
  64. package/src/merman/gantt/diagram.ts +8 -0
  65. package/src/merman/gantt/drawing.ts +193 -0
  66. package/src/merman/gantt/parser.ts +198 -0
  67. package/src/merman/gantt/render-grid.ts +17 -0
  68. package/src/merman/gantt/style.ts +36 -0
  69. package/src/merman/gantt/types.ts +45 -0
  70. package/src/merman/gitgraph/diagram.ts +8 -0
  71. package/src/merman/gitgraph/drawing.ts +234 -0
  72. package/src/merman/gitgraph/parser.ts +234 -0
  73. package/src/merman/gitgraph/render-grid.ts +17 -0
  74. package/src/merman/gitgraph/style.ts +37 -0
  75. package/src/merman/gitgraph/types.ts +36 -0
  76. package/src/merman/palette.ts +60 -0
  77. package/src/merman/sequence/diagram.ts +8 -0
  78. package/src/merman/sequence/drawing.ts +288 -0
  79. package/src/merman/sequence/options.ts +8 -0
  80. package/src/merman/sequence/parser.ts +303 -0
  81. package/src/merman/sequence/placement.ts +692 -0
  82. package/src/merman/sequence/render-grid.ts +22 -0
  83. package/src/merman/sequence/style.ts +81 -0
  84. package/src/merman/sequence/types.ts +74 -0
  85. package/src/merman/state/diagram.ts +8 -0
  86. package/src/merman/state/drawing.ts +363 -0
  87. package/src/merman/state/endpoint.ts +15 -0
  88. package/src/merman/state/layout.ts +1134 -0
  89. package/src/merman/state/note.ts +59 -0
  90. package/src/merman/state/options.ts +11 -0
  91. package/src/merman/state/parser.ts +245 -0
  92. package/src/merman/state/render-grid.ts +22 -0
  93. package/src/merman/state/routing.ts +1522 -0
  94. package/src/merman/state/search.ts +103 -0
  95. package/src/merman/state/style.ts +52 -0
  96. package/src/merman/state/types.ts +63 -0
  97. package/src/merman/state/visible-model.ts +77 -0
  98. package/src/merman/timeline/diagram.ts +8 -0
  99. package/src/merman/timeline/drawing.ts +109 -0
  100. package/src/merman/timeline/parser.ts +119 -0
  101. package/src/merman/timeline/render-grid.ts +17 -0
  102. package/src/merman/timeline/style.ts +36 -0
  103. package/src/merman/timeline/types.ts +31 -0
  104. package/src/merman.ts +3 -0
  105. package/src/statusline.tsx +183 -0
  106. package/src/theme/colors.ts +11 -0
  107. package/src/theme/defaults.ts +190 -0
  108. package/src/theme/index.ts +7 -0
  109. package/src/theme/load.ts +39 -0
  110. package/src/theme/resolve.ts +167 -0
  111. package/src/theme/syntax.ts +96 -0
  112. package/src/theme/types.ts +151 -0
  113. package/src/view/code.tsx +27 -0
  114. package/src/view/document-slots.ts +111 -0
  115. package/src/view/document-table.tsx +98 -0
  116. package/src/view/document.tsx +195 -0
  117. package/src/view/frame.tsx +83 -0
  118. package/src/view/inline-flow.tsx +113 -0
  119. package/src/view/inline-layout.ts +149 -0
  120. package/src/view/inline-math.ts +42 -0
  121. package/src/view/inline-style.ts +103 -0
  122. package/src/view/math.tsx +85 -0
  123. package/src/view/mermaid.tsx +128 -0
  124. package/src/view/table.ts +100 -0
  125. package/themes/dark.json +32 -0
  126. package/themes/example.jsonc +98 -0
  127. package/themes/light.json +32 -0
  128. package/themes/solarized-dark.json +32 -0
  129. package/themes/solarized-light.json +32 -0
@@ -0,0 +1,103 @@
1
+ import type { DiagramPoint } from "../core/geometry.js"
2
+ import type { SpatialIndex, SpatialRole } from "../core/spatial.js"
3
+
4
+ const MAX_RENDER_SEARCH_VISITS = 250_000
5
+
6
+ export interface StateSearchBudget {
7
+ remaining: number
8
+ }
9
+
10
+ export interface StateSearchSpace {
11
+ blocked: ReadonlySet<string>
12
+ minX: number
13
+ minY: number
14
+ maxX: number
15
+ maxY: number
16
+ }
17
+
18
+ export function createStateSearchBudget(): StateSearchBudget {
19
+ return { remaining: MAX_RENDER_SEARCH_VISITS }
20
+ }
21
+
22
+ export function createStateSearchSpace(
23
+ space: SpatialIndex,
24
+ clearance: (role: SpatialRole) => number = () => 0,
25
+ ): StateSearchSpace {
26
+ const blocked = new Set<string>()
27
+ let minX = 0
28
+ let minY = 0
29
+ let maxX = 0
30
+ let maxY = 0
31
+
32
+ for (const claim of space.claims) {
33
+ const padding = clearance(claim.role)
34
+ for (const span of claim.spans) {
35
+ for (let y = span.y - padding; y <= span.y + padding; y++) {
36
+ for (let x = span.fromX - padding; x <= span.toX + padding; x++) {
37
+ blocked.add(`${x}:${y}`)
38
+ minX = Math.min(minX, x)
39
+ minY = Math.min(minY, y)
40
+ maxX = Math.max(maxX, x)
41
+ maxY = Math.max(maxY, y)
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ return { blocked, minX, minY, maxX, maxY }
48
+ }
49
+
50
+ export function findStateManhattanPath(
51
+ starts: readonly DiagramPoint[],
52
+ goal: DiagramPoint,
53
+ space: Pick<StateSearchSpace, "blocked">,
54
+ bounds: Pick<StateSearchSpace, "minX" | "minY" | "maxX" | "maxY">,
55
+ budget: StateSearchBudget,
56
+ isFree: (point: DiagramPoint) => boolean = (point) => !space.blocked.has(pointKey(point)),
57
+ ): DiagramPoint[] | undefined {
58
+ const queue = [...new Map(starts.filter(isFree).map((point) => [pointKey(point), point])).values()]
59
+ const parents = new Map(queue.map((point) => [pointKey(point), undefined as string | undefined]))
60
+ const points = new Map(queue.map((point) => [pointKey(point), point]))
61
+
62
+ for (let cursor = 0; cursor < queue.length && budget.remaining > 0; cursor++, budget.remaining--) {
63
+ const current = queue[cursor]!
64
+ if (current.x === goal.x && current.y === goal.y) break
65
+ const dx = Math.sign(goal.x - current.x)
66
+ const dy = Math.sign(goal.y - current.y)
67
+ const candidates = [
68
+ ...(dx === 0 ? [] : [{ x: current.x + dx, y: current.y }]),
69
+ ...(dy === 0 ? [] : [{ x: current.x, y: current.y + dy }]),
70
+ { x: current.x + 1, y: current.y },
71
+ { x: current.x, y: current.y + 1 },
72
+ { x: current.x - 1, y: current.y },
73
+ { x: current.x, y: current.y - 1 },
74
+ ]
75
+ for (const candidate of candidates) {
76
+ if (
77
+ candidate.x < bounds.minX ||
78
+ candidate.x > bounds.maxX ||
79
+ candidate.y < bounds.minY ||
80
+ candidate.y > bounds.maxY
81
+ )
82
+ continue
83
+ const key = pointKey(candidate)
84
+ if (parents.has(key) || !isFree(candidate)) continue
85
+ parents.set(key, pointKey(current))
86
+ points.set(key, candidate)
87
+ queue.push(candidate)
88
+ }
89
+ }
90
+
91
+ if (!parents.has(pointKey(goal))) return undefined
92
+ const path: DiagramPoint[] = []
93
+ let cursor: string | undefined = pointKey(goal)
94
+ while (cursor) {
95
+ path.push(points.get(cursor)!)
96
+ cursor = parents.get(cursor)
97
+ }
98
+ return path.reverse()
99
+ }
100
+
101
+ function pointKey(point: DiagramPoint): string {
102
+ return `${point.x}:${point.y}`
103
+ }
@@ -0,0 +1,52 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { createColorRampTheme, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js"
3
+ import type { BaseStateCellStyle, NoteConnectorRampStyle, StateCellStyle, StateDepartureRampStyle } from "./types.js"
4
+
5
+ const DEFAULT_THEME_RGB = {
6
+ state: [228, 239, 232],
7
+ stateBorder: [228, 239, 232],
8
+ composite: [111, 138, 126],
9
+ compositeLabel: [111, 138, 126],
10
+ transition: [134, 225, 200],
11
+ label: [134, 225, 200],
12
+ noteBorder: [141, 169, 155],
13
+ noteText: [215, 229, 221],
14
+ noteConnector: [141, 169, 155],
15
+ start: [134, 225, 200],
16
+ end: [230, 177, 126],
17
+ choice: [134, 225, 200],
18
+ } as const satisfies Record<BaseStateCellStyle, DiagramRgb>
19
+ const STATE_RAMP_STEPS = [1, 2, 3] as const
20
+ export const NOTE_CONNECTOR_RAMP_STYLES = numberedStyleKeys(
21
+ "noteConnectorRamp",
22
+ STATE_RAMP_STEPS,
23
+ ) satisfies readonly NoteConnectorRampStyle[]
24
+ export const STATE_DEPARTURE_RAMP_STYLES = numberedStyleKeys(
25
+ "stateDepartureRamp",
26
+ STATE_RAMP_STEPS,
27
+ ) satisfies readonly StateDepartureRampStyle[]
28
+ export type StateStyleColors = Required<Record<StateCellStyle, RGBA>>
29
+
30
+ export function resolveStateStyleColors(
31
+ colors: Partial<Record<BaseStateCellStyle, RGBA | undefined>> = {},
32
+ ): StateStyleColors {
33
+ const resolved = {
34
+ state: colors.state ?? rgba(DEFAULT_THEME_RGB.state),
35
+ stateBorder: colors.stateBorder ?? colors.state ?? rgba(DEFAULT_THEME_RGB.stateBorder),
36
+ composite: colors.composite ?? rgba(DEFAULT_THEME_RGB.composite),
37
+ compositeLabel: colors.compositeLabel ?? colors.composite ?? rgba(DEFAULT_THEME_RGB.compositeLabel),
38
+ transition: colors.transition ?? rgba(DEFAULT_THEME_RGB.transition),
39
+ label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
40
+ noteBorder: colors.noteBorder ?? rgba(DEFAULT_THEME_RGB.noteBorder),
41
+ noteText: colors.noteText ?? rgba(DEFAULT_THEME_RGB.noteText),
42
+ noteConnector: colors.noteConnector ?? rgba(DEFAULT_THEME_RGB.noteConnector),
43
+ start: colors.start ?? rgba(DEFAULT_THEME_RGB.start),
44
+ end: colors.end ?? rgba(DEFAULT_THEME_RGB.end),
45
+ choice: colors.choice ?? rgba(DEFAULT_THEME_RGB.choice),
46
+ }
47
+ return {
48
+ ...resolved,
49
+ ...createColorRampTheme(NOTE_CONNECTOR_RAMP_STYLES, resolved.noteConnector, resolved.noteBorder),
50
+ ...createColorRampTheme(STATE_DEPARTURE_RAMP_STYLES, resolved.stateBorder, resolved.transition),
51
+ }
52
+ }
@@ -0,0 +1,63 @@
1
+ import type { BorderStyle } from "@opentui/core"
2
+
3
+ export type StateDiagramDirection = "TB" | "TD" | "BT" | "LR" | "RL"
4
+ export type StateDiagramArrowHeadStyle = "filled" | "line"
5
+
6
+ export interface StateDiagramState {
7
+ id: string
8
+ label: string
9
+ kind: "state" | "start" | "end" | "choice" | "fork" | "join"
10
+ parentId?: string
11
+ }
12
+
13
+ export interface StateDiagramTransition {
14
+ from: string
15
+ to: string
16
+ label: string
17
+ }
18
+
19
+ export interface StateDiagramCompositeState {
20
+ id: string
21
+ label: string
22
+ parentId?: string
23
+ }
24
+
25
+ export interface StateDiagramNote {
26
+ target: string
27
+ position: "left" | "right"
28
+ lines: string[]
29
+ }
30
+
31
+ export interface StateDiagram {
32
+ direction: StateDiagramDirection
33
+ states: StateDiagramState[]
34
+ transitions: StateDiagramTransition[]
35
+ composites: StateDiagramCompositeState[]
36
+ notes: StateDiagramNote[]
37
+ }
38
+
39
+ export interface StateDiagramRenderOptions {
40
+ direction?: StateDiagramDirection
41
+ borderStyle?: BorderStyle
42
+ arrowHeadStyle?: StateDiagramArrowHeadStyle
43
+ minStateGap?: number
44
+ /** Target rendered width. Oversized horizontal layouts fold vertically. */
45
+ layoutMaxWidth?: number
46
+ }
47
+
48
+ export type NoteConnectorRampStyle = `noteConnectorRamp${1 | 2 | 3}`
49
+ export type StateDepartureRampStyle = `stateDepartureRamp${1 | 2 | 3}`
50
+ export type BaseStateCellStyle =
51
+ | "state"
52
+ | "stateBorder"
53
+ | "composite"
54
+ | "compositeLabel"
55
+ | "transition"
56
+ | "label"
57
+ | "noteBorder"
58
+ | "noteText"
59
+ | "noteConnector"
60
+ | "start"
61
+ | "end"
62
+ | "choice"
63
+ export type StateCellStyle = BaseStateCellStyle | NoteConnectorRampStyle | StateDepartureRampStyle
@@ -0,0 +1,77 @@
1
+ import type { StateDiagram, StateDiagramState, StateDiagramTransition } from "./types.js"
2
+
3
+ export type StateVisibleTransition = StateDiagramTransition
4
+
5
+ export interface StateVisibleDiagram extends Omit<StateDiagram, "transitions"> {
6
+ transitions: StateVisibleTransition[]
7
+ }
8
+
9
+ export function isHiddenCompositeMarker(state: StateDiagramState | undefined): boolean {
10
+ return Boolean(state?.parentId && (state.kind === "start" || state.kind === "end"))
11
+ }
12
+
13
+ function composeTransitionLabel(incoming: StateDiagramTransition, outgoing: StateDiagramTransition): string {
14
+ return [incoming.label, outgoing.label].filter(Boolean).join("<br/>")
15
+ }
16
+
17
+ function collapseHiddenCompositeMarkerTransitionsOnce(
18
+ transitions: readonly StateVisibleTransition[],
19
+ statesById: ReadonlyMap<string, StateDiagramState>,
20
+ ): { transitions: StateVisibleTransition[]; changed: boolean } {
21
+ const hiddenMarkers = new Set(
22
+ [...statesById.values()].filter((state) => isHiddenCompositeMarker(state)).map((state) => state.id),
23
+ )
24
+ if (hiddenMarkers.size === 0) return { transitions: [...transitions], changed: false }
25
+
26
+ for (const markerId of hiddenMarkers) {
27
+ const incoming = transitions.filter((transition) => transition.to === markerId && transition.from !== markerId)
28
+ const outgoing = transitions.filter((transition) => transition.from === markerId && transition.to !== markerId)
29
+ if (incoming.length === 0 || outgoing.length === 0) continue
30
+
31
+ const skipped = new Set([...incoming, ...outgoing])
32
+ return {
33
+ transitions: [
34
+ ...transitions.filter((transition) => !skipped.has(transition)),
35
+ ...incoming.flatMap((incomingTransition) =>
36
+ outgoing.map((outgoingTransition) => ({
37
+ from: incomingTransition.from,
38
+ to: outgoingTransition.to,
39
+ label: composeTransitionLabel(incomingTransition, outgoingTransition),
40
+ })),
41
+ ),
42
+ ],
43
+ changed: true,
44
+ }
45
+ }
46
+
47
+ return { transitions: [...transitions], changed: false }
48
+ }
49
+
50
+ function collapseHiddenCompositeMarkerTransitions(diagram: StateDiagram): StateVisibleTransition[] {
51
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
52
+ let transitions: StateVisibleTransition[] = diagram.transitions.map((transition) => ({ ...transition }))
53
+
54
+ while (true) {
55
+ const result = collapseHiddenCompositeMarkerTransitionsOnce(transitions, statesById)
56
+ transitions = result.transitions
57
+ if (!result.changed) return transitions
58
+ }
59
+ }
60
+
61
+ export function prepareVisibleStateDiagram(diagram: StateDiagram): StateVisibleDiagram {
62
+ const transitions = collapseHiddenCompositeMarkerTransitions(diagram)
63
+ const referencedHiddenMarkers = new Set<string>()
64
+ const statesById = new Map(diagram.states.map((state) => [state.id, state]))
65
+ for (const transition of transitions) {
66
+ const from = statesById.get(transition.from)
67
+ const to = statesById.get(transition.to)
68
+ if (from && isHiddenCompositeMarker(from)) referencedHiddenMarkers.add(from.id)
69
+ if (to && isHiddenCompositeMarker(to)) referencedHiddenMarkers.add(to.id)
70
+ }
71
+
72
+ return {
73
+ ...diagram,
74
+ states: diagram.states.filter((state) => !isHiddenCompositeMarker(state) || referencedHiddenMarkers.has(state.id)),
75
+ transitions,
76
+ }
77
+ }
@@ -0,0 +1,8 @@
1
+ import { drawTimelineDiagramGrid } from "./drawing.js"
2
+ import { parseMermaidTimelineDiagram } from "./parser.js"
3
+ import { renderTimelineGridText } from "./render-grid.js"
4
+ import type { TimelineDiagramRenderOptions } from "./types.js"
5
+
6
+ export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string {
7
+ return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options))
8
+ }
@@ -0,0 +1,109 @@
1
+ import { DiagramCanvas } from "../core/canvas.js"
2
+ import { splitDiagramLines } from "../core/text-lines.js"
3
+ import { diagramTextWidth } from "../core/text.js"
4
+ import type { TimelineGrid } from "./render-grid.js"
5
+ import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js"
6
+ import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js"
7
+
8
+ interface PeriodLayout {
9
+ period: TimelinePeriod
10
+ periodLines: string[]
11
+ eventLines: string[][]
12
+ height: number
13
+ }
14
+
15
+ const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length
16
+ const SPINE_OFFSET = JOIN_WIDTH + 1
17
+ const EVENT_OFFSET = 3
18
+
19
+ export function drawTimelineDiagramGrid(
20
+ diagram: TimelineDiagram,
21
+ _options: TimelineDiagramRenderOptions = {},
22
+ ): TimelineGrid {
23
+ const periodLayouts = new Map<TimelinePeriod, PeriodLayout>()
24
+ let leftWidth = 0
25
+ let rightWidth = 0
26
+ let bodyHeight = 0
27
+
28
+ for (const entry of diagram.entries) {
29
+ if (entry.type === "section") {
30
+ const lines = splitDiagramLines(entry.section.label)
31
+ bodyHeight += lines.length + 1
32
+ for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
33
+ continue
34
+ }
35
+ const periodLines = splitDiagramLines(entry.period.period)
36
+ const eventLines = entry.period.events.map(splitDiagramLines)
37
+ const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0)
38
+ const height = Math.max(periodLines.length, eventHeight)
39
+ periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height })
40
+ for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line))
41
+ for (const lines of eventLines) {
42
+ for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line))
43
+ }
44
+ bodyHeight += height + 1
45
+ }
46
+
47
+ const titleLines = diagram.title ? splitDiagramLines(diagram.title) : []
48
+ const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1
49
+ let titleWidth = 0
50
+ for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line))
51
+ const width = Math.max(bodyWidth, titleWidth)
52
+ const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1)
53
+ if (width === 0) return new DiagramCanvas(0, 0)
54
+
55
+ const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight)
56
+ titleLines.forEach((line, index) =>
57
+ setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"),
58
+ )
59
+ if (diagram.entries.length === 0) return grid
60
+
61
+ const spineX = leftWidth + SPINE_OFFSET
62
+ let y = titleHeight
63
+ let railStarted = false
64
+ for (const entry of diagram.entries) {
65
+ if (entry.type === "section") {
66
+ const lines = splitDiagramLines(entry.section.label)
67
+ lines.forEach((line, index) => {
68
+ setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section")
69
+ if (index > 0) setCell(grid, spineX, y + index, "│", "spine")
70
+ })
71
+ drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES)
72
+ setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine")
73
+ setCell(grid, spineX, y + lines.length, "│", "spine")
74
+ railStarted = true
75
+ y += lines.length + 1
76
+ continue
77
+ }
78
+
79
+ const layout = periodLayouts.get(entry.period)!
80
+ for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine")
81
+ railStarted = true
82
+ setCell(grid, spineX, y, "●", "spine")
83
+ layout.periodLines.forEach((line, index) => {
84
+ const lineWidth = diagramTextWidth(line)
85
+ setText(grid, leftWidth - lineWidth, y + index, line, "period")
86
+ })
87
+ drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES)
88
+
89
+ let eventY = y
90
+ for (const lines of layout.eventLines) {
91
+ lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event"))
92
+ eventY += lines.length
93
+ }
94
+ y += layout.height + 1
95
+ }
96
+ return grid
97
+ }
98
+
99
+ function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void {
100
+ styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style))
101
+ }
102
+
103
+ function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void {
104
+ grid.setCell(x, y, char, style)
105
+ }
106
+
107
+ function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void {
108
+ grid.setText(x, y, text, style)
109
+ }
@@ -0,0 +1,119 @@
1
+ import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
2
+ import { MermaidSyntaxError } from "../diagnostics.js"
3
+ import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js"
4
+
5
+ const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i
6
+ const TITLE_RE = /^title(?:\s+(.+))?$/i
7
+ const SECTION_RE = /^section(?:\s+(.+))?$/i
8
+ const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
9
+
10
+ export function isMermaidTimelineDiagram(content: string): boolean {
11
+ return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
12
+ }
13
+
14
+ export function parseMermaidTimelineDiagram(content: string): TimelineDiagram {
15
+ const sections: TimelineSection[] = []
16
+ const periods: TimelinePeriod[] = []
17
+ const entries: TimelineEntry[] = []
18
+ let direction: TimelineDirection = "LR"
19
+ let title: string | undefined
20
+ let currentPeriod: TimelinePeriod | undefined
21
+ let inAccessibilityDescription = false
22
+
23
+ for (const source of meaningfulNumberedMermaidLines(content)) {
24
+ const line = stripTimelineComment(source.text)
25
+ if (inAccessibilityDescription) {
26
+ if (line === "}") inAccessibilityDescription = false
27
+ continue
28
+ }
29
+ if (/^accDescr\s*\{$/i.test(line)) {
30
+ inAccessibilityDescription = true
31
+ continue
32
+ }
33
+ if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue
34
+ const header = line.match(HEADER_RE)
35
+ if (header) {
36
+ direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR"
37
+ continue
38
+ }
39
+
40
+ const titleMatch = line.match(TITLE_RE)
41
+ if (titleMatch) {
42
+ if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty")
43
+ title = stripMermaidQuotes(titleMatch[1])
44
+ continue
45
+ }
46
+
47
+ const sectionMatch = line.match(SECTION_RE)
48
+ if (sectionMatch) {
49
+ if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty")
50
+ const section = { label: stripMermaidQuotes(sectionMatch[1]) }
51
+ sections.push(section)
52
+ entries.push({ type: "section", section })
53
+ currentPeriod = undefined
54
+ continue
55
+ }
56
+
57
+ if (line.startsWith(":")) {
58
+ if (!currentPeriod) {
59
+ throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period")
60
+ }
61
+ currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line))
62
+ continue
63
+ }
64
+
65
+ const fields = splitEventFields(line)
66
+ const periodLabel = stripMermaidQuotes(fields.shift()!)
67
+ if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty")
68
+ const period = {
69
+ period: periodLabel,
70
+ events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line),
71
+ }
72
+ periods.push(period)
73
+ entries.push({ type: "period", period })
74
+ currentPeriod = period
75
+ }
76
+
77
+ return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries }
78
+ }
79
+
80
+ function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] {
81
+ return parseEventFields(splitEventFields(value), lineNumber, sourceLine)
82
+ }
83
+
84
+ function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] {
85
+ const events = fields.map(stripMermaidQuotes)
86
+ if (events.length === 0 || events.some((event) => event.length === 0)) {
87
+ throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty")
88
+ }
89
+ return events
90
+ }
91
+
92
+ function splitEventFields(value: string): string[] {
93
+ const fields: string[] = []
94
+ let quote: '"' | "'" | undefined
95
+ let start = 0
96
+ for (let index = 0; index < value.length; index++) {
97
+ const char = value[index]
98
+ if (char === '"' || char === "'") {
99
+ if (quote === char) quote = undefined
100
+ else if (quote === undefined && value.slice(start, index).trim() === "") quote = char
101
+ continue
102
+ }
103
+ const next = value[index + 1]
104
+ if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue
105
+ fields.push(value.slice(start, index))
106
+ start = index + 1
107
+ }
108
+ fields.push(value.slice(start))
109
+ return fields
110
+ }
111
+
112
+ function stripTimelineComment(value: string): string {
113
+ const comment = value.indexOf("%%")
114
+ return (comment < 0 ? value : value.slice(0, comment)).trim()
115
+ }
116
+
117
+ function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
118
+ return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason)
119
+ }
@@ -0,0 +1,17 @@
1
+ import type { StyledText } from "@opentui/core"
2
+ import type { DiagramCanvas } from "../core/canvas.js"
3
+ import { renderDiagramGridStyledText } from "../core/render-grid.js"
4
+ import type { TimelineStyleColors } from "./style.js"
5
+ import type { TimelineCellStyle } from "./types.js"
6
+
7
+ export type TimelineGrid = DiagramCanvas<TimelineCellStyle>
8
+
9
+ export function renderTimelineGridText(grid: TimelineGrid): string {
10
+ return grid.toString({ trimBottom: true })
11
+ }
12
+
13
+ export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText {
14
+ return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, {
15
+ trimBottom: true,
16
+ })
17
+ }
@@ -0,0 +1,36 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js"
3
+ import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js"
4
+
5
+ const DEFAULT_THEME_RGB = {
6
+ title: [228, 239, 232],
7
+ section: [154, 184, 169],
8
+ period: [230, 177, 126],
9
+ spine: [111, 138, 126],
10
+ event: [134, 225, 200],
11
+ } as const satisfies Record<TimelineBaseCellStyle, DiagramRgb>
12
+
13
+ export type TimelineStyleColors = Required<Record<TimelineCellStyle, RGBA>>
14
+ export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const)
15
+ export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const)
16
+
17
+ export function resolveTimelineStyleColors(
18
+ colors: Partial<Record<TimelineBaseCellStyle, RGBA | undefined>> = {},
19
+ ): TimelineStyleColors {
20
+ const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section)
21
+ const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period)
22
+ const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine)
23
+ return {
24
+ title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
25
+ section,
26
+ period,
27
+ spine,
28
+ event: colors.event ?? rgba(DEFAULT_THEME_RGB.event),
29
+ sectionFade1: blendColor(section, spine, 0.5),
30
+ sectionFade2: blendColor(section, spine, 0.67),
31
+ sectionFade3: blendColor(section, spine, 0.83),
32
+ periodFade1: blendColor(period, spine, 0.5),
33
+ periodFade2: blendColor(period, spine, 0.67),
34
+ periodFade3: blendColor(period, spine, 0.83),
35
+ }
36
+ }
@@ -0,0 +1,31 @@
1
+ export type TimelineDirection = "TD" | "LR"
2
+
3
+ export interface TimelineSection {
4
+ label: string
5
+ }
6
+
7
+ export interface TimelinePeriod {
8
+ period: string
9
+ events: string[]
10
+ }
11
+
12
+ export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod }
13
+
14
+ export interface TimelineDiagram {
15
+ direction: TimelineDirection
16
+ title?: string
17
+ sections: TimelineSection[]
18
+ periods: TimelinePeriod[]
19
+ entries: TimelineEntry[]
20
+ }
21
+
22
+ export interface TimelineDiagramRenderOptions {
23
+ /** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */
24
+ direction?: TimelineDirection
25
+ }
26
+
27
+ export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event"
28
+ export type TimelineFadeStep = 1 | 2 | 3
29
+ export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}`
30
+ export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}`
31
+ export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle
package/src/merman.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { detectMermaidDiagram } from "./merman/detect"
2
+ export { MermaidSyntaxError, type MermaidDiagramKind } from "./merman/diagnostics"
3
+ export { createOpenCodeDiagramPalette, resolveOpenCodeDiagramPalette, type OpenCodeDiagramPalette } from "./merman/palette"