opentui-markdown-render 0.0.0-stage → 0.1.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 +173 -2
- 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 +62 -4
- package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
- package/src/app.tsx +180 -0
- package/src/config/index.ts +45 -0
- package/src/config/load.ts +308 -0
- package/src/config/resolve.ts +37 -0
- package/src/config/theme.ts +88 -0
- package/src/config/types.ts +102 -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 +98 -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 +5 -0
- package/src/theme/resolve.ts +166 -0
- package/src/theme/syntax.ts +96 -0
- package/src/theme/types.ts +150 -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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ThemeOverride } from "../theme/types"
|
|
2
|
+
import type { ConfigReporter } from "./load"
|
|
3
|
+
|
|
4
|
+
// A JSON file only carries strings, not RGBA instances. OpenTUI accepts these
|
|
5
|
+
// colour names and hex forms; rejecting anything else here avoids its silent
|
|
6
|
+
// magenta fallback for misspelled colours.
|
|
7
|
+
const colorNames = new Set([
|
|
8
|
+
"transparent", "black", "white", "red", "green", "blue", "yellow", "cyan", "magenta",
|
|
9
|
+
"silver", "gray", "grey", "maroon", "olive", "lime", "aqua", "teal", "navy",
|
|
10
|
+
"fuchsia", "purple", "orange", "brightblack", "brightred", "brightgreen",
|
|
11
|
+
"brightblue", "brightyellow", "brightcyan", "brightmagenta", "brightwhite",
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
type Field = "color" | "mode" | "colors" | { readonly [key: string]: Field }
|
|
15
|
+
const colors = <K extends string>(keys: readonly K[]): Record<K, Field> =>
|
|
16
|
+
Object.fromEntries(keys.map((key) => [key, "color"])) as Record<K, Field>
|
|
17
|
+
|
|
18
|
+
// The allowlist follows ThemeOverride, not the resolved Theme: headingLevels
|
|
19
|
+
// is an internal six-colour array, while heading1..heading6 are file options.
|
|
20
|
+
const themeFields: Record<string, Field> = {
|
|
21
|
+
mode: "mode",
|
|
22
|
+
accent: "color",
|
|
23
|
+
categorical: "colors",
|
|
24
|
+
text: {
|
|
25
|
+
base: "color",
|
|
26
|
+
muted: "color",
|
|
27
|
+
feedback: colors(["error", "warning", "success", "info"]),
|
|
28
|
+
action: { primary: { focused: "color" } },
|
|
29
|
+
},
|
|
30
|
+
background: { base: "color" },
|
|
31
|
+
border: { base: "color" },
|
|
32
|
+
frame: colors(["border", "title"]),
|
|
33
|
+
statusline: colors(["background", "foreground", "title", "muted", "border"]),
|
|
34
|
+
markdown: colors([
|
|
35
|
+
"text", "heading", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6",
|
|
36
|
+
"link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule",
|
|
37
|
+
"listItem", "listEnumeration", "image", "imageText", "codeBlock",
|
|
38
|
+
]),
|
|
39
|
+
syntax: colors(["comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation"]),
|
|
40
|
+
diff: {
|
|
41
|
+
text: colors(["added", "removed", "context"]),
|
|
42
|
+
background: colors(["added", "removed", "context"]),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isColor(value: unknown): value is string {
|
|
47
|
+
return typeof value === "string" && (
|
|
48
|
+
/^#?(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value) ||
|
|
49
|
+
colorNames.has(value.toLowerCase())
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateTheme(value: Record<string, unknown>, file: string, report?: ConfigReporter): ThemeOverride {
|
|
58
|
+
const warn = (message: string) => {
|
|
59
|
+
if (report) report(message)
|
|
60
|
+
else console.warn(`[opentui-markdown-render] ${message}`)
|
|
61
|
+
}
|
|
62
|
+
const validate = (source: Record<string, unknown>, fields: Record<string, Field>, prefix: string): Record<string, unknown> => {
|
|
63
|
+
const result: Record<string, unknown> = Object.create(null)
|
|
64
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
65
|
+
const field = Object.hasOwn(fields, key) ? fields[key] : undefined
|
|
66
|
+
const location = `${prefix}.${key}`
|
|
67
|
+
if (field === undefined) {
|
|
68
|
+
warn(`Unknown theme option "${location}" in ${file}`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (field === "color" && isColor(entry)) result[key] = entry
|
|
72
|
+
else if (field === "mode" && (entry === "light" || entry === "dark" || entry === "solarized-light" || entry === "solarized-dark")) result[key] = entry
|
|
73
|
+
else if (field === "colors" && Array.isArray(entry)) {
|
|
74
|
+
result[key] = entry.filter((color, index) => {
|
|
75
|
+
if (isColor(color)) return true
|
|
76
|
+
warn(`Invalid theme option "${location}[${index}]" in ${file}`)
|
|
77
|
+
return false
|
|
78
|
+
})
|
|
79
|
+
} else if (typeof field === "object" && isRecord(entry)) {
|
|
80
|
+
result[key] = validate(entry, field, location)
|
|
81
|
+
} else {
|
|
82
|
+
warn(`Invalid theme option "${location}" in ${file}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
return validate(value, themeFields, "theme") as ThemeOverride
|
|
88
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { FrameConfig } from "../core/frame"
|
|
2
|
+
import type { TablesConfig } from "../core/tables"
|
|
3
|
+
import type { ThemeOverride } from "../theme/types"
|
|
4
|
+
|
|
5
|
+
// The configuration file is a plain JSON/JSONC object. Every section is
|
|
6
|
+
// optional; a missing section keeps the bundled default.
|
|
7
|
+
export type MarkdownConfig = {
|
|
8
|
+
// Colour scheme. Mirrors the theme override shape.
|
|
9
|
+
readonly theme?: ThemeOverride
|
|
10
|
+
readonly statusline?: StatusLineConfig
|
|
11
|
+
readonly images?: ImagesConfig
|
|
12
|
+
readonly mermaid?: MermaidConfig
|
|
13
|
+
readonly headings?: HeadingsConfig
|
|
14
|
+
// How the viewer window is drawn around the document.
|
|
15
|
+
readonly frame?: FrameConfig
|
|
16
|
+
// How tables size themselves and what happens when they are too wide.
|
|
17
|
+
readonly tables?: TablesConfig
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The status line is a single row of labelled fields. `fields` selects and
|
|
21
|
+
// orders them; a field without a value is skipped.
|
|
22
|
+
export type StatusLineConfig = {
|
|
23
|
+
readonly enabled?: boolean
|
|
24
|
+
readonly fields?: readonly StatusLineField[]
|
|
25
|
+
readonly separator?: string
|
|
26
|
+
readonly border?: StatusLineBorder
|
|
27
|
+
// "plain" keeps the line in the page colours, "bar" gives it its own
|
|
28
|
+
// background and an accent border.
|
|
29
|
+
readonly style?: StatusLineStyle
|
|
30
|
+
// The field that gets the accent treatment.
|
|
31
|
+
readonly highlight?: StatusLineField
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type StatusLineField = "title" | "mode" | "protocol" | "cell" | "mathScale" | "position" | "hint" | "text"
|
|
35
|
+
|
|
36
|
+
export type StatusLineBorder = "none" | "top" | "bottom"
|
|
37
|
+
|
|
38
|
+
export type StatusLineStyle = "plain" | "bar"
|
|
39
|
+
|
|
40
|
+
export const statusLineStyles: readonly StatusLineStyle[] = ["plain", "bar"]
|
|
41
|
+
|
|
42
|
+
export function isStatusLineStyle(value: unknown): value is StatusLineStyle {
|
|
43
|
+
return typeof value === "string" && statusLineStyles.includes(value as StatusLineStyle)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ImagesConfig = {
|
|
47
|
+
// Maximum width of a rendered image in terminal columns.
|
|
48
|
+
readonly maxColumns?: number
|
|
49
|
+
// Maximum height of a rendered image in terminal rows.
|
|
50
|
+
readonly maxRows?: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type MermaidConfig = {
|
|
54
|
+
readonly compact?: boolean
|
|
55
|
+
readonly layoutMaxWidth?: number
|
|
56
|
+
/** Class diagrams only; defaults to the built-in layout. */
|
|
57
|
+
readonly classLayout?: "builtin" | "dagre"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The heading levels are addressed by their number, the way Markdown writes
|
|
61
|
+
// them.
|
|
62
|
+
export type HeadingLevelKey = "1" | "2" | "3" | "4" | "5" | "6"
|
|
63
|
+
|
|
64
|
+
// The look of a single heading level. Every option is optional: a missing one
|
|
65
|
+
// keeps the bundled default for that level.
|
|
66
|
+
export type HeadingLevelConfig = {
|
|
67
|
+
readonly bold?: boolean
|
|
68
|
+
readonly italic?: boolean
|
|
69
|
+
readonly underline?: boolean
|
|
70
|
+
readonly dim?: boolean
|
|
71
|
+
// Render the text of the heading in upper case.
|
|
72
|
+
readonly uppercase?: boolean
|
|
73
|
+
// Empty rows above the heading.
|
|
74
|
+
readonly marginTop?: number
|
|
75
|
+
// Empty rows below the heading.
|
|
76
|
+
readonly marginBottom?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type HeadingsConfig = {
|
|
80
|
+
readonly levels?: { readonly [K in HeadingLevelKey]?: HeadingLevelConfig }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const headingLevelKeys: readonly HeadingLevelKey[] = ["1", "2", "3", "4", "5", "6"]
|
|
84
|
+
|
|
85
|
+
export function isHeadingLevelKey(value: string): value is HeadingLevelKey {
|
|
86
|
+
return (headingLevelKeys as readonly string[]).includes(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const statusLineFields: readonly StatusLineField[] = [
|
|
90
|
+
"title",
|
|
91
|
+
"mode",
|
|
92
|
+
"protocol",
|
|
93
|
+
"cell",
|
|
94
|
+
"mathScale",
|
|
95
|
+
"position",
|
|
96
|
+
"hint",
|
|
97
|
+
"text",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
export function isStatusLineField(value: unknown): value is StatusLineField {
|
|
101
|
+
return typeof value === "string" && statusLineFields.includes(value as StatusLineField)
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type SourceRange = { readonly start: number; readonly end: number }
|
|
2
|
+
|
|
3
|
+
export type NodeBase = { readonly raw: string; readonly source: SourceRange | null }
|
|
4
|
+
|
|
5
|
+
export type Document = { readonly source: string; readonly blocks: readonly Block[] }
|
|
6
|
+
|
|
7
|
+
export type Block =
|
|
8
|
+
| (NodeBase & { readonly type: 'paragraph'; readonly children: readonly Inline[] })
|
|
9
|
+
| (NodeBase & { readonly type: 'heading'; readonly level: 1 | 2 | 3 | 4 | 5 | 6; readonly children: readonly Inline[] })
|
|
10
|
+
| (NodeBase & { readonly type: 'quote'; readonly blocks: readonly Block[] })
|
|
11
|
+
| (NodeBase & { readonly type: 'list'; readonly ordered: boolean; readonly start: number; readonly loose: boolean; readonly items: readonly ListItem[] })
|
|
12
|
+
| (NodeBase & { readonly type: 'table'; readonly align: readonly ('left' | 'center' | 'right' | null)[]; readonly header: readonly TableCell[]; readonly rows: readonly (readonly TableCell[])[] })
|
|
13
|
+
| (NodeBase & { readonly type: 'code'; readonly language: string | null; readonly content: string; readonly fenced: boolean })
|
|
14
|
+
| (NodeBase & { readonly type: 'mermaid'; readonly content: string; readonly language: string })
|
|
15
|
+
| (NodeBase & { readonly type: 'rule' })
|
|
16
|
+
| (NodeBase & { readonly type: 'display-math'; readonly content: string; readonly open: '$$' | '\\['; readonly close: '$$' | '\\]' })
|
|
17
|
+
| (NodeBase & { readonly type: 'image'; readonly href: string; readonly alt: string; readonly title: string | null })
|
|
18
|
+
| (NodeBase & { readonly type: 'literal'; readonly text: string })
|
|
19
|
+
|
|
20
|
+
export type ListItem = {
|
|
21
|
+
readonly task: boolean
|
|
22
|
+
readonly checked: boolean | null
|
|
23
|
+
readonly loose: boolean
|
|
24
|
+
readonly blocks: readonly Block[]
|
|
25
|
+
readonly source: SourceRange | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type UnitInfo =
|
|
29
|
+
| { readonly kind: 'block'; readonly block: Exclude<Block, { type: 'list' }>; readonly source: SourceRange | null }
|
|
30
|
+
| { readonly kind: 'list-item'; readonly item: ListItem; readonly source: SourceRange | null }
|
|
31
|
+
|
|
32
|
+
export type TableCell = { readonly children: readonly Inline[]; readonly header: boolean; readonly align: 'left' | 'center' | 'right' | null }
|
|
33
|
+
|
|
34
|
+
export type Inline =
|
|
35
|
+
| { readonly type: 'text' | 'code'; readonly text: string }
|
|
36
|
+
| { readonly type: 'strong' | 'em' | 'del'; readonly children: readonly Inline[] }
|
|
37
|
+
| { readonly type: 'link'; readonly href: string; readonly title: string | null; readonly children: readonly Inline[] }
|
|
38
|
+
| { readonly type: 'inline-image'; readonly href: string; readonly alt: string; readonly title: string | null }
|
|
39
|
+
| { readonly type: 'break'; readonly hard: boolean }
|
|
40
|
+
| { readonly type: 'math'; readonly content: string; readonly open: '$' | '\\('; readonly close: '$' | '\\)'; readonly raw: string }
|
|
41
|
+
|
|
42
|
+
export function assertNever(value: never): never {
|
|
43
|
+
throw new Error(`Unexpected document node: ${String(value)}`)
|
|
44
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import stringWidth from "string-width"
|
|
2
|
+
|
|
3
|
+
// The frame is the chrome of the viewer: a window the document is drawn in. It
|
|
4
|
+
// only ever constrains and decorates the area around the document; the document
|
|
5
|
+
// itself stays anchored to the left edge of that area.
|
|
6
|
+
export const frameAligns = ["left", "center", "right"] as const
|
|
7
|
+
|
|
8
|
+
export type FrameAlign = (typeof frameAligns)[number]
|
|
9
|
+
|
|
10
|
+
export function isFrameAlign(value: unknown): value is FrameAlign {
|
|
11
|
+
return typeof value === "string" && (frameAligns as readonly string[]).includes(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const frameStyles = ["box", "ascii", "rounded", "heavy", "double"] as const
|
|
15
|
+
|
|
16
|
+
// "box" is the bundled single-line window. "ascii" draws the same window with
|
|
17
|
+
// `+ - |` for terminals and fonts where the Unicode box characters do not line
|
|
18
|
+
// up. The rest are the native OpenTUI border sets.
|
|
19
|
+
export type FrameStyle = (typeof frameStyles)[number]
|
|
20
|
+
|
|
21
|
+
export function isFrameStyle(value: unknown): value is FrameStyle {
|
|
22
|
+
return typeof value === "string" && (frameStyles as readonly string[]).includes(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FrameConfig = {
|
|
26
|
+
// The frame is opt-in: without it the document uses the whole measured area,
|
|
27
|
+
// which is what an embedded host (a message bubble, a sidebar) wants.
|
|
28
|
+
readonly enabled?: boolean
|
|
29
|
+
// The width of the frame in terminal columns, border and padding included.
|
|
30
|
+
// Omitted means "as wide as the area allows".
|
|
31
|
+
readonly width?: number
|
|
32
|
+
// Where the frame sits when it is narrower than the area around it.
|
|
33
|
+
readonly align?: FrameAlign
|
|
34
|
+
readonly style?: FrameStyle
|
|
35
|
+
// Empty columns between the border and the document.
|
|
36
|
+
readonly paddingX?: number
|
|
37
|
+
// Empty rows between the border and the document.
|
|
38
|
+
readonly paddingY?: number
|
|
39
|
+
// A label carved out of the middle of the top border.
|
|
40
|
+
readonly title?: string
|
|
41
|
+
readonly titleAlign?: FrameAlign
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FrameInsets = {
|
|
45
|
+
readonly left: number
|
|
46
|
+
readonly right: number
|
|
47
|
+
readonly top: number
|
|
48
|
+
readonly bottom: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ResolvedFrame = {
|
|
52
|
+
readonly enabled: boolean
|
|
53
|
+
// The width every block of the document is laid out for. This is the single
|
|
54
|
+
// content width shared across the document.
|
|
55
|
+
readonly contentWidth: number
|
|
56
|
+
// The outer width, border and padding included.
|
|
57
|
+
readonly width: number
|
|
58
|
+
// The column the frame starts at inside the area around it.
|
|
59
|
+
readonly offsetX: number
|
|
60
|
+
readonly insets: FrameInsets
|
|
61
|
+
readonly align: FrameAlign
|
|
62
|
+
readonly style: FrameStyle
|
|
63
|
+
readonly paddingX: number
|
|
64
|
+
readonly paddingY: number
|
|
65
|
+
readonly title?: string
|
|
66
|
+
readonly titleAlign: FrameAlign
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const borderWidth = 1
|
|
70
|
+
const noInsets: FrameInsets = { left: 0, right: 0, top: 0, bottom: 0 }
|
|
71
|
+
|
|
72
|
+
function whole(value: number | undefined, fallback: number) {
|
|
73
|
+
if (value === undefined) return fallback
|
|
74
|
+
if (!Number.isFinite(value)) return fallback
|
|
75
|
+
return Math.max(0, Math.floor(value))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function alignOffset(align: FrameAlign, available: number, width: number) {
|
|
79
|
+
if (align === "right") return Math.max(0, available - width)
|
|
80
|
+
if (align === "center") return Math.max(0, Math.floor((available - width) / 2))
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A title wider than the top border would be clipped by the terminal instead of
|
|
85
|
+
// by us, so it is cut here where the layout knows the frame width.
|
|
86
|
+
function fitTitle(title: string | undefined, columns: number) {
|
|
87
|
+
if (!title || columns <= 0) return undefined
|
|
88
|
+
let result = ""
|
|
89
|
+
for (const character of title) {
|
|
90
|
+
if (stringWidth(result + character) > columns) break
|
|
91
|
+
result += character
|
|
92
|
+
}
|
|
93
|
+
return result || undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// `resolveFrame` is the single place that turns the host's area width and the
|
|
97
|
+
// frame configuration into the widths the renderer uses. It has no dependency on
|
|
98
|
+
// OpenTUI or Solid, so a host can call it without mounting anything.
|
|
99
|
+
export function resolveFrame(viewport: number, config?: FrameConfig): ResolvedFrame {
|
|
100
|
+
const available = Math.max(1, Math.floor(viewport) || 1)
|
|
101
|
+
const align = config?.align ?? "left"
|
|
102
|
+
const style = config?.style ?? "box"
|
|
103
|
+
const titleAlign = config?.titleAlign ?? "left"
|
|
104
|
+
const enabled = config?.enabled ?? false
|
|
105
|
+
if (!enabled) {
|
|
106
|
+
return {
|
|
107
|
+
enabled: false,
|
|
108
|
+
contentWidth: available,
|
|
109
|
+
width: available,
|
|
110
|
+
offsetX: 0,
|
|
111
|
+
insets: noInsets,
|
|
112
|
+
align,
|
|
113
|
+
style,
|
|
114
|
+
paddingX: 0,
|
|
115
|
+
paddingY: 0,
|
|
116
|
+
title: undefined,
|
|
117
|
+
titleAlign,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const paddingX = whole(config?.paddingX, 1)
|
|
121
|
+
const paddingY = whole(config?.paddingY, 0)
|
|
122
|
+
const insets: FrameInsets = {
|
|
123
|
+
left: borderWidth + paddingX,
|
|
124
|
+
right: borderWidth + paddingX,
|
|
125
|
+
top: borderWidth + paddingY,
|
|
126
|
+
bottom: borderWidth + paddingY,
|
|
127
|
+
}
|
|
128
|
+
const minWidth = insets.left + insets.right + 1
|
|
129
|
+
const width = config?.width === undefined ? available : Math.max(minWidth, Math.min(available, Math.floor(config.width)))
|
|
130
|
+
const contentWidth = Math.max(1, width - insets.left - insets.right)
|
|
131
|
+
return {
|
|
132
|
+
enabled: true,
|
|
133
|
+
contentWidth,
|
|
134
|
+
width,
|
|
135
|
+
offsetX: alignOffset(align, available, width),
|
|
136
|
+
insets,
|
|
137
|
+
align,
|
|
138
|
+
style,
|
|
139
|
+
paddingX,
|
|
140
|
+
paddingY,
|
|
141
|
+
title: fitTitle(config?.title, Math.max(0, width - borderWidth * 2 - 2)),
|
|
142
|
+
titleAlign,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Retained as a public helper for hosts validating a standalone image before
|
|
2
|
+
// passing the Markdown to the renderer.
|
|
3
|
+
export type MarkdownImage = { readonly alt: string; readonly href: string }
|
|
4
|
+
|
|
5
|
+
export function standaloneImage(source: string): MarkdownImage | undefined {
|
|
6
|
+
const match = /^!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))\)\s*$/.exec(source.trim())
|
|
7
|
+
if (!match) return undefined
|
|
8
|
+
return { alt: match[1] ?? "", href: match[2] ?? match[3] ?? "" }
|
|
9
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolveFrame, type FrameConfig, type ResolvedFrame } from "./frame"
|
|
2
|
+
import { resolveTableOptions, type ResolvedTableOptions, type TablesConfig } from "./tables"
|
|
3
|
+
|
|
4
|
+
export type DocumentLayoutInput = {
|
|
5
|
+
// The width the host gives the document, in terminal columns.
|
|
6
|
+
readonly viewport: number
|
|
7
|
+
readonly frame?: FrameConfig
|
|
8
|
+
readonly tables?: TablesConfig
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Everything a host needs to size the document without mounting the renderer.
|
|
12
|
+
// The frame width and the content width are resolved together so no block can
|
|
13
|
+
// disagree about where the right edge is.
|
|
14
|
+
export type DocumentLayout = {
|
|
15
|
+
readonly frame: ResolvedFrame
|
|
16
|
+
readonly contentWidth: number
|
|
17
|
+
readonly tables: ResolvedTableOptions
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDocumentLayout(input: DocumentLayoutInput): DocumentLayout {
|
|
21
|
+
const frame = resolveFrame(input.viewport, input.frame)
|
|
22
|
+
return {
|
|
23
|
+
frame,
|
|
24
|
+
contentWidth: frame.contentWidth,
|
|
25
|
+
tables: resolveTableOptions(input.tables),
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/core/math.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { TokenizerAndRendererExtension } from "marked"
|
|
2
|
+
|
|
3
|
+
export type MathInlineToken = {
|
|
4
|
+
type: "mathInline"
|
|
5
|
+
raw: string
|
|
6
|
+
text: string
|
|
7
|
+
content: string
|
|
8
|
+
open: "$" | "\\("
|
|
9
|
+
close: "$" | "\\)"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MathDisplayToken = {
|
|
13
|
+
type: "mathDisplay"
|
|
14
|
+
raw: string
|
|
15
|
+
text: string
|
|
16
|
+
content: string
|
|
17
|
+
open: "$$" | "\\["
|
|
18
|
+
close: "$$" | "\\]"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MathToken = MathInlineToken | MathDisplayToken
|
|
22
|
+
|
|
23
|
+
function isEscaped(src: string, index: number): boolean {
|
|
24
|
+
let backslashes = 0
|
|
25
|
+
let i = index - 1
|
|
26
|
+
while (i >= 0 && src[i] === "\\") {
|
|
27
|
+
backslashes++
|
|
28
|
+
i--
|
|
29
|
+
}
|
|
30
|
+
return backslashes % 2 === 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function closing(src: string, delimiter: string, start: number, singleLine: boolean): number {
|
|
34
|
+
for (let index = start; index < src.length; index++) {
|
|
35
|
+
if (singleLine && src[index] === "\n") break
|
|
36
|
+
if (src.startsWith(delimiter, index) && !isEscaped(src, index)) return index
|
|
37
|
+
}
|
|
38
|
+
return -1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMathInlineExtension(): TokenizerAndRendererExtension {
|
|
42
|
+
return {
|
|
43
|
+
name: "mathInline",
|
|
44
|
+
level: "inline",
|
|
45
|
+
start(src: string) {
|
|
46
|
+
let i = 0
|
|
47
|
+
while (i < src.length) {
|
|
48
|
+
const ch = src[i]
|
|
49
|
+
if (ch === "\\") {
|
|
50
|
+
if (src[i + 1] === "(") {
|
|
51
|
+
if (!isEscaped(src, i)) return i
|
|
52
|
+
}
|
|
53
|
+
i += 2
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (ch === "$") {
|
|
57
|
+
if (src[i + 1] === "$") {
|
|
58
|
+
i += 2
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (!isEscaped(src, i)) return i
|
|
62
|
+
}
|
|
63
|
+
i++
|
|
64
|
+
}
|
|
65
|
+
return
|
|
66
|
+
},
|
|
67
|
+
tokenizer(src: string) {
|
|
68
|
+
// Try \(...\)
|
|
69
|
+
if (src.startsWith("\\(")) {
|
|
70
|
+
// look for \)
|
|
71
|
+
const end = closing(src, "\\)", 2, true)
|
|
72
|
+
if (end > 2 && src.slice(2, end).indexOf("\n") === -1) {
|
|
73
|
+
const content = src.slice(2, end)
|
|
74
|
+
const raw = src.slice(0, end + 2)
|
|
75
|
+
return {
|
|
76
|
+
type: "mathInline",
|
|
77
|
+
raw,
|
|
78
|
+
text: content,
|
|
79
|
+
content,
|
|
80
|
+
open: "\\(",
|
|
81
|
+
close: "\\)",
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Try single $
|
|
86
|
+
if (src[0] === "$" && src[1] !== "$") {
|
|
87
|
+
// find closing $ not escaped
|
|
88
|
+
let j = 1
|
|
89
|
+
while (j < src.length) {
|
|
90
|
+
if (src[j] === "\n") break
|
|
91
|
+
if (src[j] === "$" && !isEscaped(src, j)) {
|
|
92
|
+
if (j === 1) break // empty
|
|
93
|
+
const content = src.slice(1, j)
|
|
94
|
+
const raw = src.slice(0, j + 1)
|
|
95
|
+
return {
|
|
96
|
+
type: "mathInline",
|
|
97
|
+
raw,
|
|
98
|
+
text: content,
|
|
99
|
+
content,
|
|
100
|
+
open: "$",
|
|
101
|
+
close: "$",
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
j++
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createMathDisplayExtension(): TokenizerAndRendererExtension {
|
|
113
|
+
return {
|
|
114
|
+
name: "mathDisplay",
|
|
115
|
+
level: "inline",
|
|
116
|
+
start(src: string) {
|
|
117
|
+
let i = 0
|
|
118
|
+
while (i < src.length) {
|
|
119
|
+
const ch = src[i]
|
|
120
|
+
if (ch === "\\") {
|
|
121
|
+
if (src[i + 1] === "[") {
|
|
122
|
+
if (!isEscaped(src, i)) return i
|
|
123
|
+
}
|
|
124
|
+
i += 2
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (ch === "$") {
|
|
128
|
+
if (src[i + 1] === "$" && !isEscaped(src, i)) return i
|
|
129
|
+
}
|
|
130
|
+
i++
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
},
|
|
134
|
+
tokenizer(src: string) {
|
|
135
|
+
// $$...$$
|
|
136
|
+
if (src.startsWith("$$")) {
|
|
137
|
+
const end = closing(src, "$$", 2, false)
|
|
138
|
+
if (end > 2) {
|
|
139
|
+
const content = src.slice(2, end)
|
|
140
|
+
const raw = src.slice(0, end + 2)
|
|
141
|
+
return {
|
|
142
|
+
type: "mathDisplay",
|
|
143
|
+
raw,
|
|
144
|
+
text: content,
|
|
145
|
+
content,
|
|
146
|
+
open: "$$",
|
|
147
|
+
close: "$$",
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// \[...\]
|
|
152
|
+
if (src.startsWith("\\[")) {
|
|
153
|
+
const end = closing(src, "\\]", 2, false)
|
|
154
|
+
if (end > 2) {
|
|
155
|
+
const content = src.slice(2, end)
|
|
156
|
+
const raw = src.slice(0, end + 2)
|
|
157
|
+
return {
|
|
158
|
+
type: "mathDisplay",
|
|
159
|
+
raw,
|
|
160
|
+
text: content,
|
|
161
|
+
content,
|
|
162
|
+
open: "\\[",
|
|
163
|
+
close: "\\]",
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
}
|