@openpresentation/opf-editor 0.26.3 → 0.26.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/json-editor.d.ts +7 -2
- package/dist/json-editor.js +54 -26
- package/dist/source-a11y.js +18 -1
- package/dist/source-completions.js +378 -38
- package/dist/source-details.js +17 -0
- package/dist/source-editing.js +265 -0
- package/dist/source-fixes-syntax.js +204 -0
- package/dist/source-fixes-text.js +160 -0
- package/dist/source-fixes.js +25 -1
- package/dist/source-lines.js +4 -0
- package/dist/source-markdown.js +546 -0
- package/dist/source-messages.js +21 -205
- package/dist/source-pane.d.ts +10 -2
- package/dist/source-pane.js +127 -17
- package/dist/source-problems.js +9 -4
- package/dist/source-ranges.js +8 -5
- package/dist/source-references.js +227 -0
- package/dist/source-snippets.js +159 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-yaml.js +623 -0
- package/dist/styles.css +49 -0
- package/package.json +10 -7
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.d.ts
CHANGED
|
@@ -71,8 +71,10 @@ export interface SourcePaneOptions {
|
|
|
71
71
|
/** Accessible name of the code editor (default `OPF source`). */
|
|
72
72
|
label?: string;
|
|
73
73
|
catalogs?: JsonCatalogContext;
|
|
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
|
|
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. The same for YAML and Markdown (front matter, slide and block options, fences, `Note:` and snippets). Default true. */
|
|
75
75
|
completions?: boolean;
|
|
76
|
+
/** Enter continues a list: a new string item after a string in a JSON list, the next `- ` in YAML, the next marker (or `> `) in Markdown; an empty one ends it. Default true. */
|
|
77
|
+
smartListEnter?: boolean;
|
|
76
78
|
onError?(error: unknown): void;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -110,10 +112,16 @@ export interface SourcePane {
|
|
|
110
112
|
/** Switch format. Returns false, and says why in the findings, when the document cannot be written in it. Does not call `onFormatChange`. */
|
|
111
113
|
setFormat(format: SourceFormat): boolean;
|
|
112
114
|
getFormat(): SourceFormat;
|
|
113
|
-
/** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format
|
|
115
|
+
/** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format (core's `locate` finds it in JSON, YAML and Markdown). Returns the line. */
|
|
114
116
|
focusPath(path: string): number;
|
|
115
117
|
/** The text as shown, with any pending edit applied first. */
|
|
116
118
|
getText(): string;
|
|
119
|
+
/**
|
|
120
|
+
* Canonicalize with OPF: rewrite the YAML or Markdown text from the document with core's writer (the old behavior of Format). It drops comments, so it
|
|
121
|
+
* asks first when there are any (an inline confirmation naming how many); `confirmed: true` has asked already. Returns "applied", "unchanged",
|
|
122
|
+
* "confirm" (waiting for the answer) or "refused" (JSON, read-only, or text that does not read as a document).
|
|
123
|
+
*/
|
|
124
|
+
canonicalize(options?: { confirmed?: boolean }): "applied" | "unchanged" | "confirm" | "refused";
|
|
117
125
|
getName(): string;
|
|
118
126
|
setName(name: string): void;
|
|
119
127
|
setDensity(density: SourcePaneDensity): void;
|
package/dist/source-pane.js
CHANGED
|
@@ -7,9 +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 {
|
|
12
|
-
import {
|
|
10
|
+
import { withSyntaxFixes } from "./source-fixes-syntax.js";
|
|
11
|
+
import { detailsFor } from "./source-details.js";
|
|
12
|
+
import { schemaAtPath, schemaVariants } from "./schema.js";
|
|
13
|
+
import { locateText } from "./source-ranges.js";
|
|
14
|
+
import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
|
|
13
15
|
import { listCatalogRecords } from "./catalogs.js";
|
|
14
16
|
|
|
15
17
|
// The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
|
|
@@ -33,6 +35,7 @@ const ICONS = {
|
|
|
33
35
|
Check: '<path d="M20 6 9 17l-5-5"/>',
|
|
34
36
|
CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
|
35
37
|
Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
38
|
+
Sparkles: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>',
|
|
36
39
|
};
|
|
37
40
|
function icon(name, size = 14) {
|
|
38
41
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
@@ -73,6 +76,19 @@ function same(a, b) {
|
|
|
73
76
|
const SEVERITY = { error: 0, warning: 1, info: 2 };
|
|
74
77
|
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
75
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
|
+
|
|
76
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. */
|
|
77
93
|
function readText(format, text, catalogs) {
|
|
78
94
|
if (format === "json") {
|
|
@@ -84,9 +100,12 @@ function readText(format, text, catalogs) {
|
|
|
84
100
|
return { deck, findings: [] };
|
|
85
101
|
}
|
|
86
102
|
const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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) };
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
/**
|
|
@@ -124,6 +143,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
124
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
|
|
125
144
|
// syntax pass.
|
|
126
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); } };
|
|
127
149
|
let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
|
|
128
150
|
// The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
|
|
129
151
|
// (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
|
|
@@ -170,10 +192,18 @@ export function createSourcePane(container, options = {}) {
|
|
|
170
192
|
const formatAction = actionButton("Format (fix indentation)", "Braces", "format");
|
|
171
193
|
formatAction.title = "Format (fix indentation), Shift+Alt+F";
|
|
172
194
|
formatAction.setAttribute("aria-keyshortcuts", "Shift+Alt+F");
|
|
195
|
+
// The old Format (a rewrite through OPF's own writer) is its own command: it drops the comments of YAML and Markdown, so it asks first.
|
|
196
|
+
const canonicalAction = actionButton("Canonicalize with OPF", "Sparkles", "canonicalize");
|
|
197
|
+
canonicalAction.title = "Canonicalize with OPF: rewrite the text the way OPF writes it (drops comments)";
|
|
173
198
|
const copyAction = actionButton("Copy source", "Copy", "copy");
|
|
174
199
|
const downloadAction = actionButton("Download source", "Download", "download");
|
|
175
200
|
bar.append(file, actions);
|
|
176
201
|
|
|
202
|
+
const confirm = el("div", "opf-source-confirm", { role: "alertdialog", "aria-labelledby": `${id}-confirm-text`, hidden: "" });
|
|
203
|
+
const confirmText = el("p", "opf-source-confirm-text", { id: `${id}-confirm-text` });
|
|
204
|
+
const confirmAccept = Object.assign(el("button", "opf-source-confirm-accept", { type: "button" }), { textContent: "Canonicalize anyway" });
|
|
205
|
+
const confirmCancel = Object.assign(el("button", "opf-source-confirm-cancel", { type: "button" }), { textContent: "Cancel" });
|
|
206
|
+
confirm.append(confirmText, confirmAccept, confirmCancel);
|
|
177
207
|
const code = el("div", "opf-source-code");
|
|
178
208
|
const status = el("div", "opf-source-status", { role: "status", "aria-live": "polite" });
|
|
179
209
|
|
|
@@ -185,7 +215,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
185
215
|
const list = el("ul", "opf-source-findings-list", { id: `${id}-findings` });
|
|
186
216
|
findingsRegion.append(toggle, list);
|
|
187
217
|
|
|
188
|
-
root.append(bar, code, findingsRegion, status);
|
|
218
|
+
root.append(bar, confirm, code, findingsRegion, status);
|
|
189
219
|
container.append(root);
|
|
190
220
|
|
|
191
221
|
// ---- Document <-> text --------------------------------------------------------------------------------------------
|
|
@@ -233,6 +263,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
233
263
|
language: formatOf(format).language,
|
|
234
264
|
readOnly,
|
|
235
265
|
completions: options.completions !== false,
|
|
266
|
+
smartListEnter: options.smartListEnter,
|
|
236
267
|
catalogs: options.catalogs ?? editor.catalogs,
|
|
237
268
|
describedBy: `${id}-findings`,
|
|
238
269
|
onChange: () => schedule(),
|
|
@@ -246,6 +277,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
246
277
|
|
|
247
278
|
// ---- Applying edits -----------------------------------------------------------------------------------------------
|
|
248
279
|
function schedule() {
|
|
280
|
+
closeConfirm(false);
|
|
249
281
|
clearTimeout(timer);
|
|
250
282
|
clearTimeout(syntaxTimer);
|
|
251
283
|
if (delay === 0) { flush(); return; }
|
|
@@ -287,7 +319,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
287
319
|
renderFindings();
|
|
288
320
|
return;
|
|
289
321
|
}
|
|
290
|
-
syntaxFindings = [];
|
|
322
|
+
syntaxFindings = read.notes ?? [];
|
|
291
323
|
baseline = source;
|
|
292
324
|
lastDraft = read.deck;
|
|
293
325
|
const before = deck();
|
|
@@ -325,6 +357,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
325
357
|
|
|
326
358
|
/** Show `value` as the text of the current format, as an external change. */
|
|
327
359
|
function show(value) {
|
|
360
|
+
closeConfirm(false);
|
|
328
361
|
baseline = value;
|
|
329
362
|
shownInvalid = false;
|
|
330
363
|
control.update(value, false, null, `${id}-findings`);
|
|
@@ -632,6 +665,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
632
665
|
nameButton.setAttribute("aria-label", `Rename ${name}`);
|
|
633
666
|
formatLabel.textContent = `.opf.${current.extension}`;
|
|
634
667
|
root.dataset.format = current.id;
|
|
668
|
+
canonicalAction.hidden = current.id === "json";
|
|
635
669
|
for (const item of menuItems) item.setAttribute("aria-checked", String(item.dataset.format === current.id));
|
|
636
670
|
}
|
|
637
671
|
|
|
@@ -670,6 +704,61 @@ export function createSourcePane(container, options = {}) {
|
|
|
670
704
|
return true;
|
|
671
705
|
}
|
|
672
706
|
formatAction.addEventListener("click", () => { formatDocument(); });
|
|
707
|
+
|
|
708
|
+
// Canonicalize with OPF: the text rewritten through core's writer (the old Format). Unlike Format it drops comments, so it asks first and says how many.
|
|
709
|
+
function droppedComments(text) {
|
|
710
|
+
if (format === "yaml") return scanYamlComments(text).count;
|
|
711
|
+
if (format !== "md") return 0;
|
|
712
|
+
const scan = scanMarkdown(text);
|
|
713
|
+
let count = scan.comments.filter((comment) => comment.kind === "comment").length;
|
|
714
|
+
if (scan.frontMatter) count += scanYamlComments(text.slice(scan.frontMatter.yamlFrom, scan.frontMatter.yamlTo)).count;
|
|
715
|
+
for (const fence of scan.fences) if (fence.kind === "opf-slide" || fence.kind === "opf-block") count += scanYamlComments(text.slice(fence.bodyFrom, fence.bodyTo)).count;
|
|
716
|
+
return count;
|
|
717
|
+
}
|
|
718
|
+
let pendingCanonical = null;
|
|
719
|
+
function closeConfirm(returnFocus) {
|
|
720
|
+
if (confirm.hidden) return;
|
|
721
|
+
confirm.hidden = true;
|
|
722
|
+
pendingCanonical = null;
|
|
723
|
+
if (returnFocus) control.api.focus();
|
|
724
|
+
}
|
|
725
|
+
/** Rewrite the text through OPF's own writer. Asks first when that drops comments (`confirmed: true` has asked already). Returns "applied", "unchanged", "confirm" (waiting for the answer) or "refused". */
|
|
726
|
+
function canonicalize({ confirmed = false } = {}) {
|
|
727
|
+
flush();
|
|
728
|
+
if (readOnly || format === "json" || destroyed) return "refused";
|
|
729
|
+
if (dirty) { announce("Fix the errors first: the text cannot be read as a document."); control.api.say("Fix the errors first: the text cannot be read as a document."); return "refused"; }
|
|
730
|
+
let written;
|
|
731
|
+
try { written = write(format, deck()); } catch (error) { announce(`Cannot canonicalize: ${errorText(error)}`); return "refused"; }
|
|
732
|
+
const before = control.api.getValue();
|
|
733
|
+
if (written.text === before) { announce("Already canonical"); control.api.say("Already canonical"); return "unchanged"; }
|
|
734
|
+
const count = droppedComments(before);
|
|
735
|
+
if (count > 0 && !confirmed) {
|
|
736
|
+
pendingCanonical = written.text;
|
|
737
|
+
confirmText.textContent = `Canonicalizing rewrites the text the way OPF writes it, and drops ${plural(count, "comment")}. Undo brings ${count === 1 ? "it" : "them"} back.`;
|
|
738
|
+
confirm.hidden = false;
|
|
739
|
+
confirmAccept.focus();
|
|
740
|
+
control.api.say(confirmText.textContent);
|
|
741
|
+
return "confirm";
|
|
742
|
+
}
|
|
743
|
+
closeConfirm(false);
|
|
744
|
+
control.api.replaceDiff(written.text);
|
|
745
|
+
flush();
|
|
746
|
+
control.api.focus();
|
|
747
|
+
announce("Canonicalized");
|
|
748
|
+
control.api.say(count > 0 ? `Canonicalized. ${plural(count, "comment")} dropped.` : "Canonicalized");
|
|
749
|
+
return "applied";
|
|
750
|
+
}
|
|
751
|
+
canonicalAction.addEventListener("click", () => { canonicalize(); });
|
|
752
|
+
confirmAccept.addEventListener("click", () => { closeConfirm(false); canonicalize({ confirmed: true }); });
|
|
753
|
+
confirmCancel.addEventListener("click", () => closeConfirm(true));
|
|
754
|
+
confirm.addEventListener("keydown", (event) => {
|
|
755
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeConfirm(true); }
|
|
756
|
+
else if (event.key === "Tab") {
|
|
757
|
+
const order = [confirmAccept, confirmCancel], at = order.indexOf(document.activeElement);
|
|
758
|
+
event.preventDefault();
|
|
759
|
+
order[(at + (event.shiftKey ? order.length - 1 : 1)) % order.length].focus();
|
|
760
|
+
}
|
|
761
|
+
});
|
|
673
762
|
copyAction.addEventListener("click", async () => {
|
|
674
763
|
const info = currentFile();
|
|
675
764
|
try {
|
|
@@ -738,13 +827,13 @@ export function createSourcePane(container, options = {}) {
|
|
|
738
827
|
function problemContext() {
|
|
739
828
|
const draft = lastDraft ?? deck();
|
|
740
829
|
return {
|
|
830
|
+
document: draft,
|
|
831
|
+
format,
|
|
741
832
|
unit: control.api.indentUnit(),
|
|
742
833
|
valueAt: (segments) => segments.reduce((value, key) => (value !== null && typeof value === "object" ? value[key] : undefined), draft),
|
|
743
|
-
schemaAt: (segments) => attempt(() => schemaAtPath(draft, segments), undefined),
|
|
744
834
|
keysOf: (segments) => attempt(() => [...new Set(schemaVariants(schemaAtPath(draft, segments)).flatMap((variant) => Object.keys(variant.properties ?? {})))], []),
|
|
745
|
-
catalogKind: (segments) => catalogKindAt(segments),
|
|
746
835
|
catalogCount: (kind) => attempt(() => listCatalogRecords(draft, kind, { catalogs: catalogs() }).length, 0),
|
|
747
|
-
starter: (segments, property) => attempt(() => (
|
|
836
|
+
starter: (segments, property) => attempt(() => starterValue(draft, segments, property), undefined),
|
|
748
837
|
};
|
|
749
838
|
}
|
|
750
839
|
/** Select the field a core "focus" fix names: its path, else the field inside it, else the nearest place there is one. */
|
|
@@ -752,11 +841,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
752
841
|
flush();
|
|
753
842
|
const text = control.api.getValue();
|
|
754
843
|
const segments = String(focus.path ?? "").replace(/^\//, "").split("/").filter(Boolean).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (where) { control.api.setSelection(where.from, where.to); control.api.focus(); return; }
|
|
759
|
-
}
|
|
844
|
+
const wanted = focus.field ? locateText(format, text, [...segments, focus.field], "value") : null;
|
|
845
|
+
const where = wanted?.exact ? wanted : locateText(format, text, segments, "value");
|
|
846
|
+
if (where) { control.api.setSelection(where.from, where.to); control.api.focus(); return; }
|
|
760
847
|
focusPath(focus.path ?? "");
|
|
761
848
|
}
|
|
762
849
|
const fixActions = {
|
|
@@ -836,13 +923,32 @@ export function createSourcePane(container, options = {}) {
|
|
|
836
923
|
options.onFindingSelect?.(finding, target);
|
|
837
924
|
});
|
|
838
925
|
item.append(button);
|
|
839
|
-
|
|
926
|
+
const details = detailsFor(finding);
|
|
927
|
+
if (row.fixes.length || details) {
|
|
840
928
|
const fixes = el("span", "opf-source-finding-fixes");
|
|
841
929
|
for (const fix of row.fixes.slice(0, 3)) {
|
|
842
930
|
const fixButton = Object.assign(el("button", "opf-source-finding-fix", { type: "button", "aria-label": `${fix.title}: ${row.message}` }), { textContent: fix.title });
|
|
843
931
|
fixButton.addEventListener("click", () => applyFix(fix, row));
|
|
844
932
|
fixes.append(fixButton);
|
|
845
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
|
+
}
|
|
846
952
|
item.append(fixes);
|
|
847
953
|
}
|
|
848
954
|
return item;
|
|
@@ -913,6 +1019,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
913
1019
|
root.toggleAttribute("data-readonly", readOnly);
|
|
914
1020
|
nameButton.disabled = readOnly;
|
|
915
1021
|
formatAction.disabled = readOnly;
|
|
1022
|
+
canonicalAction.disabled = readOnly;
|
|
916
1023
|
renderFindings();
|
|
917
1024
|
syncAttributes();
|
|
918
1025
|
|
|
@@ -936,6 +1043,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
936
1043
|
getFormat: () => format,
|
|
937
1044
|
/** Move the cursor to the line of a JSON Pointer in the current format (best effort for YAML and Markdown). Returns the line. */
|
|
938
1045
|
focusPath: (path) => focusPath(path),
|
|
1046
|
+
/** Canonicalize with OPF (rewrite through core's writer; asks before dropping comments). See `canonicalize` in the pane. */
|
|
1047
|
+
canonicalize,
|
|
939
1048
|
/** The source text exactly as shown, with any edit still waiting applied first. */
|
|
940
1049
|
getText() { flush(); return control.api.getValue(); },
|
|
941
1050
|
getName: () => name,
|
|
@@ -946,6 +1055,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
946
1055
|
root.toggleAttribute("data-readonly", readOnly);
|
|
947
1056
|
nameButton.disabled = readOnly;
|
|
948
1057
|
formatAction.disabled = readOnly;
|
|
1058
|
+
canonicalAction.disabled = readOnly;
|
|
949
1059
|
control.setReadOnly(readOnly);
|
|
950
1060
|
},
|
|
951
1061
|
/** The problems drawn in the text now, in position order: `{ from, to, severity, message, next, rule, fixes }` at source offsets. */
|
package/dist/source-problems.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// column) and the quick fixes that go with it. The pane lists them in the panel and draws them in the text with the same objects.
|
|
3
3
|
import { describeFinding, segmentsOf } from "./source-messages.js";
|
|
4
4
|
import { fixesForFinding } from "./source-fixes.js";
|
|
5
|
-
import {
|
|
5
|
+
import { textFixesForFinding } from "./source-fixes-text.js";
|
|
6
|
+
import { lineColumn, locateText, partOf } from "./source-ranges.js";
|
|
6
7
|
|
|
7
8
|
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
8
9
|
|
|
@@ -30,8 +31,9 @@ export function buildProblems({ format, text, findings, context = {}, actions =
|
|
|
30
31
|
const { message, next } = describeFinding(finding, context);
|
|
31
32
|
let range = null;
|
|
32
33
|
if (finding.range) range = { from: finding.range.from, to: finding.range.to };
|
|
33
|
-
else if (
|
|
34
|
-
|
|
34
|
+
else if (finding.kind !== "syntax" && finding.path !== undefined && finding.ruleId?.startsWith("opf/")) {
|
|
35
|
+
// The finding names a field of the document: core finds it in the text of any format.
|
|
36
|
+
const located = locateText(format, text, segmentsOf(finding.path), partOf(finding));
|
|
35
37
|
if (located) range = { from: located.from, to: located.to };
|
|
36
38
|
}
|
|
37
39
|
if (!range && Number.isInteger(finding.location?.offset)) range = { from: finding.location.offset, to: finding.location.offset + Math.max(0, finding.location.length ?? 0) };
|
|
@@ -40,8 +42,11 @@ export function buildProblems({ format, text, findings, context = {}, actions =
|
|
|
40
42
|
if (fix.action === "switch-format") fixes.push({ id: fix.id, title: fix.title, safe: fix.safe === true, run: () => actions.switchFormat?.(fix.format) });
|
|
41
43
|
else fixes.push({ ...fix, edits: toEdits(fix.edits) });
|
|
42
44
|
}
|
|
43
|
-
if (
|
|
45
|
+
if (finding.kind !== "syntax") fixes.push(...(format === "json" ? fixesForFinding(finding, text, context) : textFixesForFinding(format, finding, text, context)));
|
|
44
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); }
|
|
45
50
|
const row = { finding, index, severity: finding.severity, message, next, rule: finding.ruleId, path: finding.path ?? "", fixes };
|
|
46
51
|
if (range) {
|
|
47
52
|
row.from = range.from;
|