@openpresentation/opf-editor 0.26.4 → 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.
package/README.md CHANGED
@@ -120,7 +120,8 @@ Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below)
120
120
  - **Completions.** In JSON, YAML and Markdown the pane offers what the schema and the catalogs say belongs at the caret (`completions: false` keeps the field menu only). *Keys*: the properties of the object at the caret, the required ones first, then the common ones in a curated order (a slide: `title`, `subtitle`, `layout`, `items`, `text`, `notes`, ...), then the rest; the promoted region keys of a slide (`top+middle:left+center`) show only once you type three characters of one. Keys already in the object are not offered. Accepting one writes the whole pair: a string key gets `"key": "|"`, a number `"key": |`, an array `"key": [|]`, an object `"key": {|}`, and an object with required members (`organization`: `id`, `name`) writes them one per line, each a tab stop that `Tab` walks (the last `Tab` leaves the braces). A key with a closed set of values (an enum, a catalog id, a boolean) opens the list of its values at once. A comma is added when a property follows, and not before `}`. *Values*: enum values and booleans, and catalog ids (layouts, themes, color and font schemes, narratives, audiences, purposes, tones: the document's own records first, then the registered catalogs', layouts in the order of their intents, each with its description and regions in the documentation panel); after a colon, `{}` and `[]` templates. *Snippets*: `slide`, `slide-text`, `slide-image`, `slide-chart`, `slide-quote`, `slide-metric`, `slide-table`, `slide-code`, `slide-two-column` and `section` in `slides`, `reference` in `references`, `dataset` in `datasets` and `deck` in a blank document, with tab stops (the chart type stop opens the list of chart types). *Triggers*: a quote or a word at a key or value place, `:` and the space after it, and `Ctrl+Space` (`Cmd+I` on macOS) anywhere the schema has something to offer, also right after a finished string; free text (titles, notes, alt text) and numbers never open a list on their own. *Keys in the list*: `Tab` and `Enter` accept (at once, right after a keystroke too), `Shift+Tab` and `Shift+Enter` accept too (an accepted item always replaces the whole token), `Escape` closes it, `Left`, `Right` and `Backspace` inside the word keep it open and filter by the text before the caret, `Ctrl+Space` with the list open shows or hides the documentation panel. With several cursors the accepted item goes in at each of them (as plain text). The data is core's (`describeField` and `catalogKindAt` of `@openpresentation/opf/schema`; `getJsonCompletions` of `/json-options` remains for hosts that want the plain list). `mountJsonCodeEditor` takes the same `completions` option, off by default there.
121
121
  - **Completions in YAML.** The same keys, values, ids, templates and snippets, from the same schema, in block and flow style. A line-and-indent reader (`source-yaml.js`) places the caret without parsing, so it works while the text is half typed: after `key: ` the values of that field, at a key place the keys of the mapping (never one already there, above or below the caret), after a list dash the keys of the item and, in `slides` and `references`, the item snippets, inside `[ ]` and `{ }` on one line the items and keys of that collection. Accepting a key writes it the way YAML spells it: `key: ` for a scalar (and the list of its values for an enum or a boolean), `key:` and an indented new line for a mapping (required members one per line, each a tab stop), `key:` and `- ` for a list; enum values and ids are plain scalars, quoted when YAML would read them as another type (`"true"`, `"12"`). Comments, block scalars and free text (titles, notes, numbers) never open a list. `Enter` indents one level after `key:` and `key: |`, aligns under the key after `- key: value`, goes on with the next `- ` after a list item that is a value, and leaves the list on an empty `- `. `[`, `{` and quotes pair (not `(` and not an apostrophe inside a word).
122
122
  - **Completions in Markdown.** The front matter and the YAML in `opf-slide` and `opf-block` fences are the YAML above (the front matter never offers `slides`); `<!-- ` offers `slide:` and `block:`; inside a slide options comment the keys (`id`, `type`, `layout`, `section`, `tag`, `hidden`, `beat`) and their values (layout ids, slide types, `true` and `false`), inside a block options comment `id`, `type`, `as` (`bullets`, `video`) and `region` (the regions of the slide's layout, then the promoted ones); after a fence's three backticks the fence kinds (`chart`, `metric`, `timeline`, `opf-block`, `opf-slide` and the code languages), after `chart ` the chart types, after the info word the attributes (`alt=`, `name=`, `description=`, `title=`); the keys and values of a `metric` fence and the status marks (`[x]`, `[>]`, `[ ]`) at the start of a `timeline` event; `{` after `]` the attributes of an inline span (`bold`, `color=`, `size=`, `font=`, `lang=`); and at the start of a line `Note:`, `Notes:` and the snippets (a slide of each kind, with its `---` separator and a blank line above it when it follows another slide). Code fences, chart data, speaker notes and plain comments never open a list, and prose does not either: a word at the start of a line opens one from the third letter on, only while it is the start of a keyword or a snippet (`Ctrl+Space` opens it anywhere). Hover (and `Cmd/Ctrl+K Cmd/Ctrl+I`) explains a front matter key or value and a slide option as in YAML, and the dialect's own syntax: the `#` of a title and the `##` of a subtitle, a list marker, `Note:`, a quote's `>`, the word of a `chart`, `metric`, `timeline`, `opf-slide` or `opf-block` fence, `slide:` and `block:`, and a `---` line, each with the schema's description of the field it writes and how it is written. The vocabulary is core's (`scanMarkdown`, `SLIDE_OPTION_KEYS`, `FENCE_KINDS`, ... of `@openpresentation/opf/source`).
123
- - **Hover and problems.** Hovering a key shows the schema's description, its type and its allowed values; hovering a catalog id shows the record (name, description, regions, where it comes from); `Cmd/Ctrl+K Cmd/Ctrl+I` shows the same at the caret and `Escape` hides it. Every finding that has a place in the text is underlined (errors, warnings and notes, in every format that gives core a range; JSON gets one for each schema and reference finding too), with a tooltip that says what is wrong, what could come next and how many quick fixes there are. JSON syntax errors come from core's recovery analyzer (`analyzeJsonSyntax` of `@openpresentation/opf/source`): one problem per cause, at the cause (a key with no value is reported on its own line, not at the next token), up to five, 150 ms after the last key; a problem is held back while a list of keys is open next to it, so typing `"des` never flashes red. The panel rows say `Ln 3, Col 5`, the message, `Next: ...`, the rule and the quick fix buttons. `F8` and `Shift+F8` select the next and the previous problem (and say it; they wrap), `Cmd/Ctrl+Shift+M` moves focus to the panel (arrows, Home and End move between rows, Enter selects the range and returns to the code, Escape returns, `Cmd/Ctrl+.` applies the first fix of the row), `Cmd/Ctrl+.` in the code lists the fixes at the caret (the safest first; arrows, Enter, Escape). A fix is one undo step. Quick fixes in this version: the syntax repairs (insert or remove a comma, add `{ }` or remove a key with no value, close a bracket, straight quotes, ...), "Change to ..." for an enum or a reference that resolves nowhere, "Rename to ..." and "Remove property" for an unknown key, "Add `slides`" and the like for a missing required property, "Wrap in a list", "Add a slide", "Remove the first/second" for a duplicate key, and the patch and focus fixes core ships (alt text, readable color, language). `pane.getProblems()`, `nextProblem()`, `previousProblem()`, `showProblem(index)` and `triggerCompletion()` expose the same to a host.
123
+ - **References inside the document.** In all three formats the lists also offer the ids the document itself declares, read from the text being edited (a draft too): `asset:<id>` from `assets` (and `var:<id>` of image variables) where an image or other asset goes, dataset ids from `datasets` (`chart.data.dataset`, `table.dataset`), the column names of that dataset (or of the chart's own `data.columns`) in `fields`, `mapping`, `line`, `secondaryAxis` and `highlight.series`, `cite` ids from `references`, `beat` ids from the narrative the deck names (in a Markdown slide options comment too), `region` names from the slide's layout (then the promoted regions), `var:<id>` color variables where a color goes, and the variables of `variables` after `{{` in any text (`Tab` writes the name and the closing braces; speaker notes stay verbatim). Which field takes which kind is one table in `source-references.js`, checked against the schema by a unit test. A slide with a `layout` lists the keys that layout's regions call for first (after the required ones). A list of catalog records ends with the line "Records from catalog X are not loaded" when the document names a catalog group whose source the host did not register (its embedded records are still offered). Accepting a key of a closed set of values and then its value is one undo step. The deck's `design`, `organization` and `speaker` keys are accepted as snippets (a tab stop for each member; `design` opens the theme, color scheme and font scheme lists in turn). In YAML (and the YAML of Markdown) the list also works on the lines of a flow collection or a quoted scalar that runs over several lines.
124
+ - **Hover and problems.** Hovering a key shows the schema's description, its type and its allowed values; hovering a catalog id shows the record (name, description, regions, where it comes from); `Cmd/Ctrl+K Cmd/Ctrl+I` shows the same at the caret and `Escape` hides it. Every finding that has a place in the text is underlined (errors, warnings and notes, in every format that gives core a range; JSON gets one for each schema and reference finding too), with a tooltip that says what is wrong, what could come next and how many quick fixes there are. JSON syntax errors come from core's recovery analyzer (`analyzeJsonSyntax` of `@openpresentation/opf/source`): one problem per cause, at the cause (a key with no value is reported on its own line, not at the next token), up to five, 150 ms after the last key; a problem is held back while a list of keys is open next to it, so typing `"des` never flashes red. The panel rows say `Ln 3, Col 5`, the message, `Next: ...`, the rule and the quick fix buttons. `F8` and `Shift+F8` select the next and the previous problem (and say it; they wrap), `Cmd/Ctrl+Shift+M` moves focus to the panel (arrows, Home and End move between rows, Enter selects the range and returns to the code, Escape returns, `Cmd/Ctrl+.` applies the first fix of the row), `Cmd/Ctrl+.` in the code lists the fixes at the caret (the safest first; arrows, Enter, Escape). A fix is one undo step. Quick fixes in this version: the syntax repairs (insert or remove a comma, add `{ }` or remove a key with no value, close a bracket, straight quotes, ...), "Change to ..." for an enum or a reference that resolves nowhere, "Rename to ..." and "Remove property" for an unknown key, "Add `slides`" and the like for a missing required property, "Wrap in a list", "Add a slide", "Remove the first/second" for a duplicate key, and the patch and focus fixes core ships (alt text, readable color, language). Phase 3 writes every problem in plain words from core (`describeFinding`: the schema keywords, all 77 `opf/*` rules, the YAML and Markdown readers; each with a `Next:` line) and adds a Details disclosure per row (`explain(ruleId)`: what the rule checks, why, how to fix it, an example). YAML and Markdown syntax problems have fixes too (`Convert tabs to spaces`, `Insert closing quote`, ``Insert ]``, `Add a colon`, `Quote the value`, `Insert ---`, `Insert closing fence`, `Insert -->`, `Rename to layout`, `Insert column`, ``Add `numbering` ``, ...), and every format gets `Move into extensions`, `Convert to string`, `Wrap in a list` and `Add a slide` as comment-preserving text edits. `pane.getProblems()`, `nextProblem()`, `previousProblem()`, `showProblem(index)` and `triggerCompletion()` expose the same to a host.
124
125
  - **Keyboard.** The keys are VS Code's. `Tab` accepts a suggestion when the list is open; otherwise it inserts spaces up to the next tab stop (replacing a selection inside one line; a selection over several lines, or a whole line, is indented line by line) and `Shift+Tab` outdents the line. The indentation unit is the document's (JSON: read once when a document is opened, two spaces if nothing is indented, or a tab; YAML and Markdown: two spaces) and typing never changes it. `Alt+Up` and `Alt+Down` move the line, `Shift+Alt+Up` and `Shift+Alt+Down` copy it, `Cmd/Ctrl+Shift+K` deletes it. `Cmd/Ctrl+D` adds the next occurrence, `Cmd/Ctrl+Shift+L` all of them, `Cmd/Ctrl+Alt+Up` and `Down` a cursor above or below, `Alt+click` a cursor, `Shift+Alt+drag` a column; `Escape` goes back to one cursor. The field menu (a searchable dialog of the choices of one field) is on `Alt+Shift+O`; `Tab` and `Shift+Tab` stay inside it and `Escape` returns to the editor. A paste inserts the clipboard text as it is, however many lines it has.
125
126
  - **Keyboard in YAML and Markdown.** `Enter` follows the text (see the two bullets above); in Markdown it continues `- `, `* `, `+ ` and `1. ` items (the number counts up), continues `> `, ends the list on an empty marker (one level out when it is nested), and keeps the indentation inside a code fence. `Tab` on a Markdown list item nests it one level (two spaces) and `Shift+Tab` brings it out; `Backspace` right after a marker takes the marker away. `Cmd/Ctrl+/` toggles `# ` on YAML lines and in the front matter, and wraps a Markdown line in `<!-- -->` (a slide or block options comment says it already is one). Typing `*`, `_`, `~` or `"` over a Markdown selection wraps it (`*` twice makes bold). `Cmd/Ctrl+Alt+[` and `]` fold and unfold a YAML mapping and, in Markdown, the front matter, a fenced block and a slide (a `---` inside a fence is not the end of one); the fold gutter and the chords for folding everything come with the navigation round. The squiggle, `F8`, the quick fix menu (`Cmd/Ctrl+.`) and the `Ln n, Col m` of the problems panel are at the right place in YAML and Markdown too: core's `locate` finds a field's key or value in the text (and a Markdown slide's title, list item or `layout=` token); the fixes (`Change to center`, `Rename to design`, `Remove property`, ``Add `name` ``) are text edits that keep the comments.
126
127
  - **Format document.** `Shift+Alt+F` (and `Cmd/Ctrl+Shift+F`) and the Format button do the same in JSON, YAML and Markdown, and change whitespace only, as one undo step with the cursor kept on its token. JSON is laid out token by token with the document's indentation, also while it does not parse; YAML keeps its comments, blank lines, quote styles, number spellings and key order (the pane says so and does nothing when the YAML has errors); Markdown gets at most one blank line between blocks (code fences untouched), its front matter formatted as YAML, and one newline at the end. A format that would change the document is refused. The pane does not rewrite the text from the document (`toYaml`, `toMarkdown`) any more, so it no longer drops comments.
@@ -11,6 +11,7 @@ import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
11
11
  import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
12
12
  import {textInputMap} from './text-input.js';
13
13
  import {detectIndent,formatJsonSource,whitespaceEdits} from './json-format.js';
14
+ import { redoPair, undoPair } from "./source-undo-groups.js";
14
15
  import {a11yExtension,createAnnouncer} from './source-a11y.js';
15
16
  import { forEachDiagnostic } from "@codemirror/lint";
16
17
  import {createDiagnostics,editsTransaction} from './source-diagnostics.js';
@@ -338,8 +339,8 @@ function mountJsonCodeEditor(parent, options) {
338
339
  return loadOptions().then(() => (disposed || context.aborted ? null : completionResult(context)), (error) => { options.onError?.(error); return null; });
339
340
  }
340
341
  // Undo and Redo run CodeMirror's history first. When it has nothing left, the host's fallback may step further (the source pane routes a whole-document replace to the session); a fallback answers true when it did something.
341
- const undoCommand = (target) => undo(target) || Boolean(options.historyFallback?.undo?.());
342
- const redoCommand = (target) => redo(target) || Boolean(options.historyFallback?.redo?.());
342
+ const undoCommand = (target) => undoPair(target) || undo(target) || Boolean(options.historyFallback?.undo?.());
343
+ const redoCommand = (target) => redoPair(target) || redo(target) || Boolean(options.historyFallback?.redo?.());
343
344
  const historyBindings = historyKeymap.map((binding) => binding.run === undo ? { ...binding, run: undoCommand } : binding.run === redo ? { ...binding, run: redoCommand } : binding);
344
345
  // Accept with no minimum delay: the list is already valid for the typed text, so Tab or Enter right after a keystroke accepts (VS Code does).
345
346
  // Screen reader announcements and the problems drawn in the text (squiggles, F8, Cmd/Ctrl+.). The pane supplies the problems.
@@ -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
  }
@@ -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, 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,132 @@ 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: 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) {
187
328
  const info = describeField(context.path, context.document);
188
329
  const options = [];
189
330
  if (!info.known) return { options };
190
331
  const variant = primaryVariant(info);
191
- 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
+ }
192
337
  else if (info.enum?.length) {
193
338
  // In the order VS Code lists them: by label.
194
339
  info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
@@ -201,6 +346,7 @@ function valueOptions(context, catalogs) {
201
346
  const label = variant.type === "object" ? "{}" : "[]";
202
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)) });
203
348
  }
349
+ if (site) options.push(...referenceOptions(site, context, catalogs, scope));
204
350
  for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
205
351
  return { options };
206
352
  }
@@ -234,12 +380,15 @@ export function completionResult(state, position, { explicit, catalogs, format =
234
380
  const text = state.doc.toString();
235
381
  let context = contextFor(format, text, position, explicit);
236
382
  if (!context) return null;
237
- if (context.format === "md") return markdownResult(context, text, position, { explicit, 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 });
238
385
  if (context.format === "yaml") {
239
386
  context = settleYamlContext(context, text);
240
387
  if (!context) return null;
241
388
  }
242
- const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
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);
243
392
  if (!options.length) return null;
244
393
  return listResult(context, options, advanced, { position, explicit });
245
394
  }
@@ -261,8 +410,8 @@ function listResult(context, options, advanced, { position, explicit }) {
261
410
  };
262
411
  }
263
412
 
264
- function markdownResult(context, text, position, { explicit, catalogs }) {
265
- const built = markdownOptions(context, { catalogs, explicit, text, position, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
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 } });
266
415
  if (!built?.options.length) return null;
267
416
  const { token } = context;
268
417
  return {
@@ -289,9 +438,14 @@ const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/
289
438
  const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
290
439
 
291
440
  /** 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) {
441
+ function yamlKeyPlan(context, key, text, end, snippetKey) {
293
442
  const written = yamlKey(key);
294
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
+ }
295
449
  let info;
296
450
  try { info = describeField([...context.path, key], context.document); } catch { info = null; }
297
451
  const kind = info?.known ? variantKind(info) : "string";
@@ -313,7 +467,7 @@ function yamlKeyPlan(context, key, text, end) {
313
467
  /** What accepting `completion` writes in YAML: `{ start, end, template, fields, chain, choices }`. */
314
468
  function planYaml(context, completion, text, end) {
315
469
  const kind = completion.opf?.kind;
316
- if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end);
470
+ if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end, completion.opf.snippet);
317
471
  if (kind === "literal") {
318
472
  const value = completion.opf.value;
319
473
  const prefix = context.needsSpace ? " " : "";
@@ -345,6 +499,11 @@ export function planCompletion(context, completion, text, end) {
345
499
  const key = completion.opf.key;
346
500
  const quoted = JSON.stringify(key);
347
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
+ }
348
507
  let info;
349
508
  try { info = describeField([...context.path, key], context.document); } catch { info = null; }
350
509
  const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
@@ -363,7 +522,7 @@ export function planCompletion(context, completion, text, end) {
363
522
  if (kind === "snippet") {
364
523
  const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
365
524
  if (!entry) return null;
366
- const rendered = snippetJson(entry, { comma: needsComma && context.mode !== "key" });
525
+ const rendered = snippetJson(entry, { comma: needsComma && !/^\s*:/.test(text.slice(end)) });
367
526
  return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
368
527
  }
369
528
  return null;
@@ -374,10 +533,26 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
374
533
  const { state } = view;
375
534
  const text = state.doc.toString();
376
535
  const main = state.selection.main;
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);
377
545
  const context = contextFor(format, text, main.head, true);
378
546
  const token = context?.token;
379
547
  const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
380
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
+ };
381
556
  if (!plan) {
382
557
  view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
383
558
  return;
@@ -393,6 +568,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
393
568
  if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
394
569
  const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
395
570
  view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
571
+ finish(false);
396
572
  return;
397
573
  }
398
574
  // YAML and Markdown values inside quotes: only the inside changes, and the caret ends after the closing quote.
@@ -400,6 +576,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
400
576
  const insert = plan.template.replace(/\\([{}\\])/g, "$1");
401
577
  const caret = token.closed ? token.end + insert.length - (token.to - token.from) : token.from + insert.length;
402
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);
403
580
  return;
404
581
  }
405
582
  const from = plan.replaceFrom ?? start, to = plan.replaceTo ?? end;
@@ -407,16 +584,13 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
407
584
  snippet(plan.template)(view, completion, from, to);
408
585
  if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
409
586
  else snippetRuns.delete(view);
410
- 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}`)) });
411
588
  } else {
412
589
  const insert = plan.template.replace(/\\([{}\\])/g, "$1");
413
590
  view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
414
591
  snippetRuns.delete(view);
415
592
  }
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);
593
+ finish(plan.chain);
420
594
  }
421
595
 
422
596
  // With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
@@ -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
+ }