@openpresentation/opf-editor 0.26.4 → 0.26.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/json-editor.js +3 -2
- package/dist/source-a11y.js +18 -1
- package/dist/source-completions.js +196 -22
- package/dist/source-details.js +17 -0
- package/dist/source-fixes-syntax.js +204 -0
- package/dist/source-fixes-text.js +75 -2
- package/dist/source-fixes.js +25 -1
- package/dist/source-markdown.js +11 -16
- package/dist/source-messages.js +21 -205
- package/dist/source-pane.js +50 -11
- package/dist/source-problems.js +3 -0
- package/dist/source-references.js +227 -0
- package/dist/source-snippets.js +20 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-yaml.js +94 -9
- package/dist/styles.css +26 -0
- package/package.json +8 -7
package/dist/source-messages.js
CHANGED
|
@@ -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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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`: `{
|
|
7
|
-
* `{
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
195
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
return [...new Set([...close, ...
|
|
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
|
-
|
|
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
|
}
|
package/dist/source-pane.js
CHANGED
|
@@ -7,10 +7,11 @@ import { errorText, normalizeFinding, rejectionFindings as findingsOfError } fro
|
|
|
7
7
|
import { formatSource } from "./source-format.js";
|
|
8
8
|
import { buildProblems, summarize } from "./source-problems.js";
|
|
9
9
|
import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { withSyntaxFixes } from "./source-fixes-syntax.js";
|
|
11
|
+
import { detailsFor } from "./source-details.js";
|
|
12
|
+
import { schemaAtPath, schemaVariants } from "./schema.js";
|
|
12
13
|
import { locateText } from "./source-ranges.js";
|
|
13
|
-
import { scanMarkdown } from "@openpresentation/opf/source";
|
|
14
|
+
import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
|
|
14
15
|
import { listCatalogRecords } from "./catalogs.js";
|
|
15
16
|
|
|
16
17
|
// The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
|
|
@@ -75,6 +76,19 @@ function same(a, b) {
|
|
|
75
76
|
const SEVERITY = { error: 0, warning: 1, info: 2 };
|
|
76
77
|
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
77
78
|
|
|
79
|
+
/** The rule's explanation as the body of a row's Details: what it checks, why, what to do, an example and the documentation link. */
|
|
80
|
+
function fillDetails(body, details) {
|
|
81
|
+
const line = (label, text) => { const row = el("p", "opf-source-finding-explain-line"); row.append(Object.assign(el("strong"), { textContent: `${label} ` }), document.createTextNode(text)); return row; };
|
|
82
|
+
body.append(line("Checks:", details.summary), line("Why:", details.why), line("Fix:", details.fix));
|
|
83
|
+
if (details.schema) body.append(line("Schema:", details.schema));
|
|
84
|
+
if (details.example) {
|
|
85
|
+
const example = el("div", "opf-source-finding-explain-example");
|
|
86
|
+
example.append(Object.assign(el("code"), { textContent: details.example.problem || "(empty)" }), Object.assign(el("span", "opf-source-finding-explain-arrow"), { textContent: " becomes " }), Object.assign(el("code"), { textContent: details.example.fixed }));
|
|
87
|
+
body.append(example);
|
|
88
|
+
}
|
|
89
|
+
if (details.docs && /^https?:/.test(details.docs)) body.append(Object.assign(el("a", "opf-source-finding-explain-link", { href: details.docs, target: "_blank", rel: "noopener noreferrer" }), { textContent: "Documentation" }));
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
/** Read the text of one format. `{ deck }` on success; `findings` carry the syntax errors (`syntax: true`) or the OPF findings of the parsed deck. */
|
|
79
93
|
function readText(format, text, catalogs) {
|
|
80
94
|
if (format === "json") {
|
|
@@ -86,9 +100,12 @@ function readText(format, text, catalogs) {
|
|
|
86
100
|
return { deck, findings: [] };
|
|
87
101
|
}
|
|
88
102
|
const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
// The reader's own findings: errors stop the document being read; its warnings (a numbered list that became bullets) come with the deck.
|
|
104
|
+
const readerFindings = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
|
|
105
|
+
const errors = readerFindings.filter((finding) => finding.severity === "error");
|
|
106
|
+
const syntax = (list) => withSyntaxFixes(format, text, list).map((finding) => normalizeFinding(finding, "syntax"));
|
|
107
|
+
if (errors.length) return { syntax: true, findings: syntax(errors) };
|
|
108
|
+
return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")), notes: syntax(readerFindings) };
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
/**
|
|
@@ -126,6 +143,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
126
143
|
// The problems now shown (rows of source-problems.js), the document they were checked as, and the pause timers of the count announcement and the
|
|
127
144
|
// syntax pass.
|
|
128
145
|
const rowOf = new WeakMap();
|
|
146
|
+
// The problems whose Details are open (by rule, path and message), so a redraw of the panel keeps them open, and a number per key for the element ids.
|
|
147
|
+
const detailsOpen = new Set();
|
|
148
|
+
const detailsKeys = { ids: new Map(), id(key) { if (!this.ids.has(key)) this.ids.set(key, this.ids.size + 1); return this.ids.get(key); } };
|
|
129
149
|
let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
|
|
130
150
|
// The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
|
|
131
151
|
// (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
|
|
@@ -299,7 +319,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
299
319
|
renderFindings();
|
|
300
320
|
return;
|
|
301
321
|
}
|
|
302
|
-
syntaxFindings = [];
|
|
322
|
+
syntaxFindings = read.notes ?? [];
|
|
303
323
|
baseline = source;
|
|
304
324
|
lastDraft = read.deck;
|
|
305
325
|
const before = deck();
|
|
@@ -807,13 +827,13 @@ export function createSourcePane(container, options = {}) {
|
|
|
807
827
|
function problemContext() {
|
|
808
828
|
const draft = lastDraft ?? deck();
|
|
809
829
|
return {
|
|
830
|
+
document: draft,
|
|
831
|
+
format,
|
|
810
832
|
unit: control.api.indentUnit(),
|
|
811
833
|
valueAt: (segments) => segments.reduce((value, key) => (value !== null && typeof value === "object" ? value[key] : undefined), draft),
|
|
812
|
-
schemaAt: (segments) => attempt(() => schemaAtPath(draft, segments), undefined),
|
|
813
834
|
keysOf: (segments) => attempt(() => [...new Set(schemaVariants(schemaAtPath(draft, segments)).flatMap((variant) => Object.keys(variant.properties ?? {})))], []),
|
|
814
|
-
catalogKind: (segments) => catalogKindAt(segments),
|
|
815
835
|
catalogCount: (kind) => attempt(() => listCatalogRecords(draft, kind, { catalogs: catalogs() }).length, 0),
|
|
816
|
-
starter: (segments, property) => attempt(() => (
|
|
836
|
+
starter: (segments, property) => attempt(() => starterValue(draft, segments, property), undefined),
|
|
817
837
|
};
|
|
818
838
|
}
|
|
819
839
|
/** Select the field a core "focus" fix names: its path, else the field inside it, else the nearest place there is one. */
|
|
@@ -903,13 +923,32 @@ export function createSourcePane(container, options = {}) {
|
|
|
903
923
|
options.onFindingSelect?.(finding, target);
|
|
904
924
|
});
|
|
905
925
|
item.append(button);
|
|
906
|
-
|
|
926
|
+
const details = detailsFor(finding);
|
|
927
|
+
if (row.fixes.length || details) {
|
|
907
928
|
const fixes = el("span", "opf-source-finding-fixes");
|
|
908
929
|
for (const fix of row.fixes.slice(0, 3)) {
|
|
909
930
|
const fixButton = Object.assign(el("button", "opf-source-finding-fix", { type: "button", "aria-label": `${fix.title}: ${row.message}` }), { textContent: fix.title });
|
|
910
931
|
fixButton.addEventListener("click", () => applyFix(fix, row));
|
|
911
932
|
fixes.append(fixButton);
|
|
912
933
|
}
|
|
934
|
+
if (details) {
|
|
935
|
+
// The rule's own explanation, behind a disclosure that stays open across the panel's redraws.
|
|
936
|
+
const key = `${row.rule}|${row.path}|${row.message}`;
|
|
937
|
+
const body = el("div", "opf-source-finding-explain", { id: `${id}-explain-${detailsKeys.id(key)}`, role: "region", "aria-label": `About ${row.rule}` });
|
|
938
|
+
const toggleDetails = Object.assign(el("button", "opf-source-finding-details", { type: "button", "aria-expanded": String(detailsOpen.has(key)), "aria-controls": body.id }), { textContent: "Details" });
|
|
939
|
+
body.hidden = !detailsOpen.has(key);
|
|
940
|
+
fillDetails(body, details);
|
|
941
|
+
toggleDetails.addEventListener("click", () => {
|
|
942
|
+
const open = body.hidden;
|
|
943
|
+
body.hidden = !open;
|
|
944
|
+
toggleDetails.setAttribute("aria-expanded", String(open));
|
|
945
|
+
if (open) detailsOpen.add(key); else detailsOpen.delete(key);
|
|
946
|
+
if (open) control.api.say(`${details.summary} ${details.fix}`);
|
|
947
|
+
});
|
|
948
|
+
fixes.append(toggleDetails);
|
|
949
|
+
item.append(fixes, body);
|
|
950
|
+
return item;
|
|
951
|
+
}
|
|
913
952
|
item.append(fixes);
|
|
914
953
|
}
|
|
915
954
|
return item;
|
package/dist/source-problems.js
CHANGED
|
@@ -44,6 +44,9 @@ export function buildProblems({ format, text, findings, context = {}, actions =
|
|
|
44
44
|
}
|
|
45
45
|
if (finding.kind !== "syntax") fixes.push(...(format === "json" ? fixesForFinding(finding, text, context) : textFixesForFinding(format, finding, text, context)));
|
|
46
46
|
for (const fix of finding.fixes ?? []) { const row = coreFix(fix, actions); if (row) fixes.push(row); }
|
|
47
|
+
// A repair core ships as a patch and the editor also writes as a text edit is offered once, as the text edit (it keeps comments and layout).
|
|
48
|
+
const titles = new Set();
|
|
49
|
+
for (let at = 0; at < fixes.length; at++) { if (titles.has(fixes[at].title)) fixes.splice(at--, 1); else titles.add(fixes[at].title); }
|
|
47
50
|
const row = { finding, index, severity: finding.severity, message, next, rule: finding.ruleId, path: finding.path ?? "", fixes };
|
|
48
51
|
if (range) {
|
|
49
52
|
row.from = range.from;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Cross-references inside the document as completion values (artifacts/source-editor/SPEC.md, section 3.4): `asset:<id>` from `assets`, dataset ids
|
|
2
|
+
// and column names from `datasets`, `var:<id>` and `{{name}}` from `variables`, `cite` ids from `references`, `beat` ids from the deck's narrative and
|
|
3
|
+
// `region` names from the slide's layout. Which field takes which kind of reference is ONE table (`REFERENCE_RULES`) read against the schema: a rule
|
|
4
|
+
// names the schema definitions whose fields are references (`Asset`, `DatasetId`, ...) and the property names, with a phrase the property's own
|
|
5
|
+
// description must carry (any of `phrases`), for the fields written inline in their parent. test/source-completions-units.mjs checks every entry against the schema,
|
|
6
|
+
// so a renamed definition or a reworded description fails the test instead of silently dropping a list. Plain functions, no DOM.
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
import { describeField } from "@openpresentation/opf/schema";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The rules. `definitions`: a field whose own schema is that definition (`$defs` name). `keys` + `phrases`: a string field with one of those property
|
|
12
|
+
* names whose description contains one of the phrases. `item`: the field is a list (or a string or a list) and every entry is a reference.
|
|
13
|
+
*/
|
|
14
|
+
export const REFERENCE_RULES = Object.freeze([
|
|
15
|
+
{ kind: "asset", definitions: ["Asset", "LogoSource", "ImageSource"], keys: ["src", "logo", "watermark"], phrases: ["asset:<id>", "or Asset"] },
|
|
16
|
+
{ kind: "dataset", definitions: ["DatasetId"], keys: [], phrases: [] },
|
|
17
|
+
{ kind: "datasetColumn", definitions: ["DatasetFields"], keys: ["category", "x", "series", "line", "secondaryAxis"], phrases: ["column"], item: true },
|
|
18
|
+
{ kind: "cite", definitions: [], keys: ["cite"], phrases: ["references"], item: true },
|
|
19
|
+
{ kind: "beat", definitions: [], keys: ["beat"], phrases: ["narrative"], item: true },
|
|
20
|
+
{ kind: "region", definitions: [], keys: ["region"], phrases: ["template layout"] },
|
|
21
|
+
{ kind: "color", definitions: ["ColorRef"], keys: ["color"], phrases: ["var:<id>"] },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** The promoted regions of a slide a block can be pinned to (the lower-case names the `region` pattern accepts). */
|
|
25
|
+
export const PROMOTED_REGIONS = Object.freeze(["top", "middle", "bottom", "left", "center", "right"]);
|
|
26
|
+
|
|
27
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
const isIndex = (segment) => typeof segment === "number" || /^(?:0|[1-9]\d*)$/.test(String(segment));
|
|
29
|
+
|
|
30
|
+
function ruleOf(info, key) {
|
|
31
|
+
if (!info?.known || !(info.types.includes("string") || info.types.includes("array"))) return null;
|
|
32
|
+
for (const rule of REFERENCE_RULES) {
|
|
33
|
+
if (info.definition && rule.definitions.includes(info.definition)) return rule;
|
|
34
|
+
if (key !== undefined && rule.keys.includes(key) && rule.phrases.some((phrase) => (info.fullDescription ?? info.description ?? "").includes(phrase))) return rule;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the value at `path` of `document` references: `{ kind, path }` (`path` is the field, for a list entry the list's), or null. A list entry
|
|
41
|
+
* (`cite: ["a", |]`, `fields: [|]`) is the entry of the list field the rule names; a field that can only be a list takes its references one by one.
|
|
42
|
+
*/
|
|
43
|
+
export function referenceSiteAt(path, document) {
|
|
44
|
+
if (!Array.isArray(path) || !path.length) return null;
|
|
45
|
+
const last = path.at(-1);
|
|
46
|
+
const describe = (at) => { try { return describeField(at, document); } catch { return null; } };
|
|
47
|
+
if (!isIndex(last)) {
|
|
48
|
+
const info = describe(path);
|
|
49
|
+
const rule = ruleOf(info, String(last));
|
|
50
|
+
const listOnly = info?.types.length === 1 && info.types[0] === "array";
|
|
51
|
+
return rule && !listOnly ? { kind: rule.kind, path } : null;
|
|
52
|
+
}
|
|
53
|
+
// An entry whose own schema is a reference definition (`chart.colors[i]`: a ColorRef) is a reference whatever the list is called.
|
|
54
|
+
const own = describe(path);
|
|
55
|
+
const byDefinition = own?.known && own.definition ? REFERENCE_RULES.find((rule) => rule.definitions.includes(own.definition)) : undefined;
|
|
56
|
+
if (byDefinition && !own.types.every((type) => type === "array")) return { kind: byDefinition.kind, path };
|
|
57
|
+
const field = path.slice(0, -1);
|
|
58
|
+
while (field.length && isIndex(field.at(-1))) field.pop();
|
|
59
|
+
if (!field.length) return null;
|
|
60
|
+
const rule = ruleOf(describe(field), String(field.at(-1)));
|
|
61
|
+
return rule?.item ? { kind: rule.kind, path: field } : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- the facts of a document -------------------------------------------------------------------------------------------------------
|
|
65
|
+
const stringOf = (value) => (typeof value === "string" ? value : undefined);
|
|
66
|
+
const columnName = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : undefined);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The parts of a deck the references read, from the plain value of the top level (`assets`, `datasets`, `variables`, `references`, `narrative`):
|
|
70
|
+
* `{ assets: [{ id, detail }], datasets: [{ id, title, columns }], variables: [{ id, type }], references: [{ id, text }], narrative }`.
|
|
71
|
+
* Anything else the value holds, and anything malformed, is ignored: the text being edited is a draft.
|
|
72
|
+
*/
|
|
73
|
+
export function factsOf(deck) {
|
|
74
|
+
const out = { assets: [], datasets: [], variables: [], references: [], narrative: undefined };
|
|
75
|
+
if (!isObject(deck)) return out;
|
|
76
|
+
if (isObject(deck.assets)) {
|
|
77
|
+
for (const [id, asset] of Object.entries(deck.assets)) out.assets.push({ id, detail: stringOf(asset) ?? stringOf(asset?.src) ?? stringOf(asset?.title) ?? "" });
|
|
78
|
+
}
|
|
79
|
+
if (isObject(deck.datasets)) {
|
|
80
|
+
for (const [id, dataset] of Object.entries(deck.datasets)) {
|
|
81
|
+
const columns = Array.isArray(dataset?.columns) ? dataset.columns.map(columnName).filter(Boolean) : [];
|
|
82
|
+
out.datasets.push({ id, title: stringOf(dataset?.title) ?? "", columns });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (isObject(deck.variables)) {
|
|
86
|
+
for (const [id, variable] of Object.entries(deck.variables)) {
|
|
87
|
+
// A hex string is shorthand for a color variable.
|
|
88
|
+
const type = typeof variable === "string" ? "color" : stringOf(variable?.type) ?? "color";
|
|
89
|
+
out.variables.push({ id, type });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(deck.references)) {
|
|
93
|
+
for (const reference of deck.references) if (isObject(reference) && typeof reference.id === "string" && reference.id) out.references.push({ id: reference.id, text: stringOf(reference.text) ?? "" });
|
|
94
|
+
}
|
|
95
|
+
out.narrative = stringOf(deck.narrative);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The top-level parts of a JSON draft the references read (`root` is a jsonc-parser tree), as a plain value for `factsOf`. */
|
|
100
|
+
export function jsonDeckParts(root, getNodeValue) {
|
|
101
|
+
const deck = {};
|
|
102
|
+
if (root?.type !== "object") return deck;
|
|
103
|
+
for (const property of root.children ?? []) {
|
|
104
|
+
const key = property.children?.[0]?.value, value = property.children?.[1];
|
|
105
|
+
if (!value || !["assets", "datasets", "variables", "references", "narrative"].includes(key)) continue;
|
|
106
|
+
if (key === "datasets" && value.type === "object") {
|
|
107
|
+
// Only the names and the columns: the rows can be the bulk of a deck.
|
|
108
|
+
deck.datasets = {};
|
|
109
|
+
for (const entry of value.children ?? []) {
|
|
110
|
+
const id = entry.children?.[0]?.value, body = entry.children?.[1];
|
|
111
|
+
if (typeof id !== "string" || body?.type !== "object") continue;
|
|
112
|
+
const columns = (body.children ?? []).find((child) => child.children?.[0]?.value === "columns")?.children?.[1];
|
|
113
|
+
const title = (body.children ?? []).find((child) => child.children?.[0]?.value === "title")?.children?.[1];
|
|
114
|
+
deck.datasets[id] = { columns: columns ? getNodeValue(columns) : [], title: title?.type === "string" ? title.value : undefined };
|
|
115
|
+
}
|
|
116
|
+
} else deck[key] = getNodeValue(value);
|
|
117
|
+
}
|
|
118
|
+
return deck;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TOP_LEVEL = ["assets", "datasets", "variables", "references", "narrative"];
|
|
122
|
+
|
|
123
|
+
/** The text of the top-level blocks of a YAML text that hold the keys the references read, parsed (tolerant: a broken block gives nothing). */
|
|
124
|
+
export function yamlDeckParts(text) {
|
|
125
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
126
|
+
const deck = {};
|
|
127
|
+
for (let number = 0; number < lines.length; number++) {
|
|
128
|
+
const match = /^([A-Za-z]+)\s*:/.exec(lines[number]);
|
|
129
|
+
if (!match || !TOP_LEVEL.includes(match[1])) continue;
|
|
130
|
+
let end = number + 1;
|
|
131
|
+
while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]) || lines[end].startsWith("- "))) end++;
|
|
132
|
+
try {
|
|
133
|
+
const value = parseYaml(lines.slice(number, end).join("\n"), { strict: false, uniqueKeys: false });
|
|
134
|
+
if (isObject(value) && match[1] in value) deck[match[1]] = value[match[1]];
|
|
135
|
+
} catch { /* A block that does not parse offers nothing. */ }
|
|
136
|
+
}
|
|
137
|
+
return deck;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `value` at `path` of a plain document, or undefined. */
|
|
141
|
+
export function valueAt(value, path) {
|
|
142
|
+
let current = value;
|
|
143
|
+
for (const step of path) {
|
|
144
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
145
|
+
current = current[step];
|
|
146
|
+
}
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What a column field reads its names from: `{ dataset }` (the id of the dataset: a `dataset` beside the field, or the chart's `data.dataset`) or
|
|
152
|
+
* `{ inline }` (the `columns` of the chart's own `data`), looking outward from the field; null when the draft says neither. `read(path)` gives a
|
|
153
|
+
* value of the document.
|
|
154
|
+
*/
|
|
155
|
+
export function columnSourceFor(site, read) {
|
|
156
|
+
for (let depth = site.path.length - 1; depth >= 0; depth--) {
|
|
157
|
+
const owner = site.path.slice(0, depth);
|
|
158
|
+
const direct = read([...owner, "dataset"]);
|
|
159
|
+
if (typeof direct === "string") return { dataset: direct };
|
|
160
|
+
const via = read([...owner, "data", "dataset"]);
|
|
161
|
+
if (typeof via === "string") return { dataset: via };
|
|
162
|
+
const inline = read([...owner, "data", "columns"]);
|
|
163
|
+
if (Array.isArray(inline)) return { inline: inline.map(columnName).filter(Boolean) };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The names of the regions of a layout record a block can be pinned to (the title and the subtitle are not regions of the body). */
|
|
169
|
+
export function regionNames(record) {
|
|
170
|
+
const names = [];
|
|
171
|
+
const walk = (regions) => {
|
|
172
|
+
for (const region of Array.isArray(regions) ? regions : []) {
|
|
173
|
+
if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
|
|
174
|
+
if (region?.regions) walk(region.regions);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(record?.regions);
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The regions of a layout record in reading order (nested ones included) as `{ name, accepts }`. */
|
|
182
|
+
export function flatRegions(record) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const walk = (regions) => {
|
|
185
|
+
for (const region of Array.isArray(regions) ? regions : []) {
|
|
186
|
+
if (typeof region?.name === "string") out.push({ name: region.name, accepts: Array.isArray(region.accepts) ? region.accepts : [] });
|
|
187
|
+
if (region?.regions) walk(region.regions);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
walk(record?.regions);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The slide key that holds content of a kind a region accepts.
|
|
195
|
+
const PAYLOAD_KEYS = { text: "text", list: "items", image: "image", video: "video", chart: "chart", table: "table", quote: "quote", code: "code", metric: "metric", timeline: "timeline" };
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The slide keys a layout's regions call for, in region order: a region named like a slide key (`title`, `items`) calls for that key; any other region
|
|
199
|
+
* that accepts one or two kinds of content (`media`: image, video) for the keys of those kinds. `keys` are the slide keys that exist.
|
|
200
|
+
*/
|
|
201
|
+
export function layoutKeys(record, keys) {
|
|
202
|
+
const wanted = [];
|
|
203
|
+
const add = (key) => { if (keys.includes(key) && !wanted.includes(key)) wanted.push(key); };
|
|
204
|
+
for (const region of flatRegions(record)) {
|
|
205
|
+
if (keys.includes(region.name)) add(region.name);
|
|
206
|
+
else if (region.accepts.length > 0 && region.accepts.length <= 2) for (const kind of region.accepts) add(PAYLOAD_KEYS[kind]);
|
|
207
|
+
}
|
|
208
|
+
return wanted;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The ids of the beats of a narrative record. */
|
|
212
|
+
export function beatsOf(record) {
|
|
213
|
+
return (Array.isArray(record?.beats) ? record.beats : []).filter((beat) => isObject(beat) && typeof beat.id === "string").map((beat) => ({ id: beat.id, name: stringOf(beat.name) ?? "", description: stringOf(beat.description) ?? "" }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The names of the variables a `{{` in text can reference: the document's own, with their type. (The built-in `organization.*`, `speaker.*` and
|
|
218
|
+
* `deck.*` variables are not offered: they are not declared in the document.)
|
|
219
|
+
*/
|
|
220
|
+
export function variableNames(facts) {
|
|
221
|
+
return facts.variables.map((variable) => ({ id: variable.id, type: variable.type }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The slide a path of a deck sits in: `["slides", n]`, or null. */
|
|
225
|
+
export function slidePath(path) {
|
|
226
|
+
return path[0] === "slides" && isIndex(path[1]) ? [path[0], Number(path[1])] : null;
|
|
227
|
+
}
|