@k2b/editor 0.0.0 → 0.1.1

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 (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +32 -1
  3. package/package.json +116 -4
  4. package/src/BubbleMenu.tsx +126 -0
  5. package/src/Editor.tsx +32 -0
  6. package/src/SlashMenu.tsx +13 -0
  7. package/src/Toolbar.tsx +205 -0
  8. package/src/actions.ts +102 -0
  9. package/src/chart-schema.ts +274 -0
  10. package/src/charts.ts +710 -0
  11. package/src/collab/history.ts +39 -0
  12. package/src/collab/index.ts +32 -0
  13. package/src/collab/relative-cursor.ts +18 -0
  14. package/src/collab/remote-cursors.ts +358 -0
  15. package/src/editor-state.ts +199 -0
  16. package/src/formula.ts +1283 -0
  17. package/src/index.ts +16 -0
  18. package/src/markdown/_lib/cursor-zone-field.ts +338 -0
  19. package/src/markdown/_lib/inline-syntax.ts +9 -0
  20. package/src/markdown/_lib/link-opener.ts +50 -0
  21. package/src/markdown/_lib/list-item-line.ts +34 -0
  22. package/src/markdown/_lib/list-structure.ts +227 -0
  23. package/src/markdown/_lib/math-syntax.ts +3 -0
  24. package/src/markdown/_lib/table-cell.ts +74 -0
  25. package/src/markdown/basic.ts +37 -0
  26. package/src/markdown/block-previews.tsx +453 -0
  27. package/src/markdown/chart-block-messages.ts +5 -0
  28. package/src/markdown/chart-copy.ts +20 -0
  29. package/src/markdown/code-fence-snippets.ts +167 -0
  30. package/src/markdown/code-font.ts +54 -0
  31. package/src/markdown/completion-icon.ts +9 -0
  32. package/src/markdown/data-block-template.ts +11 -0
  33. package/src/markdown/data-blocks.ts +214 -0
  34. package/src/markdown/editor-scope.ts +92 -0
  35. package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
  36. package/src/markdown/fade-scroll-margins.ts +25 -0
  37. package/src/markdown/image-syntax.ts +26 -0
  38. package/src/markdown/images.ts +192 -0
  39. package/src/markdown/index.ts +54 -0
  40. package/src/markdown/info-block-snippets.ts +159 -0
  41. package/src/markdown/info-blocks.ts +156 -0
  42. package/src/markdown/katex.ts +303 -0
  43. package/src/markdown/lib/chart-blocks.ts +511 -0
  44. package/src/markdown/lib/heading-anchors.ts +40 -0
  45. package/src/markdown/lib/ligatures.ts +107 -0
  46. package/src/markdown/lib/markdown-context.ts +72 -0
  47. package/src/markdown/lib/markdown-fences.ts +22 -0
  48. package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
  49. package/src/markdown/lib/markdown-image.ts +36 -0
  50. package/src/markdown/lib/named-blocks.ts +403 -0
  51. package/src/markdown/lib/query-blocks.ts +145 -0
  52. package/src/markdown/ligatures.ts +223 -0
  53. package/src/markdown/links.ts +158 -0
  54. package/src/markdown/list-commands.ts +1124 -0
  55. package/src/markdown/lists.ts +290 -0
  56. package/src/markdown/mark.ts +103 -0
  57. package/src/markdown/markdown.ts +78 -0
  58. package/src/markdown/markup.ts +385 -0
  59. package/src/markdown/mermaid-export.ts +78 -0
  60. package/src/markdown/mermaid-preview.tsx +76 -0
  61. package/src/markdown/mermaid-render.ts +22 -0
  62. package/src/markdown/mermaid-viewport.tsx +78 -0
  63. package/src/markdown/mermaid.ts +242 -0
  64. package/src/markdown/messages.ts +17 -0
  65. package/src/markdown/named-blocks.ts +84 -0
  66. package/src/markdown/note-scroller.browser-harness.ts +84 -0
  67. package/src/markdown/note-scroller.ts +57 -0
  68. package/src/markdown/notebook-charts.tsx +142 -0
  69. package/src/markdown/pretty-table-messages.ts +33 -0
  70. package/src/markdown/pretty-table.ts +220 -0
  71. package/src/markdown/search-theme.ts +98 -0
  72. package/src/markdown/shared-tables.ts +286 -0
  73. package/src/markdown/sub-sup.ts +115 -0
  74. package/src/markdown/surface-press.ts +17 -0
  75. package/src/markdown/tab-indent.ts +104 -0
  76. package/src/markdown/table-columns.ts +188 -0
  77. package/src/markdown/table-formulas.ts +178 -0
  78. package/src/markdown/tables.ts +703 -0
  79. package/src/markdown/theme.ts +243 -0
  80. package/src/markdown/urls.ts +29 -0
  81. package/src/messages.ts +45 -0
  82. package/src/slash-menu.ts +220 -0
  83. package/src/styles.css +376 -0
@@ -0,0 +1,72 @@
1
+ import { scanMarkdownInfoBlock } from "@k2b/ui";
2
+ import { Lexer, Tokenizer } from "marked";
3
+ import { isIndentedCodeLine, openingCodeFence } from "./markdown-fences";
4
+
5
+ export const NOTEBOOK_DIRECTIVE_START = /^ {0,3}:::/;
6
+
7
+ const NOTEBOOK_DIRECTIVE = /^ {0,3}:::(?:query|toc|data|chart|map)[ \t]*$/;
8
+ const lineEnd = (source: string, from: number): number => {
9
+ const end = source.indexOf("\n", from);
10
+ return end < 0 ? source.length : end;
11
+ };
12
+ const blockTokenizer = () => {
13
+ const tokenizer = new Tokenizer({ gfm: true });
14
+ // Wire the public tokenizer to its lexer without parsing ordinary paragraphs.
15
+ new Lexer({ gfm: true, tokenizer }).blockTokens("");
16
+ return tokenizer;
17
+ };
18
+
19
+ const container = (source: string, line: string, tokenizer: Tokenizer) => {
20
+ if (isIndentedCodeLine(line)) return tokenizer.code(source);
21
+ if (openingCodeFence(line)) return tokenizer.fences(source);
22
+ if (/^ {0,3}(?:[-+*][ \t]|\d{1,9}[.)][ \t])/.test(line)) return tokenizer.list(source);
23
+ if (/^ {0,3}>/.test(line)) return tokenizer.blockquote(source);
24
+ if (/^ {0,3}</.test(line)) return tokenizer.html(source);
25
+ };
26
+
27
+ /** Notices follow the shared `@k2b/ui` info-block grammar; query, toc, data, and chart blocks end at the next `:::`. */
28
+ const directiveLength = (source: string): number | null => {
29
+ const notice = scanMarkdownInfoBlock(source);
30
+ if (notice) return notice.length;
31
+ const firstEnd = lineEnd(source, 0);
32
+ if (!NOTEBOOK_DIRECTIVE.test(source.slice(0, firstEnd))) return null;
33
+ let offset = Math.min(firstEnd + 1, source.length);
34
+ while (offset < source.length) {
35
+ const end = lineEnd(source, offset);
36
+ if (/^ {0,3}:::[ \t]*$/.test(source.slice(offset, end))) return Math.min(end + 1, source.length);
37
+ offset = Math.min(end + 1, source.length);
38
+ }
39
+ return source.length;
40
+ };
41
+
42
+ /** Exact outer directive extent, shared by the renderer and source scanners. */
43
+ export const notebookDirectiveLength = (source: string): number | null => directiveLength(source);
44
+
45
+ /** Zero-based source lines where document-level notebook blocks cannot start. */
46
+ export const literalMarkdownLines = (source: string): ReadonlySet<number> => {
47
+ const normalized = source.replace(/\r\n?/g, "\n");
48
+ const tokenizer = blockTokenizer();
49
+ const literal = new Set<number>();
50
+ let offset = 0;
51
+ let lineNumber = 0;
52
+ while (offset < normalized.length) {
53
+ const end = lineEnd(normalized, offset);
54
+ const line = normalized.slice(offset, end);
55
+ const directive = NOTEBOOK_DIRECTIVE_START.test(line) ? directiveLength(normalized.slice(offset)) : null;
56
+ const token =
57
+ directive === null && /^(?:[ \t]|[-+*\d><`~])/.test(line) ? container(normalized.slice(offset), line, tokenizer) : undefined;
58
+ const length = directive ?? token?.raw.length;
59
+ if (length) {
60
+ const raw = normalized.slice(offset, offset + length);
61
+ const newlines = raw.split("\n").length - 1;
62
+ const count = newlines + (raw.endsWith("\n") ? 0 : 1);
63
+ for (let index = directive === null ? 0 : 1; index < count; index++) literal.add(lineNumber + index);
64
+ lineNumber += newlines;
65
+ offset += length;
66
+ } else {
67
+ lineNumber++;
68
+ offset = Math.min(end + 1, normalized.length);
69
+ }
70
+ }
71
+ return literal;
72
+ };
@@ -0,0 +1,22 @@
1
+ export type MarkdownCodeFence = { marker: "`" | "~"; length: number };
2
+
3
+ /** CommonMark fences allow at most three leading spaces, not code indentation. */
4
+ export const openingCodeFence = (line: string): MarkdownCodeFence | null => {
5
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)\r?$/.exec(line);
6
+ if (!match || (match[1]![0] === "`" && match[2]!.includes("`"))) return null;
7
+ return { marker: match[1]!.startsWith("`") ? "`" : "~", length: match[1]!.length };
8
+ };
9
+
10
+ export const closesCodeFence = (line: string, fence: MarkdownCodeFence): boolean => {
11
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/.exec(line);
12
+ return !!match && match[1]![0] === fence.marker && match[1]!.length >= fence.length;
13
+ };
14
+
15
+ export const isIndentedCodeLine = (line: string): boolean => /^(?: {4}| {0,3}\t)/.test(line);
16
+
17
+ /** Widget fences use the complete info string, without language aliases. */
18
+ export const codeFenceLanguage = (line: string): string =>
19
+ line
20
+ .replace(/^(```|~~~)/, "")
21
+ .trim()
22
+ .toLowerCase();
@@ -0,0 +1,20 @@
1
+ export const DOCUMENTED_IMAGE_FORMS = ["attach://GHI789", "https://example.test/a.png"].flatMap((href) =>
2
+ [
3
+ { name: "plain", suffix: "", width: null, height: null, title: null },
4
+ { name: "width and height", suffix: " =400x300", width: 400, height: 300, title: null },
5
+ { name: "width only", suffix: " =400x", width: 400, height: null, title: null },
6
+ { name: "height only", suffix: " =x300", width: null, height: 300, title: null },
7
+ { name: "plain with title", suffix: ' "Ground floor"', width: null, height: null, title: "Ground floor" },
8
+ { name: "width and height with title", suffix: ' =400x300 "Ground floor"', width: 400, height: 300, title: "Ground floor" },
9
+ { name: "width only with title", suffix: ' =400x "Ground floor"', width: 400, height: null, title: "Ground floor" },
10
+ { name: "height only with title", suffix: ' =x300 "Ground floor"', width: null, height: 300, title: "Ground floor" },
11
+ ].map(({ name, suffix, width, height, title }) => ({
12
+ name: `${href}: ${name}`,
13
+ markdown: `![Floor plan](${href}${suffix})`,
14
+ href,
15
+ alt: "Floor plan",
16
+ title,
17
+ width,
18
+ height,
19
+ })),
20
+ );
@@ -0,0 +1,36 @@
1
+ type SizedMarkdownImage = {
2
+ length: number;
3
+ alt: string;
4
+ href: string;
5
+ hrefFrom: number;
6
+ hrefTo: number;
7
+ title: string | null;
8
+ titleFrom: number | null;
9
+ titleTo: number | null;
10
+ width: number | null;
11
+ height: number | null;
12
+ };
13
+
14
+ /** Parse a leading `![Alt](HREF =WxH)`, `=Wx`, or `=xH`, with an optional quoted title after the size. */
15
+ export const parseSizedMarkdownImage = (source: string): SizedMarkdownImage | null => {
16
+ const match = /^!\[([^\]\n]*)\]\(([^\s()]+)[ \t]+=(\d*)x(\d*)(?:[ \t]+("[^"\n]*"))?[ \t]*\)/.exec(source);
17
+ if (!match || (!match[3] && !match[4])) return null;
18
+ const alt = match[1]!;
19
+ const href = match[2]!;
20
+ const hrefFrom = alt.length + 4;
21
+ const hrefTo = hrefFrom + href.length;
22
+ const quotedTitle = match[5];
23
+ const titleFrom = quotedTitle === undefined ? null : match[0].indexOf('"', hrefTo);
24
+ return {
25
+ length: match[0].length,
26
+ alt,
27
+ href,
28
+ hrefFrom,
29
+ hrefTo,
30
+ title: quotedTitle === undefined ? null : quotedTitle.slice(1, -1),
31
+ titleFrom,
32
+ titleTo: titleFrom === null ? null : titleFrom + quotedTitle!.length,
33
+ width: match[3] ? Number(match[3]) : null,
34
+ height: match[4] ? Number(match[4]) : null,
35
+ };
36
+ };
@@ -0,0 +1,403 @@
1
+ import { literalMarkdownLines } from "./markdown-context";
2
+ import { isIndentedCodeLine } from "./markdown-fences";
3
+
4
+ export type NamedBlockType = "table" | "list" | "data" | "section" | "unknown";
5
+
6
+ export type NamedBlock = {
7
+ name: string;
8
+ type: NamedBlockType;
9
+ line: number;
10
+ handleStart: number;
11
+ handleEnd: number;
12
+ blockStart: number;
13
+ blockEnd: number;
14
+ startLine: number;
15
+ endLine: number;
16
+ };
17
+
18
+ export type NamedBlockSummary = Pick<NamedBlock, "name" | "type" | "line">;
19
+
20
+ export type DataBlock = Omit<NamedBlock, "name"> & {
21
+ name: string | null;
22
+ };
23
+
24
+ export type NamedDataEntry = {
25
+ key: string;
26
+ value: NamedDataValue;
27
+ };
28
+
29
+ export type NamedDataScalar = string | number | boolean;
30
+ export type NamedDataValue = NamedDataScalar | NamedDataScalar[];
31
+ export type NamedDataDiagnostic = {
32
+ code: "duplicate-block" | "duplicate-key" | "invalid-value" | "too-many-items" | "unclosed-block" | "unexpected-line";
33
+ line: number;
34
+ path: string;
35
+ };
36
+
37
+ export type NamedDataProperties = Record<string, Record<string, NamedDataValue>>;
38
+
39
+ export const DATA_MAX_ENTRIES = 64;
40
+ export const DATA_MAX_LIST_ITEMS = 128;
41
+ export const DATA_MAX_STRING_LENGTH = 2_000;
42
+
43
+ type Line = {
44
+ text: string;
45
+ from: number;
46
+ to: number;
47
+ nextFrom: number;
48
+ };
49
+
50
+ const HANDLE_RE = /^@([A-Za-z][A-Za-z0-9_-]{0,63})\s*$/;
51
+ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
52
+ const LIST_RE = /^\s*(?:[-*+]|\d+[.)])\s+/;
53
+ const HEADING_RE = /^(#{1,6})\s+/;
54
+
55
+ export const isNamedBlockHandle = (line: string): string | null => line.trim().match(HANDLE_RE)?.[1] ?? null;
56
+
57
+ const linesWithOffsets = (md: string): Line[] => {
58
+ const lines: Line[] = [];
59
+ let from = 0;
60
+ while (from <= md.length) {
61
+ const nl = md.indexOf("\n", from);
62
+ const to = nl === -1 ? md.length : nl;
63
+ lines.push({ text: md.slice(from, to), from, to, nextFrom: nl === -1 ? to : nl + 1 });
64
+ if (nl === -1) break;
65
+ from = nl + 1;
66
+ }
67
+ return lines;
68
+ };
69
+
70
+ const nextContentLine = (lines: Line[], start: number): number | null => {
71
+ for (let i = start; i < lines.length; i++) {
72
+ if (lines[i]!.text.trim().length > 0) return i;
73
+ }
74
+ return null;
75
+ };
76
+
77
+ const isTableStart = (lines: Line[], index: number): boolean =>
78
+ !!lines[index]?.text.includes("|") && !!lines[index + 1] && TABLE_SEPARATOR_RE.test(lines[index + 1]!.text);
79
+
80
+ const tableEndLine = (lines: Line[], start: number): number => {
81
+ let end = start + 1;
82
+ for (let i = start + 2; i < lines.length; i++) {
83
+ if (!lines[i]!.text.includes("|") || lines[i]!.text.trim().length === 0) break;
84
+ end = i;
85
+ }
86
+ return end;
87
+ };
88
+
89
+ const listEndLine = (lines: Line[], start: number): number => {
90
+ let end = start;
91
+ for (let i = start + 1; i < lines.length; i++) {
92
+ const line = lines[i]!.text;
93
+ if (line.trim().length === 0) {
94
+ break;
95
+ }
96
+ if (!LIST_RE.test(line) && !/^\s{2,}\S/.test(line)) break;
97
+ end = i;
98
+ }
99
+ return end;
100
+ };
101
+
102
+ const fencedEndLine = (lines: Line[], start: number, marker: string): number => {
103
+ for (let i = start + 1; i < lines.length; i++) {
104
+ if (lines[i]!.text.trim() === marker) return i;
105
+ }
106
+ return start;
107
+ };
108
+
109
+ const sectionEndLine = (lines: Line[], start: number, literal: ReadonlySet<number>): number => {
110
+ const match = lines[start]!.text.match(HEADING_RE);
111
+ const level = match?.[1]?.length ?? 6;
112
+ let end = lines.length - 1;
113
+ for (let i = start + 1; i < lines.length; i++) {
114
+ if (literal.has(i)) continue;
115
+ const next = lines[i]!.text.match(HEADING_RE);
116
+ if (next && next[1]!.length <= level) {
117
+ end = Math.max(start, i - 1);
118
+ break;
119
+ }
120
+ }
121
+ return end;
122
+ };
123
+
124
+ export const extractNamedBlocks = (md: string | null | undefined): NamedBlock[] => {
125
+ if (!md || !md.includes("@")) return [];
126
+ const lines = linesWithOffsets(md);
127
+ const blocks: NamedBlock[] = [];
128
+ const literal = literalMarkdownLines(md);
129
+
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const line = lines[i]!;
132
+ if (literal.has(i) || isIndentedCodeLine(line.text)) continue;
133
+
134
+ const name = isNamedBlockHandle(line.text);
135
+ if (!name) continue;
136
+
137
+ const startLine = nextContentLine(lines, i + 1);
138
+ if (startLine === null) {
139
+ blocks.push({
140
+ name,
141
+ type: "unknown",
142
+ line: i,
143
+ handleStart: line.from,
144
+ handleEnd: line.to,
145
+ blockStart: line.to,
146
+ blockEnd: line.to,
147
+ startLine: i,
148
+ endLine: i,
149
+ });
150
+ continue;
151
+ }
152
+
153
+ let type: NamedBlockType = "unknown";
154
+ let endLine = startLine;
155
+ const startText = lines[startLine]!.text.trim();
156
+ if (!isIndentedCodeLine(lines[startLine]!.text) && isTableStart(lines, startLine)) {
157
+ type = "table";
158
+ endLine = tableEndLine(lines, startLine);
159
+ } else if (!isIndentedCodeLine(lines[startLine]!.text) && LIST_RE.test(lines[startLine]!.text)) {
160
+ type = "list";
161
+ endLine = listEndLine(lines, startLine);
162
+ } else if (!literal.has(startLine) && !isIndentedCodeLine(lines[startLine]!.text) && /^:::data\b/.test(startText)) {
163
+ type = "data";
164
+ endLine = fencedEndLine(lines, startLine, ":::");
165
+ } else if (HEADING_RE.test(lines[startLine]!.text)) {
166
+ type = "section";
167
+ endLine = sectionEndLine(lines, startLine, literal);
168
+ }
169
+
170
+ blocks.push({
171
+ name,
172
+ type,
173
+ line: i,
174
+ handleStart: line.from,
175
+ handleEnd: line.to,
176
+ blockStart: lines[startLine]!.from,
177
+ blockEnd: lines[endLine]!.to,
178
+ startLine,
179
+ endLine,
180
+ });
181
+ // Sections may contain additional named blocks. Unknown handles must not
182
+ // skip a following independent handle; literal source is masked above.
183
+ if (type !== "section" && type !== "unknown") i = endLine;
184
+ }
185
+
186
+ return blocks;
187
+ };
188
+
189
+ export const extractNamedBlockSummaries = (md: string | null | undefined): NamedBlockSummary[] =>
190
+ extractNamedBlocks(md).map(({ name, type, line }) => ({ name, type, line }));
191
+
192
+ export const extractDataBlocks = (md: string | null | undefined): DataBlock[] => {
193
+ if (!md || !md.includes(":::data")) return [];
194
+ const lines = linesWithOffsets(md);
195
+ const namedDataBlocks = extractNamedBlocks(md).filter((block) => block.type === "data");
196
+ const namedDataStarts = new Set(namedDataBlocks.map((block) => block.blockStart));
197
+ const blocks: DataBlock[] = namedDataBlocks.map((block) => ({ ...block }));
198
+ const literal = literalMarkdownLines(md);
199
+
200
+ for (let i = 0; i < lines.length; i++) {
201
+ const line = lines[i]!;
202
+ const trimmed = line.text.trim();
203
+ if (literal.has(i) || isIndentedCodeLine(line.text) || !/^:::data\b/.test(trimmed) || namedDataStarts.has(line.from)) continue;
204
+
205
+ const endLine = fencedEndLine(lines, i, ":::");
206
+ blocks.push({
207
+ name: null,
208
+ type: "data",
209
+ line: i,
210
+ handleStart: line.from,
211
+ handleEnd: line.from,
212
+ blockStart: line.from,
213
+ blockEnd: lines[endLine]!.to,
214
+ startLine: i,
215
+ endLine,
216
+ });
217
+ i = endLine;
218
+ }
219
+
220
+ return blocks.sort((a, b) => a.handleStart - b.handleStart);
221
+ };
222
+
223
+ export const findNamedBlocks = (md: string, name: string, type?: NamedBlockType): NamedBlock[] =>
224
+ extractNamedBlocks(md).filter((block) => block.name === name && (!type || block.type === type));
225
+
226
+ const escapeHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
227
+
228
+ export const namedBlockBody = (md: string, block: Pick<NamedBlock, "startLine" | "endLine" | "blockStart" | "blockEnd">): string => {
229
+ const lines = linesWithOffsets(md);
230
+ const firstContent = lines[block.startLine]?.nextFrom ?? block.blockStart;
231
+ const closingLineStart = block.endLine > block.startLine ? (lines[block.endLine]?.from ?? block.blockEnd) : block.blockEnd;
232
+ return md.slice(firstContent, closingLineStart).trim();
233
+ };
234
+
235
+ const parseDataScalar = (raw: string): NamedDataScalar | null => {
236
+ const value = raw.trim();
237
+ if (!value) return null;
238
+ if (value === "true") return true;
239
+ if (value === "false") return false;
240
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
241
+ const parsed = Number(value);
242
+ return Number.isFinite(parsed) ? parsed : null;
243
+ }
244
+ if (value.startsWith('"') && value.endsWith('"')) {
245
+ try {
246
+ const parsed: unknown = JSON.parse(value);
247
+ return typeof parsed === "string" && parsed.length <= DATA_MAX_STRING_LENGTH ? parsed : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+ if (value.startsWith("'") && value.endsWith("'")) {
253
+ const parsed = value.slice(1, -1).replace(/''/g, "'");
254
+ return parsed.length <= DATA_MAX_STRING_LENGTH ? parsed : null;
255
+ }
256
+ if (/^[\[\]{}&*!|>@`]/.test(value)) return null;
257
+ return value.length <= DATA_MAX_STRING_LENGTH ? value : null;
258
+ };
259
+
260
+ export const parseNamedDataBlockResult = (src: string): { entries: NamedDataEntry[]; diagnostics: NamedDataDiagnostic[] } => {
261
+ const entries: NamedDataEntry[] = [];
262
+ const diagnostics: NamedDataDiagnostic[] = [];
263
+ const keys = new Set<string>();
264
+ let activeArray: NamedDataEntry | null = null;
265
+ for (const [index, sourceLine] of src.split("\n").entries()) {
266
+ const line = sourceLine.replace(/\r$/, "");
267
+ const lineNumber = index + 1;
268
+ if (!line.trim()) continue;
269
+ const item = line.match(/^\s{2}-\s+(.+)$/);
270
+ if (activeArray && item?.[1]) {
271
+ if ((activeArray.value as NamedDataScalar[]).length >= DATA_MAX_LIST_ITEMS) {
272
+ diagnostics.push({ code: "too-many-items", line: lineNumber, path: activeArray.key });
273
+ continue;
274
+ }
275
+ const value = parseDataScalar(item[1]);
276
+ if (value === null) diagnostics.push({ code: "invalid-value", line: lineNumber, path: activeArray.key });
277
+ else (activeArray.value as NamedDataScalar[]).push(value);
278
+ continue;
279
+ }
280
+
281
+ const pair = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
282
+ if (!pair?.[1]) {
283
+ diagnostics.push({ code: "unexpected-line", line: lineNumber, path: "data" });
284
+ activeArray = null;
285
+ continue;
286
+ }
287
+ const key = pair[1];
288
+ if (keys.has(key)) {
289
+ diagnostics.push({ code: "duplicate-key", line: lineNumber, path: key });
290
+ activeArray = null;
291
+ continue;
292
+ }
293
+ keys.add(key);
294
+ if (entries.length >= DATA_MAX_ENTRIES) {
295
+ diagnostics.push({ code: "too-many-items", line: lineNumber, path: "data" });
296
+ activeArray = null;
297
+ continue;
298
+ }
299
+ const rawValue = pair[2]?.trim() ?? "";
300
+ const value = rawValue ? parseDataScalar(rawValue) : [];
301
+ if (value === null) {
302
+ diagnostics.push({ code: "invalid-value", line: lineNumber, path: key });
303
+ activeArray = null;
304
+ continue;
305
+ }
306
+ const entry: NamedDataEntry = { key, value };
307
+ entries.push(entry);
308
+ activeArray = Array.isArray(entry.value) ? entry : null;
309
+ }
310
+ return { entries, diagnostics };
311
+ };
312
+
313
+ export const parseNamedDataBlock = (src: string): NamedDataEntry[] => parseNamedDataBlockResult(src).entries;
314
+
315
+ export const extractNamedDataProperties = (
316
+ md: string | null | undefined,
317
+ ): { properties: NamedDataProperties; diagnostics: NamedDataDiagnostic[] } => {
318
+ if (!md) return { properties: {}, diagnostics: [] };
319
+ const blocks = extractNamedBlocks(md).filter((block) => block.type === "data");
320
+ const counts = new Map<string, number>();
321
+ for (const block of blocks) counts.set(block.name, (counts.get(block.name) ?? 0) + 1);
322
+
323
+ const properties: NamedDataProperties = {};
324
+ const diagnostics: NamedDataDiagnostic[] = [];
325
+ const reportedDuplicates = new Set<string>();
326
+ for (const block of blocks) {
327
+ if ((counts.get(block.name) ?? 0) > 1) {
328
+ if (!reportedDuplicates.has(block.name)) {
329
+ diagnostics.push({ code: "duplicate-block", line: block.line + 1, path: block.name });
330
+ reportedDuplicates.add(block.name);
331
+ }
332
+ continue;
333
+ }
334
+ if (block.endLine === block.startLine) {
335
+ diagnostics.push({ code: "unclosed-block", line: block.startLine + 1, path: block.name });
336
+ continue;
337
+ }
338
+ const parsed = parseNamedDataBlockResult(namedBlockBody(md, block));
339
+ diagnostics.push(
340
+ ...parsed.diagnostics.map((entry) => ({ ...entry, line: block.startLine + entry.line + 1, path: `${block.name}.${entry.path}` })),
341
+ );
342
+ if (parsed.diagnostics.length > 0) continue;
343
+ properties[block.name] = Object.fromEntries(parsed.entries.map(({ key, value }) => [key, value]));
344
+ }
345
+ return { properties, diagnostics };
346
+ };
347
+
348
+ const humanizeKey = (key: string): string =>
349
+ key
350
+ .replace(/[-_]+/g, " ")
351
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
352
+ .replace(/^./, (char) => char.toUpperCase());
353
+
354
+ export const renderDataBlockHtml = (name: string | null, src: string): string => {
355
+ const entries = parseNamedDataBlock(src);
356
+ const handle = name
357
+ ? `<div class="md-block-handle" data-block-name="${escapeHtml(name)}">@${escapeHtml(name)}</div>`
358
+ : `<div class="md-data-handle-row"><div class="md-block-handle">data</div></div>`;
359
+ const rows =
360
+ entries.length > 0
361
+ ? entries
362
+ .map((entry) => {
363
+ const value = Array.isArray(entry.value)
364
+ ? entry.value.map((item) => `<span class="md-data-chip">${escapeHtml(String(item))}</span>`).join("")
365
+ : escapeHtml(String(entry.value));
366
+ return `<div class="md-data-row"><span class="md-data-key">${escapeHtml(humanizeKey(entry.key))}</span><span class="md-data-value">${value}</span></div>`;
367
+ })
368
+ .join("")
369
+ : `<div class="md-data-empty">No data</div>`;
370
+
371
+ return (
372
+ `<div class="md-data-block"${name ? ` data-block-name="${escapeHtml(name)}"` : ""}>` +
373
+ handle +
374
+ `<div class="md-data-grid">${rows}</div>` +
375
+ `</div>`
376
+ );
377
+ };
378
+
379
+ export const renderNamedDataBlockHtml = (name: string, src: string): string => renderDataBlockHtml(name, src);
380
+
381
+ export const renderNamedBlockHandlesMarkdown = (md: string | null | undefined): string => {
382
+ if (!md) return "";
383
+ let out = md;
384
+
385
+ const replacements: { from: number; to: number; html: string }[] = [];
386
+ for (const block of extractDataBlocks(md)) {
387
+ replacements.push({
388
+ from: block.name ? block.handleStart : block.blockStart,
389
+ to: block.blockEnd,
390
+ html: renderDataBlockHtml(block.name, namedBlockBody(md, block)),
391
+ });
392
+ }
393
+ for (const block of extractNamedBlocks(md)) {
394
+ if (block.type === "data") continue;
395
+ const handle = `<div class="md-block-handle" data-block-name="${escapeHtml(block.name)}">@${escapeHtml(block.name)}</div>`;
396
+ replacements.push({ from: block.handleStart, to: block.handleEnd, html: handle });
397
+ }
398
+
399
+ for (const replacement of replacements.sort((a, b) => b.from - a.from)) {
400
+ out = `${out.slice(0, replacement.from)}${replacement.html}${out.slice(replacement.to)}`;
401
+ }
402
+ return out;
403
+ };