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.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +27 -0
  3. package/README.md +173 -2
  4. package/native/Cargo.lock +3708 -0
  5. package/native/Cargo.toml +28 -0
  6. package/native/build.rs +3 -0
  7. package/native/src/lib.rs +880 -0
  8. package/package.json +62 -4
  9. package/prebuilds/linux-x64-gnu/opentui_markdown_render.node +4 -0
  10. package/src/app.tsx +180 -0
  11. package/src/config/index.ts +45 -0
  12. package/src/config/load.ts +308 -0
  13. package/src/config/resolve.ts +37 -0
  14. package/src/config/theme.ts +88 -0
  15. package/src/config/types.ts +102 -0
  16. package/src/core/document.ts +44 -0
  17. package/src/core/frame.ts +144 -0
  18. package/src/core/image.ts +9 -0
  19. package/src/core/index.ts +6 -0
  20. package/src/core/layout.ts +27 -0
  21. package/src/core/math.ts +170 -0
  22. package/src/core/parse.ts +208 -0
  23. package/src/core/tables.ts +58 -0
  24. package/src/graphics.ts +85 -0
  25. package/src/headings.ts +77 -0
  26. package/src/index.ts +98 -0
  27. package/src/math/index.ts +86 -0
  28. package/src/math/native.ts +39 -0
  29. package/src/math/renderer.ts +84 -0
  30. package/src/math/validate.ts +44 -0
  31. package/src/media.tsx +267 -0
  32. package/src/merman/class/dagre-layout.ts +171 -0
  33. package/src/merman/class/diagram.ts +8 -0
  34. package/src/merman/class/drawing.ts +343 -0
  35. package/src/merman/class/labels.ts +38 -0
  36. package/src/merman/class/layout.ts +531 -0
  37. package/src/merman/class/parser.ts +308 -0
  38. package/src/merman/class/render-grid.ts +18 -0
  39. package/src/merman/class/style.ts +27 -0
  40. package/src/merman/class/types.ts +57 -0
  41. package/src/merman/core/canvas.ts +228 -0
  42. package/src/merman/core/color/style.ts +56 -0
  43. package/src/merman/core/drawing.ts +307 -0
  44. package/src/merman/core/geometry.ts +347 -0
  45. package/src/merman/core/mermaid.ts +38 -0
  46. package/src/merman/core/render-grid.ts +51 -0
  47. package/src/merman/core/spatial.ts +243 -0
  48. package/src/merman/core/text-lines.ts +32 -0
  49. package/src/merman/core/text.ts +44 -0
  50. package/src/merman/detect.ts +19 -0
  51. package/src/merman/diagnostics.ts +16 -0
  52. package/src/merman/diagram.ts +268 -0
  53. package/src/merman/flowchart/drawing.ts +433 -0
  54. package/src/merman/flowchart/labels.ts +144 -0
  55. package/src/merman/flowchart/layout.ts +1057 -0
  56. package/src/merman/flowchart/options.ts +12 -0
  57. package/src/merman/flowchart/parser.ts +477 -0
  58. package/src/merman/flowchart/render.ts +10 -0
  59. package/src/merman/flowchart/routing.ts +1429 -0
  60. package/src/merman/flowchart/style.ts +79 -0
  61. package/src/merman/flowchart/types.ts +63 -0
  62. package/src/merman/gantt/diagram.ts +8 -0
  63. package/src/merman/gantt/drawing.ts +193 -0
  64. package/src/merman/gantt/parser.ts +198 -0
  65. package/src/merman/gantt/render-grid.ts +17 -0
  66. package/src/merman/gantt/style.ts +36 -0
  67. package/src/merman/gantt/types.ts +45 -0
  68. package/src/merman/gitgraph/diagram.ts +8 -0
  69. package/src/merman/gitgraph/drawing.ts +234 -0
  70. package/src/merman/gitgraph/parser.ts +234 -0
  71. package/src/merman/gitgraph/render-grid.ts +17 -0
  72. package/src/merman/gitgraph/style.ts +37 -0
  73. package/src/merman/gitgraph/types.ts +36 -0
  74. package/src/merman/palette.ts +60 -0
  75. package/src/merman/sequence/diagram.ts +8 -0
  76. package/src/merman/sequence/drawing.ts +288 -0
  77. package/src/merman/sequence/options.ts +8 -0
  78. package/src/merman/sequence/parser.ts +303 -0
  79. package/src/merman/sequence/placement.ts +692 -0
  80. package/src/merman/sequence/render-grid.ts +22 -0
  81. package/src/merman/sequence/style.ts +81 -0
  82. package/src/merman/sequence/types.ts +74 -0
  83. package/src/merman/state/diagram.ts +8 -0
  84. package/src/merman/state/drawing.ts +363 -0
  85. package/src/merman/state/endpoint.ts +15 -0
  86. package/src/merman/state/layout.ts +1134 -0
  87. package/src/merman/state/note.ts +59 -0
  88. package/src/merman/state/options.ts +11 -0
  89. package/src/merman/state/parser.ts +245 -0
  90. package/src/merman/state/render-grid.ts +22 -0
  91. package/src/merman/state/routing.ts +1522 -0
  92. package/src/merman/state/search.ts +103 -0
  93. package/src/merman/state/style.ts +52 -0
  94. package/src/merman/state/types.ts +63 -0
  95. package/src/merman/state/visible-model.ts +77 -0
  96. package/src/merman/timeline/diagram.ts +8 -0
  97. package/src/merman/timeline/drawing.ts +109 -0
  98. package/src/merman/timeline/parser.ts +119 -0
  99. package/src/merman/timeline/render-grid.ts +17 -0
  100. package/src/merman/timeline/style.ts +36 -0
  101. package/src/merman/timeline/types.ts +31 -0
  102. package/src/merman.ts +3 -0
  103. package/src/statusline.tsx +183 -0
  104. package/src/theme/colors.ts +11 -0
  105. package/src/theme/defaults.ts +190 -0
  106. package/src/theme/index.ts +5 -0
  107. package/src/theme/resolve.ts +166 -0
  108. package/src/theme/syntax.ts +96 -0
  109. package/src/theme/types.ts +150 -0
  110. package/src/view/code.tsx +27 -0
  111. package/src/view/document-slots.ts +111 -0
  112. package/src/view/document-table.tsx +98 -0
  113. package/src/view/document.tsx +195 -0
  114. package/src/view/frame.tsx +83 -0
  115. package/src/view/inline-flow.tsx +113 -0
  116. package/src/view/inline-layout.ts +149 -0
  117. package/src/view/inline-math.ts +42 -0
  118. package/src/view/inline-style.ts +103 -0
  119. package/src/view/math.tsx +85 -0
  120. package/src/view/mermaid.tsx +128 -0
  121. 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,6 @@
1
+ export * from "./image"
2
+ export * from "./frame"
3
+ export * from "./layout"
4
+ export * from "./tables"
5
+ export * from "./document"
6
+ export { parseDocument } from "./parse"
@@ -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
+ }
@@ -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
+ }