@openpresentation/opf-editor 0.26.4 → 0.26.6

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.
@@ -77,7 +77,8 @@ export function a11yExtension(announcer, describe = () => "") {
77
77
  const open = completionStatus(state) === "active" && currentCompletions(state).length > 0;
78
78
  const selected = open ? selectedCompletion(state) : null;
79
79
  if (open && !this.open) {
80
- announcer.say(plural(currentCompletions(state).length, "suggestion"));
80
+ const footer = currentCompletions(state).find((completion) => completion.opfFooter)?.opfFooter;
81
+ announcer.say(`${plural(currentCompletions(state).length, "suggestion")}${footer ? `. ${footer}` : ""}`);
81
82
  } else if (open && selected && selected !== this.selected) {
82
83
  clearTimeout(this.timer);
83
84
  this.timer = setTimeout(() => {
@@ -98,12 +99,28 @@ export function a11yExtension(announcer, describe = () => "") {
98
99
  const { view } = this, { state } = view;
99
100
  const completions = currentCompletions(state);
100
101
  if (!completions.length) return;
102
+ this.footer(completions);
101
103
  for (const node of view.dom.querySelectorAll(".cm-tooltip-autocomplete [role=option]")) {
102
104
  const index = Number(node.id.slice(node.id.lastIndexOf("-") + 1));
103
105
  const completion = completions[index];
104
106
  if (completion) node.setAttribute("aria-label", optionName(completion, index, completions.length));
105
107
  }
106
108
  }
109
+ // The line under a list of catalog records whose catalog is not loaded (SPEC 4.5): part of the list's own box, after the options.
110
+ footer(completions) {
111
+ const tooltip = this.view.dom.querySelector(".cm-tooltip-autocomplete");
112
+ if (!tooltip) return;
113
+ const text = completions.find((completion) => completion.opfFooter)?.opfFooter ?? "";
114
+ let node = tooltip.querySelector(":scope > .opf-completion-footer");
115
+ if (!text) { node?.remove(); return; }
116
+ if (!node) {
117
+ node = tooltip.ownerDocument.createElement("div");
118
+ node.className = "opf-completion-footer";
119
+ node.setAttribute("role", "note");
120
+ }
121
+ if (node.textContent !== text) node.textContent = text;
122
+ if (tooltip.lastElementChild !== node) tooltip.append(node);
123
+ }
107
124
  destroy() { clearTimeout(this.timer); clearTimeout(this.labelTimer); }
108
125
  });
109
126
  }
@@ -0,0 +1,90 @@
1
+ // The source pane's text checks as plain functions of their input (no DOM, no editor session), so the main thread and the Web Worker
2
+ // (`./source-worker.js`) run exactly the same code and give the same answer. `readSource` is what the pane has always done with a text
3
+ // (parse it, collect the syntax findings); `checkSource` adds the validation the editor session would run on the deck it reads, so a worker
4
+ // can hand both back and the session need not repeat it on the main thread. Everything returned is structured-cloneable data.
5
+ import { fromYaml } from "@openpresentation/opf/yaml";
6
+ import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
7
+ import { checkFormat } from "./checks.js";
8
+ import { normalizeFinding } from "./source-findings.js";
9
+ import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
10
+ import { withSyntaxFixes } from "./source-fixes-syntax.js";
11
+
12
+ /**
13
+ * Read the text of one format: `{ deck, findings, notes? }` on success, `{ syntax: true, findings }` when it cannot be read. `findings` carry the
14
+ * syntax errors (`kind: "syntax"`) or the OPF findings of the parsed deck; `notes` are the reader's warnings that come with a deck.
15
+ */
16
+ export function readSource(format, text, catalogs) {
17
+ if (format === "json") {
18
+ const problems = analyzeSyntax(text);
19
+ if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
20
+ const deck = JSON.parse(text);
21
+ const duplicates = findDuplicateKeys(text);
22
+ if (duplicates.length) return { syntax: true, findings: duplicates };
23
+ return { deck, findings: [] };
24
+ }
25
+ const result = format === "yaml" ? fromYaml(text, { catalogs }) : fromMarkdown(text, { catalogs });
26
+ // The reader's own findings: errors stop the document being read; its warnings (a numbered list that became bullets) come with the deck.
27
+ const readerFindings = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
28
+ const errors = readerFindings.filter((finding) => finding.severity === "error");
29
+ const syntax = (list) => withSyntaxFixes(format, text, list).map((finding) => normalizeFinding(finding, "syntax"));
30
+ if (errors.length) return { syntax: true, findings: syntax(errors) };
31
+ return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")), notes: syntax(readerFindings) };
32
+ }
33
+
34
+ /**
35
+ * `readSource` plus, when a deck was read, `validation`: the report the editor session computes for that deck (`checkFormat`, with the same
36
+ * catalogs). `validate: false` skips it (the main thread's own path, where the session validates the patch itself).
37
+ */
38
+ export function checkSource({ format, text, catalogs, validate = true }) {
39
+ const read = readSource(format, text, catalogs);
40
+ if (validate && read.deck) {
41
+ read.validation = checkFormat(read.deck, { catalogs });
42
+ // What the Markdown writer cannot hold for this deck: the pane lists it after every applied edit, and writing a big deck back is not cheap.
43
+ if (format === "md") read.markdownNotes = markdownNotesOf(read.deck);
44
+ }
45
+ return read;
46
+ }
47
+
48
+ /** The notes about what Markdown cannot hold, as findings, from the report of `toMarkdown`. */
49
+ export function markdownNotes(report) {
50
+ const notes = report.embedded.map((part) => normalizeFinding({ path: part.path, message: `Markdown has no syntax for this, so it is kept as YAML in a fenced block (${part.reason}).`, severity: "warning" }, "format"));
51
+ for (const entry of report.loss) {
52
+ const at = entry.indexOf(": ");
53
+ notes.push(normalizeFinding({ path: at > 0 ? entry.slice(0, at) : "", message: `Left out of the Markdown: ${at > 0 ? entry.slice(at + 2) : entry}.`, severity: "warning" }, "format"));
54
+ }
55
+ return notes;
56
+ }
57
+
58
+ /** The Markdown notes of `deck`; none when it cannot be written as Markdown. */
59
+ export function markdownNotesOf(deck) {
60
+ try { return markdownNotes(toMarkdown(deck).report); } catch { return []; }
61
+ }
62
+
63
+ /** The syntax problems of a JSON text as findings (the pane's quick pass, 150 ms after the last key). */
64
+ export function checkSyntaxOnly(text) {
65
+ return analyzeSyntax(text).map((problem) => normalizeFinding(problem, "syntax"));
66
+ }
67
+
68
+ /**
69
+ * The worker's side of the protocol, free of any worker API so it can be tested in Node. `handle(message)` returns the reply for one message
70
+ * (or null for a message with no reply). Messages: `{ id, type: "catalogs", catalogs }`, `{ id, type: "check", format, text }` and
71
+ * `{ id, type: "syntax", text }`; replies: `{ id, ok: true, result }` or `{ id, ok: false, error }`.
72
+ */
73
+ export function createCheckService() {
74
+ let catalogs;
75
+ return {
76
+ handle(message) {
77
+ const id = message?.id;
78
+ try {
79
+ switch (message?.type) {
80
+ case "catalogs": catalogs = message.catalogs; return { id, ok: true, result: null };
81
+ case "check": return { id, ok: true, result: checkSource({ format: message.format, text: message.text, catalogs }) };
82
+ case "syntax": return { id, ok: true, result: checkSyntaxOnly(message.text) };
83
+ default: return { id, ok: false, error: `Unknown message type ${String(message?.type)}.` };
84
+ }
85
+ } catch (error) {
86
+ return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
87
+ }
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,130 @@
1
+ // The client side of the source worker (`./source-worker.js`): it owns the worker, numbers the requests, keeps the worker's catalogs in step with the
2
+ // pane's, and falls back to the pane's own inline path for good, once, when anything about the worker goes wrong (it cannot start, a script URL is
3
+ // blocked or missing, a message cannot be cloned, the worker throws or stops answering). It never loses an edit: a failed request rejects with
4
+ // `error.workerFailed === true`, and the pane then does the same work inline.
5
+
6
+ const DEFAULT_TIMEOUT = 15000;
7
+
8
+ /** A failure of the worker as an error the caller can tell from an exception of the checked text. */
9
+ function failure(cause) {
10
+ const error = new Error(cause instanceof Error ? cause.message : String(cause ?? "The source worker failed."));
11
+ error.workerFailed = true;
12
+ error.cause = cause;
13
+ return error;
14
+ }
15
+
16
+ /** The worker the `worker` option stands for: a function is called, an object with `postMessage` is used as given, `true` starts the package's own. */
17
+ function makeWorker(option) {
18
+ if (typeof option === "function") return option();
19
+ if (option && typeof option === "object" && typeof option.postMessage === "function") return option;
20
+ if (option === true) {
21
+ if (typeof Worker !== "function") throw new Error("Web Workers are not available here.");
22
+ return new Worker(new URL("./source-worker.js", import.meta.url), { type: "module" });
23
+ }
24
+ throw new TypeError("The worker option is true, a Worker, or a function that returns one.");
25
+ }
26
+
27
+ /**
28
+ * @param {{ worker: boolean | object | (() => object), onError?: (error: unknown) => void, timeout?: number }} options
29
+ */
30
+ export function createSourceChecker(options = {}) {
31
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
32
+ let worker = null;
33
+ let failed = false;
34
+ let disposed = false;
35
+ let nextId = 0;
36
+ let sentCatalogs;
37
+ let catalogsSent = false;
38
+ const pending = new Map();
39
+ const latest = { check: 0, syntax: 0 };
40
+ const counts = { worker: 0 };
41
+
42
+ function fail(cause) {
43
+ if (failed || disposed) return;
44
+ failed = true;
45
+ const error = failure(cause);
46
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
47
+ pending.clear();
48
+ try { worker?.terminate?.(); } catch { /* Already gone. */ }
49
+ worker = null;
50
+ try { options.onError?.(cause instanceof Error ? cause : error); } catch { /* A host callback must not break the fallback. */ }
51
+ }
52
+
53
+ function reply(event) {
54
+ const data = event?.data;
55
+ const entry = pending.get(data?.id);
56
+ if (!entry) return;
57
+ pending.delete(data.id);
58
+ clearTimeout(entry.timer);
59
+ if (data.ok === false) { entry.reject(failure(data.error)); fail(new Error(String(data.error))); return; }
60
+ if (data.skipped || (entry.kind && latest[entry.kind] !== data.id)) { entry.resolve(null); return; }
61
+ if (entry.kind) counts.worker++;
62
+ entry.resolve(data.result);
63
+ }
64
+
65
+ function start() {
66
+ if (worker) return worker;
67
+ worker = makeWorker(options.worker);
68
+ if (!worker || typeof worker.postMessage !== "function") throw new TypeError("The worker option did not give a Worker.");
69
+ worker.onmessage = reply;
70
+ worker.onerror = (event) => { event?.preventDefault?.(); fail(new Error(event?.message || "The source worker failed to start.")); };
71
+ worker.onmessageerror = () => fail(new Error("The source worker sent a message that could not be read."));
72
+ return worker;
73
+ }
74
+
75
+ function post(message, kind) {
76
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
77
+ let target;
78
+ try { target = start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
79
+ const id = ++nextId;
80
+ if (kind) latest[kind] = id;
81
+ return new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => fail(new Error("The source worker did not answer in time.")), timeout);
83
+ pending.set(id, { resolve, reject, timer, kind });
84
+ try { target.postMessage({ id, ...message }); } catch (error) { fail(error); }
85
+ });
86
+ }
87
+
88
+ /** Send the catalogs when they are not the ones the worker already has (by identity), ahead of the request that needs them. */
89
+ function syncCatalogs(catalogs) {
90
+ if (catalogsSent && sentCatalogs === catalogs) return;
91
+ sentCatalogs = catalogs;
92
+ catalogsSent = true;
93
+ post({ type: "catalogs", catalogs }).catch(() => { /* `fail` already ran; the request after it rejects too. */ });
94
+ }
95
+
96
+ return {
97
+ /** Whether the worker can still be used (it may not have been started yet). */
98
+ get available() { return !failed && !disposed; },
99
+ /** Whether the worker is running now. */
100
+ get running() { return worker !== null && !failed && !disposed; },
101
+ /** The catalogs object the worker's next check will use (the last one sent). */
102
+ get catalogs() { return catalogsSent ? sentCatalogs : undefined; },
103
+ /** How many checks the worker answered. */
104
+ get answered() { return counts.worker; },
105
+ /**
106
+ * Check `text` as `format` in the worker, with `catalogs`. Resolves with the `checkSource` result, or with null when a newer check replaced this
107
+ * one. Rejects with `error.workerFailed` when the worker is not usable (and never again starts it).
108
+ */
109
+ check(format, text, catalogs) {
110
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
111
+ try { start(); syncCatalogs(catalogs); } catch (error) { fail(error); return Promise.reject(failure(error)); }
112
+ return post({ type: "check", format, text }, "check");
113
+ },
114
+ /** The syntax findings of a JSON text (null when a newer request replaced this one). */
115
+ syntax(text) {
116
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
117
+ try { start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
118
+ return post({ type: "syntax", text }, "syntax");
119
+ },
120
+ /** Stop the worker and answer nothing more. */
121
+ dispose() {
122
+ if (disposed) return;
123
+ disposed = true;
124
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.resolve(null); }
125
+ pending.clear();
126
+ try { worker?.terminate?.(); } catch { /* Already gone. */ }
127
+ worker = null;
128
+ },
129
+ };
130
+ }
@@ -5,15 +5,18 @@ import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, st
5
5
  import { isolateHistory } from "@codemirror/commands";
6
6
  import { EditorSelection } from "@codemirror/state";
7
7
  import { describeField } from "@openpresentation/opf/schema";
8
- import { listCatalogRecords } from "./catalogs.js";
8
+ import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
9
9
  import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
10
- import { Document, parse as parseYamlDocument } from "yaml";
11
- import { pathAt } from "@openpresentation/opf/source";
10
+ import { Document, parse as parseYamlDocument, parseDocument } from "yaml";
11
+ import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
12
+ import { findNodeAtLocation, getNodeValue } from "jsonc-parser";
12
13
  import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
14
+ import { PROMOTED_REGIONS, beatsOf, builtinVariables, columnSourceFor, factsOf, jsonDeckParts, layoutKeys, referenceSiteAt, regionNames, slidePath, valueAt, variableNames, yamlDeckParts } from "./source-references.js";
15
+ import { beginUndoGroup, finishUndoGroup, takeUndoGroup } from "./source-undo-groups.js";
13
16
  import { scalarValue, yamlContextAt } from "./source-yaml.js";
14
17
  import { markdownContextAt, markdownOptions, markdownPlan, markdownHoverAt } from "./source-markdown.js";
15
18
  import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
16
- import { snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
19
+ import { keySnippetsFor, snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
17
20
 
18
21
  export { contextAt, tokenAt };
19
22
 
@@ -114,16 +117,38 @@ function advancedShown(children, typed, explicit) {
114
117
  return children.some((child) => child.advanced && child.key.toLowerCase().startsWith(typed));
115
118
  }
116
119
 
117
- function keyOptions(context, text, caret, explicit) {
120
+ /**
121
+ * The keys of an object in the order the list shows them (SPEC 4.4): the required ones first, then, for a slide with a layout, the keys the layout's
122
+ * regions call for that are not set yet, then the common order the schema gives (`children` arrives in that order and keeps it).
123
+ */
124
+ function rankKeys(context, shown, catalogs) {
125
+ const slide = context.path.length === 2 && context.path[0] === "slides" ? valueAt(context.document, context.path) : undefined;
126
+ const layout = typeof slide?.layout === "string" ? slide.layout : undefined;
127
+ let wanted = [];
128
+ if (layout) {
129
+ try {
130
+ const record = listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === layout)?.record;
131
+ wanted = layoutKeys(record, shown.map((child) => child.key));
132
+ } catch { wanted = []; }
133
+ }
134
+ const rank = (child) => (child.required ? 0 : wanted.includes(child.key) ? 1 : 2);
135
+ return shown.map((child, index) => ({ child, index })).sort((a, b) => rank(a.child) - rank(b.child) || (rank(a.child) === 1 ? wanted.indexOf(a.child.key) - wanted.indexOf(b.child.key) : a.index - b.index)).map((entry) => entry.child);
136
+ }
137
+
138
+ function keyOptions(context, text, caret, explicit, catalogs) {
118
139
  const info = describeField(context.path, context.document);
119
140
  const typed = text.slice(context.token.from, caret).toLowerCase();
120
141
  const options = [];
121
142
  if (info.known) {
122
143
  const free = info.children.filter((child) => !context.existing.has(child.key));
123
144
  const withAdvanced = advancedShown(free, typed, explicit);
124
- const shown = free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed)));
145
+ const shown = rankKeys(context, free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed))), catalogs);
125
146
  // A blank document starts with a deck: its snippet comes first.
126
147
  if (context.blank) for (const entry of snippetsFor("value", [])) options.push(snippetOption(entry, 99));
148
+ // A key a snippet is written for (the deck's `design`, `organization`, `speaker`) is accepted as that snippet, as VS Code does for a property
149
+ // with default snippets: its members come with it, each a tab stop.
150
+ const keySnippets = snippetsFor("key", context.path);
151
+ const forKey = (key) => (context.flow ? undefined : keySnippetsFor(context.path).find((entry) => entry.key === key));
127
152
  shown.forEach((child, index) => options.push({
128
153
  label: child.key,
129
154
  type: "property",
@@ -131,10 +156,10 @@ function keyOptions(context, text, caret, explicit) {
131
156
  boost: boostAt(index + (context.blank ? 1 : 0)),
132
157
  opfRequired: child.required,
133
158
  opfDoc: child.description ?? "",
134
- opf: { kind: "key", key: child.key },
159
+ opf: { kind: "key", key: child.key, ...(forKey(child.key) ? { snippet: child.key } : {}) },
135
160
  info: () => docNode(describeChild(context, child)),
136
161
  }));
137
- for (const entry of snippetsFor("key", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
162
+ for (const entry of keySnippets) options.push(snippetOption(entry, boostAt(options.length)));
138
163
  // A key right after a list dash is also the start of the item: the item snippets (a slide, a reference) fit there.
139
164
  if (context.itemStart) for (const entry of snippetsFor("item", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
140
165
  return { options, advanced: withAdvanced };
@@ -183,12 +208,141 @@ function catalogOptions(kind, context, catalogs) {
183
208
  });
184
209
  }
185
210
 
186
- function valueOptions(context, catalogs) {
211
+ // ---- cross references (SPEC 3.4) --------------------------------------------------------------------------------------------------------
212
+ /**
213
+ * Where the references of the document are read from, for the context: `facts()` the assets, datasets, variables, references and the narrative
214
+ * (once), `read(path)` a value of the text being edited (a draft: undefined when the text does not say). `format` is the editor's: "json", "yaml"
215
+ * or "md" (the deck of a Markdown text is its front matter).
216
+ */
217
+ export function referenceScope(context, text, format) {
218
+ let facts, parsed;
219
+ const deckParts = () => {
220
+ if (format === "json") return context.root ? jsonDeckParts(context.root, getNodeValue) : {};
221
+ if (format === "md") {
222
+ const front = scanMarkdown(text).frontMatter;
223
+ return front ? yamlDeckParts(text.slice(front.yamlFrom, front.yamlTo)) : {};
224
+ }
225
+ return yamlDeckParts(text);
226
+ };
227
+ return {
228
+ facts: () => (facts ??= factsOf(deckParts())),
229
+ read(path) {
230
+ if (format === "json") {
231
+ const node = context.root ? findNodeAtLocation(context.root, path) : undefined;
232
+ return node ? getNodeValue(node) : undefined;
233
+ }
234
+ if (parsed === undefined) {
235
+ try { parsed = parseDocument(context.sourceText ?? text, { strict: false, uniqueKeys: false }).toJS({ maxAliasCount: -1 }); } catch { parsed = null; }
236
+ }
237
+ return valueAt(parsed, path.slice(context.prefix?.length ?? 0));
238
+ },
239
+ };
240
+ }
241
+
242
+ const REFERENCE_LABELS = { asset: "asset", dataset: "dataset", datasetColumn: "dataset column", cite: "reference", beat: "narrative beat", region: "layout region", color: "color variable" };
243
+ const referenceOption = (id, value, extra) => literalOption(id, JSON.stringify(value), { type: "reference", value, ...extra });
244
+
245
+ function referenceOptions(site, context, catalogs, scope) {
246
+ const facts = scope.facts();
247
+ const options = [];
248
+ const add = (id, value, extra = {}) => options.push(referenceOption(id, value, { boost: boostAt(options.length), info: () => docNode({ title: id, type: REFERENCE_LABELS[site.kind], description: extra.doc || undefined, notes: [extra.detail].filter(Boolean) }), ...extra }));
249
+ switch (site.kind) {
250
+ case "asset":
251
+ for (const asset of facts.assets) add(`asset:${asset.id}`, `asset:${asset.id}`, { detail: asset.detail || "asset", doc: asset.detail });
252
+ for (const variable of facts.variables) if (variable.type === "image") add(`var:${variable.id}`, `var:${variable.id}`, { detail: "image variable" });
253
+ break;
254
+ case "dataset":
255
+ for (const dataset of facts.datasets) add(dataset.id, dataset.id, { detail: dataset.title || `${dataset.columns.length} columns`, doc: dataset.columns.join(", ") });
256
+ break;
257
+ case "datasetColumn": {
258
+ const source = columnSourceFor(site, (path) => scope.read(path));
259
+ const columns = source?.dataset !== undefined ? facts.datasets.find((entry) => entry.id === source.dataset)?.columns : source?.inline;
260
+ for (const column of columns ?? []) add(column, column, { detail: source.dataset !== undefined ? `column of ${source.dataset}` : "column" });
261
+ break;
262
+ }
263
+ case "cite":
264
+ for (const reference of facts.references) add(reference.id, reference.id, { detail: brief(reference.text) ?? "reference", doc: reference.text });
265
+ break;
266
+ case "beat": {
267
+ let record;
268
+ if (facts.narrative) {
269
+ try { record = listCatalogRecords(context.document, "narratives", { catalogs }).find((entry) => entry.id === facts.narrative)?.record; } catch { record = undefined; }
270
+ }
271
+ for (const beat of beatsOf(record)) add(beat.id, beat.id, { detail: beat.name || "beat", doc: brief(beat.description) });
272
+ break;
273
+ }
274
+ case "region": {
275
+ const slide = slidePath(context.path);
276
+ const layout = slide ? valueAt(context.document, slide)?.layout : undefined;
277
+ let names = [];
278
+ if (typeof layout === "string") {
279
+ try { names = regionNames(listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === layout)?.record); } catch { names = []; }
280
+ }
281
+ for (const name of names) add(name, name, { detail: `region of ${layout}` });
282
+ for (const name of PROMOTED_REGIONS) if (!names.includes(name)) add(name, name, { detail: "promoted region" });
283
+ break;
284
+ }
285
+ case "color":
286
+ for (const variable of facts.variables) if (variable.type === "color") add(`var:${variable.id}`, `var:${variable.id}`, { detail: "color variable" });
287
+ break;
288
+ default:
289
+ }
290
+ return options;
291
+ }
292
+
293
+ // `{{name}}` in a text, offered once `{{` is typed: the variables the document declares first, then the built-in ones the engine resolves
294
+ // (`deck.*`, `speaker.*`, `organization.*`, `slide.*`), grouped by prefix, each saying whether the document sets it. `before` is the text of the value
295
+ // before the caret.
296
+ const VARIABLE_OPEN = /\{\{([A-Za-z0-9._-]*)$/;
297
+ function variableOptions(scope) {
298
+ const facts = scope.facts();
299
+ const options = [];
300
+ variableNames(facts).forEach((variable) => options.push({ label: variable.id, type: "reference", detail: `${variable.type} variable`, boost: boostAt(options.length), opfDoc: `The variable ${variable.id}`, opf: { kind: "variable", name: variable.id } }));
301
+ for (const builtin of builtinVariables(facts)) {
302
+ options.push({
303
+ label: builtin.name, type: "reference", detail: builtin.detail, boost: boostAt(options.length), opfDoc: builtin.doc, opf: { kind: "variable", name: builtin.name, builtin: true },
304
+ info: () => docNode({ title: builtin.name, type: "built-in variable", description: builtin.doc, notes: [builtin.available ? "" : "Not set in this document"].filter(Boolean) }),
305
+ });
306
+ }
307
+ return options;
308
+ }
309
+
310
+ /** The result for a caret right after `{{` in a text value, or null: the variable names, replacing what is typed after the braces. */
311
+ function variableResult(context, text, position, scope) {
312
+ const token = context.token;
313
+ if (!token || context.mode === "key") return null;
314
+ const open = VARIABLE_OPEN.exec(text.slice(token.from, Math.min(position, token.to)));
315
+ return open ? variableList(scope, position - open[1].length, position) : null;
316
+ }
317
+
318
+ function variableList(scope, from, position) {
319
+ const options = variableOptions(scope);
320
+ return options.length ? { from, to: position, filter: true, options, validFor: (typed) => /^[A-Za-z0-9._-]*$/.test(typed) } : null;
321
+ }
322
+
323
+ /**
324
+ * The line under a list of catalog records when a group of the document names a source no registered catalog matches (SPEC 4.5): its records
325
+ * beyond the embedded ones are not available here. Empty when every source is registered.
326
+ */
327
+ export function unloadedFooter(document, catalogs) {
328
+ const groups = document && typeof document === "object" ? document.catalogs : undefined;
329
+ if (!groups || typeof groups !== "object") return "";
330
+ let registered = [];
331
+ try { registered = mergeCatalogs(catalogs).map((entry) => entry.source); } catch { registered = []; }
332
+ const names = Object.entries(groups).filter(([name, group]) => name !== "custom" && group && typeof group === "object" && typeof group.source === "string" && !registered.includes(group.source)).map(([name]) => name);
333
+ return names.length ? `Records from catalog ${names.join(", ")} are not loaded` : "";
334
+ }
335
+
336
+ function valueOptions(context, catalogs, scope) {
187
337
  const info = describeField(context.path, context.document);
188
338
  const options = [];
189
339
  if (!info.known) return { options };
190
340
  const variant = primaryVariant(info);
191
- if (info.catalogKind) options.push(...catalogOptions(info.catalogKind, context, catalogs));
341
+ const site = scope ? referenceSiteAt(context.path, context.document) : null;
342
+ if (info.catalogKind) {
343
+ const footer = unloadedFooter(context.document, catalogs);
344
+ for (const option of catalogOptions(info.catalogKind, context, catalogs)) options.push(footer ? { ...option, opfFooter: footer } : option);
345
+ }
192
346
  else if (info.enum?.length) {
193
347
  // In the order VS Code lists them: by label.
194
348
  info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
@@ -201,6 +355,7 @@ function valueOptions(context, catalogs) {
201
355
  const label = variant.type === "object" ? "{}" : "[]";
202
356
  options.push({ label, type: "keyword", detail: variant.type === "object" ? "object" : "list", boost: 99, opfDoc: info.description ?? "", opf: { kind: "template" }, info: () => docNode(fieldDoc(info, context.key ?? label)) });
203
357
  }
358
+ if (site) options.push(...referenceOptions(site, context, catalogs, scope));
204
359
  for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
205
360
  return { options };
206
361
  }
@@ -234,12 +389,15 @@ export function completionResult(state, position, { explicit, catalogs, format =
234
389
  const text = state.doc.toString();
235
390
  let context = contextFor(format, text, position, explicit);
236
391
  if (!context) return null;
237
- if (context.format === "md") return markdownResult(context, text, position, { explicit, catalogs });
392
+ const scope = referenceScope(context, text, format);
393
+ if (context.format === "md") return context.region === "variable" ? variableList(scope, context.token.from, position) : markdownResult(context, text, position, { explicit, catalogs, scope });
238
394
  if (context.format === "yaml") {
239
395
  context = settleYamlContext(context, text);
240
396
  if (!context) return null;
241
397
  }
242
- const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
398
+ const variables = context.mode !== "key" ? variableResult(context, text, position, scope) : null;
399
+ if (variables || context.multiline) return variables;
400
+ const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit, catalogs) : valueOptions(context, catalogs, scope);
243
401
  if (!options.length) return null;
244
402
  return listResult(context, options, advanced, { position, explicit });
245
403
  }
@@ -261,8 +419,8 @@ function listResult(context, options, advanced, { position, explicit }) {
261
419
  };
262
420
  }
263
421
 
264
- function markdownResult(context, text, position, { explicit, catalogs }) {
265
- const built = markdownOptions(context, { catalogs, explicit, text, position, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
422
+ function markdownResult(context, text, position, { explicit, catalogs, scope }) {
423
+ const built = markdownOptions(context, { catalogs, explicit, text, position, scope, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
266
424
  if (!built?.options.length) return null;
267
425
  const { token } = context;
268
426
  return {
@@ -289,9 +447,14 @@ const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/
289
447
  const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
290
448
 
291
449
  /** What accepting a key writes in YAML: `key: ` for a scalar, `key:` and a new line for a mapping or a list. */
292
- function yamlKeyPlan(context, key, text, end) {
450
+ function yamlKeyPlan(context, key, text, end, snippetKey) {
293
451
  const written = yamlKey(key);
294
452
  if (/^[ \t]*:/.test(text.slice(end))) return { template: escapeTemplate(written), fields: 0, chain: false };
453
+ const entry = snippetKey ? keySnippetsFor(context.path).find((candidate) => candidate.key === snippetKey) : undefined;
454
+ if (entry && !context.flow) {
455
+ const rendered = snippetYaml(entry, { dash: 0 });
456
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
457
+ }
295
458
  let info;
296
459
  try { info = describeField([...context.path, key], context.document); } catch { info = null; }
297
460
  const kind = info?.known ? variantKind(info) : "string";
@@ -313,7 +476,7 @@ function yamlKeyPlan(context, key, text, end) {
313
476
  /** What accepting `completion` writes in YAML: `{ start, end, template, fields, chain, choices }`. */
314
477
  function planYaml(context, completion, text, end) {
315
478
  const kind = completion.opf?.kind;
316
- if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end);
479
+ if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end, completion.opf.snippet);
317
480
  if (kind === "literal") {
318
481
  const value = completion.opf.value;
319
482
  const prefix = context.needsSpace ? " " : "";
@@ -345,6 +508,11 @@ export function planCompletion(context, completion, text, end) {
345
508
  const key = completion.opf.key;
346
509
  const quoted = JSON.stringify(key);
347
510
  if (/^\s*:/.test(text.slice(end))) return { template: quoted, fields: 0, chain: false };
511
+ const entry = completion.opf.snippet ? keySnippetsFor(context.path).find((candidate) => candidate.key === completion.opf.snippet) : undefined;
512
+ if (entry) {
513
+ const rendered = snippetJson(entry, { comma: needsComma });
514
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
515
+ }
348
516
  let info;
349
517
  try { info = describeField([...context.path, key], context.document); } catch { info = null; }
350
518
  const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
@@ -363,7 +531,7 @@ export function planCompletion(context, completion, text, end) {
363
531
  if (kind === "snippet") {
364
532
  const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
365
533
  if (!entry) return null;
366
- const rendered = snippetJson(entry, { comma: needsComma && context.mode !== "key" });
534
+ const rendered = snippetJson(entry, { comma: needsComma && !/^\s*:/.test(text.slice(end)) });
367
535
  return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
368
536
  }
369
537
  return null;
@@ -374,10 +542,20 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
374
542
  const { state } = view;
375
543
  const text = state.doc.toString();
376
544
  const main = state.selection.main;
545
+ if (completion.opf?.kind === "variable") { applyVariable(view, completion, fallbackFrom, fallbackTo); return; }
546
+ // A value accepted right after the key that opened its list is one undo step with that key (R-CMP-14).
547
+ const group = takeUndoGroup(view);
377
548
  const context = contextFor(format, text, main.head, true);
378
549
  const token = context?.token;
379
550
  const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
380
551
  const plan = context ? planCompletion(context, completion, text, end) : null;
552
+ // Accepting is one undo step: what is typed next starts another.
553
+ const finish = (chain) => {
554
+ view.dispatch({ annotations: isolateHistory.of("full") });
555
+ if (group) finishUndoGroup(view, group);
556
+ // A key whose value is one of a closed set opens that list at once.
557
+ if (chain) { beginUndoGroup(view, state.doc); startCompletion(view); }
558
+ };
381
559
  if (!plan) {
382
560
  view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
383
561
  return;
@@ -393,6 +571,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
393
571
  if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
394
572
  const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
395
573
  view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
574
+ finish(false);
396
575
  return;
397
576
  }
398
577
  // YAML and Markdown values inside quotes: only the inside changes, and the caret ends after the closing quote.
@@ -400,6 +579,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
400
579
  const insert = plan.template.replace(/\\([{}\\])/g, "$1");
401
580
  const caret = token.closed ? token.end + insert.length - (token.to - token.from) : token.from + insert.length;
402
581
  view.dispatch({ changes: { from: token.from, to: token.to, insert }, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
582
+ finish(false);
403
583
  return;
404
584
  }
405
585
  const from = plan.replaceFrom ?? start, to = plan.replaceTo ?? end;
@@ -407,16 +587,46 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
407
587
  snippet(plan.template)(view, completion, from, to);
408
588
  if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
409
589
  else snippetRuns.delete(view);
410
- if (plan.choices) snippetStep(view, true, { start: true, say });
590
+ if (plan.choices) snippetStep(view, true, { start: true, say: say && ((message) => say(`Inserted ${completion.label}. ${message}`)) });
411
591
  } else {
412
592
  const insert = plan.template.replace(/\\([{}\\])/g, "$1");
413
593
  view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
414
594
  snippetRuns.delete(view);
415
595
  }
416
- // Accepting is one undo step: what is typed next starts another.
417
- view.dispatch({ annotations: isolateHistory.of("full") });
418
- // A key whose value is one of a closed set opens that list at once.
419
- if (plan.chain) startCompletion(view);
596
+ finish(plan.chain);
597
+ }
598
+
599
+ /**
600
+ * `{{na|` becomes `{{name}}`: the name replaces what was typed after the braces and the closing braces are written unless they are there. With
601
+ * several cursors it does so at each of them, in one undo step. A cursor with no `{{` before it replaces as many characters as the main cursor's typed
602
+ * name when they are the same text (VS Code applies the item at every cursor the same way), and adds the braces all the same.
603
+ */
604
+ function applyVariable(view, completion, fallbackFrom, fallbackTo) {
605
+ const { state } = view;
606
+ const text = state.doc.toString();
607
+ const insert = `${completion.opf.name}}}`;
608
+ const annotations = [pickedCompletion.of(completion), isolateHistory.of("full")];
609
+ if (state.selection.ranges.length <= 1) {
610
+ const to = text.startsWith("}}", fallbackTo) ? fallbackTo + 2 : fallbackTo;
611
+ view.dispatch({ changes: { from: fallbackFrom, to, insert }, selection: { anchor: fallbackFrom + insert.length }, annotations, userEvent: "input.complete", scrollIntoView: true });
612
+ return;
613
+ }
614
+ const typed = text.slice(fallbackFrom, fallbackTo);
615
+ const sorted = [...state.selection.ranges].sort((a, b) => a.from - b.from);
616
+ const changes = [], carets = [];
617
+ let shift = 0;
618
+ for (const range of sorted) {
619
+ const head = range.head;
620
+ const open = VARIABLE_OPEN.exec(text.slice(Math.max(0, head - 200), head));
621
+ const length = open ? open[1].length : text.slice(head - typed.length, head) === typed ? typed.length : 0;
622
+ const from = head - length;
623
+ const to = text.startsWith("}}", head) ? head + 2 : head;
624
+ changes.push({ from, to, insert });
625
+ carets.push(from + shift + insert.length);
626
+ shift += insert.length - (to - from);
627
+ }
628
+ const main = sorted.indexOf(state.selection.main);
629
+ view.dispatch({ changes, selection: EditorSelection.create(carets.map((position) => EditorSelection.cursor(position)), Math.max(0, main)), annotations, userEvent: "input.complete", scrollIntoView: true });
420
630
  }
421
631
 
422
632
  // With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.