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.
- package/LICENSE +21 -0
- package/NOTICE +27 -0
- package/README.md +223 -2
- package/bin/otmd.tsx +99 -0
- package/native/Cargo.lock +3708 -0
- package/native/Cargo.toml +28 -0
- package/native/build.rs +3 -0
- package/native/src/lib.rs +880 -0
- package/package.json +67 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/cli.ts +63 -0
- package/src/config/index.ts +46 -0
- package/src/config/load.ts +337 -0
- package/src/config/resolve.ts +36 -0
- package/src/config/theme.ts +110 -0
- package/src/config/types.ts +105 -0
- package/src/core/document.ts +44 -0
- package/src/core/frame.ts +144 -0
- package/src/core/image.ts +9 -0
- package/src/core/index.ts +6 -0
- package/src/core/layout.ts +27 -0
- package/src/core/math.ts +170 -0
- package/src/core/parse.ts +208 -0
- package/src/core/tables.ts +58 -0
- package/src/graphics.ts +85 -0
- package/src/headings.ts +77 -0
- package/src/index.ts +107 -0
- package/src/math/index.ts +86 -0
- package/src/math/native.ts +39 -0
- package/src/math/renderer.ts +84 -0
- package/src/math/validate.ts +44 -0
- package/src/media.tsx +267 -0
- package/src/merman/class/dagre-layout.ts +171 -0
- package/src/merman/class/diagram.ts +8 -0
- package/src/merman/class/drawing.ts +343 -0
- package/src/merman/class/labels.ts +38 -0
- package/src/merman/class/layout.ts +531 -0
- package/src/merman/class/parser.ts +308 -0
- package/src/merman/class/render-grid.ts +18 -0
- package/src/merman/class/style.ts +27 -0
- package/src/merman/class/types.ts +57 -0
- package/src/merman/core/canvas.ts +228 -0
- package/src/merman/core/color/style.ts +56 -0
- package/src/merman/core/drawing.ts +307 -0
- package/src/merman/core/geometry.ts +347 -0
- package/src/merman/core/mermaid.ts +38 -0
- package/src/merman/core/render-grid.ts +51 -0
- package/src/merman/core/spatial.ts +243 -0
- package/src/merman/core/text-lines.ts +32 -0
- package/src/merman/core/text.ts +44 -0
- package/src/merman/detect.ts +19 -0
- package/src/merman/diagnostics.ts +16 -0
- package/src/merman/diagram.ts +268 -0
- package/src/merman/flowchart/drawing.ts +433 -0
- package/src/merman/flowchart/labels.ts +144 -0
- package/src/merman/flowchart/layout.ts +1057 -0
- package/src/merman/flowchart/options.ts +12 -0
- package/src/merman/flowchart/parser.ts +477 -0
- package/src/merman/flowchart/render.ts +10 -0
- package/src/merman/flowchart/routing.ts +1429 -0
- package/src/merman/flowchart/style.ts +79 -0
- package/src/merman/flowchart/types.ts +63 -0
- package/src/merman/gantt/diagram.ts +8 -0
- package/src/merman/gantt/drawing.ts +193 -0
- package/src/merman/gantt/parser.ts +198 -0
- package/src/merman/gantt/render-grid.ts +17 -0
- package/src/merman/gantt/style.ts +36 -0
- package/src/merman/gantt/types.ts +45 -0
- package/src/merman/gitgraph/diagram.ts +8 -0
- package/src/merman/gitgraph/drawing.ts +234 -0
- package/src/merman/gitgraph/parser.ts +234 -0
- package/src/merman/gitgraph/render-grid.ts +17 -0
- package/src/merman/gitgraph/style.ts +37 -0
- package/src/merman/gitgraph/types.ts +36 -0
- package/src/merman/palette.ts +60 -0
- package/src/merman/sequence/diagram.ts +8 -0
- package/src/merman/sequence/drawing.ts +288 -0
- package/src/merman/sequence/options.ts +8 -0
- package/src/merman/sequence/parser.ts +303 -0
- package/src/merman/sequence/placement.ts +692 -0
- package/src/merman/sequence/render-grid.ts +22 -0
- package/src/merman/sequence/style.ts +81 -0
- package/src/merman/sequence/types.ts +74 -0
- package/src/merman/state/diagram.ts +8 -0
- package/src/merman/state/drawing.ts +363 -0
- package/src/merman/state/endpoint.ts +15 -0
- package/src/merman/state/layout.ts +1134 -0
- package/src/merman/state/note.ts +59 -0
- package/src/merman/state/options.ts +11 -0
- package/src/merman/state/parser.ts +245 -0
- package/src/merman/state/render-grid.ts +22 -0
- package/src/merman/state/routing.ts +1522 -0
- package/src/merman/state/search.ts +103 -0
- package/src/merman/state/style.ts +52 -0
- package/src/merman/state/types.ts +63 -0
- package/src/merman/state/visible-model.ts +77 -0
- package/src/merman/timeline/diagram.ts +8 -0
- package/src/merman/timeline/drawing.ts +109 -0
- package/src/merman/timeline/parser.ts +119 -0
- package/src/merman/timeline/render-grid.ts +17 -0
- package/src/merman/timeline/style.ts +36 -0
- package/src/merman/timeline/types.ts +31 -0
- package/src/merman.ts +3 -0
- package/src/statusline.tsx +183 -0
- package/src/theme/colors.ts +11 -0
- package/src/theme/defaults.ts +190 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/load.ts +39 -0
- package/src/theme/resolve.ts +167 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +151 -0
- package/src/view/code.tsx +27 -0
- package/src/view/document-slots.ts +111 -0
- package/src/view/document-table.tsx +98 -0
- package/src/view/document.tsx +195 -0
- package/src/view/frame.tsx +83 -0
- package/src/view/inline-flow.tsx +113 -0
- package/src/view/inline-layout.ts +149 -0
- package/src/view/inline-math.ts +42 -0
- package/src/view/inline-style.ts +103 -0
- package/src/view/math.tsx +85 -0
- package/src/view/mermaid.tsx +128 -0
- package/src/view/table.ts +100 -0
- package/themes/dark.json +32 -0
- package/themes/example.jsonc +98 -0
- package/themes/light.json +32 -0
- package/themes/solarized-dark.json +32 -0
- 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