@openpresentation/opf-editor 0.26.3 → 0.26.4

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.
@@ -0,0 +1,551 @@
1
+ // The Markdown text adapter of the source editor (artifacts/source-editor/SPEC.md, section 3.3): what a position in an OPF Markdown draft means. Core's
2
+ // `scanMarkdown` finds the regions with the converter's own rules; this module turns the caret's region into a context for completion and hover:
3
+ // the YAML of the front matter and of `opf-slide` and `opf-block` fences (the YAML adapter, shifted into the text), the keys and values of a slide or
4
+ // block options comment, the info line of a fence, the lines of a metric fence, and the start of a line in the body (`Note:`, snippets). Plain
5
+ // functions, no DOM.
6
+ import { parse as parseYaml } from "yaml";
7
+ import { describeField } from "@openpresentation/opf/schema";
8
+ import { BLOCK_AS_VALUES, BLOCK_OPTION_KEYS, FENCE_ATTRIBUTES, LINE_KEYWORDS, METRIC_KEYS, SLIDE_OPTION_KEYS, SPAN_FLAGS, SPAN_VALUES, scanMarkdown, pathAt } from "@openpresentation/opf/source";
9
+ import { fromMarkdown } from "@openpresentation/opf/markdown";
10
+ import { yamlContextAt } from "./source-yaml.js";
11
+ import { snippetMarkdown, snippetsForMarkdown } from "./source-snippets.js";
12
+ import { fieldDoc } from "./source-docs.js";
13
+
14
+ const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
15
+ const BARE = /^[A-Za-z0-9_-]+$/;
16
+ const LANGUAGES = ["javascript", "typescript", "python", "rust", "go", "bash", "json", "yaml", "toml", "ini", "markup", "css", "sql", "java", "csharp", "kotlin", "swift", "ruby", "php", "c", "hcl", "dockerfile", "js", "ts", "py", "sh", "html", "md"];
17
+ // Keys whose values come from a closed set the schema or the catalogs know; accepting the key opens that list.
18
+ const CHAIN_SLIDE = new Set(["layout", "type", "hidden"]);
19
+ const CHAIN_BLOCK = new Set(["type", "as", "region"]);
20
+ // The promoted regions of a slide that a block can be pinned to when the layout names none.
21
+ const PROMOTED = ["top", "middle", "bottom", "left", "center", "right", "top:left", "top:center", "top:right", "middle:left", "middle:center", "middle:right", "bottom:left", "bottom:center", "bottom:right"];
22
+
23
+ const lineAt = (text, position) => {
24
+ const start = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
25
+ let end = position;
26
+ while (end < text.length && text[end] !== "\n" && text[end] !== "\r") end++;
27
+ return { start, end, text: text.slice(start, end) };
28
+ };
29
+
30
+ // ---- regions -----------------------------------------------------------------------------------------------------------------------
31
+ /**
32
+ * Which part of a Markdown deck the caret is in: `{ region, ... }` with region `front-matter` (`yamlFrom`, `yamlTo`), `comment` (`comment`),
33
+ * `fence` (`fence`, `part`: "info", "body" or "close"), `notes` or `body`. Used by the editing rules (Enter, Tab, pairs, comments) and the adapter.
34
+ */
35
+ export function markdownRegionAt(text, position) {
36
+ const scan = scanMarkdown(text);
37
+ const front = scan.frontMatter;
38
+ if (front) {
39
+ if (position < front.yamlFrom + 1 && position <= front.yamlFrom) return { region: "front-matter-edge", front };
40
+ if (position > front.yamlFrom && (front.closed ? position < front.yamlTo : true)) return { region: "front-matter", front, scan };
41
+ if (front.closed && position >= front.yamlTo && position <= front.to) return { region: "front-matter-edge", front };
42
+ }
43
+ for (const fence of scan.fences) {
44
+ if (position < fence.from || position > fence.to) continue;
45
+ const opening = lineAt(text, fence.from);
46
+ if (position <= opening.end) return { region: "fence", fence, part: "info", scan };
47
+ if (fence.closed && position >= fence.to - lineAt(text, fence.to).text.length) return { region: "fence", fence, part: "close", scan };
48
+ return { region: "fence", fence, part: "body", scan };
49
+ }
50
+ for (const comment of scan.comments) if (position >= comment.from && position <= comment.to) return { region: "comment", comment, scan };
51
+ for (const notes of scan.notes) if (position >= notes.from && position <= notes.to) return { region: "notes", notes, scan };
52
+ return { region: "body", scan };
53
+ }
54
+
55
+ const shiftToken = (token, base) => ({ ...token, start: token.start + base, end: token.end + base, from: token.from + base, to: token.to + base });
56
+
57
+ // The embedded catalogs of the front matter, so the options of a slide can list the records the deck carries.
58
+ function frontCatalogs(text, scan) {
59
+ const front = scan.frontMatter;
60
+ if (!front) return undefined;
61
+ try { return parseYaml(text.slice(front.yamlFrom, front.yamlTo))?.catalogs; } catch { return undefined; }
62
+ }
63
+
64
+ function skeletonFor(text, scan, slide) {
65
+ const document = { slides: Array.from({ length: Math.max(0, slide) + 1 }, () => ({})) };
66
+ const catalogs = frontCatalogs(text, scan);
67
+ if (catalogs) document.catalogs = catalogs;
68
+ return document;
69
+ }
70
+
71
+ // ---- the context at a caret --------------------------------------------------------------------------------------------------------
72
+ /**
73
+ * What the draft means at `position`, or null. A caret in YAML (front matter, an `opf-slide` or `opf-block` fence) gives the YAML adapter's context,
74
+ * shifted into this text (`format: "yaml"`). Otherwise `{ format: "md", region, mode, token, ... }`:
75
+ * - `region: "options"`: `optionKind` ("slide" or "block"), `mode` "key" or "value", `key`, `existing`, `needsSpace`, `slide`, `layout`
76
+ * - `region: "comment-start"`: after `<!--` at the start of a line (offers `slide:` and `block:`)
77
+ * - `region: "fence-info"`: `mode` "kind" (the first word), "type" (the chart type) or "attribute"; `word`
78
+ * - `region: "metric"`: `mode` "key" or "value", `key`, `existing`
79
+ * - `region: "body"`: the word at the start of a line (`Note:`, a snippet prefix), `separator` (a `---` goes first)
80
+ */
81
+ export function markdownContextAt(text, position, { explicit = false } = {}) {
82
+ const where = markdownRegionAt(text, position);
83
+ switch (where.region) {
84
+ case "front-matter": {
85
+ const base = where.front.yamlFrom;
86
+ const context = yamlContextAt(text.slice(base, where.front.closed ? where.front.yamlTo : text.length), position - base, { explicit, exclude: ["slides"] });
87
+ return context ? { ...context, token: shiftToken(context.token, base) } : null;
88
+ }
89
+ case "fence":
90
+ return fenceContext(text, position, where, explicit);
91
+ case "comment":
92
+ return commentContext(text, position, where, explicit);
93
+ case "body":
94
+ return bodyContext(text, position, where, explicit);
95
+ default:
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // ---- options comments --------------------------------------------------------------------------------------------------------------
101
+ /** The `key`, `key=value` tokens of an options comment between `from` and `to`: `{ start, end, key, keyEnd, eq, valueStart, quoted }`. */
102
+ function optionTokens(text, from, to) {
103
+ const tokens = [];
104
+ let index = from;
105
+ while (index < to) {
106
+ while (index < to && /\s/.test(text[index])) index++;
107
+ if (index >= to) break;
108
+ const start = index;
109
+ while (index < to && /[A-Za-z0-9_-]/.test(text[index])) index++;
110
+ const keyEnd = index;
111
+ let eq = -1, valueStart = -1, quoted = false;
112
+ if (text[index] === "=") {
113
+ eq = index;
114
+ index++;
115
+ valueStart = index;
116
+ if (text[index] === '"') {
117
+ quoted = true;
118
+ index++;
119
+ while (index < to && text[index] !== '"') index += text[index] === "\\" ? 2 : 1;
120
+ if (index < to) index++;
121
+ } else while (index < to && !/[\s,]/.test(text[index])) index++;
122
+ } else while (index < to && !/\s/.test(text[index])) index++;
123
+ tokens.push({ start, end: index, key: text.slice(start, keyEnd), keyEnd, eq, valueStart, quoted });
124
+ if (index === start) index++;
125
+ }
126
+ return tokens;
127
+ }
128
+
129
+ function slideLayout(text, scan, slide) {
130
+ for (const comment of scan.comments) {
131
+ if (comment.kind !== "slide" || comment.slide !== slide) continue;
132
+ const tokens = optionTokens(text, comment.innerFrom, comment.innerTo);
133
+ const found = tokens.find((token) => token.key === "layout" && token.eq >= 0);
134
+ if (found) return text.slice(found.valueStart + (found.quoted ? 1 : 0), found.end - (found.quoted && text[found.end - 1] === '"' ? 1 : 0));
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ function commentContext(text, position, where, explicit) {
140
+ const { comment, scan } = where;
141
+ const line = lineAt(text, position);
142
+ const before = text.slice(line.start, position);
143
+ // `<!-- ` and nothing else on the line: the comment may be a slide or block options comment.
144
+ const start = /^ {0,3}<!--[ \t]*([a-z]*)$/.exec(before);
145
+ if (start && (comment.kind === "comment" || comment.kind === "slide" || comment.kind === "block") && !/^\s*(slide|block)\s*:/.test(text.slice(line.start + before.length - start[1].length - 0, line.end))) {
146
+ const wordStart = position - start[1].length;
147
+ const afterWord = text.slice(position, line.end);
148
+ if (!explicit && start[1] === "" && !/[ \t]$/.test(before)) return null;
149
+ return { format: "md", region: "comment-start", token: { quoted: false, closed: false, start: wordStart, end: position, from: wordStart, to: position }, closes: /-->/.test(afterWord), document: {}, path: [] };
150
+ }
151
+ if (comment.kind !== "slide" && comment.kind !== "block") return null;
152
+ // A comment on one line only.
153
+ const first = lineAt(text, comment.from);
154
+ if (first.start !== line.start) return null;
155
+ const colon = /^\s*(slide|block)\s*(:?)/.exec(text.slice(comment.innerFrom, line.end));
156
+ if (!colon) return null;
157
+ const keywordEnd = comment.innerFrom + colon[0].length;
158
+ const innerEnd = comment.closed ? Math.min(comment.innerTo, line.end) : line.end;
159
+ if (position < keywordEnd || position > innerEnd) return null;
160
+ const kind = colon[1];
161
+ const tokens = optionTokens(text, keywordEnd, innerEnd);
162
+ const here = tokens.find((token) => position >= token.start && position <= token.end);
163
+ const slide = comment.slide;
164
+ const needsSpace = position === keywordEnd && colon[2] === ":" && text[position] !== " ";
165
+ const keys = kind === "slide" ? SLIDE_OPTION_KEYS : BLOCK_OPTION_KEYS;
166
+ const existing = new Set(tokens.filter((token) => token !== here && token.key !== "beat").map((token) => token.key));
167
+ const base = { format: "md", region: "options", optionKind: kind, slide, existing, needsSpace, keys, document: skeletonFor(text, scan, slide), layout: slideLayout(text, scan, slide), path: [] };
168
+ if (here && here.eq >= 0 && position > here.eq) {
169
+ const first = here.valueStart;
170
+ if (here.quoted) {
171
+ const closed = text[here.end - 1] === '"' && here.end - 1 > first;
172
+ const token = { quoted: true, closed, start: first, end: here.end, from: first + 1, to: closed ? here.end - 1 : Math.max(position, first + 1) };
173
+ return { ...base, mode: "value", key: here.key, token };
174
+ }
175
+ return { ...base, mode: "value", key: here.key, token: { quoted: false, closed: false, start: first, end: here.end, from: first, to: here.end } };
176
+ }
177
+ if (here && !OPTION_KEY.test(text.slice(here.start, here.keyEnd))) return null;
178
+ const start0 = here ? here.start : position;
179
+ const end0 = here ? here.keyEnd : position;
180
+ return { ...base, mode: "key", key: undefined, followedByEquals: Boolean(here && here.eq >= 0), token: { quoted: false, closed: false, start: start0, end: end0, from: start0, to: end0 } };
181
+ }
182
+
183
+ // ---- fences ------------------------------------------------------------------------------------------------------------------------
184
+ function fenceContext(text, position, where, explicit) {
185
+ const { fence, part, scan } = where;
186
+ if (part === "close") return null;
187
+ if (part === "info") {
188
+ const line = lineAt(text, fence.from);
189
+ const indent = /^ */.exec(line.text)[0].length;
190
+ const open = line.start + indent + fence.marker.length;
191
+ if (position < open) return null;
192
+ // Typed info: words separated by spaces; the caret is in one of them.
193
+ const before = text.slice(open, position);
194
+ const words = before.split(/\s+/);
195
+ const typed = words.at(-1);
196
+ const first = before.trimStart().split(/\s+/)[0];
197
+ const tokenStart = position - typed.length;
198
+ let tokenEnd = position;
199
+ while (tokenEnd < line.end && /[A-Za-z0-9_+#.-]/.test(text[tokenEnd])) tokenEnd++;
200
+ const token = { quoted: false, closed: false, start: tokenStart, end: tokenEnd, from: tokenStart, to: tokenEnd };
201
+ const done = before.trim().split(/\s+/).filter(Boolean);
202
+ const wordIndex = /\s$/.test(before) ? done.length : Math.max(0, done.length - 1);
203
+ if (wordIndex === 0) return { format: "md", region: "fence-info", mode: "kind", word: first || "", token, document: {}, path: [] };
204
+ const kind = first;
205
+ if (kind === "chart" && wordIndex === 1 && !/=/.test(typed)) return { format: "md", region: "fence-info", mode: "type", word: kind, token, document: skeletonFor(text, scan, fence.slide), path: [] };
206
+ if (!/=/.test(typed) && (kind === "chart" || kind === "timeline" || !["opf-slide", "opf-block", "metric"].includes(kind))) {
207
+ const have = new Set(optionTokens(text, open, line.end).map((item) => item.key));
208
+ return { format: "md", region: "fence-info", mode: "attribute", word: kind, existing: have, token, document: {}, path: [] };
209
+ }
210
+ return null;
211
+ }
212
+ // The body.
213
+ if (fence.kind === "metric") return metricContext(text, position, fence);
214
+ if (fence.kind === "timeline") return timelineContext(text, position);
215
+ if (fence.kind === "opf-slide" || fence.kind === "opf-block") {
216
+ const base = fence.bodyFrom;
217
+ const prefix = fence.kind === "opf-slide" ? ["slides", fence.slide] : ["slides", fence.slide, "blocks", 0];
218
+ const context = yamlContextAt(text.slice(base, fence.bodyTo), position - base, { explicit, prefix });
219
+ if (!context) return null;
220
+ const catalogs = frontCatalogs(text, scan);
221
+ if (catalogs && context.document && typeof context.document === "object") context.document.catalogs = catalogs;
222
+ return { ...context, token: shiftToken(context.token, base) };
223
+ }
224
+ return null;
225
+ }
226
+
227
+ // A timeline event may start with a task-list mark that sets its status: `[x]` done, `[>]` current ("we are here"), `[ ]` planned.
228
+ function timelineContext(text, position) {
229
+ const line = lineAt(text, position);
230
+ const before = text.slice(line.start, position);
231
+ if (!/^(?:\[[x> ]?\]?)?$/.test(before) || /^\s/.test(line.text)) return null;
232
+ let end = position;
233
+ while (end < line.end && /[x> \]]/.test(text[end]) && text[end] !== " ") end++;
234
+ return { format: "md", region: "timeline", token: { quoted: false, closed: false, start: line.start, end, from: line.start, to: end }, document: {}, path: [] };
235
+ }
236
+
237
+ function metricContext(text, position, fence) {
238
+ const line = lineAt(text, position);
239
+ const before = text.slice(line.start, position);
240
+ const bodyLines = text.slice(fence.bodyFrom, fence.bodyTo).split(/\r\n|\r|\n/);
241
+ const existing = new Set(bodyLines.map((item) => /^([A-Za-z]+):/.exec(item)?.[1]).filter(Boolean));
242
+ const document = { slides: [{ metric: {} }] };
243
+ const value = /^([A-Za-z]+):[ \t]*(.*)$/.exec(before);
244
+ if (value) {
245
+ const start = position - value[2].length;
246
+ let end = position;
247
+ while (end < line.end && !/\s/.test(text[end])) end++;
248
+ return { format: "md", region: "metric", mode: "value", key: value[1], existing, token: { quoted: false, closed: false, start, end, from: start, to: end }, document, path: ["slides", 0, "metric", value[1]], needsSpace: value[2] === "" && !/:[ \t]$/.test(before) };
249
+ }
250
+ if (!/^[A-Za-z]*$/.test(before)) return null;
251
+ let end = position;
252
+ while (end < line.end && /[A-Za-z]/.test(text[end])) end++;
253
+ const start = position - before.length;
254
+ const self = /^([A-Za-z]+):/.exec(line.text)?.[1];
255
+ if (self) existing.delete(self);
256
+ return { format: "md", region: "metric", mode: "key", existing, token: { quoted: false, closed: false, start, end, from: start, to: end }, document, path: ["slides", 0, "metric"] };
257
+ }
258
+
259
+ // ---- the body ----------------------------------------------------------------------------------------------------------------------
260
+ function bodyContext(text, position, where, explicit) {
261
+ const { scan } = where;
262
+ const line = lineAt(text, position);
263
+ const before = text.slice(line.start, position);
264
+ // Inside the braces of an inline span: `[text]{bold color=#B42318}`.
265
+ const span = /\]\{((?:[A-Za-z0-9_-]+(?:=(?:"[^"]*"|[^\s"}]*))?\s+)*)([A-Za-z]*)$/.exec(before);
266
+ if (span) {
267
+ let end = position;
268
+ while (end < line.end && /[A-Za-z]/.test(text[end])) end++;
269
+ const used = new Set([...span[1].matchAll(/([A-Za-z0-9_-]+)/g)].map((entry) => entry[1].toLowerCase()));
270
+ const start = position - span[2].length;
271
+ return { format: "md", region: "span", token: { quoted: false, closed: false, start, end, from: start, to: end }, existing: used, document: {}, path: [], explicit, opened: span[2] === "" };
272
+ }
273
+ if (!/^[A-Za-z][A-Za-z-]*$|^$/.test(before)) return null;
274
+ if (before === "" && !explicit) return null;
275
+ // Typing prose is not interrupted: a list opens by itself from the third letter on.
276
+ if (!explicit && before.length < 3) return null;
277
+ let end = position;
278
+ while (end < line.end && /[A-Za-z-]/.test(text[end])) end++;
279
+ // A slide follows another when there is content above that is not already a separator.
280
+ const above = text.slice(0, line.start).replace(/\s+$/, "");
281
+ const lastLine = above.slice(above.lastIndexOf("\n") + 1);
282
+ const hasContent = above.trim() !== "" && !(scan.frontMatter && above.length <= scan.frontMatter.to);
283
+ const separator = hasContent && !/^-{3,}[ \t]*$/.test(lastLine);
284
+ const previousBlank = line.start === 0 || lineAt(text, line.start - 1).text.trim() === "";
285
+ const slide = scan.slides.filter((item) => item.from < line.start).length;
286
+ return { format: "md", region: "body", token: { quoted: false, closed: false, start: line.start, end, from: line.start, to: end }, separator, previousBlank, document: {}, path: [], slide };
287
+ }
288
+
289
+ // ---- the options -------------------------------------------------------------------------------------------------------------------
290
+ const kindOption = (label, type, detail, doc, boost, kind, extra = {}) => ({ label, type, detail, boost, opfDoc: doc, opf: { kind, ...extra } });
291
+
292
+ function regionNames(record) {
293
+ const names = [];
294
+ const walk = (regions) => {
295
+ for (const region of Array.isArray(regions) ? regions : []) {
296
+ if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
297
+ if (region?.regions) walk(region.regions);
298
+ }
299
+ };
300
+ walk(record?.regions);
301
+ return names;
302
+ }
303
+
304
+ /** The completion options of a Markdown context: `{ options, validFor? }`, or null. `makers` are the option builders of the completion engine. */
305
+ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
306
+ const { literalOption, boostAt, docNode } = makers;
307
+ const options = [];
308
+ if (context.region === "comment-start") {
309
+ options.push(kindOption("slide:", "keyword", "options", "Options of this slide: id, layout, section, ...", boostAt(0), "comment", { word: "slide:" }));
310
+ options.push(kindOption("block:", "keyword", "options", "Options of the next block: id, type, as, region.", boostAt(1), "comment", { word: "block:" }));
311
+ return { options, validFor: /^[a-z]*$/ };
312
+ }
313
+ if (context.region === "options") return optionsOptions(context, { catalogs, makers });
314
+ if (context.region === "fence-info") {
315
+ if (context.mode === "kind") {
316
+ let order = 0;
317
+ for (const kind of ["chart", "metric", "timeline", "opf-block", "opf-slide"]) {
318
+ const doc = { chart: "A chart: the type, then CSV with a header row", metric: "One big number: value, label, unit, delta", timeline: "Events, one per line", "opf-block": "A content block written as YAML", "opf-slide": "Slide fields written as YAML" }[kind];
319
+ options.push(kindOption(kind, "keyword", "fence", doc, boostAt(order++), "fence", { word: kind }));
320
+ }
321
+ for (const language of LANGUAGES) options.push(kindOption(language, "text", "code", `Code in ${language}`, boostAt(order++), "fence", { word: language }));
322
+ return { options, validFor: /^[A-Za-z0-9_+#.-]*$/ };
323
+ }
324
+ if (context.mode === "type") {
325
+ let info;
326
+ try { info = describeField(["slides", 0, "chart", "type"], context.document); } catch { info = null; }
327
+ for (const entry of info?.enum ?? []) options.push(literalOption(String(entry.value), JSON.stringify(entry.value), { value: entry.value, doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "chart type", description: entry.description }) : undefined }));
328
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
329
+ }
330
+ if (context.mode === "attribute") {
331
+ const attributes = FENCE_ATTRIBUTES[context.word] ?? (["opf-slide", "opf-block", "metric"].includes(context.word) ? [] : FENCE_ATTRIBUTES.code);
332
+ attributes.filter((name) => !context.existing.has(name)).forEach((name, index) => options.push(kindOption(name, "property", "attribute", `The ${name} of the ${context.word} fence`, boostAt(index), "fenceAttribute", { name })));
333
+ return { options, validFor: /^[A-Za-z]*$/ };
334
+ }
335
+ }
336
+ if (context.region === "metric") {
337
+ if (context.mode === "key") {
338
+ METRIC_KEYS.filter((key) => !context.existing.has(key)).forEach((key, index) => {
339
+ let info;
340
+ try { info = describeField(["slides", 0, "metric", key], context.document); } catch { info = null; }
341
+ options.push({ label: key, type: "property", detail: info?.known ? (info.enum?.length ? "enum" : info.types.join(" | ")) : "string", boost: boostAt(index), opfDoc: info?.description ?? "", opf: { kind: "metricKey", key, chain: Boolean(info?.enum?.length) }, info: () => docNode({ title: key, type: info?.types.join(" | "), description: info?.description }) });
342
+ });
343
+ return { options, validFor: /^[A-Za-z]*$/ };
344
+ }
345
+ let info;
346
+ try { info = describeField(context.path, context.document); } catch { info = null; }
347
+ for (const entry of info?.enum ?? []) options.push(literalOption(String(entry.value), JSON.stringify(entry.value), { value: entry.value, doc: entry.description ?? "" }));
348
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
349
+ }
350
+ if (context.region === "timeline") {
351
+ const marks = [["[x]", "done", "The event is done."], ["[>]", "current", "The event is where the story is now (\"we are here\")."], ["[ ]", "planned", "The event is planned."]];
352
+ marks.forEach(([mark, word, doc], index) => options.push(kindOption(mark, "keyword", word, doc, boostAt(index), "status", { mark })));
353
+ return { options, validFor: /^(?:\[[x> ]?\]?)?$/ };
354
+ }
355
+ if (context.region === "span") {
356
+ // Right after `{` or a space the list opens by itself (the characters that begin a new attribute); Ctrl+Space opens it anywhere in the braces.
357
+ const docs = { bold: "Bold text", italic: "Italic text", underline: "Underlined text", strike: "Struck-through text", sup: "Superscript", sub: "Subscript", color: "Text color: a hex color, a scheme slot or var:name", size: "Font size in points", font: "Font family", lang: "Language of the text (a BCP-47 tag)" };
358
+ [...SPAN_FLAGS.map((name) => [name, false]), ...SPAN_VALUES.map((name) => [name, true])].filter(([name]) => !context.existing.has(name)).forEach(([name, takesValue], index) => options.push(kindOption(name, "property", takesValue ? "value" : "flag", docs[name], boostAt(index), "spanAttribute", { name, takesValue })));
359
+ return { options, validFor: /^[A-Za-z]*$/ };
360
+ }
361
+ if (context.region === "body") {
362
+ const typed = text.slice(context.token.from, context.token.to).toLowerCase();
363
+ let order = 0;
364
+ for (const word of LINE_KEYWORDS) if (word.toLowerCase().startsWith(typed)) options.push(kindOption(word, "keyword", "speaker notes", "Speaker notes: every line to the end of the slide.", boostAt(order++), "keyword", { word }));
365
+ for (const entry of snippetsForMarkdown()) if (entry.prefix.toLowerCase().startsWith(typed)) options.push({ label: entry.prefix, type: "snippet", detail: "snippet", boost: boostAt(order++), opfDoc: entry.description, opf: { kind: "snippet", prefix: entry.prefix }, info: () => docNode({ title: entry.prefix, type: "snippet", description: entry.description }) });
366
+ return { options, validFor: /^[A-Za-z-]*$/ };
367
+ }
368
+ return null;
369
+ }
370
+
371
+ function optionsOptions(context, { catalogs, makers }) {
372
+ const { literalOption, boostAt, docNode } = makers;
373
+ const options = [];
374
+ if (context.mode === "key") {
375
+ const chain = context.optionKind === "slide" ? CHAIN_SLIDE : CHAIN_BLOCK;
376
+ context.keys.filter((key) => !context.existing.has(key)).forEach((key, index) => {
377
+ let info;
378
+ try { info = context.optionKind === "slide" ? describeField(["slides", context.slide, key], context.document) : undefined; } catch { info = null; }
379
+ const doc = info?.description ?? BLOCK_DOCS[key] ?? "";
380
+ options.push({ label: key, type: "property", detail: info?.known ? info.typeLabel ?? info.types.join(" | ") : context.optionKind === "block" ? "option" : "string", boost: boostAt(index), opfDoc: doc, opf: { kind: "optionKey", key, chain: chain.has(key) }, info: () => docNode({ title: key, type: `${context.optionKind} option`, description: doc }) });
381
+ });
382
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
383
+ }
384
+ const key = context.key;
385
+ if (context.optionKind === "block") {
386
+ if (key === "as") for (const value of BLOCK_AS_VALUES) options.push(literalOption(value, JSON.stringify(value), { value, doc: value === "bullets" ? "Store a list as bullets instead of items." : "Store an image line as video." }));
387
+ else if (key === "region") {
388
+ const names = [];
389
+ if (context.layout) {
390
+ try { const found = makers.listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === context.layout); names.push(...regionNames(found?.record)); } catch { /* No regions known. */ }
391
+ }
392
+ for (const name of [...names, ...PROMOTED.filter((item) => !names.includes(item))]) options.push(literalOption(name, JSON.stringify(name), { value: name, doc: names.includes(name) ? `A region of the ${context.layout} layout` : "A promoted region of the slide", boost: names.includes(name) ? 99 : undefined }));
393
+ } else if (key === "type") {
394
+ let info;
395
+ try { info = describeField(["slides", 0, "blocks", 0, "type"], context.document); } catch { info = null; }
396
+ for (const entry of info?.enum ?? []) options.push(literalOption(String(entry.value), JSON.stringify(entry.value), { value: entry.value, doc: entry.description ?? "" }));
397
+ }
398
+ return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
399
+ }
400
+ if (!SLIDE_OPTION_KEYS.includes(key)) return null;
401
+ const pseudo = { format: "md", mode: "value", path: ["slides", context.slide, key], document: context.document, key, token: context.token };
402
+ const built = makers.valueOptions(pseudo, catalogs);
403
+ options.push(...built.options);
404
+ return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
405
+ }
406
+
407
+ const BLOCK_DOCS = {
408
+ id: "A name for the block, so a finding or a patch can point at it.",
409
+ type: "The block's content kind, when it cannot be told from the content.",
410
+ as: "bullets stores a list as bullets; video stores an image line as a video.",
411
+ region: "Pin the block to a region of the slide's layout, or to a promoted region (left, top:left, ...).",
412
+ };
413
+
414
+ // ---- accepting ---------------------------------------------------------------------------------------------------------------------
415
+ const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
416
+ const optionValue = (value) => (typeof value === "string" && BARE.test(value) ? value : JSON.stringify(value));
417
+
418
+ /** What accepting `completion` writes in a Markdown context: `{ template, fields, chain, choices, inner? }`. */
419
+ export function markdownPlan(context, completion, text, end) {
420
+ const kind = completion.opf?.kind;
421
+ if (context.region === "comment-start") {
422
+ const word = completion.opf.word;
423
+ return { template: context.closes ? `${word} ` : `${word} \${0} -->`, fields: context.closes ? 0 : 1, chain: true };
424
+ }
425
+ if (kind === "optionKey") {
426
+ if (context.followedByEquals) return { template: escapeTemplate(completion.opf.key), fields: 0, chain: false };
427
+ const lead = context.needsSpace ? " " : "";
428
+ return { template: `${lead}${completion.opf.key}=`, fields: 0, chain: Boolean(completion.opf.chain) };
429
+ }
430
+ if (context.region === "options" && kind === "literal") {
431
+ const value = completion.opf.value;
432
+ if (context.token.quoted) {
433
+ const inner = typeof value === "string" ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : String(value);
434
+ return { template: escapeTemplate(inner), fields: 0, chain: false, inner: true };
435
+ }
436
+ return { template: escapeTemplate(optionValue(value)), fields: 0, chain: false };
437
+ }
438
+ if (kind === "fence") return { template: `${escapeTemplate(completion.opf.word)}${completion.opf.word === "chart" ? " " : ""}`, fields: 0, chain: completion.opf.word === "chart" };
439
+ if (kind === "fenceAttribute") return { template: `${completion.opf.name}="\${1}"\${0}`, fields: 2, chain: false };
440
+ if (context.region === "fence-info" && kind === "literal") return { template: escapeTemplate(String(completion.opf.value)), fields: 0, chain: false };
441
+ if (kind === "metricKey") {
442
+ if (/^[ \t]*:/.test(text.slice(end))) return { template: completion.opf.key, fields: 0, chain: false };
443
+ return { template: `${completion.opf.key}: `, fields: 0, chain: Boolean(completion.opf.chain) };
444
+ }
445
+ if (context.region === "metric" && kind === "literal") return { template: `${context.needsSpace ? " " : ""}${escapeTemplate(String(completion.opf.value))}`, fields: 0, chain: false };
446
+ if (kind === "status") return { template: `${completion.opf.mark} `, fields: 0, chain: false };
447
+ if (kind === "spanAttribute") return { template: `${completion.opf.name}${completion.opf.takesValue ? "=" : ""}`, fields: 0, chain: false };
448
+ if (kind === "keyword") return { template: `${escapeTemplate(completion.opf.word)} `, fields: 0, chain: false };
449
+ if (kind === "snippet") {
450
+ const entry = snippetsForMarkdown().find((candidate) => candidate.prefix === completion.opf.prefix);
451
+ if (!entry) return null;
452
+ const rendered = snippetMarkdown(entry, { separator: context.separator });
453
+ // A separator needs a blank line above it.
454
+ const lead = context.separator && !context.previousBlank ? "\n" : "";
455
+ return { template: `${lead}${rendered.text}`, fields: rendered.fields.length, chain: false, choices: rendered.fields };
456
+ }
457
+ return null;
458
+ }
459
+
460
+ // ---- hover -------------------------------------------------------------------------------------------------------------------------
461
+ // What a piece of the dialect's syntax stands for, as a card: the schema's words for the field it writes, and how it is written.
462
+ const SYNTAX_FIELDS = {
463
+ title: { key: "title", written: "# Title" },
464
+ subtitle: { key: "subtitle", written: "## Subtitle" },
465
+ notes: { key: "notes", written: "Note: text, to the end of the slide" },
466
+ items: { key: "items", written: "- item (two spaces per level)" },
467
+ quote: { key: "quote", written: "> text, then > — Name, Title" },
468
+ chart: { key: "chart", written: "```chart column, then CSV" },
469
+ metric: { key: "metric", written: "```metric, then key: value lines" },
470
+ timeline: { key: "timeline", written: "```timeline, then one event per line" },
471
+ code: { key: "code", written: "```js title=\"file\"" },
472
+ image: { key: "image", written: "![alt](src)" },
473
+ table: { key: "table", written: "| a | b | rows with a | --- | row" },
474
+ };
475
+ function syntaxCard(name, document, extra) {
476
+ const spec = SYNTAX_FIELDS[name];
477
+ let info;
478
+ try { info = describeField(["slides", 0, spec.key], document); } catch { info = null; }
479
+ const model = info?.known ? fieldDoc(info, spec.key) : { title: spec.key };
480
+ return { ...model, ...extra, notes: [`Written as ${spec.written}`, ...(extra?.notes ?? [])] };
481
+ }
482
+
483
+ /** The card for the dialect syntax under `position` (a heading marker, `Note:`, a list marker, a fence word, `---`, `slide:`), as `{ from, to, model }`, or null. */
484
+ function syntaxHoverAt(text, position, where, document) {
485
+ const line = lineAt(text, position);
486
+ const column = position - line.start;
487
+ const card = (name, from, to, extra) => ({ from, to, model: syntaxCard(name, document, extra) });
488
+ if (where.region === "fence") {
489
+ if (where.part !== "info") return null;
490
+ const { fence } = where;
491
+ const wordFrom = fence.infoFrom, wordTo = fence.infoFrom + fence.word.length;
492
+ if (position < wordFrom || position > wordTo || !fence.word) return null;
493
+ if (fence.kind === "opf-slide") return { from: wordFrom, to: wordTo, model: { title: "opf-slide", type: "fence", description: "Slide fields the Markdown has no syntax for (`composition`, `design`, `extensions`, ...), written as YAML. A field set here and in Markdown is an error." } };
494
+ if (fence.kind === "opf-block") return { from: wordFrom, to: wordTo, model: { title: "opf-block", type: "fence", description: "A content block written as YAML: any content field, `id`, `type` and nested `blocks`." } };
495
+ return card(fence.kind === "code" ? "code" : fence.kind, wordFrom, wordTo);
496
+ }
497
+ if (where.region === "comment") {
498
+ const comment = where.comment;
499
+ if (comment.kind !== "slide" && comment.kind !== "block") return null;
500
+ const word = /^\s*(slide|block)/.exec(text.slice(comment.innerFrom, comment.innerTo));
501
+ if (!word) return null;
502
+ const from = comment.innerFrom + word[0].length - word[1].length, to = from + word[1].length;
503
+ if (position < from || position > to) return null;
504
+ const keys = (word[1] === "slide" ? SLIDE_OPTION_KEYS : BLOCK_OPTION_KEYS).join(", ");
505
+ return { from, to, model: { title: `${word[1]}:`, type: "options comment", description: word[1] === "slide" ? `Options of this slide, written as key=value: ${keys}. Values are bare words or JSON strings.` : `Options of the next block: ${keys}. \`as=bullets\` stores a list as bullets, \`region=\` pins it to a region.` } };
506
+ }
507
+ if (where.region === "notes") {
508
+ const match = /^(Notes?):/.exec(line.text);
509
+ return match && column <= match[1].length + 1 ? card("notes", line.start, line.start + match[1].length + 1) : null;
510
+ }
511
+ if (where.region !== "body") return null;
512
+ if (/^-{3,}[ \t]*$/.test(line.text)) return { from: line.start, to: line.end, model: { title: "---", type: "slide separator", description: "Starts the next slide. Slides are the text between these lines." } };
513
+ const heading = /^( {0,3})(#{1,6})(?=[ \t])/.exec(line.text);
514
+ if (heading && column >= heading[1].length && column <= heading[0].length) {
515
+ const level = heading[2].length;
516
+ const from = line.start + heading[1].length, to = line.start + heading[0].length;
517
+ if (level === 1) return card("title", from, to);
518
+ if (level === 2) return card("subtitle", from, to);
519
+ return { from, to, model: { title: heading[2], type: "heading", description: "Only # (title) and ## (subtitle) are slide fields: a deeper heading becomes a bold paragraph." } };
520
+ }
521
+ const item = /^(\s*)([-*+]|\d{1,9}[.)])(?=[ \t])/.exec(line.text);
522
+ if (item && column >= item[1].length && column <= item[0].length) return card("items", line.start + item[1].length, line.start + item[0].length);
523
+ const quote = /^ {0,3}>/.exec(line.text);
524
+ if (quote && column <= quote[0].length) return card("quote", line.start, line.start + quote[0].length);
525
+ return null;
526
+ }
527
+
528
+ /**
529
+ * The card for what is under `position`: a front matter key or value, a slide option, or the dialect's own syntax (a heading marker, `Note:`, a list
530
+ * marker, the word of a fence, `---`, `slide:`): `{ from, to, model }` or null. `describeFound` is the completion engine's card builder.
531
+ */
532
+ export function markdownHoverAt(text, position, { catalogs, describeFound }) {
533
+ const where = markdownRegionAt(text, position);
534
+ let inOptions = false;
535
+ if (where.region === "comment") inOptions = where.comment.kind === "slide";
536
+ let document;
537
+ const deck = () => {
538
+ if (!document) { try { document = fromMarkdown(text, { validate: false }).presentation; } catch { document = { slides: [] }; } }
539
+ return document;
540
+ };
541
+ if (where.region === "front-matter" || inOptions) {
542
+ const found = pathAt(text, "md", position);
543
+ if (found?.range && (found.part === "key" || found.part === "value")) {
544
+ const source = text.slice(found.range.offset, found.range.offset + found.range.length);
545
+ const value = found.part === "value" && !/^[[{]/.test(source) ? source.replace(/^"|"$/g, "") : undefined;
546
+ const card = describeFound({ kind: found.part, path: found.path, from: found.range.offset, to: found.range.offset + found.range.length, value, nodeType: /^[[{]/.test(source) ? "array" : "scalar" }, deck(), catalogs);
547
+ if (card) return card;
548
+ }
549
+ }
550
+ return syntaxHoverAt(text, position, where, { slides: [{}] });
551
+ }
@@ -71,8 +71,10 @@ export interface SourcePaneOptions {
71
71
  /** Accessible name of the code editor (default `OPF source`). */
72
72
  label?: string;
73
73
  catalogs?: JsonCatalogContext;
74
- /** Live completions while typing JSON: property names from the schema and values (enums, booleans, catalog ids, templates, snippets) at the cursor's JSON path. Tab and Enter accept the selected item, Escape closes the list, Ctrl+Space (Cmd+I on macOS) opens it; the field menu is on Alt+Shift+O. Hover documentation, squiggles with quick fixes (Cmd/Ctrl+.) and F8 come with it. YAML and Markdown have no completions yet. Default true. */
74
+ /** Live completions while typing JSON: property names from the schema and values (enums, booleans, catalog ids, templates, snippets) at the cursor's JSON path. Tab and Enter accept the selected item, Escape closes the list, Ctrl+Space (Cmd+I on macOS) opens it; the field menu is on Alt+Shift+O. Hover documentation, squiggles with quick fixes (Cmd/Ctrl+.) and F8 come with it. The same for YAML and Markdown (front matter, slide and block options, fences, `Note:` and snippets). Default true. */
75
75
  completions?: boolean;
76
+ /** Enter continues a list: a new string item after a string in a JSON list, the next `- ` in YAML, the next marker (or `> `) in Markdown; an empty one ends it. Default true. */
77
+ smartListEnter?: boolean;
76
78
  onError?(error: unknown): void;
77
79
  }
78
80
 
@@ -110,10 +112,16 @@ export interface SourcePane {
110
112
  /** Switch format. Returns false, and says why in the findings, when the document cannot be written in it. Does not call `onFormatChange`. */
111
113
  setFormat(format: SourceFormat): boolean;
112
114
  getFormat(): SourceFormat;
113
- /** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format; best effort for YAML and Markdown. Returns the line. */
115
+ /** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format (core's `locate` finds it in JSON, YAML and Markdown). Returns the line. */
114
116
  focusPath(path: string): number;
115
117
  /** The text as shown, with any pending edit applied first. */
116
118
  getText(): string;
119
+ /**
120
+ * Canonicalize with OPF: rewrite the YAML or Markdown text from the document with core's writer (the old behavior of Format). It drops comments, so it
121
+ * asks first when there are any (an inline confirmation naming how many); `confirmed: true` has asked already. Returns "applied", "unchanged",
122
+ * "confirm" (waiting for the answer) or "refused" (JSON, read-only, or text that does not read as a document).
123
+ */
124
+ canonicalize(options?: { confirmed?: boolean }): "applied" | "unchanged" | "confirm" | "refused";
117
125
  getName(): string;
118
126
  setName(name: string): void;
119
127
  setDensity(density: SourcePaneDensity): void;