@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.
@@ -2,16 +2,42 @@
2
2
  // ranked, and what accepting an item writes. Keys come from the schema (`describeField` of core), values from the schema's enums, booleans and the
3
3
  // catalogs, templates for objects and lists, snippets for slides and the like. Loaded lazily by the code editor with the schema and the catalogs.
4
4
  import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, startCompletion } from "@codemirror/autocomplete";
5
+ import { isolateHistory } from "@codemirror/commands";
5
6
  import { EditorSelection } from "@codemirror/state";
6
7
  import { describeField } from "@openpresentation/opf/schema";
7
- import { listCatalogRecords } from "./catalogs.js";
8
+ import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
8
9
  import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
10
+ import { Document, parse as parseYamlDocument, parseDocument } from "yaml";
11
+ import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
12
+ import { findNodeAtLocation, getNodeValue } from "jsonc-parser";
9
13
  import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
14
+ import { PROMOTED_REGIONS, beatsOf, columnSourceFor, factsOf, jsonDeckParts, layoutKeys, referenceSiteAt, regionNames, slidePath, valueAt, variableNames, yamlDeckParts } from "./source-references.js";
15
+ import { beginUndoGroup, finishUndoGroup, takeUndoGroup } from "./source-undo-groups.js";
16
+ import { scalarValue, yamlContextAt } from "./source-yaml.js";
17
+ import { markdownContextAt, markdownOptions, markdownPlan, markdownHoverAt } from "./source-markdown.js";
10
18
  import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
11
- import { snippetJson, snippetsFor } from "./source-snippets.js";
19
+ import { keySnippetsFor, snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
12
20
 
13
21
  export { contextAt, tokenAt };
14
22
 
23
+ /** The context of the caret in a text of `format` ("json", "yaml" or "md"), or null: what the list offers, and the token it replaces. */
24
+ export function contextFor(format, text, position, explicit) {
25
+ if (format === "yaml") {
26
+ const context = yamlContextAt(text, position, { explicit });
27
+ // A blank document starts with a deck.
28
+ return context && context.mode === "key" && context.path.length === 0 && text.trim() === "" ? { ...context, blank: true } : context;
29
+ }
30
+ if (format === "md") return markdownContextAt(text, position, { explicit });
31
+ return contextAt(text, position, { explicit });
32
+ }
33
+
34
+ /** The token being typed at `position` in a text of `format`, or null. */
35
+ export function tokenFor(format, text, position) {
36
+ if (format === "json") return tokenAt(text, position, false);
37
+ const context = contextFor(format, text, position, false);
38
+ return context?.token ?? null;
39
+ }
40
+
15
41
  // A quoted token keeps its list for any text between the quotes; a bare word for word characters only.
16
42
  const QUOTED_TEXT = /^[^"\\\n\r]*$/;
17
43
  const WORD_TEXT = /^[A-Za-z0-9_.-]*$/;
@@ -91,25 +117,51 @@ function advancedShown(children, typed, explicit) {
91
117
  return children.some((child) => child.advanced && child.key.toLowerCase().startsWith(typed));
92
118
  }
93
119
 
94
- 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) {
95
139
  const info = describeField(context.path, context.document);
96
140
  const typed = text.slice(context.token.from, caret).toLowerCase();
97
141
  const options = [];
98
142
  if (info.known) {
99
143
  const free = info.children.filter((child) => !context.existing.has(child.key));
100
144
  const withAdvanced = advancedShown(free, typed, explicit);
101
- 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);
146
+ // A blank document starts with a deck: its snippet comes first.
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));
102
152
  shown.forEach((child, index) => options.push({
103
153
  label: child.key,
104
154
  type: "property",
105
155
  detail: child.required ? `${child.typeLabel} · required` : child.typeLabel,
106
- boost: boostAt(index),
156
+ boost: boostAt(index + (context.blank ? 1 : 0)),
107
157
  opfRequired: child.required,
108
158
  opfDoc: child.description ?? "",
109
- opf: { kind: "key", key: child.key },
159
+ opf: { kind: "key", key: child.key, ...(forKey(child.key) ? { snippet: child.key } : {}) },
110
160
  info: () => docNode(describeChild(context, child)),
111
161
  }));
112
- 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)));
163
+ // A key right after a list dash is also the start of the item: the item snippets (a slide, a reference) fit there.
164
+ if (context.itemStart) for (const entry of snippetsFor("item", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
113
165
  return { options, advanced: withAdvanced };
114
166
  }
115
167
  return { options, advanced: false };
@@ -130,7 +182,7 @@ function snippetOption(entry, boost) {
130
182
  }
131
183
 
132
184
  function literalOption(label, literal, extra) {
133
- return { label, type: extra.type ?? "enum", boost: extra.boost, detail: extra.detail, opfDoc: extra.doc ?? "", opf: { kind: "literal", literal }, info: extra.info };
185
+ return { label, type: extra.type ?? "enum", boost: extra.boost, detail: extra.detail, opfDoc: extra.doc ?? "", opf: { kind: "literal", literal, value: extra.value }, info: extra.info };
134
186
  }
135
187
 
136
188
  function catalogOptions(kind, context, catalogs) {
@@ -150,45 +202,201 @@ function catalogOptions(kind, context, catalogs) {
150
202
  const notes = [regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean);
151
203
  const model = { title: record.id, type: KIND_LABELS[kind], description: record.description ?? undefined, notes: [record.label !== record.id ? record.label : "", ...notes].filter(Boolean) };
152
204
  return literalOption(record.id, JSON.stringify(record.id), {
153
- type: "catalog", boost: intent || record.origin === "document" ? boostAt(index) : 0, detail: record.label !== record.id ? record.label : SOURCE_LABELS[record.origin === "document" ? "document" : "registered"],
205
+ value: record.id, type: "catalog", boost: intent || record.origin === "document" ? boostAt(index) : 0, detail: record.label !== record.id ? record.label : SOURCE_LABELS[record.origin === "document" ? "document" : "registered"],
154
206
  doc: [brief(record.description), regions].filter(Boolean).join(" "), info: () => docNode(model),
155
207
  });
156
208
  });
157
209
  }
158
210
 
159
- 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: the variables the document declares, offered once `{{` is typed. `before` is the text of the value before the caret.
294
+ const VARIABLE_OPEN = /\{\{([A-Za-z0-9._-]*)$/;
295
+ function variableOptions(scope) {
296
+ const options = [];
297
+ variableNames(scope.facts()).forEach((variable, index) => options.push({ label: variable.id, type: "reference", detail: `${variable.type} variable`, boost: boostAt(index), opfDoc: `The variable ${variable.id}`, opf: { kind: "variable", name: variable.id } }));
298
+ return options;
299
+ }
300
+
301
+ /** The result for a caret right after `{{` in a text value, or null: the variable names, replacing what is typed after the braces. */
302
+ function variableResult(context, text, position, scope) {
303
+ const token = context.token;
304
+ if (!token || context.mode === "key") return null;
305
+ const open = VARIABLE_OPEN.exec(text.slice(token.from, Math.min(position, token.to)));
306
+ return open ? variableList(scope, position - open[1].length, position) : null;
307
+ }
308
+
309
+ function variableList(scope, from, position) {
310
+ const options = variableOptions(scope);
311
+ return options.length ? { from, to: position, filter: true, options, validFor: (typed) => /^[A-Za-z0-9._-]*$/.test(typed) } : null;
312
+ }
313
+
314
+ /**
315
+ * 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
316
+ * beyond the embedded ones are not available here. Empty when every source is registered.
317
+ */
318
+ export function unloadedFooter(document, catalogs) {
319
+ const groups = document && typeof document === "object" ? document.catalogs : undefined;
320
+ if (!groups || typeof groups !== "object") return "";
321
+ let registered = [];
322
+ try { registered = mergeCatalogs(catalogs).map((entry) => entry.source); } catch { registered = []; }
323
+ 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);
324
+ return names.length ? `Records from catalog ${names.join(", ")} are not loaded` : "";
325
+ }
326
+
327
+ function valueOptions(context, catalogs, scope) {
160
328
  const info = describeField(context.path, context.document);
161
329
  const options = [];
162
330
  if (!info.known) return { options };
163
331
  const variant = primaryVariant(info);
164
- if (info.catalogKind) options.push(...catalogOptions(info.catalogKind, context, catalogs));
332
+ const site = scope ? referenceSiteAt(context.path, context.document) : null;
333
+ if (info.catalogKind) {
334
+ const footer = unloadedFooter(context.document, catalogs);
335
+ for (const option of catalogOptions(info.catalogKind, context, catalogs)) options.push(footer ? { ...option, opfFooter: footer } : option);
336
+ }
165
337
  else if (info.enum?.length) {
166
338
  // In the order VS Code lists them: by label.
167
339
  info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
168
- doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "value", description: entry.description }) : undefined,
340
+ value: entry.value, doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "value", description: entry.description }) : undefined,
169
341
  })));
170
342
  } else if (info.types.includes("boolean")) {
171
- for (const value of ["true", "false"]) options.push(literalOption(value, value, { type: "boolean" }));
172
- } else if (variant && !info.freeText && (variant.type === "object" || variant.type === "array")) {
343
+ for (const value of ["true", "false"]) options.push(literalOption(value, value, { type: "boolean", value: value === "true" }));
344
+ } else if (variant && !info.freeText && (variant.type === "object" || variant.type === "array") && context.format !== "yaml") {
173
345
  // An object or a list goes in as a template: `{}` with its required members, or `[]`.
174
346
  const label = variant.type === "object" ? "{}" : "[]";
175
347
  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)) });
176
348
  }
349
+ if (site) options.push(...referenceOptions(site, context, catalogs, scope));
177
350
  for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
178
351
  return { options };
179
352
  }
180
353
 
354
+ // A word of a key, a value or an id: what the list stays valid for while the author types within the same token.
355
+ const YAML_KEY_TEXT = /^[A-Za-z0-9_.+-]*$/;
356
+ const YAML_VALUE_TEXT = /^[A-Za-z0-9_.:+-]*$/;
357
+
358
+ // In a YAML list the dash may start a mapping (a slide) or hold a scalar (an id, a line of text): the schema of the item tells.
359
+ function settleYamlContext(context, text) {
360
+ if (context.mode === "key" && context.seq) {
361
+ let info;
362
+ try { info = describeField(context.path, context.document); } catch { info = null; }
363
+ const primary = info?.known ? info.variants[info.primary] : undefined;
364
+ if (!info?.known || !primary) return null;
365
+ if (primary.type !== "object") {
366
+ // The item is a scalar, not the mapping the line reader pictured: the schema reads the document with an empty string there.
367
+ let parent = context.document;
368
+ for (const step of context.path.slice(0, -1)) parent = parent?.[step];
369
+ if (parent && typeof parent === "object") parent[context.path.at(-1)] = "";
370
+ return { ...context, mode: "item", key: undefined, itemStart: false };
371
+ }
372
+ }
373
+ return context;
374
+ }
375
+
181
376
  /**
182
- * The completion result at `position` for CodeMirror, or null. `catalogs` are the host's.
377
+ * The completion result at `position` for CodeMirror, or null. `catalogs` are the host's; `format` is "json" (the default), "yaml" or "md".
183
378
  */
184
- export function completionResult(state, position, { explicit, catalogs }) {
379
+ export function completionResult(state, position, { explicit, catalogs, format = "json" }) {
185
380
  const text = state.doc.toString();
186
- const context = contextAt(text, position, { explicit });
381
+ let context = contextFor(format, text, position, explicit);
187
382
  if (!context) return null;
188
- const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
383
+ const scope = referenceScope(context, text, format);
384
+ if (context.format === "md") return context.region === "variable" ? variableList(scope, context.token.from, position) : markdownResult(context, text, position, { explicit, catalogs, scope });
385
+ if (context.format === "yaml") {
386
+ context = settleYamlContext(context, text);
387
+ if (!context) return null;
388
+ }
389
+ const variables = context.mode !== "key" ? variableResult(context, text, position, scope) : null;
390
+ if (variables || context.multiline) return variables;
391
+ const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit, catalogs) : valueOptions(context, catalogs, scope);
189
392
  if (!options.length) return null;
393
+ return listResult(context, options, advanced, { position, explicit });
394
+ }
395
+
396
+ function listResult(context, options, advanced, { position, explicit }) {
190
397
  const { token } = context;
191
398
  const quoted = token.quoted;
399
+ const plainText = context.format !== "yaml" ? WORD_TEXT : context.mode === "value" || context.mode === "item" ? YAML_VALUE_TEXT : YAML_KEY_TEXT;
192
400
  // The result stays valid while the author types within the same token and the set of keys it shows does not change (advanced keys come and go
193
401
  // with the prefix).
194
402
  const sameSet = context.mode !== "key" ? () => true : (typed) => advancedShown(options.length ? infoChildren(context) : [], typed.toLowerCase(), explicit) === Boolean(advanced);
@@ -198,7 +406,20 @@ export function completionResult(state, position, { explicit, catalogs }) {
198
406
  to: Math.max(token.from, Math.min(position, token.to)),
199
407
  filter: true,
200
408
  options,
201
- validFor: (typed) => (quoted ? QUOTED_TEXT : WORD_TEXT).test(typed) && sameSet(typed),
409
+ validFor: (typed) => (quoted ? QUOTED_TEXT : plainText).test(typed) && sameSet(typed),
410
+ };
411
+ }
412
+
413
+ function markdownResult(context, text, position, { explicit, catalogs, scope }) {
414
+ const built = markdownOptions(context, { catalogs, explicit, text, position, scope, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
415
+ if (!built?.options.length) return null;
416
+ const { token } = context;
417
+ return {
418
+ from: token.from,
419
+ to: Math.max(token.from, Math.min(position, token.to)),
420
+ filter: true,
421
+ options: built.options,
422
+ validFor: (typed) => (token.quoted ? QUOTED_TEXT : built.validFor ?? YAML_VALUE_TEXT).test(typed),
202
423
  };
203
424
  }
204
425
  function infoChildren(context) {
@@ -208,8 +429,69 @@ function infoChildren(context) {
208
429
  // ---- accepting -------------------------------------------------------------------------------------------------------------------------
209
430
  const snippetRuns = new WeakMap();
210
431
 
211
- /** What accepting `completion` writes: `{ start, end, template, fields, chain, choices }` for the context of the caret. */
432
+ // A YAML value as text: a plain scalar when it reads back as the same value, else quoted (the `yaml` library knows the cases: `true`, `1`, `a: b`).
433
+ function yamlScalar(value) {
434
+ if (typeof value !== "string") return String(value);
435
+ try { return new Document(value).toString({ lineWidth: 0 }).trimEnd() || '""'; } catch { return JSON.stringify(value); }
436
+ }
437
+ const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/.test(key) ? key : yamlScalar(key));
438
+ const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
439
+
440
+ /** What accepting a key writes in YAML: `key: ` for a scalar, `key:` and a new line for a mapping or a list. */
441
+ function yamlKeyPlan(context, key, text, end, snippetKey) {
442
+ const written = yamlKey(key);
443
+ if (/^[ \t]*:/.test(text.slice(end))) return { template: escapeTemplate(written), fields: 0, chain: false };
444
+ const entry = snippetKey ? keySnippetsFor(context.path).find((candidate) => candidate.key === snippetKey) : undefined;
445
+ if (entry && !context.flow) {
446
+ const rendered = snippetYaml(entry, { dash: 0 });
447
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
448
+ }
449
+ let info;
450
+ try { info = describeField([...context.path, key], context.document); } catch { info = null; }
451
+ const kind = info?.known ? variantKind(info) : "string";
452
+ if (context.flow || kind === "choice" || kind === "boolean" || !["array", "object"].includes(kind)) {
453
+ return { template: `${escapeTemplate(written)}: `, fields: 0, chain: kind === "choice" || kind === "boolean" };
454
+ }
455
+ // A mapping or a list goes on the next line, one level in; after a dash the line's content starts two columns in, so the new lines do too.
456
+ const dash = " ".repeat(Math.max(0, context.column - context.indent));
457
+ const unit = " ";
458
+ const lead = `\n${dash}${unit}`;
459
+ if (kind === "array") return { template: `${escapeTemplate(written)}:${lead}- \${0}`, fields: 1, chain: false };
460
+ const variant = primaryVariant(info);
461
+ const required = variant?.requiredChildren?.length ? variant.requiredChildren : info.requiredChildren;
462
+ if (!required.length) return { template: `${escapeTemplate(written)}:${lead}\${0}`, fields: 1, chain: false };
463
+ const lines = required.map((name, index) => `${yamlKey(name)}: \${${index + 1}}`);
464
+ return { template: `${escapeTemplate(written)}:${lead}${lines.join(lead)}\${0}`, fields: required.length + 1, chain: false };
465
+ }
466
+
467
+ /** What accepting `completion` writes in YAML: `{ start, end, template, fields, chain, choices }`. */
468
+ function planYaml(context, completion, text, end) {
469
+ const kind = completion.opf?.kind;
470
+ if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end, completion.opf.snippet);
471
+ if (kind === "literal") {
472
+ const value = completion.opf.value;
473
+ const prefix = context.needsSpace ? " " : "";
474
+ if (context.token.quoted) {
475
+ const inner = typeof value === "string" ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : String(value);
476
+ return { template: escapeTemplate(inner), fields: 0, chain: false, inner: true };
477
+ }
478
+ return { template: `${prefix}${escapeTemplate(yamlScalar(value))}`, fields: 0, chain: false };
479
+ }
480
+ if (kind === "snippet") {
481
+ const mode = context.itemStart ? "item" : context.blank ? "value" : context.mode;
482
+ const entry = snippetsFor(mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
483
+ if (!entry) return null;
484
+ const dash = " ".repeat(Math.max(0, context.column - context.indent));
485
+ const rendered = snippetYaml(entry, { dash: context.seq ? dash.length : 0, blank: context.blank });
486
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
487
+ }
488
+ return null;
489
+ }
490
+
491
+ /** What accepting `completion` writes: `{ template, fields, chain, choices }` for the context of the caret. */
212
492
  export function planCompletion(context, completion, text, end) {
493
+ if (context.format === "yaml") return planYaml(context, completion, text, end);
494
+ if (context.format === "md") return markdownPlan(context, completion, text, end);
213
495
  const next = nextSignificant(text, end);
214
496
  const needsComma = next !== "" && !",}]".includes(next);
215
497
  const kind = completion.opf?.kind;
@@ -217,6 +499,11 @@ export function planCompletion(context, completion, text, end) {
217
499
  const key = completion.opf.key;
218
500
  const quoted = JSON.stringify(key);
219
501
  if (/^\s*:/.test(text.slice(end))) return { template: quoted, fields: 0, chain: false };
502
+ const entry = completion.opf.snippet ? keySnippetsFor(context.path).find((candidate) => candidate.key === completion.opf.snippet) : undefined;
503
+ if (entry) {
504
+ const rendered = snippetJson(entry, { comma: needsComma });
505
+ return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
506
+ }
220
507
  let info;
221
508
  try { info = describeField([...context.path, key], context.document); } catch { info = null; }
222
509
  const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
@@ -235,29 +522,45 @@ export function planCompletion(context, completion, text, end) {
235
522
  if (kind === "snippet") {
236
523
  const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
237
524
  if (!entry) return null;
238
- const rendered = snippetJson(entry, { comma: needsComma && context.mode !== "key" });
525
+ const rendered = snippetJson(entry, { comma: needsComma && !/^\s*:/.test(text.slice(end)) });
239
526
  return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
240
527
  }
241
528
  return null;
242
529
  }
243
530
 
244
531
  /** Accept `completion`: write its text over the token under the caret (quotes included) and, if it has tab stops, start the snippet. */
245
- export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say) {
532
+ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say, format = "json") {
246
533
  const { state } = view;
247
534
  const text = state.doc.toString();
248
535
  const main = state.selection.main;
249
- const context = contextAt(text, main.head, { explicit: true });
536
+ if (completion.opf?.kind === "variable") {
537
+ // `{{na|` becomes `{{name}}`: the name replaces what was typed after the braces and the closing braces are written unless they are there.
538
+ const to = text.startsWith("}}", fallbackTo) ? fallbackTo + 2 : fallbackTo;
539
+ const insert = `${completion.opf.name}}}`;
540
+ view.dispatch({ changes: { from: fallbackFrom, to, insert }, selection: { anchor: fallbackFrom + insert.length }, annotations: [pickedCompletion.of(completion), isolateHistory.of("full")], userEvent: "input.complete", scrollIntoView: true });
541
+ return;
542
+ }
543
+ // A value accepted right after the key that opened its list is one undo step with that key (R-CMP-14).
544
+ const group = takeUndoGroup(view);
545
+ const context = contextFor(format, text, main.head, true);
250
546
  const token = context?.token;
251
547
  const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
252
548
  const plan = context ? planCompletion(context, completion, text, end) : null;
549
+ // Accepting is one undo step: what is typed next starts another.
550
+ const finish = (chain) => {
551
+ view.dispatch({ annotations: isolateHistory.of("full") });
552
+ if (group) finishUndoGroup(view, group);
553
+ // A key whose value is one of a closed set opens that list at once.
554
+ if (chain) { beginUndoGroup(view, state.doc); startCompletion(view); }
555
+ };
253
556
  if (!plan) {
254
557
  view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
255
558
  return;
256
559
  }
257
- if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan); return; }
560
+ if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan, format); return; }
258
561
  // A string between quotes that are already there keeps them: only what is inside is replaced. In the stop of a snippet the caret stays inside
259
562
  // the quotes, so Tab goes on to the next stop; elsewhere it ends after the closing quote.
260
- if (completion.opf?.kind === "literal" && token?.quoted && token.closed && completion.opf.literal.startsWith('"')) {
563
+ if (format === "json" && completion.opf?.kind === "literal" && token?.quoted && token.closed && completion.opf.literal.startsWith('"')) {
261
564
  const inner = completion.opf.literal.slice(1, -1);
262
565
  const inSnippet = snippetRuns.has(view) && (hasNextSnippetField(state) || hasPrevSnippetField(state));
263
566
  const next = nextSignificant(text, token.end);
@@ -265,31 +568,40 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say)
265
568
  if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
266
569
  const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
267
570
  view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
571
+ finish(false);
268
572
  return;
269
573
  }
574
+ // YAML and Markdown values inside quotes: only the inside changes, and the caret ends after the closing quote.
575
+ if (plan.inner && token) {
576
+ const insert = plan.template.replace(/\\([{}\\])/g, "$1");
577
+ const caret = token.closed ? token.end + insert.length - (token.to - token.from) : token.from + insert.length;
578
+ view.dispatch({ changes: { from: token.from, to: token.to, insert }, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
579
+ finish(false);
580
+ return;
581
+ }
582
+ const from = plan.replaceFrom ?? start, to = plan.replaceTo ?? end;
270
583
  if (plan.fields > 0 || plan.template.includes("${")) {
271
- snippet(plan.template)(view, completion, start, end);
584
+ snippet(plan.template)(view, completion, from, to);
272
585
  if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
273
586
  else snippetRuns.delete(view);
274
- if (plan.choices) snippetStep(view, true, { start: true, say });
587
+ if (plan.choices) snippetStep(view, true, { start: true, say: say && ((message) => say(`Inserted ${completion.label}. ${message}`)) });
275
588
  } else {
276
589
  const insert = plan.template.replace(/\\([{}\\])/g, "$1");
277
- view.dispatch({ changes: { from: start, to: end, insert }, selection: { anchor: start + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
590
+ view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
278
591
  snippetRuns.delete(view);
279
592
  }
280
- // A key whose value is one of a closed set opens that list at once.
281
- if (plan.chain) startCompletion(view);
593
+ finish(plan.chain);
282
594
  }
283
595
 
284
596
  // With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
285
- function applyAtEveryCursor(view, completion, plan) {
597
+ function applyAtEveryCursor(view, completion, plan, format) {
286
598
  const { state } = view;
287
599
  const text = state.doc.toString();
288
600
  const plain = plainOf(plan.template);
289
601
  const changes = [], carets = [];
290
602
  let shift = 0;
291
603
  for (const range of [...state.selection.ranges].sort((a, b) => a.from - b.from)) {
292
- const context = contextAt(text, range.head, { explicit: true });
604
+ const context = contextFor(format, text, range.head, true);
293
605
  const token = context?.token ?? { start: range.from, end: range.to };
294
606
  changes.push({ from: token.start, to: token.end, insert: plain.text });
295
607
  carets.push(token.start + shift + plain.first);
@@ -327,21 +639,49 @@ export const describeOption = (completion) => completion.opfDoc ?? "";
327
639
  export { docText };
328
640
 
329
641
  // ---- hover -----------------------------------------------------------------------------------------------------------------------------
642
+ // The key or value under a position of a YAML text, from core's `pathAt` (which reads text with errors as far as it can): the same shape
643
+ // as source-json.js's `nodeAt`, with the whole document read for the schema (hover waits 300 ms, so the cost does not matter).
644
+ function yamlNodeAt(text, position) {
645
+ const found = pathAt(text, "yaml", position);
646
+ if (!found?.range || (found.part !== "key" && found.part !== "value")) return null;
647
+ const { offset, length } = found.range;
648
+ const source = text.slice(offset, offset + length);
649
+ const base = { path: found.path, from: offset, to: offset + length };
650
+ let document = {};
651
+ try { document = parseYamlDocument(text) ?? {}; } catch { document = {}; }
652
+ if (found.part === "key") return { ...base, kind: "key", value: source, document };
653
+ const collection = /^[[{]/.test(source);
654
+ return { ...base, kind: "value", value: collection ? undefined : scalarValue(source), nodeType: collection ? (source[0] === "[" ? "array" : "object") : "scalar", document };
655
+ }
656
+
330
657
  /**
331
658
  * What the schema and the catalogs say about the key or the value under `position`: `{ from, to, model }` (offsets of the token; `model` is a
332
- * source-docs.js card), or null over free text, punctuation and whitespace.
659
+ * source-docs.js card), or null over free text, punctuation and whitespace. `format` is "json", "yaml" or "md".
333
660
  */
334
- export function hoverModelAt(state, position, { catalogs }) {
335
- const found = nodeAt(state.doc.toString(), position);
336
- if (!found) return null;
661
+ export function hoverModelAt(state, position, { catalogs, format = "json" }) {
662
+ const text = state.doc.toString();
663
+ if (format === "md") return markdownHoverAt(text, position, { catalogs, describeFound });
664
+ let found, document;
665
+ if (format === "yaml") {
666
+ found = yamlNodeAt(text, position);
667
+ document = found?.document;
668
+ } else {
669
+ found = nodeAt(text, position);
670
+ document = found ? skeleton(found.root, found.path, false) : undefined;
671
+ }
672
+ return found ? describeFound(found, document, catalogs) : null;
673
+ }
674
+
675
+ /** The card for a found node `{ kind, path, from, to, value, nodeType }` of a document (shared by the three formats). */
676
+ export function describeFound(found, document, catalogs) {
337
677
  let info;
338
- try { info = describeField(found.path, skeleton(found.root, found.path, false)); } catch { return null; }
678
+ try { info = describeField(found.path, document); } catch { return null; }
339
679
  if (!info.known) return null;
340
680
  const name = String(found.path.at(-1));
341
681
  if (found.kind === "key") return { from: found.from, to: found.to, model: fieldDoc(info, name) };
342
682
  if (typeof found.value === "string" && info.catalogKind) {
343
683
  let record;
344
- try { record = listCatalogRecords(skeleton(found.root, found.path, false), info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
684
+ try { record = listCatalogRecords(document, info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
345
685
  if (!record) return { from: found.from, to: found.to, model: { title: found.value, type: `${KIND_LABELS[info.catalogKind]} id`, description: `No ${KIND_LABELS[info.catalogKind]} named ${found.value} is available here.` } };
346
686
  const regions = info.catalogKind === "layouts" ? layoutRegionSummary(record.record) : "";
347
687
  return { from: found.from, to: found.to, model: { title: record.id, type: KIND_LABELS[info.catalogKind], description: record.description, notes: [record.label !== record.id ? record.label : "", regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean) } };
@@ -0,0 +1,17 @@
1
+ // The "Details" of a problem (artifacts/source-editor/VALIDATOR-HINTS.md, section 1): what the rule checks, why it matters, what to do and a small
2
+ // example, from core's `explain(ruleId)`, behind a disclosure so the row itself stays one sentence and a hint. Pure data here; the pane draws it.
3
+ import { explain } from "@openpresentation/opf/source";
4
+
5
+ /**
6
+ * The details of a finding: `{ rule, summary, why, fix, example?, docs? }`, or null when core has nothing to say about the rule. A schema finding adds the
7
+ * keyword and the place in the schema it comes from.
8
+ */
9
+ export function detailsFor(finding) {
10
+ const ruleId = finding?.ruleId;
11
+ if (!ruleId) return null;
12
+ let found;
13
+ try { found = explain(ruleId); } catch { found = undefined; }
14
+ if (!found) return null;
15
+ const schema = finding.validation?.keyword ? `The schema keyword is ${finding.validation.keyword}${finding.definition ? `; it is defined at ${finding.definition}` : ""}.` : "";
16
+ return { rule: found.id, summary: found.summary, why: found.rationale, fix: found.fix, ...(found.example ? { example: found.example } : {}), docs: finding.definition ?? found.docs, ...(schema ? { schema } : {}) };
17
+ }