@perspective-dev/viewer 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/docs/perspective-docs.json +1 -0
  24. package/dist/esm/column-format.d.ts +19 -28
  25. package/dist/esm/perspective-viewer.d.ts +2 -0
  26. package/dist/esm/perspective-viewer.inline.js +8 -8
  27. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  28. package/dist/esm/perspective-viewer.js +9 -9
  29. package/dist/esm/perspective-viewer.js.map +4 -4
  30. package/dist/esm/providers.d.ts +79 -0
  31. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  32. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  33. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  34. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  35. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  36. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  37. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  38. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  39. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  40. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  41. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  42. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  43. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  44. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  45. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +43 -1
  46. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  47. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  48. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  49. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  50. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  51. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  52. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  53. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  54. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  55. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  56. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  57. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  58. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  59. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  60. package/dist/esm/ts-rs/ViewerConfig.d.ts +43 -1
  61. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  62. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +72 -0
  63. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  64. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  65. package/dist/esm/ts-rs/WindowSpec.d.ts +47 -0
  66. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  67. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +7 -6
  68. package/dist/wasm/perspective-viewer.d.ts +222 -26
  69. package/dist/wasm/perspective-viewer.js +378 -125
  70. package/dist/wasm/perspective-viewer.wasm +0 -0
  71. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -15
  72. package/package.json +2 -1
  73. package/src/css/_viewer_bundle.css +1 -0
  74. package/src/css/column-selector.css +9 -0
  75. package/src/css/column-settings-panel.css +67 -2
  76. package/src/css/column-style.css +4 -3
  77. package/src/css/config-selector.css +60 -4
  78. package/src/css/containers/context-menu.css +12 -6
  79. package/src/css/containers/tabs.css +2 -2
  80. package/src/css/form/code-editor.css +2 -3
  81. package/src/css/form/mirrored-textarea.css +81 -0
  82. package/src/css/viewer.css +336 -0
  83. package/src/rust/agent/client/mod.rs +19 -0
  84. package/src/rust/agent/client/protocol.rs +320 -0
  85. package/src/rust/agent/client/transport.rs +287 -0
  86. package/src/rust/agent/client/turn.rs +174 -0
  87. package/src/rust/agent/config.rs +159 -0
  88. package/src/rust/agent/docs.rs +293 -0
  89. package/src/rust/agent/markdown/block.rs +509 -0
  90. package/src/rust/agent/markdown/entity.rs +52 -0
  91. package/src/rust/agent/markdown/events.rs +71 -0
  92. package/src/rust/agent/markdown/inline.rs +497 -0
  93. package/src/rust/agent/markdown/mod.rs +466 -0
  94. package/src/rust/agent/mod.rs +23 -0
  95. package/src/rust/agent/preamble.md +16 -0
  96. package/src/rust/agent/runtime.rs +201 -0
  97. package/src/rust/agent/slot.rs +236 -0
  98. package/src/rust/agent/tools.rs +1110 -0
  99. package/src/rust/components/chat_panel.rs +289 -0
  100. package/src/rust/components/column_selector/active_column.rs +21 -27
  101. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  102. package/src/rust/components/column_selector/config_selector.rs +83 -8
  103. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  104. package/src/rust/components/column_selector/inactive_column.rs +8 -1
  105. package/src/rust/components/column_selector.rs +27 -3
  106. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  107. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  108. package/src/rust/components/column_settings_sidebar.rs +163 -16
  109. package/src/rust/components/containers/dragdrop_list.rs +110 -3
  110. package/src/rust/components/containers/sidebar.rs +99 -22
  111. package/src/rust/components/containers/split_panel.rs +6 -0
  112. package/src/rust/components/editable_header.rs +2 -0
  113. package/src/rust/components/form/code_editor.rs +14 -23
  114. package/src/rust/components/form/debug.rs +8 -5
  115. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  116. package/src/rust/components/form/mod.rs +1 -0
  117. package/src/rust/components/main_panel.rs +5 -0
  118. package/src/rust/components/mod.rs +3 -0
  119. package/src/rust/components/panel_menu.rs +7 -0
  120. package/src/rust/components/panel_tab.rs +5 -0
  121. package/src/rust/components/portal.rs +7 -0
  122. package/src/rust/components/settings_panel.rs +80 -2
  123. package/src/rust/components/viewer/msg.rs +2 -0
  124. package/src/rust/components/viewer/panels.rs +9 -14
  125. package/src/rust/components/viewer/render.rs +14 -0
  126. package/src/rust/components/viewer/settings.rs +33 -1
  127. package/src/rust/components/viewer.rs +2 -0
  128. package/src/rust/components/window_editor.rs +985 -0
  129. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  130. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  131. package/src/rust/config/number_string_format.rs +33 -0
  132. package/src/rust/config/options.rs +18 -0
  133. package/src/rust/config/plugin_static_config.rs +120 -5
  134. package/src/rust/config/viewer_config.rs +220 -1
  135. package/src/rust/config/workspace_config.rs +7 -6
  136. package/src/rust/custom_elements/viewer.rs +209 -18
  137. package/src/rust/custom_events.rs +29 -0
  138. package/src/rust/lib.rs +17 -1
  139. package/src/rust/presentation/column_locator.rs +16 -1
  140. package/src/rust/presentation.rs +8 -0
  141. package/src/rust/queries/columns_iter_set.rs +7 -1
  142. package/src/rust/queries/get_viewer_config.rs +5 -1
  143. package/src/rust/renderer/dispatch.rs +85 -58
  144. package/src/rust/renderer.rs +34 -1
  145. package/src/rust/session/column_defaults_update.rs +18 -0
  146. package/src/rust/session/drag_drop_update.rs +18 -0
  147. package/src/rust/session/metadata.rs +64 -9
  148. package/src/rust/session/replace_expression_update.rs +2 -0
  149. package/src/rust/session.rs +7 -5
  150. package/src/rust/tasks/create_panel.rs +18 -8
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/mod.rs +2 -0
  153. package/src/rust/tasks/restore_panel.rs +34 -17
  154. package/src/rust/tasks/sync_update_panels.rs +7 -23
  155. package/src/rust/utils/browser/dragdrop.rs +12 -0
  156. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  157. package/src/rust/utils/debounce.rs +76 -27
  158. package/src/rust/workspace.rs +112 -20
  159. package/src/svg/pin-icon.svg +5 -0
  160. package/src/themes/icons.css +1 -0
  161. package/src/themes/intl/de.css +23 -0
  162. package/src/themes/intl/es.css +23 -0
  163. package/src/themes/intl/fr.css +23 -0
  164. package/src/themes/intl/ja.css +23 -0
  165. package/src/themes/intl/pt.css +23 -0
  166. package/src/themes/intl/zh.css +23 -0
  167. package/src/themes/intl.css +26 -1
  168. package/src/ts/column-format.ts +67 -46
  169. package/src/ts/perspective-viewer.ts +2 -0
  170. package/src/ts/providers.ts +96 -0
  171. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  172. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  173. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  174. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  175. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  176. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  177. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  178. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  179. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  180. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  181. package/src/ts/ts-rs/FormatMode.ts +3 -0
  182. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  183. package/src/ts/ts-rs/Notation.ts +11 -0
  184. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  185. package/src/ts/ts-rs/PanelViewerConfig.ts +48 -1
  186. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  187. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  188. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  189. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  190. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  191. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  192. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  193. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  194. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  195. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  196. package/src/ts/ts-rs/Unit.ts +3 -0
  197. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  198. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  199. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  200. package/src/ts/ts-rs/ViewerConfig.ts +48 -1
  201. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  202. package/src/ts/ts-rs/ViewerConfigUpdate.ts +81 -2
  203. package/src/ts/ts-rs/WindowSort.ts +9 -0
  204. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  205. package/src/ts/ts-rs/WindowSpec.ts +47 -0
  206. package/src/ts/ts-rs/Windows.ts +10 -0
  207. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -6
  208. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline0.js +0 -0
  209. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline1.js +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline2.js +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline3.js +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline4.js +0 -0
  213. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-02e461f3d7062b26}/inline5.js +0 -0
@@ -0,0 +1,509 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! Block-level markdown: a line-oriented pass over container structure
14
+ //! (blockquotes, list items) via prefix-stripping recursion, with nom
15
+ //! recognizers for the line classifiers. Deliberately NOT combinator-driven
16
+ //! at the top level — block structure is indentation-context state, which
17
+ //! fits a line loop better than a grammar.
18
+
19
+ use nom::IResult;
20
+ use nom::branch::alt;
21
+ use nom::character::complete::{digit1, one_of};
22
+ use nom::combinator::{map, verify};
23
+ use nom::sequence::terminated;
24
+
25
+ use super::events::{Align, Event, Tag};
26
+ use super::inline;
27
+
28
+ pub fn parse(text: &str) -> Vec<Event> {
29
+ let lines = text.lines().map(str::to_owned).collect::<Vec<_>>();
30
+ let mut events = vec![];
31
+ parse_blocks(&lines, &mut events);
32
+ events
33
+ }
34
+
35
+ fn parse_blocks(lines: &[String], events: &mut Vec<Event>) {
36
+ let mut i = 0;
37
+ while i < lines.len() {
38
+ let line = &lines[i];
39
+ let trimmed = line.trim_start_matches(' ');
40
+ let indent = line.len() - trimmed.len();
41
+ if trimmed.is_empty() {
42
+ i += 1;
43
+ } else if indent <= 3
44
+ && let Some((fence, fence_len, lang)) = fence_open(trimmed)
45
+ {
46
+ i = parse_fence(lines, i, indent, fence, fence_len, lang, events);
47
+ } else if indent >= 4 {
48
+ i = parse_indented_code(lines, i, events);
49
+ } else if let Some((level, content)) = atx_heading(trimmed) {
50
+ events.push(Event::Start(Tag::Heading(level)));
51
+ inline::parse_into(content, events);
52
+ events.push(Event::End);
53
+ i += 1;
54
+ } else if thematic_break(trimmed) {
55
+ events.push(Event::Rule);
56
+ i += 1;
57
+ } else if trimmed.starts_with('>') {
58
+ i = parse_quote(lines, i, events);
59
+ } else if let Some(aligns) = table_start(lines, i) {
60
+ i = parse_table(lines, i, aligns, events);
61
+ } else if let Some((_, start, content_col)) = list_marker(line) {
62
+ i = parse_list(lines, i, start, content_col, events);
63
+ } else {
64
+ i = parse_paragraph(lines, i, events);
65
+ }
66
+ }
67
+ }
68
+
69
+ /// ` ```lang ` / `~~~lang` — 3+ fence chars, optional info string whose
70
+ /// first word is the language.
71
+ fn fence_open(trimmed: &str) -> Option<(char, usize, Option<String>)> {
72
+ let fence = trimmed.chars().next().filter(|x| *x == '`' || *x == '~')?;
73
+ let len = trimmed.chars().take_while(|x| *x == fence).count();
74
+ if len < 3 {
75
+ return None;
76
+ }
77
+
78
+ let info = trimmed[len..].trim();
79
+ if fence == '`' && info.contains('`') {
80
+ return None;
81
+ }
82
+
83
+ Some((
84
+ fence,
85
+ len,
86
+ info.split_whitespace().next().map(str::to_owned),
87
+ ))
88
+ }
89
+
90
+ #[allow(clippy::too_many_arguments)]
91
+ fn parse_fence(
92
+ lines: &[String],
93
+ i: usize,
94
+ indent: usize,
95
+ fence: char,
96
+ fence_len: usize,
97
+ lang: Option<String>,
98
+ events: &mut Vec<Event>,
99
+ ) -> usize {
100
+ let mut content = String::new();
101
+ let mut j = i + 1;
102
+ while j < lines.len() {
103
+ let line = &lines[j];
104
+ let trimmed = line.trim_start_matches(' ');
105
+ let close_len = trimmed.chars().take_while(|x| *x == fence).count();
106
+ if close_len >= fence_len && trimmed[close_len..].trim().is_empty() {
107
+ j += 1;
108
+ break;
109
+ }
110
+
111
+ // Strip up to the opening fence's indentation.
112
+ let strip = line.chars().take_while(|x| *x == ' ').count().min(indent);
113
+
114
+ content.push_str(&line[strip..]);
115
+ content.push('\n');
116
+ j += 1;
117
+ }
118
+
119
+ events.push(Event::Start(Tag::CodeBlock(lang)));
120
+ events.push(Event::Text(content));
121
+ events.push(Event::End);
122
+ j
123
+ }
124
+
125
+ fn parse_indented_code(lines: &[String], i: usize, events: &mut Vec<Event>) -> usize {
126
+ let mut collected: Vec<String> = vec![];
127
+ let mut j = i;
128
+ while j < lines.len() {
129
+ let line = &lines[j];
130
+ if line.trim().is_empty() {
131
+ collected.push(String::new());
132
+ } else if indent_of(line) >= 4 {
133
+ collected.push(line[4..].to_owned());
134
+ } else {
135
+ break;
136
+ }
137
+
138
+ j += 1;
139
+ }
140
+
141
+ while collected.last().is_some_and(|x| x.is_empty()) {
142
+ collected.pop();
143
+ }
144
+
145
+ events.push(Event::Start(Tag::CodeBlock(None)));
146
+ events.push(Event::Text(collected.join("\n") + "\n"));
147
+ events.push(Event::End);
148
+ j
149
+ }
150
+
151
+ /// 1–6 `#` + space (or end of line); optional closing `#` run stripped.
152
+ fn atx_heading(trimmed: &str) -> Option<(u8, &str)> {
153
+ let level = trimmed.chars().take_while(|x| *x == '#').count();
154
+ if level == 0 || level > 6 {
155
+ return None;
156
+ }
157
+
158
+ let rest = &trimmed[level..];
159
+ if !rest.is_empty() && !rest.starts_with(' ') {
160
+ return None;
161
+ }
162
+
163
+ let content = rest.trim();
164
+ let stripped = content.trim_end_matches('#');
165
+ let content =
166
+ if stripped.len() < content.len() && (stripped.is_empty() || stripped.ends_with(' ')) {
167
+ stripped.trim_end()
168
+ } else {
169
+ content
170
+ };
171
+
172
+ Some((level as u8, content))
173
+ }
174
+
175
+ /// 3+ of the same `-`/`_`/`*` char, optionally space-separated, alone on
176
+ /// the line. Checked before list markers so `---` is a rule.
177
+ fn thematic_break(trimmed: &str) -> bool {
178
+ for fence in ['-', '_', '*'] {
179
+ if trimmed.chars().all(|x| x == fence || x == ' ')
180
+ && trimmed.chars().filter(|x| *x == fence).count() >= 3
181
+ {
182
+ return true;
183
+ }
184
+ }
185
+
186
+ false
187
+ }
188
+
189
+ /// Consecutive `>`-prefixed lines; no lazy continuation.
190
+ fn parse_quote(lines: &[String], i: usize, events: &mut Vec<Event>) -> usize {
191
+ let mut inner = vec![];
192
+ let mut j = i;
193
+ while j < lines.len() {
194
+ let trimmed = lines[j].trim_start_matches(' ');
195
+ let Some(rest) = trimmed.strip_prefix('>') else {
196
+ break;
197
+ };
198
+
199
+ inner.push(rest.strip_prefix(' ').unwrap_or(rest).to_owned());
200
+ j += 1;
201
+ }
202
+
203
+ events.push(Event::Start(Tag::BlockQuote));
204
+ parse_blocks(&inner, events);
205
+ events.push(Event::End);
206
+ j
207
+ }
208
+
209
+ fn nom_bullet(input: &str) -> IResult<&str, Option<u64>> {
210
+ map(one_of("-*+"), |_| None)(input)
211
+ }
212
+
213
+ fn nom_ordered(input: &str) -> IResult<&str, Option<u64>> {
214
+ map(
215
+ terminated(verify(digit1, |x: &str| x.len() <= 9), one_of(".)")),
216
+ |x: &str| Some(x.parse().unwrap_or(1)),
217
+ )(input)
218
+ }
219
+
220
+ /// `- ` / `* ` / `+ ` / `1. ` / `1) ` → (indent, `List` payload, column
221
+ /// where item content begins).
222
+ fn list_marker(line: &str) -> Option<(usize, Option<u64>, usize)> {
223
+ let indent = indent_of(line);
224
+ if indent > 3 {
225
+ return None;
226
+ }
227
+
228
+ let rest = &line[indent..];
229
+ let (after, start) = alt((nom_bullet, nom_ordered))(rest).ok()?;
230
+ if !after.is_empty() && !after.starts_with(' ') {
231
+ return None;
232
+ }
233
+
234
+ let width = rest.len() - after.len();
235
+ Some((indent, start, indent + width + 1))
236
+ }
237
+
238
+ fn parse_list(
239
+ lines: &[String],
240
+ i: usize,
241
+ start: Option<u64>,
242
+ content_col: usize,
243
+ events: &mut Vec<Event>,
244
+ ) -> usize {
245
+ events.push(Event::Start(Tag::List(start)));
246
+ let mut j = i;
247
+ let mut col = content_col;
248
+ loop {
249
+ // One item: the marker line's remainder, plus blank or
250
+ // content-indented continuation lines, prefix-stripped.
251
+ let mut item = vec![line_from(&lines[j], col)];
252
+ j += 1;
253
+ while j < lines.len() {
254
+ let line = &lines[j];
255
+ if line.trim().is_empty() {
256
+ item.push(String::new());
257
+ } else if indent_of(line) >= col {
258
+ item.push(line_from(line, col));
259
+ } else {
260
+ break;
261
+ }
262
+
263
+ j += 1;
264
+ }
265
+
266
+ while item.last().is_some_and(|x| x.is_empty()) {
267
+ item.pop();
268
+ }
269
+
270
+ let mut item_events = vec![];
271
+ parse_blocks(&item, &mut item_events);
272
+ events.push(Event::Start(Tag::Item));
273
+ events.extend(strip_item_paragraphs(item_events));
274
+ events.push(Event::End);
275
+
276
+ // Another marker of the same list kind continues the list.
277
+ match lines.get(j).and_then(|x| list_marker(x)) {
278
+ Some((_, next_start, next_col)) if next_start.is_some() == start.is_some() => {
279
+ col = next_col;
280
+ },
281
+ _ => break,
282
+ }
283
+ }
284
+
285
+ events.push(Event::End);
286
+ j
287
+ }
288
+
289
+ /// All lists render tight: top-level paragraph wrappers inside an item are
290
+ /// stripped (adjacent stripped paragraphs separated by a hard break), so
291
+ /// `<li>` holds inline content directly, with nested blocks intact.
292
+ fn strip_item_paragraphs(item_events: Vec<Event>) -> Vec<Event> {
293
+ let mut out = Vec::with_capacity(item_events.len());
294
+ let mut depth = 0usize;
295
+ let mut stripped = vec![];
296
+ let mut pending_break = false;
297
+ for event in item_events {
298
+ match event {
299
+ Event::Start(Tag::Paragraph) if depth == 0 => {
300
+ if pending_break {
301
+ out.push(Event::HardBreak);
302
+ pending_break = false;
303
+ }
304
+
305
+ stripped.push(depth);
306
+ depth += 1;
307
+ },
308
+ Event::Start(tag) => {
309
+ if depth == 0 {
310
+ pending_break = false;
311
+ }
312
+
313
+ depth += 1;
314
+ out.push(Event::Start(tag));
315
+ },
316
+ Event::End => {
317
+ depth -= 1;
318
+ if stripped.last() == Some(&depth) {
319
+ stripped.pop();
320
+ pending_break = true;
321
+ } else {
322
+ out.push(Event::End);
323
+ }
324
+ },
325
+ event => {
326
+ if depth == 0 {
327
+ pending_break = false;
328
+ }
329
+
330
+ out.push(event);
331
+ },
332
+ }
333
+ }
334
+
335
+ out
336
+ }
337
+
338
+ /// A table begins at a line containing `|` whose successor is a delimiter
339
+ /// row with a MATCHING column count — the count check keeps prose containing
340
+ /// pipes (and `---` rules) from false-positives.
341
+ fn table_start(lines: &[String], i: usize) -> Option<Vec<Align>> {
342
+ if !lines[i].contains('|') {
343
+ return None;
344
+ }
345
+
346
+ let aligns = delimiter_row(lines.get(i + 1)?)?;
347
+ (split_row(&lines[i]).len() == aligns.len()).then_some(aligns)
348
+ }
349
+
350
+ fn delimiter_row(line: &str) -> Option<Vec<Align>> {
351
+ if !line.contains('|') {
352
+ return None;
353
+ }
354
+
355
+ let cells = split_row(line);
356
+ if cells.is_empty() {
357
+ return None;
358
+ }
359
+
360
+ cells
361
+ .iter()
362
+ .map(|cell| {
363
+ let cell = cell.trim();
364
+ let dashes = cell.trim_start_matches(':').trim_end_matches(':');
365
+ if dashes.is_empty() || !dashes.chars().all(|x| x == '-') {
366
+ return None;
367
+ }
368
+
369
+ Some(match (cell.starts_with(':'), cell.ends_with(':')) {
370
+ (true, true) => Align::Center,
371
+ (true, false) => Align::Left,
372
+ (false, true) => Align::Right,
373
+ (false, false) => Align::None,
374
+ })
375
+ })
376
+ .collect()
377
+ }
378
+
379
+ /// Split on unescaped `|` outside backtick code spans; outer pipes dropped,
380
+ /// cells trimmed. Per GFM, `\|` unescapes HERE (before inline parsing) —
381
+ /// that's how a pipe gets into a cell's code span; other escapes are left
382
+ /// for the inline pass.
383
+ fn split_row(line: &str) -> Vec<String> {
384
+ let chars = line.trim().chars().collect::<Vec<_>>();
385
+ let mut cells = vec![];
386
+ let mut cell = String::new();
387
+ let mut code: Option<usize> = None;
388
+ let mut i = 0;
389
+ while i < chars.len() {
390
+ match chars[i] {
391
+ '\\' if i + 1 < chars.len() => {
392
+ if chars[i + 1] != '|' {
393
+ cell.push('\\');
394
+ }
395
+
396
+ cell.push(chars[i + 1]);
397
+ i += 2;
398
+ },
399
+ '`' => {
400
+ let run = chars[i..].iter().take_while(|x| **x == '`').count();
401
+ code = match code {
402
+ None => Some(run),
403
+ Some(open) if open == run => None,
404
+ open => open,
405
+ };
406
+
407
+ cell.extend(std::iter::repeat_n('`', run));
408
+ i += run;
409
+ },
410
+ '|' if code.is_none() => {
411
+ cells.push(cell.trim().to_owned());
412
+ cell.clear();
413
+ i += 1;
414
+ },
415
+ c => {
416
+ cell.push(c);
417
+ i += 1;
418
+ },
419
+ }
420
+ }
421
+
422
+ cells.push(cell.trim().to_owned());
423
+ if cells.first().is_some_and(|x| x.is_empty()) {
424
+ cells.remove(0);
425
+ }
426
+
427
+ if cells.last().is_some_and(|x| x.is_empty()) {
428
+ cells.pop();
429
+ }
430
+
431
+ cells
432
+ }
433
+
434
+ fn parse_table(lines: &[String], i: usize, aligns: Vec<Align>, events: &mut Vec<Event>) -> usize {
435
+ let columns = aligns.len();
436
+ events.push(Event::Start(Tag::Table(aligns)));
437
+ events.push(Event::Start(Tag::TableHead));
438
+ for cell in split_row(&lines[i]) {
439
+ events.push(Event::Start(Tag::TableCell));
440
+ inline::parse_into(&cell, events);
441
+ events.push(Event::End);
442
+ }
443
+
444
+ events.push(Event::End);
445
+ let mut j = i + 2;
446
+ while j < lines.len() && !lines[j].trim().is_empty() && lines[j].contains('|') {
447
+ let cells = split_row(&lines[j]);
448
+ events.push(Event::Start(Tag::TableRow));
449
+ for c in 0..columns {
450
+ events.push(Event::Start(Tag::TableCell));
451
+ inline::parse_into(cells.get(c).map(String::as_str).unwrap_or(""), events);
452
+ events.push(Event::End);
453
+ }
454
+
455
+ events.push(Event::End);
456
+ j += 1;
457
+ }
458
+
459
+ events.push(Event::End);
460
+ j
461
+ }
462
+
463
+ /// Non-blank lines accumulate until a blank or a construct that interrupts
464
+ /// a paragraph; indented lines join it (indented code cannot interrupt).
465
+ fn parse_paragraph(lines: &[String], i: usize, events: &mut Vec<Event>) -> usize {
466
+ let mut text = String::new();
467
+ let mut j = i;
468
+ while j < lines.len() {
469
+ let line = &lines[j];
470
+ let trimmed = line.trim_start_matches(' ');
471
+ let indent = line.len() - trimmed.len();
472
+ if trimmed.is_empty()
473
+ || j > i
474
+ && indent <= 3
475
+ && (fence_open(trimmed).is_some()
476
+ || atx_heading(trimmed).is_some()
477
+ || thematic_break(trimmed)
478
+ || trimmed.starts_with('>')
479
+ || list_marker(line).is_some()
480
+ || table_start(lines, j).is_some())
481
+ {
482
+ break;
483
+ }
484
+
485
+ if j > i {
486
+ text.push('\n');
487
+ }
488
+
489
+ // Trailing spaces are significant (hard breaks) — only CR is
490
+ // stripped.
491
+ text.push_str(line.trim_end_matches('\r'));
492
+ j += 1;
493
+ }
494
+
495
+ events.push(Event::Start(Tag::Paragraph));
496
+ inline::parse_into(text.trim_start(), events);
497
+ events.push(Event::End);
498
+ j
499
+ }
500
+
501
+ fn indent_of(line: &str) -> usize {
502
+ line.chars().take_while(|x| *x == ' ').count()
503
+ }
504
+
505
+ /// The tail of `line` from byte column `col` (always a char boundary — the
506
+ /// prefix is ASCII spaces/markers), or empty when the line is shorter.
507
+ fn line_from(line: &str, col: usize) -> String {
508
+ line.get(col..).unwrap_or("").to_owned()
509
+ }
@@ -0,0 +1,52 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! Minimal HTML entity decoding: numeric references plus the named entities
14
+ //! that actually occur in LLM output. An unrecognized entity stays literal
15
+ //! text — a cosmetic divergence from CommonMark's 2,000-entry table, never a
16
+ //! correctness one (this module exists so we don't link that table).
17
+
18
+ const NAMED: &[(&str, char)] = &[
19
+ ("amp", '&'),
20
+ ("lt", '<'),
21
+ ("gt", '>'),
22
+ ("quot", '"'),
23
+ ("apos", '\''),
24
+ ("nbsp", '\u{a0}'),
25
+ ("mdash", '—'),
26
+ ("ndash", '–'),
27
+ ("hellip", '…'),
28
+ ("copy", '©'),
29
+ ("reg", '®'),
30
+ ("trade", '™'),
31
+ ("deg", '°'),
32
+ ("times", '×'),
33
+ ("plusmn", '±'),
34
+ ("middot", '·'),
35
+ ];
36
+
37
+ /// Try to decode an entity reference at the start of `text` (which begins
38
+ /// just AFTER the `&`). Returns the decoded char and the length consumed
39
+ /// (excluding the `&`).
40
+ pub fn decode(text: &str) -> Option<(char, usize)> {
41
+ let semi = text.find(';').filter(|x| *x <= 10)?;
42
+ let name = &text[..semi];
43
+ let decoded = if let Some(hex) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")) {
44
+ char::from_u32(u32::from_str_radix(hex, 16).ok()?)?
45
+ } else if let Some(dec) = name.strip_prefix('#') {
46
+ char::from_u32(dec.parse().ok()?)?
47
+ } else {
48
+ NAMED.iter().find(|(n, _)| *n == name)?.1
49
+ };
50
+
51
+ Some((decoded, semi + 1))
52
+ }
@@ -0,0 +1,71 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! The parser→renderer event vocabulary — the closed set of constructs the
14
+ //! chat renderer emits. Anything the parser can't classify degrades to
15
+ //! `Text`, so content is never lost, only unstyled.
16
+
17
+ /// GFM table column alignment.
18
+ #[derive(Clone, Debug, PartialEq)]
19
+ pub enum Align {
20
+ None,
21
+ Left,
22
+ Center,
23
+ Right,
24
+ }
25
+
26
+ #[derive(Clone, Debug, PartialEq)]
27
+ pub enum Tag {
28
+ Paragraph,
29
+
30
+ /// ATX heading level, `1..=6`.
31
+ Heading(u8),
32
+
33
+ BlockQuote,
34
+
35
+ /// A code block; the payload is the fence's language word, if any.
36
+ CodeBlock(Option<String>),
37
+
38
+ /// `None` renders `<ul>`; `Some(start)` renders `<ol>`.
39
+ List(Option<u64>),
40
+
41
+ Item,
42
+ Emphasis,
43
+ Strong,
44
+ Strikethrough,
45
+ Link {
46
+ dest_url: String,
47
+ title: String,
48
+ },
49
+ Image {
50
+ dest_url: String,
51
+ },
52
+ Table(Vec<Align>),
53
+ TableHead,
54
+ TableRow,
55
+ TableCell,
56
+ }
57
+
58
+ /// `Start`/`End` are emitted in balanced pairs by construction — both come
59
+ /// from the same lexical scope in the parser.
60
+ #[derive(Clone, Debug, PartialEq)]
61
+ pub enum Event {
62
+ Start(Tag),
63
+ End,
64
+ Text(String),
65
+
66
+ /// An inline code span (block-level code is `Tag::CodeBlock` + `Text`).
67
+ Code(String),
68
+ SoftBreak,
69
+ HardBreak,
70
+ Rule,
71
+ }