@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.
@@ -72,6 +72,27 @@ export function wrapInList(text, segments) {
72
72
  return [{ from: offset, to: offset, insert: "[" }, { from: offset + length, to: offset + length, insert: "]" }];
73
73
  }
74
74
 
75
+ /** One edit that turns `before` into `after` (the span between their common start and common end), for a change several `modify` calls make together. */
76
+ export function diffEdit(before, after) {
77
+ let start = 0;
78
+ while (start < before.length && start < after.length && before[start] === after[start]) start++;
79
+ let end = 0;
80
+ while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;
81
+ return [{ from: start, to: before.length - end, insert: after.slice(start, after.length - end) }];
82
+ }
83
+ /** Move the property whose value is at `segments` into the `extensions` object of its parent (created when there is none). */
84
+ export function moveIntoExtensions(text, segments, unit = " ") {
85
+ const found = nodeAt(text, segments);
86
+ if (!found || segments.length === 0) return null;
87
+ const parent = segments.slice(0, -1), name = segments.at(-1);
88
+ let value;
89
+ try { value = JSON.parse(text.slice(found.node.offset, found.node.offset + found.node.length)); } catch { return null; }
90
+ const options = { formattingOptions: formatting(unit, text) };
91
+ const removed = applySourceEdits(text, asEdits(modify(text, toPath(segments), undefined, options)));
92
+ const added = applySourceEdits(removed, asEdits(modify(removed, [...toPath(parent), "extensions", name], value, options)));
93
+ return added === text ? null : diffEdit(text, added);
94
+ }
95
+
75
96
  /**
76
97
  * The quick fixes of a schema or reference finding in a JSON text that parses. `context` is the one source-messages.js takes plus
77
98
  * `{ unit, starter(segments, property) }` (the value `Add` inserts).
@@ -88,14 +109,17 @@ export function fixesForFinding(finding, text, context = {}) {
88
109
  } else if (keyword === "additionalProperties") {
89
110
  const name = finding.validation.params?.additionalProperty ?? segments.at(-1);
90
111
  for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, renameKey(text, segments, value));
112
+ if (name !== "extensions" && context.keysOf?.(segments.slice(0, -1))?.includes("extensions")) push("move-to-extensions", "Move into extensions", moveIntoExtensions(text, segments, unit));
91
113
  push("remove-property", "Remove property", removeAt(text, segments, unit));
92
- void name;
93
114
  } else if (keyword === "required") {
94
115
  const property = finding.validation.params?.missingProperty;
95
116
  const starter = context.starter?.(segments, property);
96
117
  if (property && starter !== undefined) push(`add:${property}`, `Add \`${property}\``, addProperty(text, segments, property, starter, unit));
97
118
  } else if (keyword === "type" && finding.validation.params?.type === "array") {
98
119
  push("wrap-in-list", "Wrap in a list", wrapInList(text, segments));
120
+ } else if ((keyword === "type" && finding.validation.params?.type === "string") || ((keyword === "oneOf" || keyword === "anyOf") && (finding.validation.alternatives ?? []).some((entry) => String(entry).startsWith("string")))) {
121
+ const value = context.valueAt?.(segments);
122
+ if (typeof value === "number" || typeof value === "boolean") push("convert-to-string", "Convert to string", changeValue(text, segments, String(value)), { safe: true });
99
123
  } else if (keyword === "minItems" && finding.path === "/slides") {
100
124
  push("add-slide", "Add a slide", addItem(text, segments, { title: "Slide title" }, unit));
101
125
  } else if (finding.ruleId === "opf/duplicate-key") {
@@ -1,4 +1,5 @@
1
1
  import { parseTree } from "jsonc-parser";
2
+ import { locate } from "@openpresentation/opf/source";
2
3
 
3
4
  // Line lookup for a JSON Pointer in the three source formats of the source pane (RR-98). All functions return a
4
5
  // one-based line number, or null when the path cannot be placed. JSON is exact; YAML and Markdown are best effort:
@@ -197,6 +198,9 @@ export function lineForPath(format, text, path) {
197
198
  const segments = pathSegments(path);
198
199
  if (!segments.length) return 1;
199
200
  try {
201
+ // Core reads the text of every format and knows where a path is; the line heuristics below are for text it cannot place.
202
+ const found = format === "json" ? null : locate(text, format, segments);
203
+ if (found) return found.line;
200
204
  return format === "json" ? jsonLine(text, segments)
201
205
  : format === "yaml" ? yamlLine(text, segments)
202
206
  : markdownLine(text, segments);
@@ -0,0 +1,546 @@
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
+ import { regionNames } from "./source-references.js";
14
+
15
+ const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
16
+ const BARE = /^[A-Za-z0-9_-]+$/;
17
+ 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"];
18
+ // Keys whose values come from a closed set the schema or the catalogs know; accepting the key opens that list.
19
+ const CHAIN_SLIDE = new Set(["layout", "type", "hidden"]);
20
+ const CHAIN_BLOCK = new Set(["type", "as", "region"]);
21
+ // The promoted regions of a slide that a block can be pinned to when the layout names none.
22
+ 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"];
23
+
24
+ const lineAt = (text, position) => {
25
+ const start = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
26
+ let end = position;
27
+ while (end < text.length && text[end] !== "\n" && text[end] !== "\r") end++;
28
+ return { start, end, text: text.slice(start, end) };
29
+ };
30
+
31
+ // ---- regions -----------------------------------------------------------------------------------------------------------------------
32
+ /**
33
+ * Which part of a Markdown deck the caret is in: `{ region, ... }` with region `front-matter` (`yamlFrom`, `yamlTo`), `comment` (`comment`),
34
+ * `fence` (`fence`, `part`: "info", "body" or "close"), `notes` or `body`. Used by the editing rules (Enter, Tab, pairs, comments) and the adapter.
35
+ */
36
+ export function markdownRegionAt(text, position) {
37
+ const scan = scanMarkdown(text);
38
+ const front = scan.frontMatter;
39
+ if (front) {
40
+ if (position < front.yamlFrom + 1 && position <= front.yamlFrom) return { region: "front-matter-edge", front };
41
+ if (position > front.yamlFrom && (front.closed ? position < front.yamlTo : true)) return { region: "front-matter", front, scan };
42
+ if (front.closed && position >= front.yamlTo && position <= front.to) return { region: "front-matter-edge", front };
43
+ }
44
+ for (const fence of scan.fences) {
45
+ if (position < fence.from || position > fence.to) continue;
46
+ const opening = lineAt(text, fence.from);
47
+ if (position <= opening.end) return { region: "fence", fence, part: "info", scan };
48
+ if (fence.closed && position >= fence.to - lineAt(text, fence.to).text.length) return { region: "fence", fence, part: "close", scan };
49
+ return { region: "fence", fence, part: "body", scan };
50
+ }
51
+ for (const comment of scan.comments) if (position >= comment.from && position <= comment.to) return { region: "comment", comment, scan };
52
+ for (const notes of scan.notes) if (position >= notes.from && position <= notes.to) return { region: "notes", notes, scan };
53
+ return { region: "body", scan };
54
+ }
55
+
56
+ const shiftToken = (token, base) => ({ ...token, start: token.start + base, end: token.end + base, from: token.from + base, to: token.to + base });
57
+
58
+ // The embedded catalogs of the front matter, so the options of a slide can list the records the deck carries.
59
+ function frontCatalogs(text, scan) {
60
+ const front = scan.frontMatter;
61
+ if (!front) return undefined;
62
+ try { return parseYaml(text.slice(front.yamlFrom, front.yamlTo))?.catalogs; } catch { return undefined; }
63
+ }
64
+
65
+ function skeletonFor(text, scan, slide) {
66
+ const document = { slides: Array.from({ length: Math.max(0, slide) + 1 }, () => ({})) };
67
+ const catalogs = frontCatalogs(text, scan);
68
+ if (catalogs) document.catalogs = catalogs;
69
+ return document;
70
+ }
71
+
72
+ // ---- the context at a caret --------------------------------------------------------------------------------------------------------
73
+ /**
74
+ * 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,
75
+ * shifted into this text (`format: "yaml"`). Otherwise `{ format: "md", region, mode, token, ... }`:
76
+ * - `region: "options"`: `optionKind` ("slide" or "block"), `mode` "key" or "value", `key`, `existing`, `needsSpace`, `slide`, `layout`
77
+ * - `region: "comment-start"`: after `<!--` at the start of a line (offers `slide:` and `block:`)
78
+ * - `region: "fence-info"`: `mode` "kind" (the first word), "type" (the chart type) or "attribute"; `word`
79
+ * - `region: "metric"`: `mode` "key" or "value", `key`, `existing`
80
+ * - `region: "body"`: the word at the start of a line (`Note:`, a snippet prefix), `separator` (a `---` goes first)
81
+ */
82
+ export function markdownContextAt(text, position, { explicit = false } = {}) {
83
+ const where = markdownRegionAt(text, position);
84
+ switch (where.region) {
85
+ case "front-matter": {
86
+ const base = where.front.yamlFrom;
87
+ const context = yamlContextAt(text.slice(base, where.front.closed ? where.front.yamlTo : text.length), position - base, { explicit, exclude: ["slides"] });
88
+ return context ? { ...context, token: shiftToken(context.token, base) } : null;
89
+ }
90
+ case "fence":
91
+ return fenceContext(text, position, where, explicit);
92
+ case "comment":
93
+ return commentContext(text, position, where, explicit);
94
+ case "body":
95
+ return bodyContext(text, position, where, explicit);
96
+ default:
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // ---- options comments --------------------------------------------------------------------------------------------------------------
102
+ /** The `key`, `key=value` tokens of an options comment between `from` and `to`: `{ start, end, key, keyEnd, eq, valueStart, quoted }`. */
103
+ function optionTokens(text, from, to) {
104
+ const tokens = [];
105
+ let index = from;
106
+ while (index < to) {
107
+ while (index < to && /\s/.test(text[index])) index++;
108
+ if (index >= to) break;
109
+ const start = index;
110
+ while (index < to && /[A-Za-z0-9_-]/.test(text[index])) index++;
111
+ const keyEnd = index;
112
+ let eq = -1, valueStart = -1, quoted = false;
113
+ if (text[index] === "=") {
114
+ eq = index;
115
+ index++;
116
+ valueStart = index;
117
+ if (text[index] === '"') {
118
+ quoted = true;
119
+ index++;
120
+ while (index < to && text[index] !== '"') index += text[index] === "\\" ? 2 : 1;
121
+ if (index < to) index++;
122
+ } else while (index < to && !/[\s,]/.test(text[index])) index++;
123
+ } else while (index < to && !/\s/.test(text[index])) index++;
124
+ tokens.push({ start, end: index, key: text.slice(start, keyEnd), keyEnd, eq, valueStart, quoted });
125
+ if (index === start) index++;
126
+ }
127
+ return tokens;
128
+ }
129
+
130
+ function slideLayout(text, scan, slide) {
131
+ for (const comment of scan.comments) {
132
+ if (comment.kind !== "slide" || comment.slide !== slide) continue;
133
+ const tokens = optionTokens(text, comment.innerFrom, comment.innerTo);
134
+ const found = tokens.find((token) => token.key === "layout" && token.eq >= 0);
135
+ if (found) return text.slice(found.valueStart + (found.quoted ? 1 : 0), found.end - (found.quoted && text[found.end - 1] === '"' ? 1 : 0));
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ function commentContext(text, position, where, explicit) {
141
+ const { comment, scan } = where;
142
+ const line = lineAt(text, position);
143
+ const before = text.slice(line.start, position);
144
+ // `<!-- ` and nothing else on the line: the comment may be a slide or block options comment.
145
+ const start = /^ {0,3}<!--[ \t]*([a-z]*)$/.exec(before);
146
+ 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))) {
147
+ const wordStart = position - start[1].length;
148
+ const afterWord = text.slice(position, line.end);
149
+ if (!explicit && start[1] === "" && !/[ \t]$/.test(before)) return null;
150
+ return { format: "md", region: "comment-start", token: { quoted: false, closed: false, start: wordStart, end: position, from: wordStart, to: position }, closes: /-->/.test(afterWord), document: {}, path: [] };
151
+ }
152
+ if (comment.kind !== "slide" && comment.kind !== "block") return null;
153
+ // A comment on one line only.
154
+ const first = lineAt(text, comment.from);
155
+ if (first.start !== line.start) return null;
156
+ const colon = /^\s*(slide|block)\s*(:?)/.exec(text.slice(comment.innerFrom, line.end));
157
+ if (!colon) return null;
158
+ const keywordEnd = comment.innerFrom + colon[0].length;
159
+ const innerEnd = comment.closed ? Math.min(comment.innerTo, line.end) : line.end;
160
+ if (position < keywordEnd || position > innerEnd) return null;
161
+ const kind = colon[1];
162
+ const tokens = optionTokens(text, keywordEnd, innerEnd);
163
+ const here = tokens.find((token) => position >= token.start && position <= token.end);
164
+ const slide = comment.slide;
165
+ const needsSpace = position === keywordEnd && colon[2] === ":" && text[position] !== " ";
166
+ const keys = kind === "slide" ? SLIDE_OPTION_KEYS : BLOCK_OPTION_KEYS;
167
+ const existing = new Set(tokens.filter((token) => token !== here && token.key !== "beat").map((token) => token.key));
168
+ const base = { format: "md", region: "options", optionKind: kind, slide, existing, needsSpace, keys, document: skeletonFor(text, scan, slide), layout: slideLayout(text, scan, slide), path: [] };
169
+ if (here && here.eq >= 0 && position > here.eq) {
170
+ const first = here.valueStart;
171
+ if (here.quoted) {
172
+ const closed = text[here.end - 1] === '"' && here.end - 1 > first;
173
+ const token = { quoted: true, closed, start: first, end: here.end, from: first + 1, to: closed ? here.end - 1 : Math.max(position, first + 1) };
174
+ return { ...base, mode: "value", key: here.key, token };
175
+ }
176
+ return { ...base, mode: "value", key: here.key, token: { quoted: false, closed: false, start: first, end: here.end, from: first, to: here.end } };
177
+ }
178
+ if (here && !OPTION_KEY.test(text.slice(here.start, here.keyEnd))) return null;
179
+ const start0 = here ? here.start : position;
180
+ const end0 = here ? here.keyEnd : position;
181
+ 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 } };
182
+ }
183
+
184
+ // ---- fences ------------------------------------------------------------------------------------------------------------------------
185
+ function fenceContext(text, position, where, explicit) {
186
+ const { fence, part, scan } = where;
187
+ if (part === "close") return null;
188
+ if (part === "info") {
189
+ const line = lineAt(text, fence.from);
190
+ const indent = /^ */.exec(line.text)[0].length;
191
+ const open = line.start + indent + fence.marker.length;
192
+ if (position < open) return null;
193
+ // Typed info: words separated by spaces; the caret is in one of them.
194
+ const before = text.slice(open, position);
195
+ const words = before.split(/\s+/);
196
+ const typed = words.at(-1);
197
+ const first = before.trimStart().split(/\s+/)[0];
198
+ const tokenStart = position - typed.length;
199
+ let tokenEnd = position;
200
+ while (tokenEnd < line.end && /[A-Za-z0-9_+#.-]/.test(text[tokenEnd])) tokenEnd++;
201
+ const token = { quoted: false, closed: false, start: tokenStart, end: tokenEnd, from: tokenStart, to: tokenEnd };
202
+ const done = before.trim().split(/\s+/).filter(Boolean);
203
+ const wordIndex = /\s$/.test(before) ? done.length : Math.max(0, done.length - 1);
204
+ if (wordIndex === 0) return { format: "md", region: "fence-info", mode: "kind", word: first || "", token, document: {}, path: [] };
205
+ const kind = first;
206
+ 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: [] };
207
+ if (!/=/.test(typed) && (kind === "chart" || kind === "timeline" || !["opf-slide", "opf-block", "metric"].includes(kind))) {
208
+ const have = new Set(optionTokens(text, open, line.end).map((item) => item.key));
209
+ return { format: "md", region: "fence-info", mode: "attribute", word: kind, existing: have, token, document: {}, path: [] };
210
+ }
211
+ return null;
212
+ }
213
+ // The body.
214
+ if (fence.kind === "metric") return metricContext(text, position, fence);
215
+ if (fence.kind === "timeline") return timelineContext(text, position);
216
+ if (fence.kind === "opf-slide" || fence.kind === "opf-block") {
217
+ const base = fence.bodyFrom;
218
+ const prefix = fence.kind === "opf-slide" ? ["slides", fence.slide] : ["slides", fence.slide, "blocks", 0];
219
+ const context = yamlContextAt(text.slice(base, fence.bodyTo), position - base, { explicit, prefix });
220
+ if (!context) return null;
221
+ const catalogs = frontCatalogs(text, scan);
222
+ if (catalogs && context.document && typeof context.document === "object") context.document.catalogs = catalogs;
223
+ return { ...context, token: shiftToken(context.token, base) };
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // A timeline event may start with a task-list mark that sets its status: `[x]` done, `[>]` current ("we are here"), `[ ]` planned.
229
+ function timelineContext(text, position) {
230
+ const line = lineAt(text, position);
231
+ const before = text.slice(line.start, position);
232
+ if (!/^(?:\[[x> ]?\]?)?$/.test(before) || /^\s/.test(line.text)) return null;
233
+ let end = position;
234
+ while (end < line.end && /[x> \]]/.test(text[end]) && text[end] !== " ") end++;
235
+ return { format: "md", region: "timeline", token: { quoted: false, closed: false, start: line.start, end, from: line.start, to: end }, document: {}, path: [] };
236
+ }
237
+
238
+ function metricContext(text, position, fence) {
239
+ const line = lineAt(text, position);
240
+ const before = text.slice(line.start, position);
241
+ const bodyLines = text.slice(fence.bodyFrom, fence.bodyTo).split(/\r\n|\r|\n/);
242
+ const existing = new Set(bodyLines.map((item) => /^([A-Za-z]+):/.exec(item)?.[1]).filter(Boolean));
243
+ const document = { slides: [{ metric: {} }] };
244
+ const value = /^([A-Za-z]+):[ \t]*(.*)$/.exec(before);
245
+ if (value) {
246
+ const start = position - value[2].length;
247
+ let end = position;
248
+ while (end < line.end && !/\s/.test(text[end])) end++;
249
+ 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) };
250
+ }
251
+ if (!/^[A-Za-z]*$/.test(before)) return null;
252
+ let end = position;
253
+ while (end < line.end && /[A-Za-z]/.test(text[end])) end++;
254
+ const start = position - before.length;
255
+ const self = /^([A-Za-z]+):/.exec(line.text)?.[1];
256
+ if (self) existing.delete(self);
257
+ return { format: "md", region: "metric", mode: "key", existing, token: { quoted: false, closed: false, start, end, from: start, to: end }, document, path: ["slides", 0, "metric"] };
258
+ }
259
+
260
+ // ---- the body ----------------------------------------------------------------------------------------------------------------------
261
+ function bodyContext(text, position, where, explicit) {
262
+ const { scan } = where;
263
+ const line = lineAt(text, position);
264
+ const before = text.slice(line.start, position);
265
+ // Inside the braces of an inline span: `[text]{bold color=#B42318}`.
266
+ const span = /\]\{((?:[A-Za-z0-9_-]+(?:=(?:"[^"]*"|[^\s"}]*))?\s+)*)([A-Za-z]*)$/.exec(before);
267
+ if (span) {
268
+ let end = position;
269
+ while (end < line.end && /[A-Za-z]/.test(text[end])) end++;
270
+ const used = new Set([...span[1].matchAll(/([A-Za-z0-9_-]+)/g)].map((entry) => entry[1].toLowerCase()));
271
+ const start = position - span[2].length;
272
+ return { format: "md", region: "span", token: { quoted: false, closed: false, start, end, from: start, to: end }, existing: used, document: {}, path: [], explicit, opened: span[2] === "" };
273
+ }
274
+ // `{{` in a line of text: the variables of the document.
275
+ const variable = /\{\{([A-Za-z0-9._-]*)$/.exec(before);
276
+ if (variable) {
277
+ const start = position - variable[1].length;
278
+ return { format: "md", region: "variable", token: { quoted: false, closed: false, start, end: position, from: start, to: position }, document: {}, path: [] };
279
+ }
280
+ if (!/^[A-Za-z][A-Za-z-]*$|^$/.test(before)) return null;
281
+ if (before === "" && !explicit) return null;
282
+ // Typing prose is not interrupted: a list opens by itself from the third letter on.
283
+ if (!explicit && before.length < 3) return null;
284
+ let end = position;
285
+ while (end < line.end && /[A-Za-z-]/.test(text[end])) end++;
286
+ // A slide follows another when there is content above that is not already a separator.
287
+ const above = text.slice(0, line.start).replace(/\s+$/, "");
288
+ const lastLine = above.slice(above.lastIndexOf("\n") + 1);
289
+ const hasContent = above.trim() !== "" && !(scan.frontMatter && above.length <= scan.frontMatter.to);
290
+ const separator = hasContent && !/^-{3,}[ \t]*$/.test(lastLine);
291
+ const previousBlank = line.start === 0 || lineAt(text, line.start - 1).text.trim() === "";
292
+ const slide = scan.slides.filter((item) => item.from < line.start).length;
293
+ return { format: "md", region: "body", token: { quoted: false, closed: false, start: line.start, end, from: line.start, to: end }, separator, previousBlank, document: {}, path: [], slide };
294
+ }
295
+
296
+ // ---- the options -------------------------------------------------------------------------------------------------------------------
297
+ const kindOption = (label, type, detail, doc, boost, kind, extra = {}) => ({ label, type, detail, boost, opfDoc: doc, opf: { kind, ...extra } });
298
+
299
+ /** The completion options of a Markdown context: `{ options, validFor? }`, or null. `makers` are the option builders of the completion engine. */
300
+ export function markdownOptions(context, { catalogs, text, explicit, makers, scope }) {
301
+ const { literalOption, boostAt, docNode } = makers;
302
+ const options = [];
303
+ if (context.region === "comment-start") {
304
+ options.push(kindOption("slide:", "keyword", "options", "Options of this slide: id, layout, section, ...", boostAt(0), "comment", { word: "slide:" }));
305
+ options.push(kindOption("block:", "keyword", "options", "Options of the next block: id, type, as, region.", boostAt(1), "comment", { word: "block:" }));
306
+ return { options, validFor: /^[a-z]*$/ };
307
+ }
308
+ if (context.region === "options") return optionsOptions(context, { catalogs, makers, scope });
309
+ if (context.region === "fence-info") {
310
+ if (context.mode === "kind") {
311
+ let order = 0;
312
+ for (const kind of ["chart", "metric", "timeline", "opf-block", "opf-slide"]) {
313
+ 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];
314
+ options.push(kindOption(kind, "keyword", "fence", doc, boostAt(order++), "fence", { word: kind }));
315
+ }
316
+ for (const language of LANGUAGES) options.push(kindOption(language, "text", "code", `Code in ${language}`, boostAt(order++), "fence", { word: language }));
317
+ return { options, validFor: /^[A-Za-z0-9_+#.-]*$/ };
318
+ }
319
+ if (context.mode === "type") {
320
+ let info;
321
+ try { info = describeField(["slides", 0, "chart", "type"], context.document); } catch { info = null; }
322
+ 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 }));
323
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
324
+ }
325
+ if (context.mode === "attribute") {
326
+ const attributes = FENCE_ATTRIBUTES[context.word] ?? (["opf-slide", "opf-block", "metric"].includes(context.word) ? [] : FENCE_ATTRIBUTES.code);
327
+ 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 })));
328
+ return { options, validFor: /^[A-Za-z]*$/ };
329
+ }
330
+ }
331
+ if (context.region === "metric") {
332
+ if (context.mode === "key") {
333
+ METRIC_KEYS.filter((key) => !context.existing.has(key)).forEach((key, index) => {
334
+ let info;
335
+ try { info = describeField(["slides", 0, "metric", key], context.document); } catch { info = null; }
336
+ 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 }) });
337
+ });
338
+ return { options, validFor: /^[A-Za-z]*$/ };
339
+ }
340
+ let info;
341
+ try { info = describeField(context.path, context.document); } catch { info = null; }
342
+ for (const entry of info?.enum ?? []) options.push(literalOption(String(entry.value), JSON.stringify(entry.value), { value: entry.value, doc: entry.description ?? "" }));
343
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
344
+ }
345
+ if (context.region === "timeline") {
346
+ 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."]];
347
+ marks.forEach(([mark, word, doc], index) => options.push(kindOption(mark, "keyword", word, doc, boostAt(index), "status", { mark })));
348
+ return { options, validFor: /^(?:\[[x> ]?\]?)?$/ };
349
+ }
350
+ if (context.region === "span") {
351
+ // 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.
352
+ 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)" };
353
+ [...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 })));
354
+ return { options, validFor: /^[A-Za-z]*$/ };
355
+ }
356
+ if (context.region === "body") {
357
+ const typed = text.slice(context.token.from, context.token.to).toLowerCase();
358
+ let order = 0;
359
+ 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 }));
360
+ 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 }) });
361
+ return { options, validFor: /^[A-Za-z-]*$/ };
362
+ }
363
+ return null;
364
+ }
365
+
366
+ function optionsOptions(context, { catalogs, makers, scope }) {
367
+ const { literalOption, boostAt, docNode } = makers;
368
+ const options = [];
369
+ if (context.mode === "key") {
370
+ const chain = context.optionKind === "slide" ? CHAIN_SLIDE : CHAIN_BLOCK;
371
+ context.keys.filter((key) => !context.existing.has(key)).forEach((key, index) => {
372
+ let info;
373
+ try { info = context.optionKind === "slide" ? describeField(["slides", context.slide, key], context.document) : undefined; } catch { info = null; }
374
+ const doc = info?.description ?? BLOCK_DOCS[key] ?? "";
375
+ 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 }) });
376
+ });
377
+ return { options, validFor: /^[A-Za-z0-9_-]*$/ };
378
+ }
379
+ const key = context.key;
380
+ if (context.optionKind === "block") {
381
+ 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." }));
382
+ else if (key === "region") {
383
+ const names = [];
384
+ if (context.layout) {
385
+ try { const found = makers.listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === context.layout); names.push(...regionNames(found?.record)); } catch { /* No regions known. */ }
386
+ }
387
+ 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 }));
388
+ } else if (key === "type") {
389
+ let info;
390
+ try { info = describeField(["slides", 0, "blocks", 0, "type"], context.document); } catch { info = null; }
391
+ for (const entry of info?.enum ?? []) options.push(literalOption(String(entry.value), JSON.stringify(entry.value), { value: entry.value, doc: entry.description ?? "" }));
392
+ }
393
+ return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
394
+ }
395
+ if (!SLIDE_OPTION_KEYS.includes(key)) return null;
396
+ const pseudo = { format: "md", mode: "value", path: ["slides", context.slide, key], document: context.document, key, token: context.token };
397
+ const built = makers.valueOptions(pseudo, catalogs, scope);
398
+ options.push(...built.options);
399
+ return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
400
+ }
401
+
402
+ const BLOCK_DOCS = {
403
+ id: "A name for the block, so a finding or a patch can point at it.",
404
+ type: "The block's content kind, when it cannot be told from the content.",
405
+ as: "bullets stores a list as bullets; video stores an image line as a video.",
406
+ region: "Pin the block to a region of the slide's layout, or to a promoted region (left, top:left, ...).",
407
+ };
408
+
409
+ // ---- accepting ---------------------------------------------------------------------------------------------------------------------
410
+ const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
411
+ const optionValue = (value) => (typeof value === "string" && BARE.test(value) ? value : JSON.stringify(value));
412
+
413
+ /** What accepting `completion` writes in a Markdown context: `{ template, fields, chain, choices, inner? }`. */
414
+ export function markdownPlan(context, completion, text, end) {
415
+ const kind = completion.opf?.kind;
416
+ if (context.region === "comment-start") {
417
+ const word = completion.opf.word;
418
+ return { template: context.closes ? `${word} ` : `${word} \${0} -->`, fields: context.closes ? 0 : 1, chain: true };
419
+ }
420
+ if (kind === "optionKey") {
421
+ if (context.followedByEquals) return { template: escapeTemplate(completion.opf.key), fields: 0, chain: false };
422
+ const lead = context.needsSpace ? " " : "";
423
+ return { template: `${lead}${completion.opf.key}=`, fields: 0, chain: Boolean(completion.opf.chain) };
424
+ }
425
+ if (context.region === "options" && kind === "literal") {
426
+ const value = completion.opf.value;
427
+ if (context.token.quoted) {
428
+ const inner = typeof value === "string" ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : String(value);
429
+ return { template: escapeTemplate(inner), fields: 0, chain: false, inner: true };
430
+ }
431
+ return { template: escapeTemplate(optionValue(value)), fields: 0, chain: false };
432
+ }
433
+ if (kind === "fence") return { template: `${escapeTemplate(completion.opf.word)}${completion.opf.word === "chart" ? " " : ""}`, fields: 0, chain: completion.opf.word === "chart" };
434
+ if (kind === "fenceAttribute") return { template: `${completion.opf.name}="\${1}"\${0}`, fields: 2, chain: false };
435
+ if (context.region === "fence-info" && kind === "literal") return { template: escapeTemplate(String(completion.opf.value)), fields: 0, chain: false };
436
+ if (kind === "metricKey") {
437
+ if (/^[ \t]*:/.test(text.slice(end))) return { template: completion.opf.key, fields: 0, chain: false };
438
+ return { template: `${completion.opf.key}: `, fields: 0, chain: Boolean(completion.opf.chain) };
439
+ }
440
+ if (context.region === "metric" && kind === "literal") return { template: `${context.needsSpace ? " " : ""}${escapeTemplate(String(completion.opf.value))}`, fields: 0, chain: false };
441
+ if (kind === "status") return { template: `${completion.opf.mark} `, fields: 0, chain: false };
442
+ if (kind === "spanAttribute") return { template: `${completion.opf.name}${completion.opf.takesValue ? "=" : ""}`, fields: 0, chain: false };
443
+ if (kind === "keyword") return { template: `${escapeTemplate(completion.opf.word)} `, fields: 0, chain: false };
444
+ if (kind === "snippet") {
445
+ const entry = snippetsForMarkdown().find((candidate) => candidate.prefix === completion.opf.prefix);
446
+ if (!entry) return null;
447
+ const rendered = snippetMarkdown(entry, { separator: context.separator });
448
+ // A separator needs a blank line above it.
449
+ const lead = context.separator && !context.previousBlank ? "\n" : "";
450
+ return { template: `${lead}${rendered.text}`, fields: rendered.fields.length, chain: false, choices: rendered.fields };
451
+ }
452
+ return null;
453
+ }
454
+
455
+ // ---- hover -------------------------------------------------------------------------------------------------------------------------
456
+ // 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.
457
+ const SYNTAX_FIELDS = {
458
+ title: { key: "title", written: "# Title" },
459
+ subtitle: { key: "subtitle", written: "## Subtitle" },
460
+ notes: { key: "notes", written: "Note: text, to the end of the slide" },
461
+ items: { key: "items", written: "- item (two spaces per level)" },
462
+ quote: { key: "quote", written: "> text, then > — Name, Title" },
463
+ chart: { key: "chart", written: "```chart column, then CSV" },
464
+ metric: { key: "metric", written: "```metric, then key: value lines" },
465
+ timeline: { key: "timeline", written: "```timeline, then one event per line" },
466
+ code: { key: "code", written: "```js title=\"file\"" },
467
+ image: { key: "image", written: "![alt](src)" },
468
+ table: { key: "table", written: "| a | b | rows with a | --- | row" },
469
+ };
470
+ function syntaxCard(name, document, extra) {
471
+ const spec = SYNTAX_FIELDS[name];
472
+ let info;
473
+ try { info = describeField(["slides", 0, spec.key], document); } catch { info = null; }
474
+ const model = info?.known ? fieldDoc(info, spec.key) : { title: spec.key };
475
+ return { ...model, ...extra, notes: [`Written as ${spec.written}`, ...(extra?.notes ?? [])] };
476
+ }
477
+
478
+ /** 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. */
479
+ function syntaxHoverAt(text, position, where, document) {
480
+ const line = lineAt(text, position);
481
+ const column = position - line.start;
482
+ const card = (name, from, to, extra) => ({ from, to, model: syntaxCard(name, document, extra) });
483
+ if (where.region === "fence") {
484
+ if (where.part !== "info") return null;
485
+ const { fence } = where;
486
+ const wordFrom = fence.infoFrom, wordTo = fence.infoFrom + fence.word.length;
487
+ if (position < wordFrom || position > wordTo || !fence.word) return null;
488
+ 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." } };
489
+ 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`." } };
490
+ return card(fence.kind === "code" ? "code" : fence.kind, wordFrom, wordTo);
491
+ }
492
+ if (where.region === "comment") {
493
+ const comment = where.comment;
494
+ if (comment.kind !== "slide" && comment.kind !== "block") return null;
495
+ const word = /^\s*(slide|block)/.exec(text.slice(comment.innerFrom, comment.innerTo));
496
+ if (!word) return null;
497
+ const from = comment.innerFrom + word[0].length - word[1].length, to = from + word[1].length;
498
+ if (position < from || position > to) return null;
499
+ const keys = (word[1] === "slide" ? SLIDE_OPTION_KEYS : BLOCK_OPTION_KEYS).join(", ");
500
+ 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.` } };
501
+ }
502
+ if (where.region === "notes") {
503
+ const match = /^(Notes?):/.exec(line.text);
504
+ return match && column <= match[1].length + 1 ? card("notes", line.start, line.start + match[1].length + 1) : null;
505
+ }
506
+ if (where.region !== "body") return null;
507
+ 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." } };
508
+ const heading = /^( {0,3})(#{1,6})(?=[ \t])/.exec(line.text);
509
+ if (heading && column >= heading[1].length && column <= heading[0].length) {
510
+ const level = heading[2].length;
511
+ const from = line.start + heading[1].length, to = line.start + heading[0].length;
512
+ if (level === 1) return card("title", from, to);
513
+ if (level === 2) return card("subtitle", from, to);
514
+ return { from, to, model: { title: heading[2], type: "heading", description: "Only # (title) and ## (subtitle) are slide fields: a deeper heading becomes a bold paragraph." } };
515
+ }
516
+ const item = /^(\s*)([-*+]|\d{1,9}[.)])(?=[ \t])/.exec(line.text);
517
+ if (item && column >= item[1].length && column <= item[0].length) return card("items", line.start + item[1].length, line.start + item[0].length);
518
+ const quote = /^ {0,3}>/.exec(line.text);
519
+ if (quote && column <= quote[0].length) return card("quote", line.start, line.start + quote[0].length);
520
+ return null;
521
+ }
522
+
523
+ /**
524
+ * 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
525
+ * marker, the word of a fence, `---`, `slide:`): `{ from, to, model }` or null. `describeFound` is the completion engine's card builder.
526
+ */
527
+ export function markdownHoverAt(text, position, { catalogs, describeFound }) {
528
+ const where = markdownRegionAt(text, position);
529
+ let inOptions = false;
530
+ if (where.region === "comment") inOptions = where.comment.kind === "slide";
531
+ let document;
532
+ const deck = () => {
533
+ if (!document) { try { document = fromMarkdown(text, { validate: false }).presentation; } catch { document = { slides: [] }; } }
534
+ return document;
535
+ };
536
+ if (where.region === "front-matter" || inOptions) {
537
+ const found = pathAt(text, "md", position);
538
+ if (found?.range && (found.part === "key" || found.part === "value")) {
539
+ const source = text.slice(found.range.offset, found.range.offset + found.range.length);
540
+ const value = found.part === "value" && !/^[[{]/.test(source) ? source.replace(/^"|"$/g, "") : undefined;
541
+ 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);
542
+ if (card) return card;
543
+ }
544
+ }
545
+ return syntaxHoverAt(text, position, where, { slides: [{}] });
546
+ }