@k2b/editor 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 (83) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +31 -2
  3. package/package.json +118 -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,511 @@
1
+ import { highlight, text } from "@k2b/stdlib";
2
+ import { prepareChartSnapshot } from "@k2b/ui";
3
+ import { barChartLayout } from "@k2b/ui/charts";
4
+ import { Lexer, type Tokens } from "marked";
5
+ import type { z } from "zod";
6
+ import {
7
+ type ChartInput,
8
+ type ChartNumberFormat,
9
+ type ChartTable,
10
+ chartLegend,
11
+ chartMarkKey,
12
+ chartReferenceMark,
13
+ chartRenderOptions,
14
+ chartRows,
15
+ chartTable,
16
+ chartTooltip,
17
+ } from "../../charts";
18
+ import { type EvalResult, evaluateTableCell, isFormula, isTotalRow } from "../../formula";
19
+ import { chartBlockMessages } from "../chart-block-messages";
20
+ import { evaluatePrettyTable } from "../pretty-table";
21
+ import { extractNamedBlocks, isNamedBlockHandle, type NamedDataEntry, namedBlockBody, parseNamedDataBlockResult } from "./named-blocks";
22
+ import { directives, type NotebookBlockDiagnostic, type NotebookBlockDiagnosticCode, parseScalar, parseValue } from "./query-blocks";
23
+
24
+ export type MarkdownChartOptions = {
25
+ maxBlocks: number;
26
+ maxSeries: number;
27
+ maxValues: number;
28
+ /** The host supplies any input policy beyond portable chart parsing. */
29
+ schema: z.ZodType<ChartInput>;
30
+ drawingIssue?: (input: ChartInput) => string | undefined;
31
+ };
32
+ export type ChartBlock = { line: number; input: ChartInput };
33
+ /** `height` is the drawing's own height in CSS pixels when it needs more than the kind's frame (app.css). */
34
+ type Frame = { kind: ChartInput["kind"]; area?: boolean; title: string; subtitle?: string; height?: number };
35
+ type Entry = { value: string; line: number };
36
+ type Cell = { raw: string; result: EvalResult };
37
+ type SourceData = { columns: string[]; rows: Cell[][]; implicit: boolean; entries?: NamedDataEntry[]; invalid?: boolean };
38
+ const TYPES = new Set(["line", "area", "bar", "pie", "donut", "scatter", "stat"]);
39
+ const escape = highlight.escape;
40
+ const scalarCell = (value: string | number | boolean): Cell => {
41
+ const raw = String(value);
42
+ return {
43
+ raw,
44
+ result:
45
+ typeof value === "string"
46
+ ? evaluateTableCell(0, 0, { headers: ["value"], rows: [[raw]], currentRow: 0, currentCol: 0 })
47
+ : { kind: "ok", value },
48
+ };
49
+ };
50
+
51
+ /** Reads only local note data. Markdown table tokens match the Book table renderer's cell text. */
52
+ const namedSource = (
53
+ markdown: string,
54
+ namedBlocks: ReturnType<typeof extractNamedBlocks>,
55
+ name: string,
56
+ options: MarkdownChartOptions,
57
+ ): SourceData | "duplicate" | null => {
58
+ const matches = namedBlocks.filter((candidate) => candidate.name === name);
59
+ // Two blocks of one name would make the chart pick one silently; named data and queries reject them too.
60
+ if (matches.length > 1) return "duplicate";
61
+ const block = matches[0];
62
+ if (!block) return null;
63
+ const source = markdown.slice(block.blockStart, block.blockEnd);
64
+ if (block.type === "table") {
65
+ const table = Lexer.lex(source, { gfm: true }).find((token): token is Tokens.Table => token.type === "table");
66
+ if (!table || table.type !== "table") return null;
67
+ const columns = table.header.map((cell) => cell.text);
68
+ const data = { headers: columns, rows: table.rows.map((row) => row.map((cell) => cell.text)) };
69
+ // Count before evaluating formulas in a table that cannot fit the chart budget.
70
+ const valueRows = data.rows.filter((row) => !isTotalRow(row));
71
+ const rows =
72
+ valueRows.length > options.maxValues
73
+ ? valueRows
74
+ .slice(0, options.maxValues + 1)
75
+ .map((row) => row.map((cell) => ({ raw: cell, result: { kind: "ok" as const, value: cell } })))
76
+ : evaluatePrettyTable(data)
77
+ .filter((row) => !row.isTotalRow)
78
+ .map((row) => row.cells);
79
+ return { columns, rows, implicit: false };
80
+ }
81
+ if (block.type === "list") {
82
+ const list = Lexer.lex(source, { gfm: true }).find((token): token is Tokens.List => token.type === "list");
83
+ if (!list || list.type !== "list") return null;
84
+ const rows = list.items.slice(0, options.maxValues + 1).map((item) => {
85
+ // The last colon followed by a space ends the label, so times like 10:30 stay in it.
86
+ const pair = item.text.match(/^([^\n]+):[ \t]+(\S.*)$/);
87
+ return pair ? [scalarCell(pair[1]!.trim()), scalarCell(pair[2]!)] : [scalarCell(item.text), scalarCell("")];
88
+ });
89
+ return { columns: ["label", "value"], rows, implicit: true };
90
+ }
91
+ if (block.type === "data") {
92
+ const parsed = parseNamedDataBlockResult(namedBlockBody(markdown, block));
93
+ if (parsed.diagnostics.length) return { columns: [], rows: [], implicit: true, invalid: true };
94
+ const entries = parsed.entries.filter((entry) => typeof entry.value === "number");
95
+ return {
96
+ columns: ["label", "value"],
97
+ implicit: true,
98
+ entries: parsed.entries,
99
+ rows: entries.map((entry) => [scalarCell(text.humanize(entry.key)), scalarCell(typeof entry.value === "number" ? entry.value : "")]),
100
+ };
101
+ }
102
+ return null;
103
+ };
104
+
105
+ const parseChart = (
106
+ directive: ReturnType<typeof directives>[number],
107
+ markdown: string,
108
+ namedBlocks: ReturnType<typeof extractNamedBlocks>,
109
+ sources: Map<string, SourceData | "duplicate" | null>,
110
+ options: MarkdownChartOptions,
111
+ ) => {
112
+ const diagnostics: NotebookBlockDiagnostic[] = [];
113
+ const top = new Map<string, Entry>();
114
+ const tooltip = new Map<string, Entry>();
115
+ const rows: { cells: Cell[]; line: number }[] = [];
116
+ const add = (
117
+ code: NotebookBlockDiagnosticCode,
118
+ key: string,
119
+ line = top.get(key)?.line ?? directive.line,
120
+ params?: NotebookBlockDiagnostic["params"],
121
+ ) => diagnostics.push({ code, line, path: key ? `chart.${key}` : "chart", ...(params ? { params } : {}) });
122
+ const pair = (target: Map<string, Entry>, key: string, entry: Entry, path: string) => {
123
+ if (target.has(key)) add("duplicate-key", path, entry.line);
124
+ else target.set(key, entry);
125
+ };
126
+ let section = "";
127
+ let rowsExceeded = false;
128
+ for (const [index, source] of directive.body.entries()) {
129
+ const line = directive.line + index + 1;
130
+ if (!source.trim()) continue;
131
+ const entry = source.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/);
132
+ if (entry) {
133
+ const key = entry[1]!;
134
+ section = "";
135
+ if (
136
+ !["type", "title", "subtitle", "data", "columns", "rows", "x", "y", "label", "value", "format", "unit", "tooltip"].includes(key)
137
+ ) {
138
+ add("unknown-key", key, line);
139
+ continue;
140
+ }
141
+ pair(top, key, { value: entry[2]!, line }, key);
142
+ if (key === "rows" || key === "tooltip") {
143
+ section = key;
144
+ if (entry[2]!.trim()) add("invalid-type", key, line);
145
+ }
146
+ continue;
147
+ }
148
+ const row = section === "rows" ? source.match(/^ {2}- (.+)$/) : null;
149
+ if (row) {
150
+ if (rows.length >= options.maxValues) {
151
+ if (!rowsExceeded) add("too-many-items", `rows.${rows.length}`, line, { limit: options.maxValues });
152
+ rowsExceeded = true;
153
+ continue;
154
+ }
155
+ const values = parseValue(row[1]!);
156
+ if (!Array.isArray(values)) add("invalid-type", `rows.${rows.length}`, line);
157
+ else rows.push({ cells: values.map(scalarCell), line });
158
+ continue;
159
+ }
160
+ const nested = section === "tooltip" ? source.match(/^ {2}([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) : null;
161
+ if (nested) {
162
+ const key = nested[1]!;
163
+ if (!["label", "format", "unit"].includes(key)) add("unknown-key", `tooltip.${key}`, line);
164
+ else pair(tooltip, key, { value: nested[2]!, line }, `tooltip.${key}`);
165
+ continue;
166
+ }
167
+ add("unexpected-line", section, line);
168
+ }
169
+ if (!directive.closed) add("unclosed-block", "");
170
+ const string = (key: string, entries = top): string | undefined => {
171
+ const entry = entries.get(key);
172
+ if (!entry) return undefined;
173
+ const value = parseScalar(entry.value);
174
+ if (typeof value === "string" && value.trim()) return value;
175
+ add("invalid-type", entries === tooltip ? `tooltip.${key}` : key, entry.line);
176
+ return undefined;
177
+ };
178
+ for (const key of ["type", "title"]) if (!top.has(key)) add("missing-key", key);
179
+ const type = string("type");
180
+ const title = string("title");
181
+ const subtitle = string("subtitle");
182
+ if (type && !TYPES.has(type)) add("invalid-type", "type");
183
+ let data: SourceData = { columns: [], rows: [], implicit: false };
184
+ if (top.has("data")) {
185
+ if (top.has("columns") || top.has("rows")) add("invalid-type", "data");
186
+ const name = isNamedBlockHandle(top.get("data")!.value);
187
+ if (!name) add("invalid-type", "data");
188
+ else {
189
+ if (!sources.has(name)) sources.set(name, namedSource(markdown, namedBlocks, name, options));
190
+ const source = sources.get(name);
191
+ if (source === "duplicate") add("duplicate-block", "data", undefined, { name });
192
+ else if (!source) add("unknown-block", "data", undefined, { name });
193
+ else if (source.invalid) add("invalid-source", "data", undefined, { name });
194
+ else data = source;
195
+ }
196
+ } else if (top.has("columns") || top.has("rows")) {
197
+ for (const key of ["columns", "rows"]) if (!top.has(key)) add("missing-key", key);
198
+ const columns = parseValue(top.get("columns")?.value ?? "");
199
+ if (
200
+ !Array.isArray(columns) ||
201
+ !columns.length ||
202
+ !columns.every((column): column is string => typeof column === "string" && !!column.trim())
203
+ ) {
204
+ if (top.has("columns")) add("invalid-type", "columns");
205
+ } else {
206
+ if (new Set(columns).size !== columns.length) add("duplicate-key", "columns");
207
+ data = { columns, rows: rows.map((row) => row.cells), implicit: false };
208
+ rows.forEach((row, index) => {
209
+ if (row.cells.length !== columns.length) add("invalid-type", `rows.${index}`, row.line);
210
+ });
211
+ }
212
+ } else if (type !== "stat") add("missing-key", "data");
213
+ const dataKey = top.has("data") ? "data" : "rows";
214
+ if (data.rows.length > options.maxValues) add("too-many-items", dataKey, undefined, { limit: options.maxValues });
215
+ if (diagnostics.length || !type || !title) return { diagnostics };
216
+
217
+ const column = (key: string, defaultName?: string): string | undefined => {
218
+ const name = top.has(key) ? string(key) : defaultName;
219
+ if (name === undefined) add("missing-key", key);
220
+ else if (!data.columns.includes(name)) add("unknown-column", key, undefined, { name });
221
+ return name;
222
+ };
223
+ const cells = (name: string) => data.rows.map((row) => row[data.columns.indexOf(name)]!);
224
+ // A column reports its first bad cell only: one fix at a time, never a diagnostic per row.
225
+ const reported = new Set<string>();
226
+ const once = (code: NotebookBlockDiagnosticCode, key: string, params: NotebookBlockDiagnostic["params"]) => {
227
+ if (reported.has(`${code}:${key}:${params?.name}`)) return;
228
+ reported.add(`${code}:${key}:${params?.name}`);
229
+ add(code, key, undefined, params);
230
+ };
231
+ const numbers = (name: string, key: string) =>
232
+ cells(name).map((cell, index) => {
233
+ if (cell.result.kind === "ok" && typeof cell.result.value === "number" && Number.isFinite(cell.result.value))
234
+ return cell.result.value;
235
+ once("not-a-number", key, { name, row: index + 1 });
236
+ return 0;
237
+ });
238
+ const labels = (name: string, key: string) =>
239
+ cells(name).map((cell, index) => {
240
+ if (cell.result.kind === "error") once("not-a-number", key, { name, row: index + 1 });
241
+ return isFormula(cell.raw) && cell.result.kind === "ok" ? String(cell.result.value) : cell.raw.trim();
242
+ });
243
+ const readFormat = (entries: Map<string, Entry>): ChartNumberFormat | undefined => {
244
+ let format: ChartNumberFormat | undefined;
245
+ const name = string("format", entries);
246
+ const unit = string("unit", entries);
247
+ if (name === "number" || name === "percent") format = { style: name };
248
+ else if (name && /^[A-Z]{3}$/.test(name)) format = { style: "currency", currency: name };
249
+ else if (name) add("invalid-type", entries === tooltip ? "tooltip.format" : "format", entries.get("format")?.line);
250
+ return unit ? { ...format, unit } : format;
251
+ };
252
+ // The chart's format writes its axis, printed values and tooltips; a tooltip format applies to tooltips first.
253
+ const chartFormat = readFormat(top);
254
+ const tooltipFormat = readFormat(tooltip);
255
+ const format = tooltipFormat ?? chartFormat;
256
+ const axisFormat = chartFormat ?? tooltipFormat;
257
+ const namedValue = string("label", tooltip);
258
+ let valueLabel: string | undefined;
259
+ let mapped: unknown;
260
+ const common = { title, ...(subtitle ? { subtitle } : {}) };
261
+ if (type === "stat") {
262
+ const raw = top.get("value");
263
+ const value = raw ? parseScalar(raw.value) : undefined;
264
+ if (typeof value === "number") mapped = { ...common, kind: "stat", value, format };
265
+ else if (typeof value === "string" && data.entries?.some((entry) => entry.key === value)) {
266
+ const index = data.entries.findIndex((entry) => entry.key === value);
267
+ valueLabel = text.humanize(value);
268
+ const entry = data.entries[index]!;
269
+ if (typeof entry.value !== "number" || !Number.isFinite(entry.value))
270
+ add("not-a-number", "value", raw?.line, { name: value, row: index + 1 });
271
+ else mapped = { ...common, kind: "stat", value: entry.value, format };
272
+ } else {
273
+ const name = column("value", data.implicit ? "value" : undefined);
274
+ if (name && !diagnostics.length) {
275
+ if (!data.implicit) valueLabel = name;
276
+ const values = numbers(name, "value");
277
+ mapped = { ...common, kind: "stat", value: values.at(-1), ...(values.length >= 2 ? { sparkline: values } : {}), format };
278
+ }
279
+ }
280
+ } else if (type === "pie" || type === "donut") {
281
+ const label = column("label", data.implicit ? "label" : undefined);
282
+ const value = column("value", data.implicit ? "value" : undefined);
283
+ if (label && value && !diagnostics.length) {
284
+ if (!data.implicit) valueLabel = value;
285
+ const values = numbers(value, "value");
286
+ mapped = { ...common, kind: type, data: labels(label, "label").map((label, i) => ({ label, value: values[i] })), format };
287
+ }
288
+ } else {
289
+ const x = column("x", data.implicit ? "label" : undefined);
290
+ const y = top.has("y") ? parseValue(top.get("y")!.value) : data.implicit ? "value" : undefined;
291
+ const ys = typeof y === "string" ? [y] : y;
292
+ if (!ys) add("missing-key", "y");
293
+ else if (!Array.isArray(ys) || !ys.length || !ys.every((name) => typeof name === "string")) add("invalid-type", "y");
294
+ else {
295
+ for (const name of ys) if (!data.columns.includes(String(name))) add("unknown-column", "y", undefined, { name: String(name) });
296
+ if (type === "bar" && ys.length > 1) add("chart-one-series", "y");
297
+ if (ys.length > options.maxSeries) add("too-many-items", "y", undefined, { limit: options.maxSeries });
298
+ if (x && !diagnostics.length) {
299
+ // The implicit `label` and `value` of a list or data block are no names a reader wrote: the contract's own
300
+ // localized value label stays.
301
+ if (ys.length === 1 && !data.implicit) valueLabel = String(ys[0]);
302
+ // Axis titles name what a reader wrote: the x column and the value, as the tooltip calls it.
303
+ const valueTitle = namedValue ?? valueLabel;
304
+ const yAxis =
305
+ valueTitle || axisFormat
306
+ ? { yAxis: { ...(valueTitle ? { label: valueTitle } : {}), ...(axisFormat ? { format: axisFormat } : {}) } }
307
+ : {};
308
+ if (type === "bar") {
309
+ const values = numbers(String(ys[0]), "y");
310
+ mapped = {
311
+ ...common,
312
+ kind: "bar",
313
+ data: labels(x, "x").map((label, i) => ({ label, value: values[i] })),
314
+ ...(data.implicit ? {} : { xAxis: { label: x } }),
315
+ ...yAxis,
316
+ };
317
+ } else {
318
+ const xs = cells(x).map((cell, i) => {
319
+ if (cell.result.kind === "error") {
320
+ once("not-a-number", "x", { name: x, row: i + 1 });
321
+ return 0;
322
+ }
323
+ const value = cell.result.value;
324
+ // Calendar validity is checked by the shared ChartDate schema below.
325
+ if (typeof value === "number" || (typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?$/.test(value)))
326
+ return value;
327
+ once("chart-x-type", "x", { name: x });
328
+ return 0;
329
+ });
330
+ mapped = {
331
+ ...common,
332
+ kind: type === "area" ? "line" : type,
333
+ ...(type === "area" ? { area: true } : {}),
334
+ ...(data.implicit ? {} : { xAxis: { label: x } }),
335
+ ...yAxis,
336
+ series: ys.map((name) => ({ label: name, data: numbers(String(name), "y").map((y, i) => ({ x: xs[i], y })) })),
337
+ };
338
+ }
339
+ }
340
+ }
341
+ }
342
+ const tooltipLabel = namedValue ?? valueLabel;
343
+ const parsed = options.schema.safeParse({
344
+ ...(typeof mapped === "object" && mapped !== null ? mapped : {}),
345
+ ...(tooltipLabel || format ? { tooltip: { ...(tooltipLabel ? { label: tooltipLabel } : {}), ...(format ? { format } : {}) } } : {}),
346
+ });
347
+ if (diagnostics.length) return { diagnostics };
348
+ if (!parsed.success) {
349
+ const unsupported = new Set<string>();
350
+ for (const issue of parsed.error.issues) {
351
+ const path = issue.path.map(String);
352
+ const first = path[0];
353
+ // The axis and printed values share the format and value name with the tooltip; an issue points at the entry the
354
+ // value came from, once.
355
+ const yLabel = first === "yAxis" && path[1] === "label";
356
+ const formatField = path.includes("unit") ? "unit" : "format";
357
+ const fromTooltip =
358
+ first === "tooltip" ||
359
+ (first === "format" && !!tooltipFormat) ||
360
+ (first === "yAxis" && (yLabel ? !!namedValue : path.includes("format") && !chartFormat));
361
+ const tooltipField = fromTooltip ? ((first === "tooltip" && path[1] === "label") || yLabel ? "label" : formatField) : undefined;
362
+ const key = tooltipField
363
+ ? `tooltip.${tooltipField}`
364
+ : first === "format" || (first === "yAxis" && path.includes("format"))
365
+ ? formatField
366
+ : first === "series"
367
+ ? path.includes("x")
368
+ ? "x"
369
+ : "y"
370
+ : first === "data" || first === "sparkline"
371
+ ? dataKey
372
+ : first === "xAxis"
373
+ ? "x"
374
+ : first === "yAxis"
375
+ ? "y"
376
+ : first === "kind"
377
+ ? "type"
378
+ : (first ?? "");
379
+ const line = (tooltipField ? (tooltip.get(tooltipField)?.line ?? top.get("tooltip")?.line) : top.get(key)?.line) ?? directive.line;
380
+ const id = `${line}:${key}`;
381
+ if (unsupported.has(id)) continue;
382
+ unsupported.add(id);
383
+ add("chart-unsupported", key, line);
384
+ }
385
+ return { diagnostics };
386
+ }
387
+ if (options.drawingIssue?.(parsed.data)) {
388
+ add("chart-unsupported", dataKey);
389
+ return { diagnostics };
390
+ }
391
+ return { diagnostics, block: { line: directive.line, input: parsed.data } };
392
+ };
393
+
394
+ /**
395
+ * Every chart block of a note, or with `line` only the one opening there: reading one block never resolves or draws
396
+ * the others, and it still counts against the note's block budget.
397
+ */
398
+ export const parseNotebookChartBlocks = (
399
+ markdown: string,
400
+ options: MarkdownChartOptions & { line?: number },
401
+ ): { blocks: ChartBlock[]; diagnostics: NotebookBlockDiagnostic[] } => {
402
+ const blocks: ChartBlock[] = [];
403
+ const diagnostics: NotebookBlockDiagnostic[] = [];
404
+ const namedBlocks = extractNamedBlocks(markdown);
405
+ const sources = new Map<string, SourceData | "duplicate" | null>();
406
+ for (const [index, directive] of directives(markdown)
407
+ .filter((entry) => entry.type === "chart")
408
+ .entries()) {
409
+ if (options.line !== undefined && directive.line !== options.line) continue;
410
+ if (index >= options.maxBlocks) {
411
+ diagnostics.push({ code: "too-many-items", line: directive.line, path: "chart" });
412
+ continue;
413
+ }
414
+ const result = parseChart(directive, markdown, namedBlocks, sources, options);
415
+ diagnostics.push(...result.diagnostics);
416
+ if (result.block) blocks.push(result.block);
417
+ }
418
+ return { blocks, diagnostics };
419
+ };
420
+
421
+ /** A cheap frame for an editor placeholder; it never resolves or draws data. */
422
+ export const chartBlockFrame = (body: string[], options: Pick<MarkdownChartOptions, "schema">): Frame | null => {
423
+ const entries = new Map<string, string>();
424
+ for (const line of body) {
425
+ const pair = /^(type|title|subtitle):\s*(.*)$/.exec(line);
426
+ if (!pair) continue;
427
+ if (entries.has(pair[1]!)) return null;
428
+ entries.set(pair[1]!, pair[2]!);
429
+ }
430
+ const type = parseScalar(entries.get("type") ?? "");
431
+ const title = parseScalar(entries.get("title") ?? "");
432
+ const subtitle = entries.has("subtitle") ? parseScalar(entries.get("subtitle")!) : undefined;
433
+ if (typeof type !== "string" || !TYPES.has(type) || typeof title !== "string" || (subtitle !== undefined && typeof subtitle !== "string"))
434
+ return null;
435
+ // Validate the shared header fields without reading data or drawing a chart.
436
+ const header = options.schema.safeParse({ kind: "stat", title, subtitle, value: 0 });
437
+ if (!header.success) return null;
438
+ switch (type) {
439
+ case "area":
440
+ return { kind: "line", area: true, title: header.data.title, ...(header.data.subtitle ? { subtitle: header.data.subtitle } : {}) };
441
+ case "line":
442
+ case "bar":
443
+ case "scatter":
444
+ case "pie":
445
+ case "donut":
446
+ case "stat":
447
+ return { kind: type, title: header.data.title, ...(header.data.subtitle ? { subtitle: header.data.subtitle } : {}) };
448
+ default:
449
+ return null;
450
+ }
451
+ };
452
+
453
+ /**
454
+ * The height of a chart's drawing where it grows past the kind's frame: bar rows take one line per category, so many
455
+ * categories make the drawing taller. The server and an editor placeholder read it from the same parsed block, so
456
+ * both reserve the same box. Undefined for every other drawing, which fits the frame.
457
+ */
458
+ export const notebookChartHeight = (input: ChartInput, locale: string): number | undefined => {
459
+ const options = chartRenderOptions(input, locale, { legend: "outside" });
460
+ if (options.kind !== "bar") return undefined;
461
+ const layout = barChartLayout(options);
462
+ return layout.layout === "rows" ? layout.height : undefined;
463
+ };
464
+
465
+ /**
466
+ * The figure around a chart: title, subtitle, a place for the chart's controls and a viewport whose height comes from
467
+ * the kind (app.css), or from `frame.height` where the drawing needs more. The editor draws the same frame with an
468
+ * empty viewport while a preview loads, so the drawing never moves the text. `input` is the drawn chart for the browser enhancement (`notebook-charts.tsx`): it adds
469
+ * tooltips, the table view and Copy data in place, so the controls' box is reserved here. Print frames have no controls.
470
+ */
471
+ export const notebookChartFrameHtml = (
472
+ frame: Frame,
473
+ viewportHtml: string,
474
+ options: { legend?: boolean; input?: ChartInput; print?: boolean } = {},
475
+ ): string =>
476
+ `<figure class="notebook-book-chart" data-kind="${frame.kind === "line" && frame.area ? "area" : frame.kind}"${options.input ? ` data-chart="${escape(JSON.stringify(options.input))}"` : ""}><figcaption class="notebook-book-chart__header"><span class="notebook-book-chart__title">${escape(frame.title)}</span>${frame.subtitle ? `<span class="notebook-book-chart__subtitle">${escape(frame.subtitle)}</span>` : ""}</figcaption>${options.print ? "" : '<div class="notebook-book-chart__actions"></div>'}<div class="notebook-book-chart__viewport"${options.legend ? ' data-legend="true"' : ""}${frame.height ? ` style="--notebook-chart-height:${frame.height}px"` : ""}>${viewportHtml}</div></figure>`;
477
+
478
+ /** A chart's data as a note table: the PDF prints it below the figure, and the table view shows it in the frame. */
479
+ export const notebookChartTableHtml = (table: ChartTable, options: { label?: string; className?: string } = {}): string =>
480
+ `<div class="md-table-wrap${options.className ? ` ${options.className}` : ""}"><table class="md-table"${options.label ? ` aria-label="${escape(options.label)}"` : ""}><thead><tr>${table.columns.map((col) => `<th scope="col">${escape(col.label)}</th>`).join("")}</tr></thead><tbody>${table.rows.map((row) => `<tr>${table.columns.map((col) => `<td><span${col.numeric ? ' class="md-align-right"' : ""}>${escape(row.cells[col.id] ?? "")}</span></td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
481
+
482
+ /** Only the shared renderer supplies SVG. A failed drawing leaves the caller's source visible. */
483
+ export const renderNotebookChartFigure = (input: ChartInput, locale: string, options: { print?: boolean } = {}): string | null => {
484
+ try {
485
+ const rows = chartRows(input, locale);
486
+ const snapshot = prepareChartSnapshot(chartRenderOptions(input, locale, { legend: "outside" }), {
487
+ key: ({ datum }) => chartMarkKey(datum),
488
+ tooltip: ({ datum }) => {
489
+ const row = rows.add(datum);
490
+ return chartTooltip(rows.columns(), row);
491
+ },
492
+ reference: ({ datum }) => chartReferenceMark(input, datum),
493
+ });
494
+ if (!snapshot.marks.length) return null;
495
+ const legend = chartLegend(input, locale);
496
+ const t = chartBlockMessages.resolve([locale]).t;
497
+ const legendHtml = legend.length
498
+ ? `<ul class="k2b-chart-legend" aria-label="${escape(t.chartLegend)}">${legend.map((entry) => `<li><span class="k2b-chart-legend__swatch"${entry.style && entry.style !== "solid" ? ` data-style="${entry.style}"` : ""}${entry.reference ? ' data-reference="true"' : ""} style="--k2b-chart-legend-color:var(--stdlib-chart-c${(entry.color % 8) + 1})" aria-hidden="true"></span>${escape(entry.label)}</li>`).join("")}</ul>`
499
+ : "";
500
+ const viewport = `<div class="k2b-chart" data-chart-kind="${snapshot.kind}"><div class="k2b-chart__svg"${snapshot.stretch ? ' data-stretch="true"' : ""} style="--k2b-chart-width:${snapshot.width}px;--k2b-chart-height:${snapshot.height}px">${snapshot.svg}</div></div>${legendHtml}`;
501
+ const height = notebookChartHeight(input, locale);
502
+ const figure = notebookChartFrameHtml(height ? { ...input, height } : input, viewport, {
503
+ legend: legend.length > 0,
504
+ ...(options.print ? { print: true } : { input }),
505
+ });
506
+ if (!options.print) return figure;
507
+ return `${figure}${notebookChartTableHtml(chartTable(input, locale))}`;
508
+ } catch {
509
+ return null;
510
+ }
511
+ };
@@ -0,0 +1,40 @@
1
+ import { text } from "@k2b/stdlib";
2
+
3
+ /** Book's id for a heading with this plain text. Book adds `-2`, `-3`, … to a repeated id in document order. */
4
+ export const headingAnchor = (title: string): string => `heading-${text.slugify(title) || "section"}`;
5
+
6
+ /**
7
+ * The note and heading a `note://<shortId>#<anchor>` link names. The anchor is the heading's slug, the way a
8
+ * Markdown viewer reads `backup.md#restore`, so it opens Book's `heading-restore`. An anchor without a slug
9
+ * names no heading; the link opens the note at its top.
10
+ */
11
+ export const parseNoteLink = (href: string): { noteId: string; anchor: string | null } | null => {
12
+ const match = /^note:\/\/([A-Za-z0-9]{6})(?:#(\S*))?$/.exec(href);
13
+ if (!match) return null;
14
+ let fragment = match[2] ?? "";
15
+ try {
16
+ fragment = decodeURIComponent(fragment);
17
+ } catch {
18
+ /* A malformed escape stays literal text. */
19
+ }
20
+ const slug = text.slugify(fragment);
21
+ return { noteId: match[1]!, anchor: slug ? `heading-${slug}` : null };
22
+ };
23
+
24
+ /** `#<anchor>` for a parsed note link, or nothing for the note's top. */
25
+ export const anchorHash = (anchor: string | null): string => (anchor ? `#${anchor}` : "");
26
+
27
+ /**
28
+ * One-based line of `markdown` where Book's heading `id` starts, read from a Book rendering of that note, or null.
29
+ * The rendering may be older than `markdown`: its line counts only while that line still holds the same text.
30
+ */
31
+ export const renderedHeadingLine = (
32
+ rendered: { markdown: string; headings: ReadonlyArray<{ id: string; line: number }> },
33
+ id: string,
34
+ markdown: string,
35
+ ): number | null => {
36
+ const line = rendered.headings.find((heading) => heading.id === id)?.line;
37
+ if (line === undefined) return null;
38
+ const source = rendered.markdown.split("\n")[line - 1];
39
+ return source !== undefined && markdown.split("\n")[line - 1] === source ? line : null;
40
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Display ligatures: typed character sequences that Notebooks shows as one
3
+ * typographic symbol. Display only; the stored Markdown keeps the typed
4
+ * characters, so search, export, the Assistant and collaboration never see a
5
+ * substitution. Both the rich editor and the Book renderer use this table.
6
+ */
7
+
8
+ /** How a sequence must sit in its surroundings before it becomes a symbol. */
9
+ type Guard =
10
+ /** Not part of a longer operator run such as `-->`, `<==` or `!==`. */
11
+ | "operator"
12
+ /** Not glued to a word, so `f(c)` stays a call and `(c)2` stays literal. */
13
+ | "word"
14
+ /** Not followed by a word; a trademark sign usually follows its name, as in `Notebooks(tm)`. */
15
+ | "suffix"
16
+ /** Not part of a longer run of dots. */
17
+ | "dots"
18
+ /** Only between spaces, so `---`, `--flag` and table rules never match. */
19
+ | "spaced";
20
+
21
+ type Ligature = { source: string; symbol: string; guard: Guard };
22
+
23
+ /** Longest sources first, so `<->` and `<=>` win over `<-`, `->`, `<=` and `=>`. */
24
+ export const LIGATURES: readonly Ligature[] = [
25
+ { source: "(tm)", symbol: "™", guard: "suffix" },
26
+ { source: "(TM)", symbol: "™", guard: "suffix" },
27
+ { source: "<=>", symbol: "⇔", guard: "operator" },
28
+ { source: "<->", symbol: "↔", guard: "operator" },
29
+ { source: "...", symbol: "…", guard: "dots" },
30
+ { source: "(c)", symbol: "©", guard: "word" },
31
+ { source: "(C)", symbol: "©", guard: "word" },
32
+ { source: "(r)", symbol: "®", guard: "word" },
33
+ { source: "(R)", symbol: "®", guard: "word" },
34
+ { source: "->", symbol: "→", guard: "operator" },
35
+ { source: "<-", symbol: "←", guard: "operator" },
36
+ { source: "=>", symbol: "⇒", guard: "operator" },
37
+ { source: "<=", symbol: "≤", guard: "operator" },
38
+ { source: ">=", symbol: "≥", guard: "operator" },
39
+ { source: "!=", symbol: "≠", guard: "operator" },
40
+ { source: "+-", symbol: "±", guard: "operator" },
41
+ { source: "--", symbol: "–", guard: "spaced" },
42
+ ];
43
+
44
+ export type LigatureMatch = { from: number; to: number; source: string; symbol: string };
45
+
46
+ const OPERATOR_CHARS = new Set(["<", ">", "=", "!", "+", "-", "~"]);
47
+ const WORD_CHAR = /[\p{L}\p{N}_]/u;
48
+ const FIRST_CHARS = new Set(LIGATURES.map((ligature) => ligature.source[0]));
49
+
50
+ const accepts = (guard: Guard, before: string | undefined, after: string | undefined): boolean => {
51
+ if (guard === "operator") return !(before && OPERATOR_CHARS.has(before)) && !(after && OPERATOR_CHARS.has(after));
52
+ if (guard === "word") return !(before && WORD_CHAR.test(before)) && !(after && WORD_CHAR.test(after));
53
+ if (guard === "suffix") return !(after && WORD_CHAR.test(after));
54
+ if (guard === "dots") return before !== "." && after !== ".";
55
+ return (before === " " || before === "\t") && (after === " " || after === "\t");
56
+ };
57
+
58
+ /**
59
+ * Finds display ligatures that start and end inside `[from, to)` of `text`.
60
+ * Characters outside that window still count as neighbours; the ends of
61
+ * `text` count as a boundary. Matches never overlap.
62
+ */
63
+ export const findLigatures = (text: string, from = 0, to = text.length): LigatureMatch[] => {
64
+ const matches: LigatureMatch[] = [];
65
+ let index = from;
66
+ while (index < to) {
67
+ if (!FIRST_CHARS.has(text[index])) {
68
+ index++;
69
+ continue;
70
+ }
71
+ const ligature = LIGATURES.find(
72
+ ({ source, guard }) =>
73
+ index + source.length <= to && text.startsWith(source, index) && accepts(guard, text[index - 1], text[index + source.length]),
74
+ );
75
+ if (!ligature) {
76
+ index++;
77
+ continue;
78
+ }
79
+ matches.push({ from: index, to: index + ligature.source.length, source: ligature.source, symbol: ligature.symbol });
80
+ index += ligature.source.length;
81
+ }
82
+ return matches;
83
+ };
84
+
85
+ /** Same escaping as Marked's text renderer: an entity the author typed (`&amp;`) stays an entity. */
86
+ const escapeHtml = (value: string): string =>
87
+ value.replace(
88
+ /[<>"']|&(?!#\d{1,7};|#[Xx][a-fA-F0-9]{1,6};|\w+;)/g,
89
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char,
90
+ );
91
+
92
+ export const LIGATURE_CLASS = "notebook-ligature";
93
+
94
+ /** Marked-escaped HTML for inline text with each ligature as a span whose title keeps the typed characters. */
95
+ export const ligatureHtml = (text: string): string => {
96
+ let html = "";
97
+ let last = 0;
98
+ for (const match of findLigatures(text)) {
99
+ html += `${escapeHtml(text.slice(last, match.from))}<span class="${LIGATURE_CLASS}" title="${escapeHtml(match.source)}">${match.symbol}</span>`;
100
+ last = match.to;
101
+ }
102
+ return html + escapeHtml(text.slice(last));
103
+ };
104
+
105
+ /** Length of a leading YAML front matter block (`---` … `---` or `...`), or 0. */
106
+ export const frontMatterLength = (markdown: string): number =>
107
+ /^---[ \t]*\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(markdown)?.[0].length ?? 0;