@openpresentation/opf-editor 0.26.0 → 0.26.2

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.
@@ -7,9 +7,20 @@ export function normalizeFinding(input, kind) {
7
7
  const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
8
8
  const line = finding.line ?? finding.location?.line;
9
9
  if (Number.isInteger(line) && line > 0) out.line = line;
10
+ const column = finding.column ?? finding.location?.column;
11
+ if (Number.isInteger(column) && column > 0) out.column = column;
10
12
  const own = finding.kind ?? kind;
11
13
  if (own) out.kind = own;
12
14
  if (finding.ruleId) out.ruleId = finding.ruleId;
15
+ // What core knows about a finding beyond the sentence: the help, the source range, the schema keyword and parameters, the suggested
16
+ // values and the fixes. The pane writes the problem text and its quick fixes from them.
17
+ for (const key of ["help", "category", "location", "validation", "suggestions", "fixes", "lookup", "definition", "slide", "slideId", "measured", "next"]) {
18
+ if (finding[key] !== undefined && finding[key] !== null) out[key] = finding[key];
19
+ }
20
+ // A problem with a range of its own (the JSON syntax analyzer) and the quick fixes that go with it.
21
+ if (finding.range && Number.isInteger(finding.range.from) && Number.isInteger(finding.range.to)) out.range = finding.range;
22
+ if (finding.editFixes) out.editFixes = finding.editFixes;
23
+ if (Array.isArray(finding.related)) out.related = finding.related;
13
24
  return out;
14
25
  }
15
26
 
@@ -0,0 +1,106 @@
1
+ // Quick fixes for the findings of the source pane (artifacts/source-editor/VALIDATOR-HINTS.md, section 12): the text edits behind "Change to center",
2
+ // "Rename to slides", "Remove property", "Add `slides`" and the like, for a JSON text that parses. Pure functions over the source text:
3
+ // each returns `{ id, title, safe, edits: [{ from, to, insert }], caret? }`, source offsets in, so the editor applies it as one undoable change.
4
+ import { findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5
+ import { replacementValues, segmentsOf } from "./source-messages.js";
6
+
7
+ const toPath = (segments) => segments.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment));
8
+
9
+ /** jsonc-parser's edits as `{ from, to, insert }`. */
10
+ const asEdits = (edits) => edits.map((edit) => ({ from: edit.offset, to: edit.offset + edit.length, insert: edit.content }));
11
+
12
+ function formatting(unit, text) {
13
+ return { insertSpaces: unit !== "\t", tabSize: unit === "\t" ? 4 : unit.length, eol: /\r\n/.test(text) ? "\r\n" : "\n" };
14
+ }
15
+ /** The text after `edits` (source offsets, not overlapping; edits at one offset keep their order). */
16
+ export function applySourceEdits(text, edits) {
17
+ const ordered = edits.map((edit, index) => ({ edit, index })).sort((a, b) => a.edit.from - b.edit.from || a.index - b.index);
18
+ let next = text;
19
+ for (const { edit } of ordered.reverse()) next = next.slice(0, edit.from) + edit.insert + next.slice(edit.to);
20
+ return next;
21
+ }
22
+
23
+ /** The node at `segments`: `{ node, parent }` or null. */
24
+ function nodeAt(text, segments) {
25
+ const root = parseTree(text, [], { allowTrailingComma: true });
26
+ if (!root) return null;
27
+ const node = findNodeAtLocation(root, toPath(segments));
28
+ return node ? { root, node } : null;
29
+ }
30
+ /** The property node (`"key": value`) whose value sits at `segments`, and its key node. */
31
+ function propertyAt(text, segments) {
32
+ const found = nodeAt(text, segments);
33
+ const property = found?.node.parent;
34
+ if (!property || property.type !== "property") return null;
35
+ return { property, key: property.children[0], value: property.children[1] };
36
+ }
37
+
38
+ /** Replace the value at `segments` with `value` (any JSON value). */
39
+ export function changeValue(text, segments, value) {
40
+ const found = nodeAt(text, segments);
41
+ if (!found) return null;
42
+ return [{ from: found.node.offset, to: found.node.offset + found.node.length, insert: JSON.stringify(value) }];
43
+ }
44
+ /** Rename the property whose value is at `segments`. */
45
+ export function renameKey(text, segments, name) {
46
+ const found = propertyAt(text, segments);
47
+ if (!found) return null;
48
+ return [{ from: found.key.offset, to: found.key.offset + found.key.length, insert: JSON.stringify(name) }];
49
+ }
50
+ /** Remove the property (or list item) at `segments`, with the comma that belonged to it. */
51
+ export function removeAt(text, segments, unit = " ") {
52
+ const edits = modify(text, toPath(segments), undefined, { formattingOptions: formatting(unit, text) });
53
+ return edits.length ? asEdits(edits) : null;
54
+ }
55
+ /** Add `key: value` to the object at `segments`, after its last property. */
56
+ export function addProperty(text, segments, key, value, unit = " ") {
57
+ const edits = modify(text, [...toPath(segments), key], value, { formattingOptions: formatting(unit, text) });
58
+ return edits.length ? asEdits(edits) : null;
59
+ }
60
+ /** Add `value` at the end of the list at `segments`. */
61
+ export function addItem(text, segments, value, unit = " ") {
62
+ const found = nodeAt(text, segments);
63
+ const length = found?.node.type === "array" ? (found.node.children?.length ?? 0) : 0;
64
+ const edits = modify(text, [...toPath(segments), length], value, { formattingOptions: formatting(unit, text), isArrayInsertion: true });
65
+ return edits.length ? asEdits(edits) : null;
66
+ }
67
+ /** Wrap the value at `segments` in a one-item list. */
68
+ export function wrapInList(text, segments) {
69
+ const found = nodeAt(text, segments);
70
+ if (!found) return null;
71
+ const { offset, length } = found.node;
72
+ return [{ from: offset, to: offset, insert: "[" }, { from: offset + length, to: offset + length, insert: "]" }];
73
+ }
74
+
75
+ /**
76
+ * The quick fixes of a schema or reference finding in a JSON text that parses. `context` is the one source-messages.js takes plus
77
+ * `{ unit, starter(segments, property) }` (the value `Add` inserts).
78
+ */
79
+ export function fixesForFinding(finding, text, context = {}) {
80
+ if (!finding.path && finding.validation?.keyword !== "required") return [];
81
+ const segments = segmentsOf(finding.path);
82
+ const keyword = finding.validation?.keyword;
83
+ const unit = context.unit ?? " ";
84
+ const fixes = [];
85
+ const push = (id, title, edits, extra = {}) => { if (edits?.length) fixes.push({ id, title, edits, safe: false, ...extra }); };
86
+ if (keyword === "enum" || finding.ruleId === "opf/unresolved-reference") {
87
+ for (const value of replacementValues(finding, context)) push(`change-to:${value}`, `Change to ${value}`, changeValue(text, segments, value));
88
+ } else if (keyword === "additionalProperties") {
89
+ const name = finding.validation.params?.additionalProperty ?? segments.at(-1);
90
+ for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, renameKey(text, segments, value));
91
+ push("remove-property", "Remove property", removeAt(text, segments, unit));
92
+ void name;
93
+ } else if (keyword === "required") {
94
+ const property = finding.validation.params?.missingProperty;
95
+ const starter = context.starter?.(segments, property);
96
+ if (property && starter !== undefined) push(`add:${property}`, `Add \`${property}\``, addProperty(text, segments, property, starter, unit));
97
+ } else if (keyword === "type" && finding.validation.params?.type === "array") {
98
+ push("wrap-in-list", "Wrap in a list", wrapInList(text, segments));
99
+ } else if (keyword === "minItems" && finding.path === "/slides") {
100
+ push("add-slide", "Add a slide", addItem(text, segments, { title: "Slide title" }, unit));
101
+ } else if (finding.ruleId === "opf/duplicate-key") {
102
+ // The pane marks the second key; removing either one leaves the other.
103
+ push("remove-duplicate", "Remove the second", removeAt(text, segments, unit));
104
+ }
105
+ return fixes;
106
+ }
@@ -0,0 +1,92 @@
1
+ // Format document for the source pane (Shift+Alt+F, Cmd/Ctrl+Shift+F and the Format button): whitespace only, in every format, and nothing the
2
+ // author wrote is dropped. JSON is laid out token by token (so a draft that does not parse is laid out too), YAML goes through the `yaml`
3
+ // library's document round trip (comments, anchors, key order and scalar styles stay), and Markdown gets one blank line between blocks with its
4
+ // front matter formatted as YAML. Plain functions, no DOM: `formatSource(format, text, { unit })` returns `{ text }` or `{ error }`.
5
+ import { parseDocument } from "yaml";
6
+ import { formatJsonSource } from "./json-format.js";
7
+
8
+ const lineEnding = (source) => /\r\n|\r|\n/.exec(source)?.[0] ?? "\n";
9
+
10
+ /** YAML text laid out with a 2-space indent, long lines kept whole; its comments, blank lines, scalar styles and key order stay as written. */
11
+ export function formatYamlSource(source) {
12
+ if (source.trim() === "") return { text: source };
13
+ const bom = source.startsWith("\uFEFF") ? "\uFEFF" : "";
14
+ const eol = lineEnding(source);
15
+ const document = parseDocument(bom ? source.slice(1) : source, { prettyErrors: false, customTags: keepNumberSpelling });
16
+ if (document.errors.length) return { error: "The YAML has errors, so it cannot be formatted. Fix them first." };
17
+ // Only comments: there is no document to write, and writing one would add a `null`.
18
+ if (document.contents === null || document.contents === undefined) return { text: source };
19
+ let text = document.toString({ indent: 2, lineWidth: 0, flowCollectionPadding: false });
20
+ // The library ends every document with a newline; keep the author's choice.
21
+ if (!/[\r\n]\s*$/.test(source) && text.endsWith("\n")) text = text.slice(0, -1);
22
+ if (eol !== "\n") text = text.replaceAll("\n", eol);
23
+ return { text: bom + text };
24
+ }
25
+
26
+ // The library writes a number from its value (1e3 becomes 1e+3, 0x1F 0x1f, .5 0.5, +1 1); the author's spelling is kept instead.
27
+ const NUMBER_TAGS = new Set(["tag:yaml.org,2002:int", "tag:yaml.org,2002:float"]);
28
+ const keepNumberSpelling = (tags) => tags.map((tag) => (
29
+ typeof tag === "object" && tag && typeof tag.stringify === "function" && NUMBER_TAGS.has(tag.tag)
30
+ ? { ...tag, stringify: (item, context, onComment, onChompKeep) => (typeof item.source === "string" && item.source !== "" ? item.source : tag.stringify(item, context, onComment, onChompKeep)) }
31
+ : tag
32
+ ));
33
+
34
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
35
+ const INDENTED = /^(?: {4}|\t)/;
36
+
37
+ /**
38
+ * Markdown laid out with at most one blank line between blocks (code fences and runs inside an indented block are left as written), the front
39
+ * matter formatted as YAML, one line ending (the first one found) and one newline at the end.
40
+ */
41
+ export function formatMarkdownSource(source) {
42
+ const eol = lineEnding(source);
43
+ const lines = source.split(/\r\n|\r|\n/);
44
+ let body = 0;
45
+ const out = [];
46
+ if (lines[0] === "---") {
47
+ const close = lines.findIndex((line, index) => index > 0 && (line === "---" || line === "..."));
48
+ if (close > 0) {
49
+ const front = formatYamlSource(lines.slice(1, close).join("\n"));
50
+ out.push("---", ...(front.error || front.text === "" ? lines.slice(1, close) : front.text.replace(/\n$/, "").split("\n")), lines[close]);
51
+ body = close + 1;
52
+ }
53
+ }
54
+ let fence = null, blanks = 0, lastIndented = false;
55
+ for (let index = body; index < lines.length; index++) {
56
+ const line = lines[index];
57
+ if (fence) {
58
+ out.push(line);
59
+ const marker = FENCE.exec(line)?.[1];
60
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) fence = null;
61
+ continue;
62
+ }
63
+ if (line.trim() === "") { blanks++; continue; }
64
+ if (blanks) {
65
+ // A blank run inside an indented block (code) is content; elsewhere it is one blank line.
66
+ const keep = lastIndented && INDENTED.test(line) ? blanks : 1;
67
+ for (let count = 0; count < keep; count++) out.push("");
68
+ }
69
+ blanks = 0;
70
+ out.push(line);
71
+ lastIndented = INDENTED.test(line);
72
+ const opened = FENCE.exec(line)?.[1];
73
+ if (opened) fence = opened;
74
+ }
75
+ // A document that ends inside a fence keeps its last lines as written; otherwise it ends with one newline.
76
+ while (!fence && out.length && out[out.length - 1].trim() === "") out.pop();
77
+ return { text: out.length ? out.join(eol) + (fence ? "" : eol) : "" };
78
+ }
79
+
80
+ /**
81
+ * Format `text` as `format` ("json", "yaml" or "md"). `unit` is the editor's indentation unit for JSON (a tab or spaces). Returns `{ text }`
82
+ * (possibly the same text) or `{ error }` with a sentence for the author.
83
+ */
84
+ export function formatSource(format, text, { unit } = {}) {
85
+ try {
86
+ if (format === "yaml") return formatYamlSource(text);
87
+ if (format === "md") return formatMarkdownSource(text);
88
+ return { text: formatJsonSource(text, unit) };
89
+ } catch (error) {
90
+ return { error: `Formatting failed: ${error instanceof Error ? error.message : String(error)}` };
91
+ }
92
+ }
@@ -0,0 +1,139 @@
1
+ // The JSON text adapter of the source editor (artifacts/source-editor/SPEC.md, section 3.1): what a position in a JSON draft means. Everything the
2
+ // completion list, the hover and the hints know about the caret comes from `contextAt`: the token being typed, whether it sits where a key, a value
3
+ // or a list item belongs, the path of the node in the document, the keys already there, and what the text around needs when something is inserted
4
+ // (a colon, a comma). It reads drafts that do not parse: the token under the caret is swapped for a placeholder string and the rest is read with
5
+ // the tolerant parser.
6
+ import { findNodeAtOffset, getNodePath, getNodeValue, parseTree } from "jsonc-parser";
7
+
8
+ const PLACEHOLDER = "__opf_completion__";
9
+ const WORD = /[A-Za-z0-9_.-]/;
10
+
11
+ /**
12
+ * The token being typed at `position`: inside a string (a quote opened earlier on this line), or a bare word, or nothing yet where a value or key
13
+ * would go. JSON strings never span lines. Returns `{ quoted, closed, start, end, from, to }` (`start..end` is the whole token with its quotes,
14
+ * `from..to` the text between them), or null when no token can be completed here. `explicit` (Ctrl+Space) also takes the empty place after `{`,
15
+ * `,`, `:` and `[`, the string just closed, and a blank document; typing `:` or the space after it takes the empty place after the colon.
16
+ */
17
+ export function tokenAt(text, position, explicit) {
18
+ const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
19
+ let open = -1, last = null;
20
+ for (let index = lineStart; index < position; index++) {
21
+ if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') { last = { start: open, end: index }; open = -1; } }
22
+ else if (text[index] === '"') open = index;
23
+ }
24
+ // Asked for explicitly (Ctrl+Space) right after a closing quote: the string just ended is the token, as in VS Code.
25
+ if (open < 0 && explicit && last?.end === position - 1) return { quoted: true, closed: true, start: last.start, end: position, from: last.start + 1, to: position - 1 };
26
+ if (open >= 0) {
27
+ let close = -1;
28
+ for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
29
+ if (text[index] === "\\") index++;
30
+ else if (text[index] === '"') { close = index; break; }
31
+ }
32
+ return { quoted: true, closed: close >= 0, start: open, end: close >= 0 ? close + 1 : position, from: open + 1, to: close >= 0 ? close : position };
33
+ }
34
+ let start = position, end = position;
35
+ while (start > lineStart && WORD.test(text[start - 1])) start--;
36
+ while (end < text.length && WORD.test(text[end])) end++;
37
+ if (start === position && !explicit && !/:[ \t]?$/.test(text.slice(lineStart, position))) return null;
38
+ let before = start - 1;
39
+ while (before >= lineStart && /\s/.test(text[before])) before--;
40
+ // A bare word starts a key or a value only after `{`, `,`, `:` or `[` (or at the start of an empty line).
41
+ const previous = before >= lineStart ? text[before] : (text.slice(0, lineStart).trimEnd().at(-1) ?? "");
42
+ const blank = previous === "" && text.slice(0, start).trim() === "" && text.slice(end).trim() === "";
43
+ if (!["{", ",", ":", "["].includes(previous) && !blank) return null;
44
+ return { quoted: false, closed: false, start, end, from: start, to: end };
45
+ }
46
+
47
+ /** The first non-blank character after `offset` (skipping whitespace and line breaks), or "". */
48
+ export function nextSignificant(text, offset) {
49
+ for (let index = offset; index < text.length; index++) if (!/\s/.test(text[index])) return text[index];
50
+ return "";
51
+ }
52
+
53
+ /**
54
+ * A copy of the document with only what choosing the schema form along `path` needs: the nodes on the path in full, every other value cut to its
55
+ * type (an object to `{}`, a list to an empty list as long as it is), so the cost does not grow with the deck. `catalogs` is kept in full because
56
+ * embedded records are offered as values.
57
+ */
58
+ export function skeleton(root, path, skip) {
59
+ const shallow = (node) => {
60
+ if (node.type === "object") return {};
61
+ if (node.type === "array") return new Array((node.children ?? []).length);
62
+ return node.value;
63
+ };
64
+ const build = (node, rest, skip) => {
65
+ if (node.type === "object") {
66
+ const out = {};
67
+ for (const property of node.children ?? []) {
68
+ const key = property.children?.[0]?.value, value = property.children?.[1];
69
+ if (typeof key !== "string" || !value) continue;
70
+ if (rest.length && String(key) === String(rest[0])) {
71
+ if (rest.length === 1 && skip) continue;
72
+ out[key] = build(value, rest.slice(1), skip);
73
+ } else out[key] = key === "catalogs" && rest.length === 0 ? getNodeValue(value) : shallow(value);
74
+ }
75
+ return out;
76
+ }
77
+ if (node.type === "array") {
78
+ const out = new Array((node.children ?? []).length);
79
+ (node.children ?? []).forEach((child, index) => {
80
+ if (rest.length && String(index) === String(rest[0])) { if (!(rest.length === 1 && skip)) out[index] = build(child, rest.slice(1), skip); }
81
+ else out[index] = shallow(child);
82
+ });
83
+ return out;
84
+ }
85
+ return node.value;
86
+ };
87
+ const document = build(root, path, skip);
88
+ // The embedded catalogs, whole, whatever the path (their records are offered as values).
89
+ const catalogs = (root.children ?? []).find((property) => property.children?.[0]?.value === "catalogs")?.children?.[1];
90
+ if (catalogs && root.type === "object" && path[0] !== "catalogs") document.catalogs = getNodeValue(catalogs);
91
+ return document;
92
+ }
93
+
94
+ /**
95
+ * What the draft means at `position`, or null: `{ mode, token, path, key, existing, siblingsAfter, document, node }`.
96
+ * `mode` is "key" (a property name goes here; `path` is the object), "value" (the value of the property at `path`; `key` is its name) or "item" (an
97
+ * entry of the list at `path`, which ends with the entry's index). `existing` holds the other property names of the object. `document` is the
98
+ * skeleton that `describeField` reads. `node` is the placeholder node of the draft.
99
+ */
100
+ export function contextAt(text, position, { explicit = false } = {}) {
101
+ const token = tokenAt(text, position, explicit);
102
+ if (!token) return null;
103
+ const draft = text.slice(0, token.start) + JSON.stringify(PLACEHOLDER) + text.slice(token.end);
104
+ const root = parseTree(draft, [], { allowTrailingComma: true });
105
+ if (!root) return null;
106
+ const node = findNodeAtOffset(root, token.start + 1, true);
107
+ if (node?.type !== "string" || node.value !== PLACEHOLDER) return null;
108
+ const parent = node.parent;
109
+ if (!parent) return { mode: "value", token, path: [], key: undefined, existing: new Set(), document: {}, node, root };
110
+ if (parent.type === "property" && parent.children?.[0] === node) {
111
+ const object = parent.parent;
112
+ if (!object || object.type !== "object") return null;
113
+ const path = getNodePath(object);
114
+ const existing = new Set((object.children ?? []).filter((property) => property !== parent).map((property) => property.children?.[0]?.value));
115
+ return { mode: "key", token, path, key: undefined, existing, document: skeleton(root, path, false), node, root, object };
116
+ }
117
+ const path = getNodePath(node);
118
+ if (parent.type === "property") return { mode: "value", token, path, key: String(path.at(-1)), existing: new Set(), document: skeleton(root, path, true), node, root };
119
+ if (parent.type === "array") return { mode: "item", token, path, key: undefined, existing: new Set(), document: skeleton(root, path, true), node, root };
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * The node under `position` in a JSON text for hover: `{ kind: "key" | "value", path, from, to, value }` (offsets of the key or value token), or
125
+ * null. Works on text that does not parse.
126
+ */
127
+ export function nodeAt(text, position) {
128
+ const root = parseTree(text, [], { allowTrailingComma: true, disallowComments: false });
129
+ if (!root) return null;
130
+ const node = findNodeAtOffset(root, position, true);
131
+ if (!node) return null;
132
+ const parent = node.parent;
133
+ if (node.type === "string" && parent?.type === "property" && parent.children?.[0] === node) {
134
+ return { kind: "key", path: [...getNodePath(parent), node.value], from: node.offset, to: node.offset + node.length, value: node.value, root, node };
135
+ }
136
+ if (node.type === "property") return null;
137
+ if (!["string", "number", "boolean", "object", "array", "null"].includes(node.type)) return null;
138
+ return { kind: "value", path: getNodePath(node), from: node.offset, to: node.offset + node.length, value: node.type === "object" || node.type === "array" ? undefined : node.value, nodeType: node.type, root, node };
139
+ }
@@ -0,0 +1,231 @@
1
+ // Problem text for the source pane (artifacts/source-editor/VALIDATOR-HINTS.md): what is wrong, where, and what could come next, written from what core's
2
+ // findings already carry (the schema keyword and its parameters, the suggestions, the help). Pure functions, no DOM: `describeFinding(finding, context)`
3
+ // returns `{ message, next }`; the quick fix edits that go with a message are in source-fixes.js.
4
+
5
+ /**
6
+ * `context`: `{ valueAt(segments) }` reads a value of the document being checked (the actual value makes "but is `middle`" possible),
7
+ * `{ schemaAt(segments) }` the resolved schema of a field, `{ catalogKind(segments) }` the catalog kind a field references,
8
+ * `{ catalogCount(kind) }` how many records of a kind are available, `{ keysOf(segments) }` the property names the schema allows in an object.
9
+ */
10
+
11
+ const WORDS = { object: "an object", array: "a list", string: "text", number: "a number", integer: "a whole number", boolean: "true or false", null: "null" };
12
+ const KIND_WORDS = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone", languages: "language" };
13
+ const KIND_EXAMPLES = { layouts: "`pillars` or `acme:hero`", themes: "`minimal` or `acme:bold`", colorSchemes: "`boost` or `acme:night`", fontSchemes: "`aptos` or `acme:brand`", narratives: "`classic-story`", audiences: "`board`", purposes: "`inform`", tones: "`formal`" };
14
+ const ALIASES = { middle: "center", centre: "center", centered: "center", centred: "center", justify: "left", horizontal: "horizontal", col: "column", cols: "column", pic: "image", picture: "image", photo: "image", paragraph: "text", bullets: "list", bullet: "list" };
15
+
16
+ /** The segments of a JSON Pointer ("/slides/0/title"); "" is the document. */
17
+ export function segmentsOf(path) {
18
+ if (Array.isArray(path)) return path.map(String);
19
+ if (typeof path !== "string" || path === "" || path === "/") return [];
20
+ return path.replace(/^\//, "").split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
21
+ }
22
+
23
+ const code = (value) => `\`${value}\``;
24
+ const isIndex = (segment) => /^\d+$/.test(String(segment));
25
+
26
+ /** What a field is called in a sentence: its name, or "<name> entry" for a list item. */
27
+ export function fieldLabel(segments) {
28
+ if (!segments.length) return "the deck";
29
+ const last = segments[segments.length - 1];
30
+ if (isIndex(last)) {
31
+ const owner = [...segments].reverse().find((segment) => !isIndex(segment));
32
+ return owner === "slides" ? `slide ${Number(last) + 1}` : owner ? `${owner} entry ${Number(last) + 1}` : `entry ${Number(last) + 1}`;
33
+ }
34
+ return code(last);
35
+ }
36
+ /** What an object is called in a sentence ("the deck", "a slide", "`design`"). */
37
+ function containerName(segments) {
38
+ if (!segments.length) return "the deck";
39
+ if (segments.length === 2 && segments[0] === "slides" && isIndex(segments[1])) return "a slide";
40
+ const last = segments[segments.length - 1];
41
+ return isIndex(last) ? fieldLabel(segments) : code(last);
42
+ }
43
+
44
+ /** What a value is, with its text: "text (`Hello`)", "a number (`42`)", "a list". */
45
+ function describeKind(value) {
46
+ if (typeof value === "string") return value === "" ? "empty text" : `text (${describeValue(value)})`;
47
+ return describeValue(value);
48
+ }
49
+
50
+ function typeWords(type) {
51
+ const list = (Array.isArray(type) ? type : [type]).filter(Boolean).map((entry) => WORDS[entry] ?? String(entry));
52
+ return [...new Set(list)].join(" or ") || "a different kind of value";
53
+ }
54
+ /** A value in a sentence: "`Hello`", "a number (`42`)", "a list". */
55
+ export function describeValue(value) {
56
+ if (value === undefined) return "missing";
57
+ if (value === null) return "null";
58
+ if (Array.isArray(value)) return value.length ? `a list of ${value.length}` : "an empty list";
59
+ if (typeof value === "object") return "an object";
60
+ if (typeof value === "string") return value === "" ? "empty" : `${code(value.length > 30 ? `${value.slice(0, 29)}…` : value)}`;
61
+ return `${typeof value === "number" ? "a number" : "true or false"} (${code(String(value))})`;
62
+ }
63
+
64
+ /** Edit distance between two short strings (Damerau-free; enough for "did you mean"). */
65
+ export function distance(a, b) {
66
+ const x = a.toLowerCase(), y = b.toLowerCase();
67
+ if (x === y) return 0;
68
+ const row = Array.from({ length: y.length + 1 }, (_, index) => index);
69
+ for (let i = 1; i <= x.length; i++) {
70
+ let previous = row[0];
71
+ row[0] = i;
72
+ for (let j = 1; j <= y.length; j++) {
73
+ const keep = row[j];
74
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (x[i - 1] === y[j - 1] ? 0 : 1));
75
+ previous = keep;
76
+ }
77
+ }
78
+ return row[y.length];
79
+ }
80
+ /** The candidates ordered by closeness to `value`; only those within an edit distance of 2 (or a known alias) come first. */
81
+ export function nearest(value, candidates, limit = 5) {
82
+ const text = String(value ?? "");
83
+ const alias = ALIASES[text.toLowerCase()];
84
+ const ranked = candidates.map((candidate) => ({ candidate, score: String(candidate).toLowerCase() === alias ? 0 : distance(text, String(candidate)) }));
85
+ ranked.sort((a, b) => a.score - b.score);
86
+ const close = ranked.filter((entry) => entry.score <= Math.max(2, Math.floor(text.length / 4))).map((entry) => entry.candidate);
87
+ return { close: close.slice(0, limit), all: ranked.map((entry) => entry.candidate) };
88
+ }
89
+
90
+ /** Values joined for a sentence: "left, center or right"; above five, the first three and the count. */
91
+ function valueList(values, max = 5) {
92
+ const list = values.map((value) => (typeof value === "string" ? value : JSON.stringify(value)));
93
+ if (list.length <= max) return list.length > 1 ? `${list.slice(0, -1).join(", ")} or ${list.at(-1)}` : list.join("");
94
+ return `one of ${list.length} values (${list.slice(0, 3).join(", ")}, …)`;
95
+ }
96
+
97
+ const firstSentence = (text) => /^.*?[.!?](?=\s|$)/.exec(String(text ?? "").replace(/\s+/g, " ").trim())?.[0] ?? "";
98
+ const chord = (name) => name;
99
+
100
+ /** The schema keywords of `opf/schema` findings, written in plain words. */
101
+ function describeSchema(finding, segments, context) {
102
+ const { keyword, params = {}, alternatives } = finding.validation ?? {};
103
+ const label = fieldLabel(segments);
104
+ const actual = context.valueAt?.(segments);
105
+ switch (keyword) {
106
+ case "required": {
107
+ const property = params.missingProperty;
108
+ const sample = property === "slides" ? '[ { "title": "…" } ]' : starterText(context.schemaAt?.([...segments, property]), property);
109
+ return { message: `${cap(containerName(segments))} is missing ${code(property)}.`, next: `add "${property}": ${sample}.` };
110
+ }
111
+ case "type": {
112
+ const expected = typeWords(params.type);
113
+ const next = params.type === "array" && typeof actual === "string" ? `write [${JSON.stringify(actual)}].` : params.type === "string" && typeof actual === "number" ? `write ${JSON.stringify(String(actual))}.` : `write ${expected}.`;
114
+ return { message: `${cap(label)} should be ${expected}, but it is ${describeKind(actual)}.`, next };
115
+ }
116
+ case "oneOf":
117
+ case "anyOf": {
118
+ const forms = (alternatives ?? []).map((entry) => String(entry).split(":")[0].trim());
119
+ const words = forms.length ? [...new Set(forms.map((form) => WORDS[form] ?? form))].slice(0, 3).join(" or ") : "a different kind of value";
120
+ return { message: `${cap(label)} should be ${words}, but it is ${describeKind(actual)}.`, next: forms.length ? `write ${words}.` : "check the schema reference for this field." };
121
+ }
122
+ case "enum": {
123
+ const values = params.allowedValues ?? finding.suggestions?.map((entry) => entry.value) ?? [];
124
+ const where = nearest(actual, values);
125
+ const guess = where.close[0];
126
+ const is = actual === undefined ? "" : `, but is ${describeValue(actual)}`;
127
+ return {
128
+ message: `${cap(label)} must be ${valueList(values)}${is}.`,
129
+ next: guess !== undefined ? `did you mean ${code(guess)}?` : values.length > 5 ? `press ${chord("Ctrl+Space")} for the ${values.length} values.` : "choose one of these values.",
130
+ };
131
+ }
132
+ case "const":
133
+ return { message: `${cap(label)} must be exactly ${code(params.allowedValue)}.`, next: `write ${JSON.stringify(params.allowedValue)}.` };
134
+ case "pattern": {
135
+ const kind = context.catalogKind?.(segments);
136
+ if (kind) {
137
+ const word = KIND_WORDS[kind] ?? "catalog";
138
+ const count = context.catalogCount?.(kind);
139
+ const is = actual === "" ? "it is empty" : `it is ${describeValue(actual)}`;
140
+ return { message: `${cap(label)} must be a ${word} id such as ${KIND_EXAMPLES[kind] ?? "`id` or `name:id`"} (lowercase letters, digits and hyphens), but ${is}.`, next: `press ${chord("Ctrl+Space")}${count ? ` for the ${count} ${word}s` : " for the available ids"}, or remove it${kind === "layouts" ? " to let OPF pick" : ""}.` };
141
+ }
142
+ if (/#/.test(params.pattern ?? "") && /0-9a-fA-F/.test(params.pattern ?? "")) return { message: `${cap(label)} must be a hex color like ${code("#1F4E79")} (3, 6 or 8 hex digits), but is ${describeValue(actual)}.`, next: "write the color as #RRGGBB." };
143
+ return { message: `${cap(label)} does not match the expected format${typeof actual === "string" ? `: ${describeValue(actual)}` : ""}.`, next: "check the schema reference for this field." };
144
+ }
145
+ case "additionalProperties": {
146
+ const name = params.additionalProperty ?? segments.at(-1);
147
+ const parent = segments.slice(0, -1);
148
+ const allowed = context.keysOf?.(parent) ?? [];
149
+ const hint = nearest(name, allowed).close[0];
150
+ return {
151
+ message: `${code(name)} is not a property of ${containerName(parent)}.`,
152
+ next: `${hint ? `did you mean ${code(hint)}? ` : ""}To keep custom data, put it under ${code("extensions")}.`.trim(),
153
+ };
154
+ }
155
+ case "minItems":
156
+ return { message: `${cap(label)} needs at least ${params.limit} item${params.limit === 1 ? "" : "s"}.`, next: label === "`slides`" ? "add a slide." : "add an item." };
157
+ case "maxItems":
158
+ return { message: `${cap(label)} can hold at most ${params.limit} items.`, next: "remove the extra ones." };
159
+ case "uniqueItems":
160
+ return { message: `${cap(label)} repeats an entry.`, next: "remove the repeated entry." };
161
+ case "minLength":
162
+ return { message: `${cap(label)} is empty.`, next: "write some text, or remove the key." };
163
+ case "maxLength":
164
+ return { message: `${cap(label)} is longer than ${params.limit} characters.`, next: "shorten it." };
165
+ case "minimum":
166
+ case "exclusiveMinimum":
167
+ case "maximum":
168
+ case "exclusiveMaximum": {
169
+ const bound = /minimum/i.test(keyword) ? "at least" : "at most";
170
+ return { message: `${cap(label)} must be ${keyword.startsWith("exclusive") ? (bound === "at least" ? "greater than" : "less than") : bound} ${params.limit}, but is ${describeValue(actual)}.`, next: `write ${params.limit}${keyword.startsWith("exclusive") ? " or a different number" : ""}.` };
171
+ }
172
+ case "format":
173
+ return { message: `${cap(label)} must be a ${params.format === "uri" ? "web address like https://example.com" : params.format}.`, next: "check the value." };
174
+ default:
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function cap(text) {
180
+ return text[0] === "`" ? text : text.charAt(0).toUpperCase() + text.slice(1);
181
+ }
182
+ function starterText(schema, property) {
183
+ const example = schema?.examples?.find((value) => typeof value === "string" && !value.includes("..."));
184
+ if (example) return JSON.stringify(example);
185
+ const type = Array.isArray(schema?.type) ? schema.type[0] : schema?.type;
186
+ if (type === "object") return "{ … }";
187
+ if (type === "array") return "[ … ]";
188
+ if (type === "number" || type === "integer") return "0";
189
+ if (type === "boolean") return "false";
190
+ return property === "id" ? '"acme"' : '"…"';
191
+ }
192
+
193
+ /**
194
+ * The message and the hint for a finding. Findings of the schema are written from their keyword and parameters, an unresolved reference from
195
+ * its suggestions, a duplicate key plainly; any other finding keeps core's own sentence and takes its help as the hint.
196
+ * `finding.path` is a JSON Pointer.
197
+ */
198
+ export function describeFinding(finding, context = {}) {
199
+ const segments = segmentsOf(finding.path);
200
+ let described = null;
201
+ if (finding.ruleId === "opf/schema" && finding.validation?.keyword) described = describeSchema(finding, segments, context);
202
+ else if (finding.ruleId === "opf/duplicate-key") {
203
+ const key = segments.at(-1);
204
+ described = { message: `${code(key)} appears twice in this object.`, next: "JSON keeps only the last one, so the first value is ignored. Keep one of them." };
205
+ } else if (finding.ruleId === "opf/unresolved-reference" && finding.suggestions?.length) {
206
+ const first = finding.suggestions[0].value;
207
+ described = { message: finding.message, next: `did you mean ${code(first)}?${finding.suggestions.length > 1 ? ` Others: ${finding.suggestions.slice(1, 4).map((entry) => code(entry.value)).join(", ")}.` : ""}` };
208
+ }
209
+ if (described) return described;
210
+ // A problem that brings its own hint (the syntax analyzer's) keeps it whole; core's help is cut to its first sentence.
211
+ if (finding.next) return { message: finding.message, next: finding.next };
212
+ const help = firstSentence(finding.help);
213
+ return { message: finding.message, next: help ? help.charAt(0).toLowerCase() + help.slice(1) : undefined };
214
+ }
215
+
216
+ /** The values a quick fix can change an enum or a reference to: the nearest first, then the rest, at most `limit`. */
217
+ export function replacementValues(finding, context = {}, limit = 5) {
218
+ const segments = segmentsOf(finding.path);
219
+ const keyword = finding.validation?.keyword;
220
+ if (keyword === "enum") {
221
+ const values = finding.validation.params?.allowedValues ?? [];
222
+ const { close, all } = nearest(context.valueAt?.(segments), values, limit);
223
+ return [...new Set([...close, ...all])].slice(0, limit);
224
+ }
225
+ if (finding.ruleId === "opf/unresolved-reference" && finding.suggestions?.length) return finding.suggestions.map((entry) => entry.value).slice(0, limit);
226
+ if (keyword === "additionalProperties") {
227
+ const parent = segments.slice(0, -1);
228
+ return nearest(finding.validation.params?.additionalProperty ?? segments.at(-1), context.keysOf?.(parent) ?? [], 3).close;
229
+ }
230
+ return [];
231
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EditorChange, EditorSession } from "./index.js";
2
2
  import type { JsonCatalogContext } from "./json-options.js";
3
- import type { JsonCodeEditor } from "./json-editor.js";
3
+ import type { EditorProblem, JsonCodeEditor } from "./json-editor.js";
4
4
 
5
5
  /** The source formats of the pane: `json` is canonical; `yaml` and `md` are core's `@openpresentation/opf/yaml` and `/markdown`. */
6
6
  export type SourceFormat = "json" | "yaml" | "md";
@@ -18,6 +18,18 @@ export interface SourceFinding {
18
18
  /** `schema` (the default count label), `syntax`, `format` (a note about what the format cannot hold) or `rejected` (the session refused the edit). */
19
19
  kind?: string;
20
20
  ruleId?: string;
21
+ /** A one-based column, with `line`. */
22
+ column?: number;
23
+ /**
24
+ * What core's findings carry beyond the sentence, kept and used: `validation` (the schema keyword and its parameters) and `suggestions` write the
25
+ * plain-words message and its "Next" hint and offer "Change to ..." fixes; `location` ranges the squiggle; `fixes` of kind `patch` and `focus` become
26
+ * quick fixes; `help` is the hint when nothing better is known.
27
+ */
28
+ help?: string;
29
+ location?: { offset: number; length: number; line?: number; column?: number };
30
+ validation?: { keyword?: string; params?: Record<string, unknown>; alternatives?: string[] };
31
+ suggestions?: { value: unknown; label?: string }[];
32
+ fixes?: { id: string; title: string; kind: "patch" | "focus"; safe?: boolean; patch?: unknown[]; focus?: { path?: string; field?: string } }[];
21
33
  }
22
34
 
23
35
  /** What a host receives when the pane copies or downloads. */
@@ -59,7 +71,7 @@ export interface SourcePaneOptions {
59
71
  /** Accessible name of the code editor (default `OPF source`). */
60
72
  label?: string;
61
73
  catalogs?: JsonCatalogContext;
62
- /** Live completions while typing JSON: property names and values (enums, booleans, catalog references) at the cursor's JSON path, from the same data as the field menu. YAML and Markdown keep the menu only. Default true. */
74
+ /** Live completions while typing JSON: property names from the schema and values (enums, booleans, catalog ids, templates, snippets) at the cursor's JSON path. Tab and Enter accept the selected item, Escape closes the list, Ctrl+Space (Cmd+I on macOS) opens it; the field menu is on Alt+Shift+O. Hover documentation, squiggles with quick fixes (Cmd/Ctrl+.) and F8 come with it. YAML and Markdown have no completions yet. Default true. */
63
75
  completions?: boolean;
64
76
  onError?(error: unknown): void;
65
77
  }
@@ -106,6 +118,16 @@ export interface SourcePane {
106
118
  setName(name: string): void;
107
119
  setDensity(density: SourcePaneDensity): void;
108
120
  setReadOnly(readOnly: boolean): void;
121
+ /** The problems drawn in the text now, in position order, at source offsets. */
122
+ getProblems(): EditorProblem[];
123
+ /** Select the next problem in the text (F8) and say what it is; wraps. */
124
+ nextProblem(): boolean;
125
+ /** Select the previous problem (Shift+F8). */
126
+ previousProblem(): boolean;
127
+ /** Select problem number `index` (zero-based, position order) and say what it is. Returns false when there is none. */
128
+ showProblem(index: number): boolean;
129
+ /** Open the suggestion list at the caret, as Ctrl+Space does. */
130
+ triggerCompletion(): boolean;
109
131
  /** The underlying code editor (`mountJsonCodeEditor`). */
110
132
  readonly editor: JsonCodeEditor;
111
133
  destroy(): void;