@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.
- package/README.md +8 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +9 -2
- package/dist/json-editor.d.ts +20 -0
- package/dist/json-editor.js +189 -91
- package/dist/source-a11y.js +18 -1
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +232 -22
- package/dist/source-details.js +17 -0
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +334 -0
- package/dist/source-fixes-text.js +75 -2
- package/dist/source-fixes.js +25 -1
- package/dist/source-folding.js +173 -0
- package/dist/source-hints.js +376 -0
- package/dist/source-keymap.js +369 -0
- package/dist/source-markdown.js +53 -17
- package/dist/source-messages.js +21 -205
- package/dist/source-navigation-model.js +426 -0
- package/dist/source-navigation-read.js +269 -0
- package/dist/source-navigation-ui.js +244 -0
- package/dist/source-navigation.js +217 -0
- package/dist/source-outline.js +112 -0
- package/dist/source-pane.d.ts +63 -0
- package/dist/source-pane.js +240 -45
- package/dist/source-problems.js +3 -0
- package/dist/source-references.js +274 -0
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-snippets.js +20 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/source-yaml.js +94 -9
- package/dist/styles.css +288 -0
- package/package.json +12 -7
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The "Details" of a problem (artifacts/source-editor/VALIDATOR-HINTS.md, section 1): what the rule checks, why it matters, what to do and a small
|
|
2
|
+
// example, from core's `explain(ruleId)`, behind a disclosure so the row itself stays one sentence and a hint. Pure data here; the pane draws it.
|
|
3
|
+
import { explain } from "@openpresentation/opf/source";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The details of a finding: `{ rule, summary, why, fix, example?, docs? }`, or null when core has nothing to say about the rule. A schema finding adds the
|
|
7
|
+
* keyword and the place in the schema it comes from.
|
|
8
|
+
*/
|
|
9
|
+
export function detailsFor(finding) {
|
|
10
|
+
const ruleId = finding?.ruleId;
|
|
11
|
+
if (!ruleId) return null;
|
|
12
|
+
let found;
|
|
13
|
+
try { found = explain(ruleId); } catch { found = undefined; }
|
|
14
|
+
if (!found) return null;
|
|
15
|
+
const schema = finding.validation?.keyword ? `The schema keyword is ${finding.validation.keyword}${finding.definition ? `; it is defined at ${finding.definition}` : ""}.` : "";
|
|
16
|
+
return { rule: found.id, summary: found.summary, why: found.rationale, fix: found.fix, ...(found.example ? { example: found.example } : {}), docs: finding.definition ?? found.docs, ...(schema ? { schema } : {}) };
|
|
17
|
+
}
|
|
@@ -123,7 +123,7 @@ export function fixesAtCaret(state, source) {
|
|
|
123
123
|
* `getSource()` is the original text; `applyFix(fix, problem)` performs a fix (the editor's edits or the pane's own action); `say(message)` is
|
|
124
124
|
* the live region. Returns `{ extension, draw(view, problems), problems(view) }`.
|
|
125
125
|
*/
|
|
126
|
-
export function createDiagnostics({ getSource, applyFix, say }) {
|
|
126
|
+
export function createDiagnostics({ getSource, applyFix, say, km }) {
|
|
127
127
|
let announced = null;
|
|
128
128
|
const stepProblem = (forward) => (view) => {
|
|
129
129
|
const source = getSource(), map = textInputMap(source), list = currentProblems(view.state, source);
|
|
@@ -165,7 +165,8 @@ export function createDiagnostics({ getSource, applyFix, say }) {
|
|
|
165
165
|
{ key: "Escape", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
|
|
166
166
|
{ key: "Tab", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
|
|
167
167
|
]));
|
|
168
|
-
|
|
168
|
+
// F8, Shift+F8 and the quick fix key are declared in source-keymap.js; the menu's own keys above are modal and not listed.
|
|
169
|
+
const plain = keymap.of(km ? [...km.bind("problem.next", stepProblem(true)), ...km.bind("problem.previous", stepProblem(false)), ...km.bind("problem.quickFix", openMenu)] : [
|
|
169
170
|
{ key: "F8", run: stepProblem(true), shift: stepProblem(false) },
|
|
170
171
|
{ key: "Mod-.", run: openMenu },
|
|
171
172
|
]);
|
package/dist/source-editing.js
CHANGED
|
@@ -227,39 +227,59 @@ export const markdownWrapSelection = EditorView.inputHandler.of((view, _from, _t
|
|
|
227
227
|
// ---- Folding in Markdown -----------------------------------------------------------------------------------------------------------
|
|
228
228
|
const SEPARATOR = /^-{3,}[ \t]*$/;
|
|
229
229
|
|
|
230
|
+
const FRONT_END = /^(---|\.\.\.)\s*$/;
|
|
231
|
+
const foldIndexes = new WeakMap();
|
|
232
|
+
|
|
230
233
|
/**
|
|
231
|
-
*
|
|
232
|
-
* first line to the line before the next `---`). A `---` inside a
|
|
234
|
+
* Every foldable range of a Markdown text, by the number of the line it starts on, from one pass over the lines (SPEC 6.8): the front matter (from its
|
|
235
|
+
* opening line), a fenced block (from its opening fence) and a slide (from its first line to the last line before the next `---`). A `---` inside a
|
|
236
|
+
* fence or the front matter is not a separator. A fence that is also the first line of a slide folds as the fence.
|
|
233
237
|
*/
|
|
234
|
-
export
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
238
|
+
export function markdownFoldIndex(doc) {
|
|
239
|
+
const cached = foldIndexes.get(doc);
|
|
240
|
+
if (cached) return cached;
|
|
241
|
+
const index = new Map();
|
|
242
|
+
const range = (line, endLine) => ({ from: doc.line(line).to, to: doc.line(endLine).to });
|
|
243
|
+
let front = false, fence = null, previous = null, slide = null, frontEnd = 0;
|
|
244
|
+
const closeSlide = () => { if (slide && slide.last > slide.start) index.set(slide.start, range(slide.start, slide.last)); slide = null; };
|
|
245
|
+
for (let number = 1; number <= doc.lines; number++) {
|
|
246
|
+
const text = doc.line(number).text;
|
|
247
|
+
// What this line is, from the state before it (as markdownLineKind): the opening line of the front matter is body.
|
|
248
|
+
let kind = "body";
|
|
249
|
+
if (front) kind = FRONT_END.test(text) ? "body" : "front";
|
|
250
|
+
else if (fence) kind = new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text) ? "body" : fence.yaml ? "yaml-fence" : "fence";
|
|
251
|
+
const blank = text.trim() === "";
|
|
252
|
+
if (kind === "body" && !blank) {
|
|
253
|
+
if (SEPARATOR.test(text)) closeSlide();
|
|
254
|
+
else {
|
|
255
|
+
const startsSlide = !previous || ((SEPARATOR.test(previous.text) || /^\.\.\.\s*$/.test(previous.text)) && previous.kind === "body");
|
|
256
|
+
if (!slide && startsSlide) slide = { start: number, last: number };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (slide && !blank && !(kind === "body" && SEPARATOR.test(text))) slide.last = number;
|
|
260
|
+
if (!blank) previous = { text, kind };
|
|
261
|
+
// The state after this line.
|
|
262
|
+
if (number === 1 && /^---\s*$/.test(text)) {
|
|
263
|
+
front = true;
|
|
264
|
+
for (let at = 2; at <= doc.lines; at++) if (FRONT_END.test(doc.line(at).text)) { frontEnd = at; break; }
|
|
265
|
+
if (frontEnd > 2) index.set(1, range(1, frontEnd - 1));
|
|
266
|
+
} else if (front) {
|
|
267
|
+
if (FRONT_END.test(text)) front = false;
|
|
268
|
+
} else if (fence) {
|
|
269
|
+
if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text)) {
|
|
270
|
+
if (number > fence.start + 1) index.set(fence.start, range(fence.start, number - 1));
|
|
271
|
+
fence = null;
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
const open = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(text);
|
|
275
|
+
if (open && !(open[2][0] === "`" && open[3].includes("`"))) fence = { char: open[2][0], length: open[2].length, yaml: /^\s*opf-(?:slide|block)\b/.test(open[3]), start: number };
|
|
276
|
+
}
|
|
262
277
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
278
|
+
// A fence never closed folds to the end of the text.
|
|
279
|
+
if (fence && fence.start < doc.lines) index.set(fence.start, range(fence.start, doc.lines));
|
|
280
|
+
closeSlide();
|
|
281
|
+
foldIndexes.set(doc, index);
|
|
282
|
+
return index;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const markdownFolding = foldService.of((state, lineStart) => markdownFoldIndex(state.doc).get(state.doc.lineAt(lineStart).number) ?? null);
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Quick fixes for the syntax problems of a YAML or Markdown source (artifacts/source-editor/VALIDATOR-HINTS.md, sections 9, 10 and 12): "Convert tabs to
|
|
2
|
+
// spaces", "Insert closing quote", "Insert ]", "Add a colon", "Insert ---", "Insert closing fence", "Insert -->", "Rename to layout", "Insert column" and
|
|
3
|
+
// the like. The YAML ones are core's (`analyzeYamlSyntax` returns the edits with the problem); the Markdown ones are found here from the finding's range and
|
|
4
|
+
// core's `scanMarkdown`. Pure functions over the source text: each fix is `{ id, title, safe, edits: [{ from, to, insert }], caret? }`, source offsets in, so
|
|
5
|
+
// the editor applies it as one undoable change that keeps the author's comments and layout.
|
|
6
|
+
import { parseDocument } from "yaml";
|
|
7
|
+
import { analyzeYamlSyntax, scanMarkdown } from "@openpresentation/opf/source";
|
|
8
|
+
|
|
9
|
+
const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
|
|
10
|
+
const lineEnd = (text, offset) => { const at = text.indexOf("\n", offset); return at < 0 ? text.length : at; };
|
|
11
|
+
const nextLine = (text, offset) => { const end = lineEnd(text, offset); return end < text.length ? end + 1 : end; };
|
|
12
|
+
const fix = (id, title, edits, extra = {}) => ({ id, title, safe: false, edits, ...extra });
|
|
13
|
+
const fromCore = (entry) => ({ id: entry.id, title: entry.title, safe: entry.safe === true, edits: entry.edits.map((edit) => ({ from: edit.offset, to: edit.offset + edit.length, insert: edit.insert })), ...(entry.caret !== undefined ? { caret: entry.caret } : {}) });
|
|
14
|
+
|
|
15
|
+
// ---- YAML -------------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
const yamlMemo = { text: null, problems: [] };
|
|
17
|
+
function yamlProblems(text) {
|
|
18
|
+
if (yamlMemo.text !== text) { yamlMemo.text = text; yamlMemo.problems = analyzeYamlSyntax(text); }
|
|
19
|
+
return yamlMemo.problems;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The YAML regions of a Markdown text: its front matter and its `opf-slide` and `opf-block` fences, as `{ from, to }` of the YAML itself. */
|
|
23
|
+
function markdownYamlRegions(text) {
|
|
24
|
+
const scan = scanMarkdown(text);
|
|
25
|
+
const regions = [];
|
|
26
|
+
if (scan.frontMatter) regions.push({ from: scan.frontMatter.yamlFrom, to: scan.frontMatter.yamlTo });
|
|
27
|
+
for (const fence of scan.fences) if (fence.kind === "opf-slide" || fence.kind === "opf-block") regions.push({ from: fence.bodyFrom, to: fence.bodyTo });
|
|
28
|
+
return regions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The problem that starts where `finding` does, in the YAML `text` (`shift` is where `text` starts in the source). */
|
|
32
|
+
function yamlHit(problems, finding, shift) {
|
|
33
|
+
const at = (finding.location?.offset ?? finding.range?.from) - shift;
|
|
34
|
+
return problems.find((problem) => problem.range.offset === at);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function yamlSyntax(format, text, finding) {
|
|
38
|
+
if (format === "yaml") {
|
|
39
|
+
const hit = yamlHit(yamlProblems(text), finding, 0);
|
|
40
|
+
return hit ? { fixes: hit.fixes.map(fromCore), related: (hit.related ?? []).map((range) => ({ from: range.offset, to: range.offset + range.length })) } : null;
|
|
41
|
+
}
|
|
42
|
+
const at = finding.location?.offset ?? finding.range?.from;
|
|
43
|
+
const region = markdownYamlRegions(text).find((entry) => at >= entry.from && at <= entry.to);
|
|
44
|
+
if (!region) return null;
|
|
45
|
+
const slice = text.slice(region.from, region.to);
|
|
46
|
+
const hit = yamlHit(analyzeYamlSyntax(slice, { rootKind: "any" }), finding, region.from);
|
|
47
|
+
if (!hit) return null;
|
|
48
|
+
const shift = (offset) => offset + region.from;
|
|
49
|
+
return {
|
|
50
|
+
fixes: hit.fixes.map(fromCore).map((entry) => ({ ...entry, edits: entry.edits.map((edit) => ({ ...edit, from: shift(edit.from), to: shift(edit.to) })), ...(entry.caret !== undefined ? { caret: shift(entry.caret) } : {}) })),
|
|
51
|
+
related: (hit.related ?? []).map((range) => ({ from: shift(range.offset), to: shift(range.offset + range.length) })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- Markdown ---------------------------------------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** The end of the last non-blank line inside [from, to). */
|
|
58
|
+
function lastContentEnd(text, from, to) {
|
|
59
|
+
let end = Math.min(to, text.length);
|
|
60
|
+
while (end > from && /\s/.test(text[end - 1])) end--;
|
|
61
|
+
return end;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Where a front matter that was never closed should end: before the first blank line or the first line that is not YAML-shaped. */
|
|
65
|
+
function frontMatterEnd(text) {
|
|
66
|
+
const YAMLISH = /^(\s*[A-Za-z_][\w.-]*\s*:|\s*-\s|\s+\S|\s*#(?!\s)|\s*#\s*$|\s*#\s.*$)/;
|
|
67
|
+
let at = nextLine(text, 0);
|
|
68
|
+
let seen = false;
|
|
69
|
+
while (at < text.length) {
|
|
70
|
+
const end = lineEnd(text, at);
|
|
71
|
+
const content = text.slice(at, end);
|
|
72
|
+
if (content.trim() === "" || (!YAMLISH.test(content) && seen)) return at;
|
|
73
|
+
if (/^#{1,6}\s/.test(content) && !/^#\s.*:\s/.test(content)) return at;
|
|
74
|
+
seen = true;
|
|
75
|
+
at = nextLine(text, at);
|
|
76
|
+
}
|
|
77
|
+
return text.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove the whole lines that [from, to) touches. */
|
|
81
|
+
const removeLines = (text, from, to) => ({ from: lineStart(text, from), to: nextLine(text, Math.max(from, to - 1)), insert: "" });
|
|
82
|
+
|
|
83
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
84
|
+
const isBlankLine = (text, start) => text.slice(start, lineEnd(text, start)).trim() === "";
|
|
85
|
+
/** A name as the finding message writes it (a JSON string without its quotes). */
|
|
86
|
+
const unquote = (value) => { try { return JSON.parse(`"${value}"`); } catch { return value; } };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The lines of the top-level entry `key` of the YAML in text[from, to) (the key through its value, whole lines), as an edit; null when the key is
|
|
90
|
+
* not a block entry at the start of a line (a flow mapping, an entry the reader cannot place). The comments above and below stay.
|
|
91
|
+
*/
|
|
92
|
+
function yamlEntryEdit(text, from, to, key) {
|
|
93
|
+
const slice = text.slice(from, to);
|
|
94
|
+
let document;
|
|
95
|
+
try { document = parseDocument(slice, { strict: false, uniqueKeys: false }); } catch { return null; }
|
|
96
|
+
const pairs = document.contents?.items;
|
|
97
|
+
if (!Array.isArray(pairs) || document.contents.flow) return null;
|
|
98
|
+
const pair = pairs.find((entry) => String(entry.key?.value ?? entry.key) === key);
|
|
99
|
+
const keyRange = pair?.key?.range;
|
|
100
|
+
if (!keyRange) return null;
|
|
101
|
+
const start = lineStart(slice, keyRange[0]);
|
|
102
|
+
if (slice.slice(start, keyRange[0]).trim() !== "") return null;
|
|
103
|
+
const valueEnd = Math.max(keyRange[1], pair.value?.range?.[1] ?? 0);
|
|
104
|
+
const end = nextLine(slice, Math.max(keyRange[1], valueEnd - 1));
|
|
105
|
+
// The last line of a body has no line break of its own (the closing fence's follows it): the one before it goes instead.
|
|
106
|
+
const lead = end >= slice.length && !slice.endsWith("\n") && start > 0 ? (slice.slice(Math.max(0, start - 2), start) === "\r\n" ? 2 : 1) : 0;
|
|
107
|
+
return { from: from + start - lead, to: from + end, insert: "" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when the YAML text[from, to) has only blank and comment lines once `edit` is applied. */
|
|
111
|
+
function emptyAfter(text, from, to, edit) {
|
|
112
|
+
const rest = text.slice(from, edit.from) + text.slice(edit.to, to);
|
|
113
|
+
return rest.split(/\r?\n/).every((line) => line.trim() === "" || line.trim().startsWith("#"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The span of `key` (a flag, `key=bare` or `key="quoted"`) in an info line or an options comment text[from, to), with the space before it, or null. */
|
|
117
|
+
function attributeSpan(text, from, to, key) {
|
|
118
|
+
const found = new RegExp(`[ \t]+${escapeRegExp(key)}(?:=(?:"(?:[^"\\\\]|\\\\.)*"|[^\\s,"]+))?(?=[\\s,]|-->|$)`).exec(text.slice(from, to));
|
|
119
|
+
return found ? { from: from + found.index, to: from + found.index + found[0].length } : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** An attribute the fence does not take (`Unknown chart attribute "foo".`) is dropped from its info line; a known one with a bad value is left to the author. */
|
|
123
|
+
function unknownAttribute(text, finding, kind, known) {
|
|
124
|
+
const quoted = new RegExp(`^Unknown ${kind} attribute ("(?:[^"\\\\]|\\\\.)*")\\.$`).exec(finding.message)?.[1];
|
|
125
|
+
const key = quoted ? unquote(quoted.slice(1, -1)) : "";
|
|
126
|
+
if (!key || known.includes(key)) return [];
|
|
127
|
+
const open = lineStart(text, finding.location.offset);
|
|
128
|
+
const span = attributeSpan(text, open, lineEnd(text, open), key);
|
|
129
|
+
return span ? [fix(`remove-attribute:${key}`, `Remove ${key}`, [{ ...span, insert: "" }])] : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const markdownRules = {
|
|
133
|
+
"markdown/front-matter-unterminated": (text) => {
|
|
134
|
+
const at = frontMatterEnd(text);
|
|
135
|
+
const prefix = at > 0 && text[at - 1] !== "\n" ? "\n" : "";
|
|
136
|
+
return [fix("insert-front-matter-end", "Insert ---", [{ from: at, to: at, insert: `${prefix}---\n` }], { safe: true })];
|
|
137
|
+
},
|
|
138
|
+
"markdown/comment-unterminated": (text, finding) => {
|
|
139
|
+
const from = finding.location.offset;
|
|
140
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
141
|
+
return [fix("insert-comment-end", "Insert -->", [{ from: end, to: end, insert: " -->" }], { safe: true, caret: end + 4 })];
|
|
142
|
+
},
|
|
143
|
+
"markdown/fence-unterminated": (text, finding) => {
|
|
144
|
+
const from = finding.location.offset;
|
|
145
|
+
const opening = text.slice(lineStart(text, from), lineEnd(text, from));
|
|
146
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(opening)?.[1] ?? "```";
|
|
147
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
148
|
+
const at = nextLine(text, end > from ? end - 1 : from);
|
|
149
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
150
|
+
return [fix("insert-closing-fence", "Insert closing fence", [{ from: at, to: at, insert: `${prefix}${marker}\n` }], { safe: true })];
|
|
151
|
+
},
|
|
152
|
+
"markdown/options-unknown-key": (text, finding) => {
|
|
153
|
+
const key = /option "(.*)"\.$/.exec(finding.message)?.[1];
|
|
154
|
+
const { offset, length } = finding.location;
|
|
155
|
+
const found = key ? new RegExp(`(^|[\\s:])(${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?==|\\s|-->|$)`).exec(text.slice(offset, offset + length)) : null;
|
|
156
|
+
if (!found) return [];
|
|
157
|
+
const from = offset + found.index + found[1].length;
|
|
158
|
+
return (finding.suggestions ?? []).map((entry) => fix(`rename-to:${entry.value}`, `Rename to ${entry.value}`, [{ from, to: from + key.length, insert: String(entry.value) }]));
|
|
159
|
+
},
|
|
160
|
+
"markdown/options-value": (text, finding) => {
|
|
161
|
+
const { offset, length } = finding.location;
|
|
162
|
+
const body = text.slice(offset, offset + length);
|
|
163
|
+
if (/^hidden takes/.test(finding.message)) {
|
|
164
|
+
const found = /\bhidden=("[^"]*"|\S+?)(?=\s|-->)/.exec(body);
|
|
165
|
+
if (!found) return [];
|
|
166
|
+
const from = offset + found.index;
|
|
167
|
+
return ["true", "false"].map((value) => fix(`hidden-${value}`, `Use hidden=${value}`, [{ from, to: from + found[0].length, insert: `hidden=${value}` }]));
|
|
168
|
+
}
|
|
169
|
+
if (/^as takes/.test(finding.message)) {
|
|
170
|
+
const found = /\bas=("[^"]*"|\S+?)(?=\s|-->)/.exec(body);
|
|
171
|
+
if (!found) return [];
|
|
172
|
+
const from = offset + found.index;
|
|
173
|
+
return ["bullets", "video"].map((value) => fix(`as-${value}`, `Use as=${value}`, [{ from, to: from + found[0].length, insert: `as=${value}` }]));
|
|
174
|
+
}
|
|
175
|
+
// A key written as a flag (`layout`) where it needs a value: the safest repair is to drop it, not to guess the value.
|
|
176
|
+
const key = /^Option (\w+) needs a value/.exec(finding.message)?.[1];
|
|
177
|
+
const span = key ? attributeSpan(text, offset, offset + length, key) : null;
|
|
178
|
+
return span ? [fix(`remove-option:${key}`, `Remove ${key}`, [{ ...span, insert: "" }])] : [];
|
|
179
|
+
},
|
|
180
|
+
"markdown/options-syntax": (text, finding) => {
|
|
181
|
+
const { offset, length } = finding.location;
|
|
182
|
+
const body = text.slice(offset, offset + length);
|
|
183
|
+
const open = /^Unterminated quoted value for ([A-Za-z][\w-]*)\./.exec(finding.message)?.[1];
|
|
184
|
+
if (open) {
|
|
185
|
+
// The quote is closed after the last character before `-->`.
|
|
186
|
+
const quote = new RegExp(`(?:^|[\\s:,])${escapeRegExp(open)}="`).exec(body);
|
|
187
|
+
if (!quote) return [];
|
|
188
|
+
const valueStart = quote.index + quote[0].length;
|
|
189
|
+
const arrow = body.indexOf("-->", valueStart);
|
|
190
|
+
if (arrow < 0) return [];
|
|
191
|
+
let end = arrow;
|
|
192
|
+
while (end > valueStart && /\s/.test(body[end - 1])) end--;
|
|
193
|
+
return [fix("insert-closing-quote", "Insert closing quote", [{ from: offset + end, to: offset + end, insert: '"' }])];
|
|
194
|
+
}
|
|
195
|
+
const missing = /^Missing value for ([A-Za-z][\w-]*)\./.exec(finding.message)?.[1];
|
|
196
|
+
if (missing) {
|
|
197
|
+
const found = new RegExp(`[ \t]+${escapeRegExp(missing)}=(?=[\\s,]|-->|$)`).exec(body);
|
|
198
|
+
return found ? [fix(`remove-option:${missing}`, `Remove ${missing}=`, [{ from: offset + found.index, to: offset + found.index + found[0].length, insert: "" }])] : [];
|
|
199
|
+
}
|
|
200
|
+
return [];
|
|
201
|
+
},
|
|
202
|
+
"markdown/options-trailing": (text, finding) => {
|
|
203
|
+
const { offset, length } = finding.location;
|
|
204
|
+
const body = text.slice(offset, offset + length);
|
|
205
|
+
const arrow = body.indexOf("-->");
|
|
206
|
+
if (arrow < 0) return [];
|
|
207
|
+
const gap = /^[ \t]*/.exec(body.slice(arrow + 3))[0];
|
|
208
|
+
if (body.slice(arrow + 3 + gap.length).trim() === "") return [];
|
|
209
|
+
return [fix("move-text-down", "Move the text to its own line", [{ from: offset + arrow + 3, to: offset + arrow + 3 + gap.length, insert: /\r\n/.test(text) ? "\r\n" : "\n" }])];
|
|
210
|
+
},
|
|
211
|
+
"markdown/options-orphan": (text, finding) => {
|
|
212
|
+
const { offset, length } = finding.location;
|
|
213
|
+
return [fix("remove-options-comment", "Remove the options comment", [removeLines(text, offset, offset + length)])];
|
|
214
|
+
},
|
|
215
|
+
"markdown/options-duplicate": (text, finding) => {
|
|
216
|
+
const key = /^Option (\w+) is given twice/.exec(finding.message)?.[1];
|
|
217
|
+
if (!key) return [];
|
|
218
|
+
const { offset, length } = finding.location;
|
|
219
|
+
const body = text.slice(offset, offset + length);
|
|
220
|
+
const pattern = new RegExp(`\\s${key}=("[^"]*"|[^\\s>]+)`, "g");
|
|
221
|
+
const matches = [...body.matchAll(pattern)];
|
|
222
|
+
if (matches.length < 2) return [];
|
|
223
|
+
const second = matches[1];
|
|
224
|
+
return [fix("remove-duplicate-option", `Remove the second ${key}`, [{ from: offset + second.index, to: offset + second.index + second[0].length, insert: "" }])];
|
|
225
|
+
},
|
|
226
|
+
"markdown/chart-type": (text, finding) => {
|
|
227
|
+
const open = lineStart(text, finding.location.offset);
|
|
228
|
+
const line = text.slice(open, lineEnd(text, open));
|
|
229
|
+
const found = /^(\s*(?:`{3,}|~{3,})\s*chart)/.exec(line);
|
|
230
|
+
if (!found) return [];
|
|
231
|
+
const at = open + found[1].length;
|
|
232
|
+
return (finding.suggestions ?? []).map((entry) => fix(`chart-type:${entry.value}`, `Insert ${entry.value}`, [{ from: at, to: at, insert: ` ${entry.value}` }]));
|
|
233
|
+
},
|
|
234
|
+
"markdown/front-matter-slides": (text, finding) => {
|
|
235
|
+
const { offset, length } = finding.location;
|
|
236
|
+
return [fix("remove-slides-key", "Remove the slides key", [removeLines(text, offset, offset + length)])];
|
|
237
|
+
},
|
|
238
|
+
"markdown/no-slides": (text) => {
|
|
239
|
+
const empty = text.trim() === "";
|
|
240
|
+
return [fix("insert-title", "Insert # Title", [{ from: text.length, to: text.length, insert: empty ? "# Title\n" : `${text.endsWith("\n") ? "" : "\n"}\n# Title\n` }], { safe: true })];
|
|
241
|
+
},
|
|
242
|
+
"markdown/duplicate-title": (text, finding) => {
|
|
243
|
+
const open = lineStart(text, finding.location.offset);
|
|
244
|
+
return [
|
|
245
|
+
fix("start-new-slide", "Start a new slide here", [{ from: open, to: open, insert: "---\n\n" }]),
|
|
246
|
+
fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))]),
|
|
247
|
+
];
|
|
248
|
+
},
|
|
249
|
+
"markdown/duplicate-subtitle": (text, finding) => {
|
|
250
|
+
const open = lineStart(text, finding.location.offset);
|
|
251
|
+
return [fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
252
|
+
},
|
|
253
|
+
"markdown/empty-heading": (text, finding) => {
|
|
254
|
+
const open = lineStart(text, finding.location.offset);
|
|
255
|
+
return [fix("remove-empty-heading", "Remove the heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
256
|
+
},
|
|
257
|
+
"markdown/numbered-list": (text, finding) => {
|
|
258
|
+
const { offset, length } = finding.location;
|
|
259
|
+
const scan = scanMarkdown(text);
|
|
260
|
+
const slide = scan.slides.find((entry) => offset >= entry.from && offset <= entry.to);
|
|
261
|
+
// A slide that has an opf-slide fence already gets the field written into it, not a second fence.
|
|
262
|
+
if (slide && scan.fences.some((fence) => fence.kind === "opf-slide" && fence.from >= slide.from && fence.to <= slide.to + 1)) return [];
|
|
263
|
+
const end = lastContentEnd(text, offset, offset + length);
|
|
264
|
+
const at = nextLine(text, end > offset ? end - 1 : offset);
|
|
265
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
266
|
+
return [fix("add-numbering", "Add `numbering`", [{ from: at, to: at, insert: `${prefix}\n\`\`\`opf-slide\nnumbering: arabic\n\`\`\`\n` }])];
|
|
267
|
+
},
|
|
268
|
+
"markdown/empty-quote": (text, finding) => {
|
|
269
|
+
const { offset, length } = finding.location;
|
|
270
|
+
const edit = removeLines(text, offset, offset + length);
|
|
271
|
+
// The blank line after the quote goes too when the line before is blank as well, so no double gap is left.
|
|
272
|
+
if (edit.from > 0 && isBlankLine(text, lineStart(text, edit.from - 1)) && edit.to < text.length && isBlankLine(text, edit.to)) edit.to = nextLine(text, edit.to);
|
|
273
|
+
return [fix("remove-empty-quote", "Remove the empty quote", [edit])];
|
|
274
|
+
},
|
|
275
|
+
"markdown/slide-property-conflict": (text, finding) => {
|
|
276
|
+
const key = unquote(/property "(.*)" is set twice/.exec(finding.message)?.[1] ?? "");
|
|
277
|
+
const slide = Number(/^\/slides\/(\d+)\//.exec(finding.path ?? "")?.[1]);
|
|
278
|
+
if (!key || !Number.isInteger(slide)) return [];
|
|
279
|
+
for (const fence of scanMarkdown(text).fences) {
|
|
280
|
+
if (fence.kind !== "opf-slide" || fence.slide !== slide) continue;
|
|
281
|
+
const edit = yamlEntryEdit(text, fence.bodyFrom, fence.bodyTo, key);
|
|
282
|
+
if (!edit) continue;
|
|
283
|
+
if (!emptyAfter(text, fence.bodyFrom, fence.bodyTo, edit)) return [fix(`remove-from-fence:${key}`, `Remove ${key} from the fence`, [edit])];
|
|
284
|
+
// The fence held nothing else: it goes with its blank line above.
|
|
285
|
+
let from = lineStart(text, fence.from);
|
|
286
|
+
const to = nextLine(text, fence.to);
|
|
287
|
+
if (from > 0 && isBlankLine(text, lineStart(text, from - 1)) && (to >= text.length || isBlankLine(text, to))) from = lineStart(text, from - 1);
|
|
288
|
+
return [fix(`remove-from-fence:${key}`, `Remove ${key} from the fence`, [{ from, to, insert: "" }])];
|
|
289
|
+
}
|
|
290
|
+
return [];
|
|
291
|
+
},
|
|
292
|
+
"markdown/chart-attributes": (text, finding) => unknownAttribute(text, finding, "chart", ["alt"]),
|
|
293
|
+
"markdown/timeline-attributes": (text, finding) => unknownAttribute(text, finding, "timeline", ["name", "description"]),
|
|
294
|
+
"markdown/metric-block": (text, finding) => {
|
|
295
|
+
const open = lineStart(text, finding.location.offset);
|
|
296
|
+
if (finding.message === "A metric needs a value.") {
|
|
297
|
+
const at = nextLine(text, open);
|
|
298
|
+
return [fix("insert-metric-value", "Insert value:", [{ from: at, to: at, insert: "value: 0\n" }])];
|
|
299
|
+
}
|
|
300
|
+
// The offending line itself is the finding's range: a key given twice, or a key with nothing after the colon.
|
|
301
|
+
const { offset, length } = finding.location;
|
|
302
|
+
const twice = /^Metric key (\w+) is given twice\./.exec(finding.message)?.[1];
|
|
303
|
+
if (twice) return [fix("remove-duplicate-metric-key", `Remove the second ${twice}`, [removeLines(text, offset, offset + length)])];
|
|
304
|
+
const empty = /^Metric key (\w+) has no value\./.exec(finding.message)?.[1];
|
|
305
|
+
return empty && empty !== "value" ? [fix(`remove-metric-key:${empty}`, `Remove ${empty}:`, [removeLines(text, offset, offset + length)])] : [];
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** The quick fixes of a YAML or Markdown syntax finding, and the other places that belong to it: `{ fixes, related }` or null. */
|
|
310
|
+
export function syntaxFixesFor(format, text, finding) {
|
|
311
|
+
if (!finding?.location || format === "json") return null;
|
|
312
|
+
const id = String(finding.ruleId ?? "");
|
|
313
|
+
try {
|
|
314
|
+
if (id.startsWith("yaml/") || (format === "md" && (id === "markdown/front-matter" || id === "markdown/opf-slide" || id === "markdown/opf-block"))) return yamlSyntax(format, text, finding);
|
|
315
|
+
const make = markdownRules[id];
|
|
316
|
+
if (make) {
|
|
317
|
+
const fixes = make(text, finding).filter((entry) => entry.edits.length);
|
|
318
|
+
return fixes.length ? { fixes, related: [] } : null;
|
|
319
|
+
}
|
|
320
|
+
} catch { /* A fix that cannot be worked out is not offered. */ }
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Findings of a YAML or Markdown source with their quick fixes (`editFixes`) and related places (`related`) added, in the shape `normalizeFinding` and
|
|
326
|
+
* `buildProblems` read. Findings that have no fixes are returned as they are.
|
|
327
|
+
*/
|
|
328
|
+
export function withSyntaxFixes(format, text, findings) {
|
|
329
|
+
return findings.map((finding) => {
|
|
330
|
+
const made = syntaxFixesFor(format, text, finding);
|
|
331
|
+
if (!made) return finding;
|
|
332
|
+
return { ...finding, ...(made.fixes.length ? { editFixes: made.fixes } : {}), ...(made.related.length ? { related: made.related } : {}) };
|
|
333
|
+
});
|
|
334
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// slides", "Remove property", "Add `id`". The same fixes source-fixes.js writes for JSON, as text edits found with core's `locate`, so each one is a
|
|
3
3
|
// change of the text itself (one undoable change) that keeps the author's comments and layout. Pure functions: `{ id, title, safe, edits: [{ from, to, insert }] }`.
|
|
4
4
|
import { Document } from "yaml";
|
|
5
|
-
import { SLIDE_OPTION_KEYS } from "@openpresentation/opf/source";
|
|
5
|
+
import { SLIDE_OPTION_KEYS, scanMarkdown } from "@openpresentation/opf/source";
|
|
6
6
|
import { replacementValues, segmentsOf } from "./source-messages.js";
|
|
7
7
|
import { locateText } from "./source-ranges.js";
|
|
8
8
|
|
|
@@ -58,6 +58,70 @@ function addProperty(format, text, segments, key, value) {
|
|
|
58
58
|
return [{ from: at, to: at, insert: text.trim() === "" ? `${body}\n` : `\n${body}` }];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Replace the value at `segments` by `make(old)`, when the text has it exactly. */
|
|
62
|
+
function replaceValue(format, text, segments, make) {
|
|
63
|
+
const where = locateText(format, text, segments, "value");
|
|
64
|
+
if (!where?.exact || where.from === where.to) return null;
|
|
65
|
+
const insert = make(text.slice(where.from, where.to));
|
|
66
|
+
return insert === null ? null : [{ from: where.from, to: where.to, insert }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A scalar as the one item of a flow list: quotes stay, a plain scalar that flow syntax would misread is quoted. */
|
|
70
|
+
function inList(old, value) {
|
|
71
|
+
if (/^[|>&*!%@`]/.test(old) || /^[[{]/.test(old)) return null;
|
|
72
|
+
if (old.startsWith('"') || old.startsWith("'")) return `[${old}]`;
|
|
73
|
+
return /[,[\]{}#:]/.test(old) || typeof value !== "string" ? `[${JSON.stringify(value)}]` : `[${old}]`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `slides: []` (or `slides:` with nothing) becomes a block list with one slide. */
|
|
77
|
+
function addSlide(format, text) {
|
|
78
|
+
if (format !== "yaml") return null;
|
|
79
|
+
const key = locateText(format, text, ["slides"], "colon");
|
|
80
|
+
const value = locateText(format, text, ["slides"], "value");
|
|
81
|
+
if (!key?.exact) return null;
|
|
82
|
+
const empty = !value?.exact || text.slice(value.from, value.to).trim() === "" || /^\[\s*\]$/.test(text.slice(value.from, value.to));
|
|
83
|
+
if (!empty) return null;
|
|
84
|
+
const indent = " ".repeat(key.from - lineStart(text, key.from));
|
|
85
|
+
const end = value?.exact ? Math.max(value.to, key.to) : key.to;
|
|
86
|
+
return [{ from: key.to, to: end, insert: `\n${indent} - title: Slide title` }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The indentation of the first key of the block mapping at `segments` ("" for none), and where its last line ends. */
|
|
90
|
+
function blockMapping(format, text, segments) {
|
|
91
|
+
// The root of a Markdown deck is its front matter, which ends where the closing `---` line starts.
|
|
92
|
+
if (format === "md" && segments.length === 0) {
|
|
93
|
+
const matter = scanMarkdown(text).frontMatter;
|
|
94
|
+
return matter?.closed ? { column: 0, end: matter.yamlTo } : null;
|
|
95
|
+
}
|
|
96
|
+
const value = segments.length ? locateText(format, text, segments, "value") : locateText(format, text, [], "value");
|
|
97
|
+
const open = locateText(format, text, segments, "open");
|
|
98
|
+
if (!value?.exact || !open?.exact || /^[[{]/.test(text[value.from] ?? "")) return null;
|
|
99
|
+
return { column: open.from - lineStart(text, open.from), end: nextLine(text, Math.max(value.from, value.to - 1)) };
|
|
100
|
+
}
|
|
101
|
+
const nextLine = (text, offset) => { const end = lineEnd(text, offset); return end < text.length ? end + 1 : end; };
|
|
102
|
+
|
|
103
|
+
/** Move the entry at `segments` into the `extensions` mapping of its parent, creating it at the end of the parent when there is none. */
|
|
104
|
+
function moveIntoExtensions(format, text, segments) {
|
|
105
|
+
if (segments.length === 0 || (format === "md" && segments[0] === "slides")) return null;
|
|
106
|
+
const entry = locateText(format, text, segments, "entry");
|
|
107
|
+
if (!entry?.exact) return null;
|
|
108
|
+
const start = lineStart(text, entry.from);
|
|
109
|
+
if (text.slice(start, entry.from).trim() !== "") return null;
|
|
110
|
+
const end = nextLine(text, Math.max(entry.from, entry.to - 1));
|
|
111
|
+
const parent = segments.slice(0, -1);
|
|
112
|
+
const base = entry.from - start;
|
|
113
|
+
const reindent = (column) => text.slice(start, end).replace(/\n$/, "").split("\n").map((line) => `${" ".repeat(column)}${line.slice(Math.min(base, line.length - line.trimStart().length))}`).join("\n");
|
|
114
|
+
const owner = blockMapping(format, text, parent);
|
|
115
|
+
if (!owner) return null;
|
|
116
|
+
const existing = blockMapping(format, text, [...parent, "extensions"]);
|
|
117
|
+
const prefix = (at) => (at === text.length && !text.endsWith("\n") ? "\n" : "");
|
|
118
|
+
const edits = [{ from: start, to: end, insert: "" }];
|
|
119
|
+
if (existing) edits.push({ from: existing.end, to: existing.end, insert: `${prefix(existing.end)}${reindent(existing.column)}\n` });
|
|
120
|
+
else if (locateText(format, text, [...parent, "extensions"], "key")?.exact) return null;
|
|
121
|
+
else edits.push({ from: owner.end, to: owner.end, insert: `${prefix(owner.end)}${" ".repeat(owner.column)}extensions:\n${reindent(owner.column + 2)}\n` });
|
|
122
|
+
return edits;
|
|
123
|
+
}
|
|
124
|
+
|
|
61
125
|
/**
|
|
62
126
|
* The quick fixes of a schema or reference finding in a YAML or Markdown text. `context` is the one source-messages.js takes plus `{ starter(segments,
|
|
63
127
|
* property) }` (the value `Add` inserts). Returns `[]` for what the text cannot express as a plain edit.
|
|
@@ -76,12 +140,21 @@ export function textFixesForFinding(format, finding, text, context = {}) {
|
|
|
76
140
|
} else if (keyword === "additionalProperties" && !isSlideOption(format, segments)) {
|
|
77
141
|
const key = locateText(format, text, segments, "key");
|
|
78
142
|
if (key?.exact) for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, [{ from: key.from, to: key.to, insert: yamlKey(value) }]);
|
|
143
|
+
if (segments.at(-1) !== "extensions" && context.keysOf?.(segments.slice(0, -1))?.includes("extensions")) push("move-to-extensions", "Move into extensions", moveIntoExtensions(format, text, segments));
|
|
79
144
|
push("remove-property", "Remove property", removeEntry(format, text, segments));
|
|
145
|
+
} else if (keyword === "type" && finding.validation.params?.type === "array") {
|
|
146
|
+
const value = context.valueAt?.(segments);
|
|
147
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") push("wrap-in-list", "Wrap in a list", replaceValue(format, text, segments, (old) => inList(old, value)));
|
|
148
|
+
} else if ((keyword === "type" && finding.validation.params?.type === "string") || ((keyword === "oneOf" || keyword === "anyOf") && (finding.validation.alternatives ?? []).some((entry) => String(entry).startsWith("string")))) {
|
|
149
|
+
const value = context.valueAt?.(segments);
|
|
150
|
+
if (typeof value === "number" || typeof value === "boolean") fixes.push({ id: "convert-to-string", title: "Convert to string", safe: true, edits: replaceValue(format, text, segments, () => JSON.stringify(String(value))) ?? [] });
|
|
151
|
+
} else if (keyword === "minItems" && finding.path === "/slides") {
|
|
152
|
+
push("add-slide", "Add a slide", addSlide(format, text));
|
|
80
153
|
} else if (keyword === "required") {
|
|
81
154
|
const property = finding.validation.params?.missingProperty;
|
|
82
155
|
const starter = property ? context.starter?.(segments, property) : undefined;
|
|
83
156
|
// In Markdown only the front matter is a mapping this can add to; a slide's fields are written as Markdown.
|
|
84
157
|
if (property && starter !== undefined && (format === "yaml" || segments[0] !== "slides")) push(`add:${property}`, `Add \`${property}\``, addProperty(format, text, segments, property, starter));
|
|
85
158
|
}
|
|
86
|
-
return fixes;
|
|
159
|
+
return fixes.filter((fix) => fix.edits.length);
|
|
87
160
|
}
|
package/dist/source-fixes.js
CHANGED
|
@@ -72,6 +72,27 @@ export function wrapInList(text, segments) {
|
|
|
72
72
|
return [{ from: offset, to: offset, insert: "[" }, { from: offset + length, to: offset + length, insert: "]" }];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** One edit that turns `before` into `after` (the span between their common start and common end), for a change several `modify` calls make together. */
|
|
76
|
+
export function diffEdit(before, after) {
|
|
77
|
+
let start = 0;
|
|
78
|
+
while (start < before.length && start < after.length && before[start] === after[start]) start++;
|
|
79
|
+
let end = 0;
|
|
80
|
+
while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;
|
|
81
|
+
return [{ from: start, to: before.length - end, insert: after.slice(start, after.length - end) }];
|
|
82
|
+
}
|
|
83
|
+
/** Move the property whose value is at `segments` into the `extensions` object of its parent (created when there is none). */
|
|
84
|
+
export function moveIntoExtensions(text, segments, unit = " ") {
|
|
85
|
+
const found = nodeAt(text, segments);
|
|
86
|
+
if (!found || segments.length === 0) return null;
|
|
87
|
+
const parent = segments.slice(0, -1), name = segments.at(-1);
|
|
88
|
+
let value;
|
|
89
|
+
try { value = JSON.parse(text.slice(found.node.offset, found.node.offset + found.node.length)); } catch { return null; }
|
|
90
|
+
const options = { formattingOptions: formatting(unit, text) };
|
|
91
|
+
const removed = applySourceEdits(text, asEdits(modify(text, toPath(segments), undefined, options)));
|
|
92
|
+
const added = applySourceEdits(removed, asEdits(modify(removed, [...toPath(parent), "extensions", name], value, options)));
|
|
93
|
+
return added === text ? null : diffEdit(text, added);
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/**
|
|
76
97
|
* The quick fixes of a schema or reference finding in a JSON text that parses. `context` is the one source-messages.js takes plus
|
|
77
98
|
* `{ unit, starter(segments, property) }` (the value `Add` inserts).
|
|
@@ -88,14 +109,17 @@ export function fixesForFinding(finding, text, context = {}) {
|
|
|
88
109
|
} else if (keyword === "additionalProperties") {
|
|
89
110
|
const name = finding.validation.params?.additionalProperty ?? segments.at(-1);
|
|
90
111
|
for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, renameKey(text, segments, value));
|
|
112
|
+
if (name !== "extensions" && context.keysOf?.(segments.slice(0, -1))?.includes("extensions")) push("move-to-extensions", "Move into extensions", moveIntoExtensions(text, segments, unit));
|
|
91
113
|
push("remove-property", "Remove property", removeAt(text, segments, unit));
|
|
92
|
-
void name;
|
|
93
114
|
} else if (keyword === "required") {
|
|
94
115
|
const property = finding.validation.params?.missingProperty;
|
|
95
116
|
const starter = context.starter?.(segments, property);
|
|
96
117
|
if (property && starter !== undefined) push(`add:${property}`, `Add \`${property}\``, addProperty(text, segments, property, starter, unit));
|
|
97
118
|
} else if (keyword === "type" && finding.validation.params?.type === "array") {
|
|
98
119
|
push("wrap-in-list", "Wrap in a list", wrapInList(text, segments));
|
|
120
|
+
} else if ((keyword === "type" && finding.validation.params?.type === "string") || ((keyword === "oneOf" || keyword === "anyOf") && (finding.validation.alternatives ?? []).some((entry) => String(entry).startsWith("string")))) {
|
|
121
|
+
const value = context.valueAt?.(segments);
|
|
122
|
+
if (typeof value === "number" || typeof value === "boolean") push("convert-to-string", "Convert to string", changeValue(text, segments, String(value)), { safe: true });
|
|
99
123
|
} else if (keyword === "minItems" && finding.path === "/slides") {
|
|
100
124
|
push("add-slide", "Add a slide", addItem(text, segments, { title: "Slide title" }, unit));
|
|
101
125
|
} else if (finding.ruleId === "opf/duplicate-key") {
|