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,79 @@
|
|
|
1
|
+
import { RGBA, type StyledText } from "@opentui/core"
|
|
2
|
+
import type { DiagramCanvas } from "../core/canvas.js"
|
|
3
|
+
import { renderDiagramGridStyledTextByStyle } from "../core/render-grid.js"
|
|
4
|
+
import {
|
|
5
|
+
createColorRampTheme,
|
|
6
|
+
DIAGRAM_FADE_STEPS,
|
|
7
|
+
numberedStyleKeys,
|
|
8
|
+
rgba,
|
|
9
|
+
type DiagramFadeStep,
|
|
10
|
+
type DiagramRgb,
|
|
11
|
+
} from "../core/color/style.js"
|
|
12
|
+
|
|
13
|
+
export type FlowchartBaseCellStyle =
|
|
14
|
+
| "node"
|
|
15
|
+
| "nodeBorder"
|
|
16
|
+
| "database"
|
|
17
|
+
| "databaseBorder"
|
|
18
|
+
| "edge"
|
|
19
|
+
| "label"
|
|
20
|
+
| "group"
|
|
21
|
+
| "groupLabel"
|
|
22
|
+
export type FlowchartNodeEdgeFadeStyle = `nodeEdgeFade${DiagramFadeStep}`
|
|
23
|
+
export type FlowchartDatabaseEdgeFadeStyle = `databaseEdgeFade${DiagramFadeStep}`
|
|
24
|
+
export type FlowchartEdgeFadeStyle = FlowchartNodeEdgeFadeStyle | FlowchartDatabaseEdgeFadeStyle
|
|
25
|
+
export type FlowchartCellStyle = FlowchartBaseCellStyle | FlowchartEdgeFadeStyle
|
|
26
|
+
export interface FlowchartCellMetadata {
|
|
27
|
+
attributes?: number
|
|
28
|
+
}
|
|
29
|
+
export type FlowchartGrid = DiagramCanvas<FlowchartCellStyle, FlowchartCellMetadata>
|
|
30
|
+
export type FlowchartStyleColors = Required<Record<FlowchartCellStyle, RGBA>>
|
|
31
|
+
export type FlowchartStyleBackgroundColors = Partial<Record<FlowchartCellStyle, RGBA>>
|
|
32
|
+
export const DEFAULT_THEME_RGB = {
|
|
33
|
+
node: [228, 239, 232],
|
|
34
|
+
nodeBorder: [141, 163, 151],
|
|
35
|
+
database: [228, 239, 232],
|
|
36
|
+
databaseBorder: [141, 163, 151],
|
|
37
|
+
edge: [134, 225, 200],
|
|
38
|
+
label: [134, 225, 200],
|
|
39
|
+
group: [76, 99, 89],
|
|
40
|
+
groupLabel: [76, 99, 89],
|
|
41
|
+
} as const satisfies Record<FlowchartBaseCellStyle, DiagramRgb>
|
|
42
|
+
|
|
43
|
+
export const NODE_EDGE_FADE_STYLES = numberedStyleKeys("nodeEdgeFade", DIAGRAM_FADE_STEPS)
|
|
44
|
+
export const DATABASE_EDGE_FADE_STYLES = numberedStyleKeys("databaseEdgeFade", DIAGRAM_FADE_STEPS)
|
|
45
|
+
|
|
46
|
+
export function resolveFlowchartStyleColors(
|
|
47
|
+
colors: Partial<Record<FlowchartCellStyle, RGBA | undefined>> = {},
|
|
48
|
+
): FlowchartStyleColors {
|
|
49
|
+
const node = colors.node ?? rgba(DEFAULT_THEME_RGB.node)
|
|
50
|
+
const nodeBorder = colors.nodeBorder ?? rgba(DEFAULT_THEME_RGB.nodeBorder)
|
|
51
|
+
const database = colors.database ?? rgba(DEFAULT_THEME_RGB.database)
|
|
52
|
+
const databaseBorder = colors.databaseBorder ?? rgba(DEFAULT_THEME_RGB.databaseBorder)
|
|
53
|
+
const edge = colors.edge ?? rgba(DEFAULT_THEME_RGB.edge)
|
|
54
|
+
return {
|
|
55
|
+
node,
|
|
56
|
+
nodeBorder,
|
|
57
|
+
database,
|
|
58
|
+
databaseBorder,
|
|
59
|
+
edge,
|
|
60
|
+
label: colors.label ?? rgba(DEFAULT_THEME_RGB.label),
|
|
61
|
+
group: colors.group ?? rgba(DEFAULT_THEME_RGB.group),
|
|
62
|
+
groupLabel: colors.groupLabel ?? colors.group ?? rgba(DEFAULT_THEME_RGB.groupLabel),
|
|
63
|
+
...createColorRampTheme(NODE_EDGE_FADE_STYLES, nodeBorder, edge),
|
|
64
|
+
...createColorRampTheme(DATABASE_EDGE_FADE_STYLES, databaseBorder, edge),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderGridStyledText(
|
|
69
|
+
grid: FlowchartGrid,
|
|
70
|
+
colors: FlowchartStyleColors,
|
|
71
|
+
backgrounds?: FlowchartStyleBackgroundColors,
|
|
72
|
+
): StyledText {
|
|
73
|
+
return renderDiagramGridStyledTextByStyle(grid, colors, backgrounds, {
|
|
74
|
+
key: (cell) => [cell.style, cell.attributes],
|
|
75
|
+
attributes: (run) => run.cell.attributes,
|
|
76
|
+
trimTop: true,
|
|
77
|
+
trimBottom: true,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { DiagramAxis, DiagramBounds, DiagramDirection, DiagramPoint } from "../core/geometry.js"
|
|
2
|
+
|
|
3
|
+
export type FlowchartDirection = "TB" | "TD" | "BT" | "LR" | "RL"
|
|
4
|
+
export type FlowchartNodeShape = "box" | "rounded" | "circle" | "database" | "decision" | "hexagon" | "parallelogram" | "subroutine"
|
|
5
|
+
export type FlowchartEdgeStyle = "thick" | "dashed"
|
|
6
|
+
|
|
7
|
+
export interface FlowchartNode {
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
shape: FlowchartNodeShape
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FlowchartEdge {
|
|
14
|
+
from: string
|
|
15
|
+
to: string
|
|
16
|
+
label: string
|
|
17
|
+
style?: FlowchartEdgeStyle
|
|
18
|
+
arrowhead?: false
|
|
19
|
+
sourceArrowhead?: true
|
|
20
|
+
orderOnly?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FlowchartSubgraph {
|
|
24
|
+
id: string
|
|
25
|
+
label: string
|
|
26
|
+
nodeIds: string[]
|
|
27
|
+
parentId?: string
|
|
28
|
+
direction?: FlowchartDirection
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FlowchartDiagram {
|
|
32
|
+
direction: FlowchartDirection
|
|
33
|
+
nodes: FlowchartNode[]
|
|
34
|
+
edges: FlowchartEdge[]
|
|
35
|
+
subgraphs?: FlowchartSubgraph[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FlowchartNodeSize {
|
|
39
|
+
width: number
|
|
40
|
+
height: number
|
|
41
|
+
lines: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FlowchartNodeBounds extends FlowchartNodeSize, DiagramBounds {
|
|
45
|
+
id: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FlowchartSubgraphBounds extends DiagramBounds {
|
|
49
|
+
id: string
|
|
50
|
+
label: string
|
|
51
|
+
labelSide: "top" | "bottom"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FlowchartPoint = DiagramPoint
|
|
55
|
+
|
|
56
|
+
export interface FlowchartEdgeRoute {
|
|
57
|
+
edge: FlowchartEdge
|
|
58
|
+
points: FlowchartPoint[]
|
|
59
|
+
labelAxis?: DiagramAxis
|
|
60
|
+
labelPoint?: FlowchartPoint
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type FlowchartEdgeDirection = DiagramDirection
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { drawGanttDiagramGrid } from "./drawing.js"
|
|
2
|
+
import { parseMermaidGanttDiagram } from "./parser.js"
|
|
3
|
+
import { renderGanttGridText } from "./render-grid.js"
|
|
4
|
+
import type { GanttDiagramRenderOptions } from "./types.js"
|
|
5
|
+
|
|
6
|
+
export function renderGanttDiagram(content: string, options: GanttDiagramRenderOptions = {}): string {
|
|
7
|
+
return renderGanttGridText(drawGanttDiagramGrid(parseMermaidGanttDiagram(content), options))
|
|
8
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { DiagramCanvas } from "../core/canvas.js"
|
|
2
|
+
import { diagramTextWidth } from "../core/text.js"
|
|
3
|
+
import type { GanttGrid } from "./render-grid.js"
|
|
4
|
+
import type {
|
|
5
|
+
GanttCellStyle,
|
|
6
|
+
GanttDiagram,
|
|
7
|
+
GanttDiagramRenderOptions,
|
|
8
|
+
GanttLabelLayout,
|
|
9
|
+
GanttLineStyle,
|
|
10
|
+
GanttRenderStyle,
|
|
11
|
+
GanttTask,
|
|
12
|
+
} from "./types.js"
|
|
13
|
+
|
|
14
|
+
const LABEL_GAP = 2
|
|
15
|
+
const MIN_CHART_WIDTH = 24
|
|
16
|
+
const MAX_CHART_WIDTH = 64
|
|
17
|
+
|
|
18
|
+
export function drawGanttDiagramGrid(diagram: GanttDiagram, options: GanttDiagramRenderOptions = {}): GanttGrid {
|
|
19
|
+
if (diagram.entries.length === 0) return new DiagramCanvas(0, 0)
|
|
20
|
+
const labels = diagram.entries.map((_, index) => entryLabel(diagram, index, options.labels ?? "left"))
|
|
21
|
+
const labelWidth = Math.max(...labels.map(diagramTextWidth))
|
|
22
|
+
const entryRows: number[] = []
|
|
23
|
+
let bodyHeight = 0
|
|
24
|
+
diagram.entries.forEach((entry, index) => {
|
|
25
|
+
if ((options.sections ?? "spaced") === "spaced" && entry.type === "section" && index > 0) bodyHeight += 1
|
|
26
|
+
entryRows.push(bodyHeight)
|
|
27
|
+
bodyHeight += 1
|
|
28
|
+
})
|
|
29
|
+
if (diagram.tasks.length === 0) {
|
|
30
|
+
const grid: GanttGrid = new DiagramCanvas(labelWidth, bodyHeight)
|
|
31
|
+
diagram.entries.forEach((entry, index) => {
|
|
32
|
+
if (entry.type === "section") grid.setText(0, entryRows[index]!, entry.section.label, "section")
|
|
33
|
+
})
|
|
34
|
+
return grid
|
|
35
|
+
}
|
|
36
|
+
const available = (options.layoutMaxWidth ?? 120) - labelWidth - LABEL_GAP
|
|
37
|
+
const chartWidth = Math.max(MIN_CHART_WIDTH, Math.min(MAX_CHART_WIDTH, available))
|
|
38
|
+
const starts = diagram.tasks.map((task) => task.start)
|
|
39
|
+
const ends = diagram.tasks.map((task) => task.end)
|
|
40
|
+
const minimum = Math.min(...starts)
|
|
41
|
+
const maximum = Math.max(...ends)
|
|
42
|
+
const span = Math.max(1, maximum - minimum)
|
|
43
|
+
const titleHeight = diagram.title ? 2 : 0
|
|
44
|
+
const axisHeight = 2
|
|
45
|
+
const grid: GanttGrid = new DiagramCanvas(labelWidth + LABEL_GAP + chartWidth, titleHeight + axisHeight + bodyHeight)
|
|
46
|
+
const chartX = labelWidth + LABEL_GAP
|
|
47
|
+
|
|
48
|
+
if (diagram.title)
|
|
49
|
+
grid.setText(
|
|
50
|
+
Math.max(0, chartX + Math.floor((chartWidth - diagramTextWidth(diagram.title)) / 2)),
|
|
51
|
+
0,
|
|
52
|
+
diagram.title,
|
|
53
|
+
"title",
|
|
54
|
+
)
|
|
55
|
+
drawAxis(grid, chartX, titleHeight, chartWidth, minimum, span, diagram.axisFormat, diagram.tickInterval)
|
|
56
|
+
|
|
57
|
+
diagram.entries.forEach((entry, index) => {
|
|
58
|
+
const y = titleHeight + axisHeight + entryRows[index]!
|
|
59
|
+
if (entry.type === "section") {
|
|
60
|
+
grid.setText(0, y, labels[index]!, "section")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const label = labels[index]!
|
|
64
|
+
const labelX = options.labels === "right" ? labelWidth - diagramTextWidth(label) : 0
|
|
65
|
+
grid.setText(labelX, y, label, entry.task.state)
|
|
66
|
+
drawTask(grid, entry.task, chartX, y, chartWidth, minimum, span, options)
|
|
67
|
+
})
|
|
68
|
+
return grid
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function drawAxis(
|
|
72
|
+
grid: GanttGrid,
|
|
73
|
+
x: number,
|
|
74
|
+
y: number,
|
|
75
|
+
width: number,
|
|
76
|
+
minimum: number,
|
|
77
|
+
span: number,
|
|
78
|
+
format: string,
|
|
79
|
+
interval?: number,
|
|
80
|
+
): void {
|
|
81
|
+
for (let offset = 0; offset < width; offset++) grid.setCell(x + offset, y + 1, "─", "axis")
|
|
82
|
+
const step = interval ? interval * Math.max(1, Math.ceil(span / (interval * 12))) : tickStep(span)
|
|
83
|
+
const ticks: number[] = []
|
|
84
|
+
for (let value = Math.ceil(minimum / step) * step; value <= minimum + span; value += step) ticks.push(value)
|
|
85
|
+
if (ticks.length === 0) ticks.push(minimum, minimum + span)
|
|
86
|
+
for (const value of ticks) {
|
|
87
|
+
const offset = Math.round(((value - minimum) / span) * (width - 1))
|
|
88
|
+
const label = formatTime(value, format)
|
|
89
|
+
const labelX = Math.max(
|
|
90
|
+
x,
|
|
91
|
+
Math.min(x + width - diagramTextWidth(label), x + offset - Math.floor(diagramTextWidth(label) / 2)),
|
|
92
|
+
)
|
|
93
|
+
grid.setText(labelX, y, label, "axis")
|
|
94
|
+
grid.setCell(x + offset, y + 1, "┬", "axis")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tickStep(span: number): number {
|
|
99
|
+
const steps = [
|
|
100
|
+
1_000, 5_000, 10_000, 30_000, 60_000, 300_000, 900_000, 3_600_000, 21_600_000, 43_200_000, 86_400_000, 172_800_000,
|
|
101
|
+
604_800_000, 2_592_000_000, 31_536_000_000,
|
|
102
|
+
]
|
|
103
|
+
return steps.find((step) => step >= span / 4) ?? steps.at(-1)!
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function drawTask(
|
|
107
|
+
grid: GanttGrid,
|
|
108
|
+
task: GanttTask,
|
|
109
|
+
x: number,
|
|
110
|
+
y: number,
|
|
111
|
+
width: number,
|
|
112
|
+
minimum: number,
|
|
113
|
+
span: number,
|
|
114
|
+
options: GanttDiagramRenderOptions,
|
|
115
|
+
): void {
|
|
116
|
+
const style: GanttRenderStyle = options.style ?? "track"
|
|
117
|
+
const line = lineGlyph(options.line ?? "thin")
|
|
118
|
+
const start = Math.round(((task.start - minimum) / span) * (width - 1))
|
|
119
|
+
const end = Math.round(((task.end - minimum) / span) * (width - 1))
|
|
120
|
+
if (task.state === "milestone" || start === end) {
|
|
121
|
+
grid.setCell(x + start, y, "◆", task.state)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (style === "track") {
|
|
125
|
+
for (let offset = 0; offset < width; offset++) {
|
|
126
|
+
grid.setCell(x + offset, y, (options.track ?? "line") === "line" ? line : "·", trackCellStyle(options))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const glyph = style === "block" ? "█" : line
|
|
130
|
+
for (let offset = start; offset <= end; offset++) grid.setCell(x + offset, y, glyph, task.state)
|
|
131
|
+
if (style === "block") return
|
|
132
|
+
if (style === "capsule" || style === "track") {
|
|
133
|
+
if (style === "track") {
|
|
134
|
+
if (options.endpoints === "points") {
|
|
135
|
+
grid.setCell(x + start, y, "●", trackCellStyle(options))
|
|
136
|
+
grid.setCell(x + end, y, "●", trackCellStyle(options))
|
|
137
|
+
}
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
const caps = capGlyphs(options.line ?? "thin")
|
|
141
|
+
grid.setCell(x + start, y, caps.start, task.state)
|
|
142
|
+
grid.setCell(x + end, y, caps.end, task.state)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (style === "points") {
|
|
146
|
+
grid.setCell(x + start, y, "●", task.state)
|
|
147
|
+
grid.setCell(x + end, y, "●", task.state)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
grid.setCell(x + start, y, "┣", task.state)
|
|
151
|
+
grid.setCell(x + end, y, "┫", task.state)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function entryLabel(diagram: GanttDiagram, index: number, layout: GanttLabelLayout): string {
|
|
155
|
+
const entry = diagram.entries[index]!
|
|
156
|
+
if (entry.type === "section" || layout !== "tree") {
|
|
157
|
+
return entry.type === "section" ? entry.section.label : entry.task.label
|
|
158
|
+
}
|
|
159
|
+
const last = diagram.entries[index + 1]?.type !== "task"
|
|
160
|
+
return ` ${last ? "└" : "├"}─ ${entry.task.label}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function lineGlyph(line: GanttLineStyle): string {
|
|
164
|
+
if (line === "thin") return "─"
|
|
165
|
+
if (line === "double") return "═"
|
|
166
|
+
if (line === "dashed") return "╌"
|
|
167
|
+
return "━"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function capGlyphs(line: GanttLineStyle): { start: string; end: string } {
|
|
171
|
+
if (line === "thin" || line === "dashed") return { start: "╶", end: "╴" }
|
|
172
|
+
if (line === "double") return { start: "╞", end: "╡" }
|
|
173
|
+
return { start: "╺", end: "╸" }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function trackCellStyle(options: GanttDiagramRenderOptions): "trackMedium" | "trackDim" | "trackFaint" {
|
|
177
|
+
if (options.trackTone === "medium") return "trackMedium"
|
|
178
|
+
if (options.trackTone === "dim") return "trackDim"
|
|
179
|
+
return "trackFaint"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatTime(value: number, format: string): string {
|
|
183
|
+
const date = new Date(value)
|
|
184
|
+
const parts: Record<string, string> = {
|
|
185
|
+
"%Y": String(date.getUTCFullYear()),
|
|
186
|
+
"%m": String(date.getUTCMonth() + 1).padStart(2, "0"),
|
|
187
|
+
"%d": String(date.getUTCDate()).padStart(2, "0"),
|
|
188
|
+
"%H": String(date.getUTCHours()).padStart(2, "0"),
|
|
189
|
+
"%M": String(date.getUTCMinutes()).padStart(2, "0"),
|
|
190
|
+
"%S": String(date.getUTCSeconds()).padStart(2, "0"),
|
|
191
|
+
}
|
|
192
|
+
return Object.entries(parts).reduce((result, [token, replacement]) => result.replaceAll(token, replacement), format)
|
|
193
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js"
|
|
2
|
+
import { MermaidSyntaxError } from "../diagnostics.js"
|
|
3
|
+
import type { GanttDiagram, GanttEntry, GanttSection, GanttTask, GanttTaskState } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const HEADER_RE = /^gantt$/i
|
|
6
|
+
const DIRECTIVE_RE = /^(title|dateFormat|axisFormat|tickInterval|excludes|todayMarker)\s+(.+)$/i
|
|
7
|
+
const SECTION_RE = /^section(?:\s+(.+))?$/i
|
|
8
|
+
const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i
|
|
9
|
+
const TASK_STATES = new Set(["active", "done", "crit", "milestone", "vert"])
|
|
10
|
+
|
|
11
|
+
export function isMermaidGanttDiagram(content: string): boolean {
|
|
12
|
+
return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseMermaidGanttDiagram(content: string): GanttDiagram {
|
|
16
|
+
const tasks: GanttTask[] = []
|
|
17
|
+
const entries: GanttEntry[] = []
|
|
18
|
+
const tasksById = new Map<string, GanttTask>()
|
|
19
|
+
let title: string | undefined
|
|
20
|
+
let dateFormat = "YYYY-MM-DD"
|
|
21
|
+
let axisFormat = "%Y-%m-%d"
|
|
22
|
+
let explicitAxisFormat = false
|
|
23
|
+
let tickInterval: number | undefined
|
|
24
|
+
let section: GanttSection | undefined
|
|
25
|
+
let headerSeen = false
|
|
26
|
+
let inAccessibilityDescription = false
|
|
27
|
+
|
|
28
|
+
for (const source of meaningfulNumberedMermaidLines(content)) {
|
|
29
|
+
const line = stripComment(source.text)
|
|
30
|
+
if (inAccessibilityDescription) {
|
|
31
|
+
if (line === "}") inAccessibilityDescription = false
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (/^accDescr\s*\{$/i.test(line)) {
|
|
35
|
+
inAccessibilityDescription = true
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (ACCESSIBILITY_RE.test(line)) continue
|
|
39
|
+
if (!line) continue
|
|
40
|
+
if (HEADER_RE.test(line)) {
|
|
41
|
+
if (headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header can only appear once")
|
|
42
|
+
headerSeen = true
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!headerSeen) throw syntaxError(source.lineNumber, line, "Gantt header is required")
|
|
46
|
+
|
|
47
|
+
const sectionMatch = line.match(SECTION_RE)
|
|
48
|
+
if (sectionMatch) {
|
|
49
|
+
if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Gantt section cannot be empty")
|
|
50
|
+
section = { label: stripMermaidQuotes(sectionMatch[1]) }
|
|
51
|
+
entries.push({ type: "section", section })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const directive = line.match(DIRECTIVE_RE)
|
|
56
|
+
if (directive) {
|
|
57
|
+
const name = directive[1]!.toLowerCase()
|
|
58
|
+
const value = directive[2]!.trim()
|
|
59
|
+
if (name === "title") title = stripMermaidQuotes(value)
|
|
60
|
+
if (name === "dateformat") {
|
|
61
|
+
dateFormat = value
|
|
62
|
+
if (!explicitAxisFormat && (value === "HH:mm" || value === "HH:mm:ss")) {
|
|
63
|
+
axisFormat = value === "HH:mm" ? "%H:%M" : "%H:%M:%S"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (name === "axisformat") {
|
|
67
|
+
axisFormat = value
|
|
68
|
+
explicitAxisFormat = true
|
|
69
|
+
}
|
|
70
|
+
if (name === "tickinterval") {
|
|
71
|
+
const duration = value.match(/^(\d+)\s*(ms|s|m|h|d|w|seconds?|minutes?|hours?|days?|weeks?)$/i)
|
|
72
|
+
const units: Record<string, number> = {
|
|
73
|
+
ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
|
|
74
|
+
second: 1_000, minute: 60_000, hour: 3_600_000, day: 86_400_000, week: 604_800_000,
|
|
75
|
+
}
|
|
76
|
+
if (!duration || Number(duration[1]) <= 0) throw syntaxError(source.lineNumber, line, `Unsupported tick interval "${value}"`)
|
|
77
|
+
const unit = duration[2]!.toLowerCase()
|
|
78
|
+
tickInterval = Number(duration[1]) * (units[unit] ?? units[unit.slice(0, -1)]!)!
|
|
79
|
+
if (!Number.isSafeInteger(tickInterval)) throw syntaxError(source.lineNumber, line, `Unsupported tick interval "${value}"`)
|
|
80
|
+
}
|
|
81
|
+
if (!["title", "dateformat", "axisformat", "tickinterval"].includes(name)) {
|
|
82
|
+
throw syntaxError(source.lineNumber, line, `${directive[1]} is not supported`)
|
|
83
|
+
}
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const separator = line.indexOf(":")
|
|
88
|
+
if (separator < 1) throw syntaxError(source.lineNumber, line)
|
|
89
|
+
const label = stripMermaidQuotes(line.slice(0, separator))
|
|
90
|
+
const fields = line
|
|
91
|
+
.slice(separator + 1)
|
|
92
|
+
.split(",")
|
|
93
|
+
.map((field) => field.trim())
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
const flags = new Set<string>()
|
|
96
|
+
while (fields[0] && TASK_STATES.has(fields[0].toLowerCase())) flags.add(fields.shift()!.toLowerCase())
|
|
97
|
+
if (fields.length < 2 || fields.length > 3) {
|
|
98
|
+
throw syntaxError(source.lineNumber, line, "Gantt tasks require a start and end or duration")
|
|
99
|
+
}
|
|
100
|
+
const id = fields.length === 3 ? fields.shift() : undefined
|
|
101
|
+
if (id && tasksById.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate task id "${id}"`)
|
|
102
|
+
const start = parseStart(fields[0]!, dateFormat, tasksById, source.lineNumber, line)
|
|
103
|
+
const end = parseEnd(fields[1]!, start, dateFormat, source.lineNumber, line)
|
|
104
|
+
if (end < start) throw syntaxError(source.lineNumber, line, "Gantt task cannot end before it starts")
|
|
105
|
+
const task: GanttTask = {
|
|
106
|
+
label,
|
|
107
|
+
...(id ? { id } : {}),
|
|
108
|
+
...(section ? { section } : {}),
|
|
109
|
+
start,
|
|
110
|
+
end,
|
|
111
|
+
state: taskState(flags),
|
|
112
|
+
}
|
|
113
|
+
tasks.push(task)
|
|
114
|
+
entries.push({ type: "task", task })
|
|
115
|
+
if (id) tasksById.set(id, task)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!headerSeen) throw new MermaidSyntaxError("gantt", 1, "", "Gantt header is required")
|
|
119
|
+
return { ...(title === undefined ? {} : { title }), dateFormat, axisFormat,
|
|
120
|
+
...(tickInterval === undefined ? {} : { tickInterval }), tasks, entries }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseStart(
|
|
124
|
+
value: string,
|
|
125
|
+
format: string,
|
|
126
|
+
tasksById: Map<string, GanttTask>,
|
|
127
|
+
lineNumber: number,
|
|
128
|
+
sourceLine: string,
|
|
129
|
+
): number {
|
|
130
|
+
const relative = value.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i)
|
|
131
|
+
if (relative && (format === "HH:mm" || format === "HH:mm:ss")) {
|
|
132
|
+
const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }
|
|
133
|
+
return Date.UTC(1970, 0, 1) + Number(relative[1]) * units[relative[2]!.toLowerCase() as keyof typeof units]
|
|
134
|
+
}
|
|
135
|
+
if (!/^after\s+/i.test(value)) return parseDate(value, format, lineNumber, sourceLine)
|
|
136
|
+
const dependencies = value
|
|
137
|
+
.replace(/^after\s+/i, "")
|
|
138
|
+
.trim()
|
|
139
|
+
.split(/\s+/)
|
|
140
|
+
const tasks = dependencies.map((id) => tasksById.get(id))
|
|
141
|
+
const missing = dependencies.find((_, index) => !tasks[index])
|
|
142
|
+
if (missing) throw syntaxError(lineNumber, sourceLine, `Unknown Gantt task id "${missing}"`)
|
|
143
|
+
return Math.max(...tasks.map((task) => task!.end))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseEnd(value: string, start: number, format: string, lineNumber: number, sourceLine: string): number {
|
|
147
|
+
const duration = value.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i)
|
|
148
|
+
if (!duration) return parseDate(value, format, lineNumber, sourceLine)
|
|
149
|
+
const units = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }
|
|
150
|
+
return start + Number(duration[1]) * units[duration[2]!.toLowerCase() as keyof typeof units]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseDate(value: string, format: string, lineNumber: number, sourceLine: string): number {
|
|
154
|
+
const numeric = Number(value)
|
|
155
|
+
if (format === "s" && Number.isFinite(numeric)) return numeric * 1_000
|
|
156
|
+
if (format === "X" && Number.isFinite(numeric)) return numeric * 1_000
|
|
157
|
+
if (format === "x" && Number.isFinite(numeric)) return numeric
|
|
158
|
+
|
|
159
|
+
const clock = value.match(/^(\d{2}):(\d{2})(?::(\d{2}))?$/)
|
|
160
|
+
if (clock && ["HH:mm", "HH:mm:ss"].includes(format)) {
|
|
161
|
+
const hours = Number(clock[1])
|
|
162
|
+
const minutes = Number(clock[2])
|
|
163
|
+
const seconds = Number(clock[3] ?? 0)
|
|
164
|
+
if (hours < 24 && minutes < 60 && seconds < 60 && (format === "HH:mm:ss") === (clock[3] !== undefined)) {
|
|
165
|
+
return Date.UTC(1970, 0, 1, hours, minutes, seconds)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const calendar = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/)
|
|
170
|
+
if (calendar && ["YYYY-MM-DD", "YYYY-MM-DD HH:mm", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DDTHH:mm", "YYYY-MM-DDTHH:mm:ss"].includes(format)) {
|
|
171
|
+
return Date.UTC(
|
|
172
|
+
Number(calendar[1]),
|
|
173
|
+
Number(calendar[2]) - 1,
|
|
174
|
+
Number(calendar[3]),
|
|
175
|
+
Number(calendar[4] ?? 0),
|
|
176
|
+
Number(calendar[5] ?? 0),
|
|
177
|
+
Number(calendar[6] ?? 0),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
throw syntaxError(lineNumber, sourceLine, `Unsupported date "${value}" for dateFormat ${format}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function taskState(flags: Set<string>): GanttTaskState {
|
|
184
|
+
if (flags.has("milestone")) return "milestone"
|
|
185
|
+
if (flags.has("crit")) return "critical"
|
|
186
|
+
if (flags.has("done")) return "done"
|
|
187
|
+
if (flags.has("active")) return "active"
|
|
188
|
+
return "task"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function stripComment(value: string): string {
|
|
192
|
+
const comment = value.indexOf("%%")
|
|
193
|
+
return (comment < 0 ? value : value.slice(0, comment)).trim()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError {
|
|
197
|
+
return new MermaidSyntaxError("gantt", lineNumber, sourceLine, reason)
|
|
198
|
+
}
|
|
@@ -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 { GanttStyleColors } from "./style.js"
|
|
5
|
+
import type { GanttCellStyle } from "./types.js"
|
|
6
|
+
|
|
7
|
+
export type GanttGrid = DiagramCanvas<GanttCellStyle>
|
|
8
|
+
|
|
9
|
+
export function renderGanttGridText(grid: GanttGrid): string {
|
|
10
|
+
return grid.toString({ trimBottom: true })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderGanttGridStyledText(grid: GanttGrid, colors: GanttStyleColors): 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, rgba, type DiagramRgb } from "../core/color/style.js"
|
|
3
|
+
import type { GanttBaseCellStyle, GanttCellStyle } from "./types.js"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_THEME_RGB = {
|
|
6
|
+
title: [228, 239, 232],
|
|
7
|
+
axis: [111, 138, 126],
|
|
8
|
+
section: [154, 184, 169],
|
|
9
|
+
task: [134, 225, 200],
|
|
10
|
+
active: [134, 225, 200],
|
|
11
|
+
critical: [230, 177, 126],
|
|
12
|
+
done: [111, 138, 126],
|
|
13
|
+
milestone: [198, 160, 246],
|
|
14
|
+
} as const satisfies Record<GanttBaseCellStyle, DiagramRgb>
|
|
15
|
+
|
|
16
|
+
export type GanttStyleColors = Required<Record<GanttCellStyle, RGBA>>
|
|
17
|
+
|
|
18
|
+
export function resolveGanttStyleColors(
|
|
19
|
+
colors: Partial<Record<GanttBaseCellStyle | "background", RGBA | undefined>> = {},
|
|
20
|
+
): GanttStyleColors {
|
|
21
|
+
const axis = colors.axis ?? rgba(DEFAULT_THEME_RGB.axis)
|
|
22
|
+
const background = colors.background ?? rgba([13, 17, 23])
|
|
23
|
+
return {
|
|
24
|
+
title: colors.title ?? rgba(DEFAULT_THEME_RGB.title),
|
|
25
|
+
axis,
|
|
26
|
+
section: colors.section ?? rgba(DEFAULT_THEME_RGB.section),
|
|
27
|
+
task: colors.task ?? rgba(DEFAULT_THEME_RGB.task),
|
|
28
|
+
active: colors.active ?? rgba(DEFAULT_THEME_RGB.active),
|
|
29
|
+
critical: colors.critical ?? rgba(DEFAULT_THEME_RGB.critical),
|
|
30
|
+
done: colors.done ?? rgba(DEFAULT_THEME_RGB.done),
|
|
31
|
+
milestone: colors.milestone ?? rgba(DEFAULT_THEME_RGB.milestone),
|
|
32
|
+
trackMedium: blendColor(axis, background, 0.35),
|
|
33
|
+
trackDim: blendColor(axis, background, 0.55),
|
|
34
|
+
trackFaint: blendColor(axis, background, 0.72),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type GanttTaskState = "task" | "active" | "critical" | "done" | "milestone"
|
|
2
|
+
|
|
3
|
+
export interface GanttSection {
|
|
4
|
+
label: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface GanttTask {
|
|
8
|
+
label: string
|
|
9
|
+
id?: string
|
|
10
|
+
section?: GanttSection
|
|
11
|
+
start: number
|
|
12
|
+
end: number
|
|
13
|
+
state: GanttTaskState
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GanttEntry = { type: "section"; section: GanttSection } | { type: "task"; task: GanttTask }
|
|
17
|
+
|
|
18
|
+
export interface GanttDiagram {
|
|
19
|
+
title?: string
|
|
20
|
+
dateFormat: string
|
|
21
|
+
axisFormat: string
|
|
22
|
+
tickInterval?: number
|
|
23
|
+
tasks: GanttTask[]
|
|
24
|
+
entries: GanttEntry[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GanttDiagramRenderOptions {
|
|
28
|
+
layoutMaxWidth?: number
|
|
29
|
+
style?: GanttRenderStyle
|
|
30
|
+
track?: "dots" | "line"
|
|
31
|
+
endpoints?: "plain" | "points"
|
|
32
|
+
line?: GanttLineStyle
|
|
33
|
+
labels?: GanttLabelLayout
|
|
34
|
+
sections?: "compact" | "spaced"
|
|
35
|
+
trackTone?: GanttTrackTone
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GanttRenderStyle = "rail" | "block" | "capsule" | "points" | "track"
|
|
39
|
+
export type GanttLineStyle = "heavy" | "thin" | "double" | "dashed"
|
|
40
|
+
export type GanttLabelLayout = "right" | "left" | "tree"
|
|
41
|
+
export type GanttTrackTone = "medium" | "dim" | "faint"
|
|
42
|
+
|
|
43
|
+
export type GanttBaseCellStyle = "title" | "axis" | "section" | GanttTaskState
|
|
44
|
+
export type GanttTrackCellStyle = "trackMedium" | "trackDim" | "trackFaint"
|
|
45
|
+
export type GanttCellStyle = GanttBaseCellStyle | GanttTrackCellStyle
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { drawGitGraphDiagramGrid } from "./drawing.js"
|
|
2
|
+
import { parseMermaidGitGraphDiagram } from "./parser.js"
|
|
3
|
+
import { renderGitGraphGridText } from "./render-grid.js"
|
|
4
|
+
import type { GitGraphDiagramRenderOptions } from "./types.js"
|
|
5
|
+
|
|
6
|
+
export function renderGitGraphDiagram(content: string, options: GitGraphDiagramRenderOptions = {}): string {
|
|
7
|
+
return renderGitGraphGridText(drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(content), options))
|
|
8
|
+
}
|