@openpresentation/opf-editor 0.26.4 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,231 +1,47 @@
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.
1
+ // Problem text for the source pane (artifacts/source-editor/VALIDATOR-HINTS.md): what is wrong, where, and what could come next. The sentences are core's
2
+ // (`describeFinding` of `@openpresentation/opf/source`: the schema keywords, every rule, the YAML and Markdown readers), so the CLI, agents and every
3
+ // editor say the same thing; this module adds what only an editor has (the problems its own analyzers found, the replacement values of a quick fix).
4
+ // Pure functions, no DOM: `describeFinding(finding, context)` returns `{ message, next }`; the quick fix edits that go with a message are in source-fixes.js.
5
+ import { describeFinding as describeWithCore, distance, nearest, pointerSegments } from "@openpresentation/opf/source";
4
6
 
5
7
  /**
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.
8
+ * `context`: what core's `FindingTextContext` takes. `{ document }` is the deck being checked (the actual value makes "but is `middle`" possible and the
9
+ * ids of the document available to the rules), `{ format }` the text format (examples are written for it), `{ catalogCount(kind) }` how many records of a
10
+ * kind can be chosen. Callbacks `valueAt(segments)`, `schemaAt(segments)`, `keysOf(segments)`, `catalogKind(segments)` and `starter(segments, property)`
11
+ * take the place of what core would read from `document`.
9
12
  */
10
13
 
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" };
14
+ export { distance, nearest };
15
15
 
16
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
- }
17
+ export const segmentsOf = pointerSegments;
192
18
 
193
19
  /**
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.
20
+ * The message and the hint for a finding. A problem that brings its own hint (the syntax analyzers') keeps it whole; every other finding is written by
21
+ * core from its data. `finding.path` is a JSON Pointer.
197
22
  */
198
23
  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
24
  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 };
25
+ return describeWithCore(finding, context);
214
26
  }
215
27
 
216
28
  /** The values a quick fix can change an enum or a reference to: the nearest first, then the rest, at most `limit`. */
217
29
  export function replacementValues(finding, context = {}, limit = 5) {
218
30
  const segments = segmentsOf(finding.path);
219
31
  const keyword = finding.validation?.keyword;
32
+ const read = (path) => (context.valueAt ? context.valueAt(path) : path.reduce((value, key) => (value !== null && typeof value === "object" ? value[key] : undefined), context.document));
220
33
  if (keyword === "enum") {
221
34
  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);
35
+ // The near misses first, then the values in the order the schema lists them.
36
+ return [...new Set([...nearest(read(segments), values, limit).close, ...values])].slice(0, limit);
224
37
  }
225
38
  if (finding.ruleId === "opf/unresolved-reference" && finding.suggestions?.length) return finding.suggestions.map((entry) => entry.value).slice(0, limit);
226
39
  if (keyword === "additionalProperties") {
227
40
  const parent = segments.slice(0, -1);
228
- return nearest(finding.validation.params?.additionalProperty ?? segments.at(-1), context.keysOf?.(parent) ?? [], 3).close;
41
+ const keys = context.keysOf?.(parent) ?? finding.suggestions?.map((entry) => entry.value) ?? [];
42
+ return nearest(finding.validation.params?.additionalProperty ?? segments.at(-1), keys, 3).close;
229
43
  }
44
+ // Findings core has suggestions for (a near-miss option name of Markdown, the chart types).
45
+ if (finding.suggestions?.length && String(finding.ruleId).startsWith("markdown/")) return finding.suggestions.map((entry) => entry.value).slice(0, limit);
230
46
  return [];
231
47
  }