@openpresentation/opf-editor 0.26.3 → 0.26.5

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.
@@ -14,15 +14,18 @@ export function lineColumn(text, offset) {
14
14
  }
15
15
 
16
16
  /**
17
- * The range of `segments` in the JSON `text`, or null (core's `locate`, which reads text that does not parse). `part`: "value" (default), "key" (the
18
- * property name), "entry" (key to the end of the value), "colon" (the key and its colon) or "open" (the first character of the value, the `{` of an
19
- * object). A path that is not there gives the range of its nearest container, with `exact: false`.
17
+ * The range of `segments` in `text` of `format` ("json", "yaml" or "md"), or null (core's `locate`, which reads text that does not parse). `part`:
18
+ * "value" (default), "key" (the property name), "entry" (key to the end of the value), "colon" (the key and its colon) or "open" (the first character
19
+ * of the value, the `{` of an object). A path that is not there gives the range of its nearest container, with `exact: false`.
20
20
  */
21
- export function locateJson(text, segments, part = "value") {
22
- const range = locate(text, "json", segments.map(String), { part });
21
+ export function locateText(format, text, segments, part = "value") {
22
+ const range = locate(text, format, segments.map(String), { part });
23
23
  return range ? { from: range.offset, to: range.offset + range.length, exact: range.exact !== false } : null;
24
24
  }
25
25
 
26
+ /** `locateText` for JSON. */
27
+ export const locateJson = (text, segments, part = "value") => locateText("json", text, segments, part);
28
+
26
29
  /** The part of the text a finding is about, by what it says (VALIDATOR-HINTS section 4). */
27
30
  export function partOf(finding) {
28
31
  const keyword = finding.validation?.keyword;
@@ -0,0 +1,227 @@
1
+ // Cross-references inside the document as completion values (artifacts/source-editor/SPEC.md, section 3.4): `asset:<id>` from `assets`, dataset ids
2
+ // and column names from `datasets`, `var:<id>` and `{{name}}` from `variables`, `cite` ids from `references`, `beat` ids from the deck's narrative and
3
+ // `region` names from the slide's layout. Which field takes which kind of reference is ONE table (`REFERENCE_RULES`) read against the schema: a rule
4
+ // names the schema definitions whose fields are references (`Asset`, `DatasetId`, ...) and the property names, with a phrase the property's own
5
+ // description must carry (any of `phrases`), for the fields written inline in their parent. test/source-completions-units.mjs checks every entry against the schema,
6
+ // so a renamed definition or a reworded description fails the test instead of silently dropping a list. Plain functions, no DOM.
7
+ import { parse as parseYaml } from "yaml";
8
+ import { describeField } from "@openpresentation/opf/schema";
9
+
10
+ /**
11
+ * The rules. `definitions`: a field whose own schema is that definition (`$defs` name). `keys` + `phrases`: a string field with one of those property
12
+ * names whose description contains one of the phrases. `item`: the field is a list (or a string or a list) and every entry is a reference.
13
+ */
14
+ export const REFERENCE_RULES = Object.freeze([
15
+ { kind: "asset", definitions: ["Asset", "LogoSource", "ImageSource"], keys: ["src", "logo", "watermark"], phrases: ["asset:<id>", "or Asset"] },
16
+ { kind: "dataset", definitions: ["DatasetId"], keys: [], phrases: [] },
17
+ { kind: "datasetColumn", definitions: ["DatasetFields"], keys: ["category", "x", "series", "line", "secondaryAxis"], phrases: ["column"], item: true },
18
+ { kind: "cite", definitions: [], keys: ["cite"], phrases: ["references"], item: true },
19
+ { kind: "beat", definitions: [], keys: ["beat"], phrases: ["narrative"], item: true },
20
+ { kind: "region", definitions: [], keys: ["region"], phrases: ["template layout"] },
21
+ { kind: "color", definitions: ["ColorRef"], keys: ["color"], phrases: ["var:<id>"] },
22
+ ]);
23
+
24
+ /** The promoted regions of a slide a block can be pinned to (the lower-case names the `region` pattern accepts). */
25
+ export const PROMOTED_REGIONS = Object.freeze(["top", "middle", "bottom", "left", "center", "right"]);
26
+
27
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
28
+ const isIndex = (segment) => typeof segment === "number" || /^(?:0|[1-9]\d*)$/.test(String(segment));
29
+
30
+ function ruleOf(info, key) {
31
+ if (!info?.known || !(info.types.includes("string") || info.types.includes("array"))) return null;
32
+ for (const rule of REFERENCE_RULES) {
33
+ if (info.definition && rule.definitions.includes(info.definition)) return rule;
34
+ if (key !== undefined && rule.keys.includes(key) && rule.phrases.some((phrase) => (info.fullDescription ?? info.description ?? "").includes(phrase))) return rule;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * What the value at `path` of `document` references: `{ kind, path }` (`path` is the field, for a list entry the list's), or null. A list entry
41
+ * (`cite: ["a", |]`, `fields: [|]`) is the entry of the list field the rule names; a field that can only be a list takes its references one by one.
42
+ */
43
+ export function referenceSiteAt(path, document) {
44
+ if (!Array.isArray(path) || !path.length) return null;
45
+ const last = path.at(-1);
46
+ const describe = (at) => { try { return describeField(at, document); } catch { return null; } };
47
+ if (!isIndex(last)) {
48
+ const info = describe(path);
49
+ const rule = ruleOf(info, String(last));
50
+ const listOnly = info?.types.length === 1 && info.types[0] === "array";
51
+ return rule && !listOnly ? { kind: rule.kind, path } : null;
52
+ }
53
+ // An entry whose own schema is a reference definition (`chart.colors[i]`: a ColorRef) is a reference whatever the list is called.
54
+ const own = describe(path);
55
+ const byDefinition = own?.known && own.definition ? REFERENCE_RULES.find((rule) => rule.definitions.includes(own.definition)) : undefined;
56
+ if (byDefinition && !own.types.every((type) => type === "array")) return { kind: byDefinition.kind, path };
57
+ const field = path.slice(0, -1);
58
+ while (field.length && isIndex(field.at(-1))) field.pop();
59
+ if (!field.length) return null;
60
+ const rule = ruleOf(describe(field), String(field.at(-1)));
61
+ return rule?.item ? { kind: rule.kind, path: field } : null;
62
+ }
63
+
64
+ // ---- the facts of a document -------------------------------------------------------------------------------------------------------
65
+ const stringOf = (value) => (typeof value === "string" ? value : undefined);
66
+ const columnName = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : undefined);
67
+
68
+ /**
69
+ * The parts of a deck the references read, from the plain value of the top level (`assets`, `datasets`, `variables`, `references`, `narrative`):
70
+ * `{ assets: [{ id, detail }], datasets: [{ id, title, columns }], variables: [{ id, type }], references: [{ id, text }], narrative }`.
71
+ * Anything else the value holds, and anything malformed, is ignored: the text being edited is a draft.
72
+ */
73
+ export function factsOf(deck) {
74
+ const out = { assets: [], datasets: [], variables: [], references: [], narrative: undefined };
75
+ if (!isObject(deck)) return out;
76
+ if (isObject(deck.assets)) {
77
+ for (const [id, asset] of Object.entries(deck.assets)) out.assets.push({ id, detail: stringOf(asset) ?? stringOf(asset?.src) ?? stringOf(asset?.title) ?? "" });
78
+ }
79
+ if (isObject(deck.datasets)) {
80
+ for (const [id, dataset] of Object.entries(deck.datasets)) {
81
+ const columns = Array.isArray(dataset?.columns) ? dataset.columns.map(columnName).filter(Boolean) : [];
82
+ out.datasets.push({ id, title: stringOf(dataset?.title) ?? "", columns });
83
+ }
84
+ }
85
+ if (isObject(deck.variables)) {
86
+ for (const [id, variable] of Object.entries(deck.variables)) {
87
+ // A hex string is shorthand for a color variable.
88
+ const type = typeof variable === "string" ? "color" : stringOf(variable?.type) ?? "color";
89
+ out.variables.push({ id, type });
90
+ }
91
+ }
92
+ if (Array.isArray(deck.references)) {
93
+ for (const reference of deck.references) if (isObject(reference) && typeof reference.id === "string" && reference.id) out.references.push({ id: reference.id, text: stringOf(reference.text) ?? "" });
94
+ }
95
+ out.narrative = stringOf(deck.narrative);
96
+ return out;
97
+ }
98
+
99
+ /** The top-level parts of a JSON draft the references read (`root` is a jsonc-parser tree), as a plain value for `factsOf`. */
100
+ export function jsonDeckParts(root, getNodeValue) {
101
+ const deck = {};
102
+ if (root?.type !== "object") return deck;
103
+ for (const property of root.children ?? []) {
104
+ const key = property.children?.[0]?.value, value = property.children?.[1];
105
+ if (!value || !["assets", "datasets", "variables", "references", "narrative"].includes(key)) continue;
106
+ if (key === "datasets" && value.type === "object") {
107
+ // Only the names and the columns: the rows can be the bulk of a deck.
108
+ deck.datasets = {};
109
+ for (const entry of value.children ?? []) {
110
+ const id = entry.children?.[0]?.value, body = entry.children?.[1];
111
+ if (typeof id !== "string" || body?.type !== "object") continue;
112
+ const columns = (body.children ?? []).find((child) => child.children?.[0]?.value === "columns")?.children?.[1];
113
+ const title = (body.children ?? []).find((child) => child.children?.[0]?.value === "title")?.children?.[1];
114
+ deck.datasets[id] = { columns: columns ? getNodeValue(columns) : [], title: title?.type === "string" ? title.value : undefined };
115
+ }
116
+ } else deck[key] = getNodeValue(value);
117
+ }
118
+ return deck;
119
+ }
120
+
121
+ const TOP_LEVEL = ["assets", "datasets", "variables", "references", "narrative"];
122
+
123
+ /** The text of the top-level blocks of a YAML text that hold the keys the references read, parsed (tolerant: a broken block gives nothing). */
124
+ export function yamlDeckParts(text) {
125
+ const lines = text.split(/\r\n|\r|\n/);
126
+ const deck = {};
127
+ for (let number = 0; number < lines.length; number++) {
128
+ const match = /^([A-Za-z]+)\s*:/.exec(lines[number]);
129
+ if (!match || !TOP_LEVEL.includes(match[1])) continue;
130
+ let end = number + 1;
131
+ while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]) || lines[end].startsWith("- "))) end++;
132
+ try {
133
+ const value = parseYaml(lines.slice(number, end).join("\n"), { strict: false, uniqueKeys: false });
134
+ if (isObject(value) && match[1] in value) deck[match[1]] = value[match[1]];
135
+ } catch { /* A block that does not parse offers nothing. */ }
136
+ }
137
+ return deck;
138
+ }
139
+
140
+ /** `value` at `path` of a plain document, or undefined. */
141
+ export function valueAt(value, path) {
142
+ let current = value;
143
+ for (const step of path) {
144
+ if (current === null || typeof current !== "object") return undefined;
145
+ current = current[step];
146
+ }
147
+ return current;
148
+ }
149
+
150
+ /**
151
+ * What a column field reads its names from: `{ dataset }` (the id of the dataset: a `dataset` beside the field, or the chart's `data.dataset`) or
152
+ * `{ inline }` (the `columns` of the chart's own `data`), looking outward from the field; null when the draft says neither. `read(path)` gives a
153
+ * value of the document.
154
+ */
155
+ export function columnSourceFor(site, read) {
156
+ for (let depth = site.path.length - 1; depth >= 0; depth--) {
157
+ const owner = site.path.slice(0, depth);
158
+ const direct = read([...owner, "dataset"]);
159
+ if (typeof direct === "string") return { dataset: direct };
160
+ const via = read([...owner, "data", "dataset"]);
161
+ if (typeof via === "string") return { dataset: via };
162
+ const inline = read([...owner, "data", "columns"]);
163
+ if (Array.isArray(inline)) return { inline: inline.map(columnName).filter(Boolean) };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /** The names of the regions of a layout record a block can be pinned to (the title and the subtitle are not regions of the body). */
169
+ export function regionNames(record) {
170
+ const names = [];
171
+ const walk = (regions) => {
172
+ for (const region of Array.isArray(regions) ? regions : []) {
173
+ if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
174
+ if (region?.regions) walk(region.regions);
175
+ }
176
+ };
177
+ walk(record?.regions);
178
+ return names;
179
+ }
180
+
181
+ /** The regions of a layout record in reading order (nested ones included) as `{ name, accepts }`. */
182
+ export function flatRegions(record) {
183
+ const out = [];
184
+ const walk = (regions) => {
185
+ for (const region of Array.isArray(regions) ? regions : []) {
186
+ if (typeof region?.name === "string") out.push({ name: region.name, accepts: Array.isArray(region.accepts) ? region.accepts : [] });
187
+ if (region?.regions) walk(region.regions);
188
+ }
189
+ };
190
+ walk(record?.regions);
191
+ return out;
192
+ }
193
+
194
+ // The slide key that holds content of a kind a region accepts.
195
+ const PAYLOAD_KEYS = { text: "text", list: "items", image: "image", video: "video", chart: "chart", table: "table", quote: "quote", code: "code", metric: "metric", timeline: "timeline" };
196
+
197
+ /**
198
+ * The slide keys a layout's regions call for, in region order: a region named like a slide key (`title`, `items`) calls for that key; any other region
199
+ * that accepts one or two kinds of content (`media`: image, video) for the keys of those kinds. `keys` are the slide keys that exist.
200
+ */
201
+ export function layoutKeys(record, keys) {
202
+ const wanted = [];
203
+ const add = (key) => { if (keys.includes(key) && !wanted.includes(key)) wanted.push(key); };
204
+ for (const region of flatRegions(record)) {
205
+ if (keys.includes(region.name)) add(region.name);
206
+ else if (region.accepts.length > 0 && region.accepts.length <= 2) for (const kind of region.accepts) add(PAYLOAD_KEYS[kind]);
207
+ }
208
+ return wanted;
209
+ }
210
+
211
+ /** The ids of the beats of a narrative record. */
212
+ export function beatsOf(record) {
213
+ return (Array.isArray(record?.beats) ? record.beats : []).filter((beat) => isObject(beat) && typeof beat.id === "string").map((beat) => ({ id: beat.id, name: stringOf(beat.name) ?? "", description: stringOf(beat.description) ?? "" }));
214
+ }
215
+
216
+ /**
217
+ * The names of the variables a `{{` in text can reference: the document's own, with their type. (The built-in `organization.*`, `speaker.*` and
218
+ * `deck.*` variables are not offered: they are not declared in the document.)
219
+ */
220
+ export function variableNames(facts) {
221
+ return facts.variables.map((variable) => ({ id: variable.id, type: variable.type }));
222
+ }
223
+
224
+ /** The slide a path of a deck sits in: `["slides", n]`, or null. */
225
+ export function slidePath(path) {
226
+ return path[0] === "slides" && isIndex(path[1]) ? [path[0], Number(path[1])] : null;
227
+ }
@@ -2,6 +2,8 @@
2
2
  // OPF fragment with numbered placeholders and rendered per format (JSON here; YAML and Markdown follow in phase 2), then handed to CodeMirror's
3
3
  // snippet engine, which walks the tab stops. Plain functions, no DOM.
4
4
 
5
+ import { Document as YamlDocument } from "yaml";
6
+
5
7
  const PLACEHOLDER = Symbol("opf-placeholder");
6
8
 
7
9
  /**
@@ -70,6 +72,20 @@ export const SNIPPETS = Object.freeze([
70
72
  { prefix: "reference", where: "reference", description: "A reference a slide can cite", fragment: { id: ph(1, "smith2020"), text: ph(2, "Citation") } },
71
73
  { prefix: "dataset", where: "dataset", description: "A table of data that charts and tables can share", name: ph(1, "sales"), fragment: { columns: [ph(2, "Category"), ph(3, "Value")], rows: [[ph(4, "A"), ph(5, "1", { raw: true })]] } },
72
74
  { prefix: "deck", where: "deck", description: "A starter deck", fragment: { name: ph(1, "Deck name"), slides: [{ title: ph(2, "Title") }] } },
75
+ // Speaker notes in Markdown: a `Note:` line (JSON and YAML have the `notes` property for it).
76
+ { prefix: "notes", where: "markdown", key: "notes", description: "Speaker notes for the slide", fragment: ph(1, "Speaker notes") },
77
+ ]);
78
+
79
+ /**
80
+ * Snippets a KEY of the deck is accepted as (SPEC 5: `design`, `organization`, `speaker`): not items of the list on their own (`SNIPPETS` holds
81
+ * those), but what accepting the property writes, as VS Code does for a property with default snippets. The fragment is the value of the key. They hold
82
+ * the required members of the object, each a tab stop (SPEC 5 lists only `name` for the speaker; the Speaker schema requires an id too), and `design`
83
+ * opens the list of the catalog at each stop.
84
+ */
85
+ export const KEY_SNIPPETS = Object.freeze([
86
+ { prefix: "design", where: "deck-key", key: "design", description: "The design of the deck: a theme, a color scheme and a font scheme", fragment: { theme: ph(1, "", { choice: true }), colorScheme: ph(2, "", { choice: true }), fontScheme: ph(3, "", { choice: true }) } },
87
+ { prefix: "organization", where: "deck-key", key: "organization", description: "The organization presenting the deck: an id and a name", fragment: { id: ph(1, "acme"), name: ph(2, "Acme Inc.") } },
88
+ { prefix: "speaker", where: "deck-key", key: "speaker", description: "The person presenting the deck: an id and a name", fragment: { id: ph(1, "alice"), name: ph(2, "Name") } },
73
89
  ]);
74
90
 
75
91
  /** The snippets that fit where the caret is: `where` and the path of the node (see source-json.js). */
@@ -84,10 +100,153 @@ export function snippetsFor(mode, path) {
84
100
  });
85
101
  }
86
102
 
103
+ /**
104
+ * The snippets a key of the object at `path` is accepted as (the deck's `design`, `organization`, `speaker`): not items of their own in the list,
105
+ * but what accepting that property writes, as VS Code does for a property with default snippets.
106
+ */
107
+ export function keySnippetsFor(path) {
108
+ return path.length === 0 ? KEY_SNIPPETS : [];
109
+ }
110
+
87
111
  /** The JSON text of a snippet for insertion: `{ text, fields }`; a dataset is a `"name": { ... }` entry. */
88
112
  export function snippetJson(snippet, { comma = false } = {}) {
113
+ if (snippet.key !== undefined) {
114
+ const body = renderJson(snippet.fragment, { comma });
115
+ return { text: `${JSON.stringify(snippet.key)}: ${body.text}`, fields: body.fields };
116
+ }
89
117
  if (snippet.where !== "dataset") return renderJson(snippet.fragment, { comma });
90
118
  const body = renderJson(snippet.fragment, { comma });
91
119
  const key = `"${field(snippet.name)}"`;
92
120
  return { text: `${key}: ${body.text}`, fields: [{ index: snippet.name.index, text: snippet.name.text }, ...body.fields].sort((a, b) => a.index - b.index) };
93
121
  }
122
+
123
+ // ---- YAML ------------------------------------------------------------------------------------------------------------------------------
124
+ const yamlText = (text) => {
125
+ try { return new YamlDocument(text).toString({ lineWidth: 0 }).trimEnd(); } catch { return JSON.stringify(text); }
126
+ };
127
+ // A placeholder as YAML: its text between the markers, quoted when plain YAML would read it as another type or as syntax (`42`, `a: b`).
128
+ function yamlField(placeholder) {
129
+ const marker = (text) => `\${${placeholder.index}${text ? `:${escapeText(text)}` : ""}}`;
130
+ const text = placeholder.text || (placeholder.choice ? (placeholder.value ?? "") : "");
131
+ if (placeholder.raw || !text) return marker(text);
132
+ return yamlText(text) === text ? marker(text) : `"${marker(JSON.stringify(text).slice(1, -1))}"`;
133
+ }
134
+
135
+ /** The lines of a mapping as `[{ depth, text }]` (block style; lists of lists and `columns` in flow style), recording placeholders in `fields`. */
136
+ function yamlLines(mapping, depth, fields) {
137
+ const scalar = (value) => {
138
+ if (isPlaceholder(value)) {
139
+ fields.push({ index: value.index, text: value.text || (value.choice ? (value.value ?? "") : ""), ...(value.choice ? { choice: true } : {}) });
140
+ return yamlField(value);
141
+ }
142
+ if (value === null) return "null";
143
+ return typeof value === "string" ? yamlText(value) : String(value);
144
+ };
145
+ const flow = (value) => (Array.isArray(value) ? `[${value.map(flow).join(", ")}]` : scalar(value));
146
+ const isCollection = (value) => value !== null && typeof value === "object" && !isPlaceholder(value);
147
+ const lines = [];
148
+ for (const [key, item] of Object.entries(mapping)) {
149
+ if (!isCollection(item)) { lines.push({ depth, text: `${key}: ${scalar(item)}` }); continue; }
150
+ if (Array.isArray(item)) {
151
+ if (!item.length) { lines.push({ depth, text: `${key}: []` }); continue; }
152
+ if (key === "columns") { lines.push({ depth, text: `${key}: ${flow(item)}` }); continue; }
153
+ lines.push({ depth, text: `${key}:` });
154
+ for (const entry of item) {
155
+ if (Array.isArray(entry)) lines.push({ depth: depth + 1, text: `- ${flow(entry)}` });
156
+ else if (isCollection(entry)) {
157
+ const inner = yamlLines(entry, depth + 2, fields);
158
+ if (inner.length) inner[0] = { depth: depth + 1, text: `- ${inner[0].text}` };
159
+ lines.push(...inner);
160
+ } else lines.push({ depth: depth + 1, text: `- ${scalar(entry)}` });
161
+ }
162
+ continue;
163
+ }
164
+ if (!Object.keys(item).length) { lines.push({ depth, text: `${key}: {}` }); continue; }
165
+ lines.push({ depth, text: `${key}:` }, ...yamlLines(item, depth + 1, fields));
166
+ }
167
+ return lines;
168
+ }
169
+
170
+ /**
171
+ * `{ text, fields }`: a fragment as block-style YAML with CodeMirror snippet markers. The first line starts where the caret is (after a dash or
172
+ * at a key position); the others are indented relative to the line the caret is on, and `dash` more columns when the caret follows a list dash.
173
+ * `entry` writes the fragment as the value of that key; `name` (a placeholder) writes it under a key the author names.
174
+ */
175
+ export function renderYaml(fragment, { dash = 0, entry, name } = {}) {
176
+ const fields = [];
177
+ let lines;
178
+ if (name !== undefined) {
179
+ fields.push({ index: name.index, text: name.text });
180
+ lines = [{ depth: 0, text: `${yamlField(name)}:` }, ...yamlLines(fragment, 1, fields)];
181
+ } else if (entry !== undefined) lines = yamlLines({ [entry]: fragment }, 0, fields);
182
+ else lines = yamlLines(fragment, 0, fields);
183
+ const pad = " ".repeat(dash);
184
+ const text = lines.map((line, index) => (index === 0 ? line.text : `${pad}${" ".repeat(line.depth)}${line.text}`)).join("\n");
185
+ return { text: `${text}\${0}`, fields: fields.sort((a, b) => a.index - b.index) };
186
+ }
187
+
188
+ /** The YAML text of a snippet for insertion: `{ text, fields }`. `dash` is the width of the list dash the caret follows (2), else 0. */
189
+ export function snippetYaml(snippet, { dash = 0 } = {}) {
190
+ if (snippet.key !== undefined) return renderYaml(snippet.fragment, { dash, entry: snippet.key });
191
+ if (snippet.where === "dataset") return renderYaml(snippet.fragment, { dash, name: snippet.name });
192
+ return renderYaml(snippet.fragment, { dash });
193
+ }
194
+
195
+ // ---- Markdown --------------------------------------------------------------------------------------------------------------------------
196
+ // A placeholder as Markdown text: the marker with its text (a choice starts with its default, `column`).
197
+ function mdField(placeholder, fields) {
198
+ const text = placeholder.text || (placeholder.choice ? (placeholder.value ?? "") : "");
199
+ fields.push({ index: placeholder.index, text, ...(placeholder.choice ? { choice: true } : {}) });
200
+ return `\${${placeholder.index}${text ? `:${escapeText(text)}` : ""}}`;
201
+ }
202
+
203
+ const SLIDE_OPTION_FIELDS = ["id", "type", "layout", "section", "tag"];
204
+
205
+ /**
206
+ * `{ text, fields }`: a slide fragment as the Markdown dialect writes it (options comment, `#` title, blocks, `Note:`), with CodeMirror snippet
207
+ * markers. `separator` puts `---` and a blank line first (a slide after another). A `notes` snippet is one `Note:` line. The shape follows
208
+ * core's writer, so the result reads back as the slide the fragment describes.
209
+ */
210
+ export function renderMarkdown(fragment, { separator = false, entry } = {}) {
211
+ const fields = [];
212
+ const value = (item) => (isPlaceholder(item) ? mdField(item, fields) : item === null || item === undefined ? "" : String(item));
213
+ if (entry === "notes") return { text: `Note: ${value(fragment)}\${0}`, fields };
214
+ const blocks = [];
215
+ const options = [];
216
+ for (const key of SLIDE_OPTION_FIELDS) if (typeof fragment[key] === "string" || isPlaceholder(fragment[key])) options.push(`${key}=${value(fragment[key])}`);
217
+ // The options comment sits directly above what follows it, as core writes it.
218
+ const lead = options.length ? `<!-- slide: ${options.join(" ")} -->` : "";
219
+ if (fragment.title !== undefined) blocks.push(`${lead ? `${lead}\n` : ""}# ${value(fragment.title)}`);
220
+ else if (lead) blocks.push(lead);
221
+ if (fragment.subtitle !== undefined) blocks.push(`## ${value(fragment.subtitle)}`);
222
+ const bullets = (items) => items.map((item) => `- ${value(item)}`).join("\n");
223
+ const csv = (rows) => rows.map((row) => row.map((cell) => value(cell)).join(",")).join("\n");
224
+ if (fragment.text !== undefined) blocks.push(value(fragment.text));
225
+ if (Array.isArray(fragment.items)) blocks.push(bullets(fragment.items));
226
+ for (const region of ["left", "right"]) {
227
+ if (fragment[region]) blocks.push(`<!-- block: region=${region} -->\n${bullets(fragment[region].items ?? [])}`);
228
+ }
229
+ if (fragment.image) blocks.push(`![${value(fragment.image.alt)}](${value(fragment.image.src)})`);
230
+ if (fragment.chart) blocks.push(`\`\`\`chart ${value(fragment.chart.type)}\n${csv([fragment.chart.data.columns, ...fragment.chart.data.rows])}\n\`\`\``);
231
+ if (fragment.quote) blocks.push(`> ${value(fragment.quote.text)}${fragment.quote.attribution === undefined ? "" : `\n> — ${value(fragment.quote.attribution)}`}`);
232
+ if (fragment.metric) blocks.push(`\`\`\`metric\nvalue: ${value(fragment.metric.value)}\nlabel: ${value(fragment.metric.label)}\n\`\`\``);
233
+ if (fragment.table) {
234
+ const row = (cells) => `| ${cells.map((cell) => value(cell)).join(" | ")} |`;
235
+ blocks.push([row(fragment.table.columns), row(fragment.table.columns.map(() => "---")), ...fragment.table.rows.map(row)].join("\n"));
236
+ }
237
+ if (fragment.code) blocks.push(`\`\`\`${value(fragment.code.language)}\n${value(fragment.code.source)}\n\`\`\``);
238
+ if (fragment.notes !== undefined) blocks.push(`Note: ${value(fragment.notes)}`);
239
+ const body = blocks.join("\n\n");
240
+ return { text: `${separator ? "---\n\n" : ""}${body}\${0}`, fields: fields.sort((a, b) => a.index - b.index) };
241
+ }
242
+
243
+ /** The Markdown text of a snippet for insertion at the start of a line: `{ text, fields }`. `separator`: a slide follows another. */
244
+ export function snippetMarkdown(snippet, { separator = false } = {}) {
245
+ if (snippet.key !== undefined) return renderMarkdown(snippet.fragment, { entry: snippet.key });
246
+ return renderMarkdown(snippet.fragment, { separator });
247
+ }
248
+
249
+ /** The snippets of the Markdown body: a slide of each kind and the speaker notes. */
250
+ export function snippetsForMarkdown() {
251
+ return SNIPPETS.filter((snippet) => snippet.where === "slide" || snippet.where === "markdown");
252
+ }
@@ -0,0 +1,46 @@
1
+ // One undo step for a suggestion and the value list it opened (artifacts/source-editor/SPEC.md, section 4.6, item 4; TEST-PLAN J-INS-017). Accepting a key
2
+ // of a closed set of values writes `"key": ""` and opens the list of the values; accepting a value is a second edit. Each is an edit of its own in
3
+ // CodeMirror's history, as every accepted suggestion is, so the pair is recorded here and Undo and Redo step over both: one Cmd+Z puts back the text
4
+ // the author had typed before accepting the key (VS Code: "accept is one step with its typed prefix undoable"). Anything typed in between, or any
5
+ // other edit after the pair, makes the pair void, so Undo is the plain one again. Plain functions over an editor view, no DOM.
6
+ import { redo, undo, undoDepth, redoDepth } from "@codemirror/commands";
7
+
8
+ const pending = new WeakMap();
9
+ const pairs = new WeakMap();
10
+
11
+ /** A suggestion that opens a list of values was accepted: the text before it and after it. */
12
+ export function beginUndoGroup(view, before) {
13
+ pending.set(view, { before, after: view.state.doc });
14
+ }
15
+
16
+ /** A suggestion is about to be accepted: the pair it completes (the key accepted just before, with nothing typed since), or null. */
17
+ export function takeUndoGroup(view) {
18
+ const link = pending.get(view);
19
+ pending.delete(view);
20
+ pairs.delete(view);
21
+ return link && link.after.eq(view.state.doc) ? link : null;
22
+ }
23
+
24
+ /** The value was accepted (and the history closed after it): the key and the value are one undo step from now on. */
25
+ export function finishUndoGroup(view, link) {
26
+ pairs.set(view, { before: link.before, after: view.state.doc, depth: undoDepth(view.state), undone: false });
27
+ }
28
+
29
+ /** Whether Undo should step over the last pair; does it when so (returns true). Otherwise the caller does the plain undo. */
30
+ export function undoPair(view) {
31
+ const pair = pairs.get(view);
32
+ if (!pair || pair.undone || undoDepth(view.state) !== pair.depth || !view.state.doc.eq(pair.after)) return false;
33
+ for (let step = 0; step < 3 && !view.state.doc.eq(pair.before); step++) if (!undo(view)) break;
34
+ pair.undone = view.state.doc.eq(pair.before);
35
+ pair.redoDepth = redoDepth(view.state);
36
+ return true;
37
+ }
38
+
39
+ /** The same for Redo: after the pair was undone, Redo puts both back. */
40
+ export function redoPair(view) {
41
+ const pair = pairs.get(view);
42
+ if (!pair || !pair.undone || redoDepth(view.state) !== pair.redoDepth || !view.state.doc.eq(pair.before)) return false;
43
+ for (let step = 0; step < 3 && !view.state.doc.eq(pair.after); step++) if (!redo(view)) break;
44
+ pair.undone = !view.state.doc.eq(pair.after);
45
+ return true;
46
+ }