@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.
- package/README.md +8 -3
- package/dist/json-editor.d.ts +7 -2
- package/dist/json-editor.js +54 -26
- package/dist/source-a11y.js +18 -1
- package/dist/source-completions.js +378 -38
- package/dist/source-details.js +17 -0
- package/dist/source-editing.js +265 -0
- package/dist/source-fixes-syntax.js +204 -0
- package/dist/source-fixes-text.js +160 -0
- package/dist/source-fixes.js +25 -1
- package/dist/source-lines.js +4 -0
- package/dist/source-markdown.js +546 -0
- package/dist/source-messages.js +21 -205
- package/dist/source-pane.d.ts +10 -2
- package/dist/source-pane.js +127 -17
- package/dist/source-problems.js +9 -4
- package/dist/source-ranges.js +8 -5
- package/dist/source-references.js +227 -0
- package/dist/source-snippets.js +159 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-yaml.js +623 -0
- package/dist/styles.css +49 -0
- package/package.json +10 -7
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
+
let context = contextFor(format, text, position, explicit);
|
|
187
382
|
if (!context) return null;
|
|
188
|
-
const
|
|
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 :
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
336
|
-
if (
|
|
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,
|
|
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(
|
|
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
|
+
}
|