@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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Editing rules of the YAML and Markdown source (artifacts/source-editor/SPEC.md, sections 6.1 to 6.6): Enter indents the way the text is built (one
|
|
2
|
+
// level in after `key:`, aligned under the key after `- key: value`, a list item continued, an empty one ended), Tab nests a Markdown list item, Backspace
|
|
3
|
+
// takes a list marker away, Cmd/Ctrl+/ comments a line, and the characters that pair or wrap a selection. Commands over a CodeMirror view; no network.
|
|
4
|
+
// They read the text line by line, so they need no parser and work on half-typed text; the main bundle carries them (a few kilobytes).
|
|
5
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
6
|
+
import { EditorView } from "@codemirror/view";
|
|
7
|
+
import { foldService } from "@codemirror/language";
|
|
8
|
+
import { indentMore } from "@codemirror/commands";
|
|
9
|
+
import { flowDepth, readEntry, readMarkers } from "./source-yaml.js";
|
|
10
|
+
|
|
11
|
+
const UNIT = " ";
|
|
12
|
+
const BLOCK_HEADER = /^[|>][+-]?\d*[+-]?\s*(?:#.*)?$/;
|
|
13
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])([ \t]+)(.*)$/;
|
|
14
|
+
const LIST_EMPTY = /^(\s*)([-*+]|\d{1,9}[.)])[ \t]*$/;
|
|
15
|
+
const QUOTE = /^(\s{0,3})>([ \t]?)(.*)$/;
|
|
16
|
+
|
|
17
|
+
// ---- language data -----------------------------------------------------------------------------------------------------------------
|
|
18
|
+
/** Pairs per language (SPEC 6.3): YAML pairs quotes and flow brackets, Markdown pairs `(`, `[`, `{` and backticks (not `"` or `<`), JSON keeps its own. */
|
|
19
|
+
export function languageDataFor(name) {
|
|
20
|
+
if (name === "yaml") return EditorState.languageData.of(() => [{ closeBrackets: { brackets: ["[", "{", '"', "'"] } }]);
|
|
21
|
+
if (name === "markdown") return EditorState.languageData.of(() => [{ closeBrackets: { brackets: ["(", "[", "{", "`"] }, commentTokens: { block: { open: "<!--", close: "-->" } } }]);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---- what a line of Markdown is in -------------------------------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Where line `number` (1-based) of a Markdown text is: "front" (inside the front matter), "fence" (inside a code fence), "yaml-fence" (inside an
|
|
28
|
+
* `opf-slide` or `opf-block` fence) or "body". Scans from the top, which is cheap next to a key press.
|
|
29
|
+
*/
|
|
30
|
+
export function markdownLineKind(doc, number) {
|
|
31
|
+
let front = false;
|
|
32
|
+
let fence = null;
|
|
33
|
+
for (let index = 1; index < number; index++) {
|
|
34
|
+
const text = doc.line(index).text;
|
|
35
|
+
if (index === 1 && /^---\s*$/.test(text)) { front = true; continue; }
|
|
36
|
+
if (front) { if (/^(---|\.\.\.)\s*$/.test(text)) front = false; continue; }
|
|
37
|
+
if (fence) {
|
|
38
|
+
if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text)) fence = null;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(text);
|
|
42
|
+
if (open && !(open[1][0] === "`" && open[2].includes("`"))) fence = { char: open[1][0], length: open[1].length, yaml: /^\s*opf-(?:slide|block)\b/.test(open[2]) };
|
|
43
|
+
}
|
|
44
|
+
const here = doc.line(number).text;
|
|
45
|
+
if (front) return /^(---|\.\.\.)\s*$/.test(here) ? "body" : "front";
|
|
46
|
+
if (fence) return new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(here) ? "body" : fence.yaml ? "yaml-fence" : "fence";
|
|
47
|
+
return "body";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- Enter -------------------------------------------------------------------------------------------------------------------------
|
|
51
|
+
/** The indentation (columns) of the new line after `before`, the text of the line up to the caret, in YAML; `"leave"` for an empty list item. */
|
|
52
|
+
export function yamlNewlineIndent(before) {
|
|
53
|
+
const parsed = readMarkers(before);
|
|
54
|
+
if (parsed.markers.length && parsed.rest.trim() === "") return "leave";
|
|
55
|
+
const entry = readEntry(parsed.rest);
|
|
56
|
+
const content = parsed.markers.length ? parsed.restStart : parsed.indent;
|
|
57
|
+
if (!entry) return content;
|
|
58
|
+
const value = parsed.rest.slice(entry.valueStart).replace(/\s+#.*$/, "").trim();
|
|
59
|
+
if (value === "" || BLOCK_HEADER.test(value)) return parsed.restStart + entry.keyStart + UNIT.length;
|
|
60
|
+
// An unclosed flow collection continues one level in.
|
|
61
|
+
let depth = 0;
|
|
62
|
+
for (const character of value) depth += character === "[" || character === "{" ? 1 : character === "]" || character === "}" ? -1 : 0;
|
|
63
|
+
return depth > 0 ? parsed.restStart + entry.keyStart + UNIT.length : parsed.markers.length || entry.keyStart > 0 ? parsed.restStart + entry.keyStart : content;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Replace each selection range with a line break and what `decide` says goes after it: `{ indent }` columns, `{ insert }` text, or `{ clear, text }` to
|
|
67
|
+
// replace the whole line (an empty list item loses its marker) instead of breaking it.
|
|
68
|
+
function newline(view, decide) {
|
|
69
|
+
const { state } = view;
|
|
70
|
+
if (state.readOnly) return false;
|
|
71
|
+
const update = state.changeByRange((range) => {
|
|
72
|
+
const line = state.doc.lineAt(range.from);
|
|
73
|
+
const result = decide(line.text.slice(0, range.from - line.from), line.text, line);
|
|
74
|
+
if (result.clear) return { changes: { from: line.from, to: Math.max(range.to, line.to), insert: result.text }, range: EditorSelection.cursor(line.from + result.text.length) };
|
|
75
|
+
const insert = `\n${result.insert ?? " ".repeat(result.indent ?? 0)}`;
|
|
76
|
+
return { changes: { from: range.from, to: range.to, insert }, range: EditorSelection.cursor(range.from + insert.length) };
|
|
77
|
+
});
|
|
78
|
+
view.dispatch(state.update(update, { scrollIntoView: true, userEvent: "input" }));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The YAML rules for the line before the caret: `{ indent }` of the new line, or `{ clear, text }` for an empty list item.
|
|
83
|
+
function yamlDecision(before, lists = true) {
|
|
84
|
+
// A line with only spaces before the caret keeps them: the new line gets the same indentation.
|
|
85
|
+
if (before.trim() === "") return { indent: before.length };
|
|
86
|
+
const indent = yamlNewlineIndent(before);
|
|
87
|
+
if (indent === "leave") return { clear: true, text: " ".repeat(Math.max(0, readMarkers(before).markers[0] - UNIT.length)) };
|
|
88
|
+
// After a list item that is a value (`- board`) the list goes on with the next dash; after `- key: value` the new line aligns under the key.
|
|
89
|
+
const parsed = readMarkers(before);
|
|
90
|
+
if (!lists && parsed.markers.length && !readEntry(parsed.rest)) return { indent: parsed.indent };
|
|
91
|
+
if (lists && parsed.markers.length && !readEntry(parsed.rest)) {
|
|
92
|
+
const value = parsed.rest.replace(/\s+#.*$/, "").trim();
|
|
93
|
+
if (value !== "" && !BLOCK_HEADER.test(value) && !/^[&*!]/.test(value) && flowDepth(value) === 0) return { insert: `${" ".repeat(parsed.markers.at(-1))}- ` };
|
|
94
|
+
}
|
|
95
|
+
return { indent };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Enter in YAML (SPEC 6.2). */
|
|
99
|
+
export function yamlEnter(view, { lists = true } = {}) {
|
|
100
|
+
return newline(view, (before) => yamlDecision(before, lists));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enter in Markdown (SPEC 6.2): lists and quotes continue, an empty marker ends them, a fence keeps its indentation, front matter follows YAML.
|
|
105
|
+
* `lists: false` (the `smartListEnter` option) keeps only the indentation.
|
|
106
|
+
*/
|
|
107
|
+
export function markdownEnter(view, { lists = true } = {}) {
|
|
108
|
+
const { state } = view;
|
|
109
|
+
return newline(view, (before, full, line) => {
|
|
110
|
+
const kind = markdownLineKind(state.doc, line.number);
|
|
111
|
+
if (kind === "front" || kind === "yaml-fence") return yamlDecision(before, lists);
|
|
112
|
+
const lead = /^\s*/.exec(before)[0];
|
|
113
|
+
if (kind === "fence" || !lists) return { indent: lead.length };
|
|
114
|
+
const atEnd = before.length === full.length;
|
|
115
|
+
// An empty item ends the list: a nested one moves out one level, a top-level one becomes an empty line.
|
|
116
|
+
const empty = LIST_EMPTY.exec(before);
|
|
117
|
+
if (empty && atEnd) return { clear: true, text: empty[1].length >= UNIT.length ? `${" ".repeat(empty[1].length - UNIT.length)}${empty[2]} ` : "" };
|
|
118
|
+
const item = LIST_ITEM.exec(before);
|
|
119
|
+
// Inside the marker itself the line just breaks.
|
|
120
|
+
if (item && before.length >= item[1].length + item[2].length + item[3].length) {
|
|
121
|
+
const marker = /^\d/.test(item[2]) ? `${Number.parseInt(item[2], 10) + 1}${item[2].slice(-1)}` : item[2];
|
|
122
|
+
return { insert: `${item[1]}${marker} ` };
|
|
123
|
+
}
|
|
124
|
+
const quote = QUOTE.exec(before);
|
|
125
|
+
if (quote) {
|
|
126
|
+
if (quote[3].trim() === "" && atEnd) return { clear: true, text: "" };
|
|
127
|
+
return { insert: `${quote[1]}> ` };
|
|
128
|
+
}
|
|
129
|
+
return { indent: lead.length };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Tab and Backspace in Markdown lists -------------------------------------------------------------------------------------------
|
|
134
|
+
/** Tab on a list item nests it one level (two spaces); false elsewhere. */
|
|
135
|
+
export function markdownTab(view) {
|
|
136
|
+
const { state } = view;
|
|
137
|
+
if (state.readOnly || state.selection.ranges.some((range) => !range.empty)) return false;
|
|
138
|
+
if (!state.selection.ranges.every((range) => LIST_ITEM.test(state.doc.lineAt(range.head).text) || LIST_EMPTY.test(state.doc.lineAt(range.head).text))) return false;
|
|
139
|
+
if (state.selection.ranges.some((range) => markdownLineKind(state.doc, state.doc.lineAt(range.head).number) !== "body")) return false;
|
|
140
|
+
return indentMore(view);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Backspace right after a list marker takes the marker away (`- |one` becomes `|one`); false elsewhere. */
|
|
144
|
+
export function markdownBackspace(view) {
|
|
145
|
+
const { state } = view;
|
|
146
|
+
if (state.readOnly || state.selection.ranges.length !== 1 || !state.selection.main.empty) return false;
|
|
147
|
+
const head = state.selection.main.head;
|
|
148
|
+
const line = state.doc.lineAt(head);
|
|
149
|
+
if (markdownLineKind(state.doc, line.number) !== "body") return false;
|
|
150
|
+
const item = LIST_ITEM.exec(line.text);
|
|
151
|
+
const empty = LIST_EMPTY.exec(line.text);
|
|
152
|
+
const markerEnd = item ? item[1].length + item[2].length + item[3].length : empty ? line.text.length : -1;
|
|
153
|
+
if (markerEnd < 0 || head - line.from !== markerEnd) return false;
|
|
154
|
+
const start = line.from + (item ? item[1].length : empty[1].length);
|
|
155
|
+
view.dispatch({ changes: { from: start, to: head }, selection: { anchor: start }, userEvent: "delete.backward", scrollIntoView: true });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Comments ----------------------------------------------------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Cmd/Ctrl+/ in Markdown: front matter and YAML fences toggle `# `; a line of the body is wrapped in `<!-- -->` or unwrapped; an options comment
|
|
162
|
+
* says it already is one. `say` announces what could not be done.
|
|
163
|
+
*/
|
|
164
|
+
export function markdownToggleComment(view, say) {
|
|
165
|
+
const { state } = view;
|
|
166
|
+
if (state.readOnly) return false;
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const range of state.selection.ranges) {
|
|
169
|
+
const first = state.doc.lineAt(range.from).number;
|
|
170
|
+
let last = state.doc.lineAt(range.to).number;
|
|
171
|
+
if (!range.empty && range.to === state.doc.line(last).from && last > first) last--;
|
|
172
|
+
for (let number = first; number <= last; number++) if (!lines.includes(number)) lines.push(number);
|
|
173
|
+
}
|
|
174
|
+
const kinds = lines.map((number) => markdownLineKind(state.doc, number));
|
|
175
|
+
const texts = lines.map((number) => state.doc.line(number).text);
|
|
176
|
+
if (kinds.some((kind) => kind === "fence")) { say?.("Code in a fence has no comment syntax."); return true; }
|
|
177
|
+
if (texts.some((text) => /^\s*<!--\s*(slide|block)\b/.test(text)) && texts.length === 1) { say?.("Slide options are already a comment. Delete the line to remove them."); return true; }
|
|
178
|
+
const changes = [];
|
|
179
|
+
const yamlStyle = kinds.every((kind) => kind === "front" || kind === "yaml-fence");
|
|
180
|
+
const blank = (text) => text.trim() === "";
|
|
181
|
+
const real = lines.filter((number, index) => !blank(texts[index]));
|
|
182
|
+
if (!real.length) return true;
|
|
183
|
+
if (yamlStyle) {
|
|
184
|
+
const all = real.every((number) => /^\s*#/.test(state.doc.line(number).text));
|
|
185
|
+
const column = Math.min(...real.map((number) => /^\s*/.exec(state.doc.line(number).text)[0].length));
|
|
186
|
+
for (const number of real) {
|
|
187
|
+
const line = state.doc.line(number);
|
|
188
|
+
if (all) {
|
|
189
|
+
const match = /^(\s*)# ?/.exec(line.text);
|
|
190
|
+
changes.push({ from: line.from + match[1].length, to: line.from + match[0].length });
|
|
191
|
+
} else changes.push({ from: line.from + column, insert: "# " });
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
const all = real.every((number) => /^\s*<!--.*-->\s*$/.test(state.doc.line(number).text));
|
|
195
|
+
for (const number of real) {
|
|
196
|
+
const line = state.doc.line(number);
|
|
197
|
+
if (all) {
|
|
198
|
+
const match = /^(\s*)<!-- ?(.*?) ?-->(\s*)$/.exec(line.text);
|
|
199
|
+
changes.push({ from: line.from + match[1].length, to: line.to, insert: match[2] });
|
|
200
|
+
} else {
|
|
201
|
+
const lead = /^\s*/.exec(line.text)[0].length;
|
|
202
|
+
changes.push({ from: line.from + lead, insert: "<!-- " }, { from: line.to, insert: " -->" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The selection takes in the comment marks that were added or removed at its ends.
|
|
207
|
+
const set = state.changes(changes);
|
|
208
|
+
const selection = EditorSelection.create(state.selection.ranges.map((range) => EditorSelection.range(set.mapPos(range.anchor, 1), set.mapPos(range.head, 1))), state.selection.mainIndex);
|
|
209
|
+
view.dispatch(state.update({ changes: set, selection, userEvent: "input.comment", scrollIntoView: true }));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Typing over a selection in Markdown -------------------------------------------------------------------------------------------
|
|
214
|
+
const WRAPPERS = { "*": "*", _: "_", "~": "~", '"': '"' };
|
|
215
|
+
|
|
216
|
+
/** Typing `*`, `_`, `~` or `"` over a selection wraps it (typing `*` twice makes bold); the selection stays on the text. */
|
|
217
|
+
export const markdownWrapSelection = EditorView.inputHandler.of((view, _from, _to, text) => {
|
|
218
|
+
if (text.length !== 1 || !(text in WRAPPERS) || view.state.readOnly) return false;
|
|
219
|
+
const { selection } = view.state;
|
|
220
|
+
if (selection.ranges.every((range) => range.empty)) return false;
|
|
221
|
+
const closer = WRAPPERS[text];
|
|
222
|
+
const changes = view.state.changes(selection.ranges.filter((range) => !range.empty).flatMap((range) => [{ from: range.from, insert: text }, { from: range.to, insert: closer }]));
|
|
223
|
+
view.dispatch({ changes, selection: selection.map(changes), userEvent: "input.type", scrollIntoView: true });
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- Folding in Markdown -----------------------------------------------------------------------------------------------------------
|
|
228
|
+
const SEPARATOR = /^-{3,}[ \t]*$/;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What a line of Markdown can fold (SPEC 6.8): the front matter (from its opening line), a fenced block (from its opening fence), and a slide (from its
|
|
232
|
+
* first line to the line before the next `---`). A `---` inside a fence or the front matter is not a separator.
|
|
233
|
+
*/
|
|
234
|
+
export const markdownFolding = foldService.of((state, lineStart) => {
|
|
235
|
+
const { doc } = state;
|
|
236
|
+
const line = doc.lineAt(lineStart);
|
|
237
|
+
const text = line.text;
|
|
238
|
+
const last = (number) => { const end = doc.line(number); return end.to; };
|
|
239
|
+
// The front matter.
|
|
240
|
+
if (line.number === 1 && /^---\s*$/.test(text)) {
|
|
241
|
+
for (let number = 2; number <= doc.lines; number++) if (/^(---|\.\.\.)\s*$/.test(doc.line(number).text)) return number > 2 ? { from: line.to, to: doc.line(number - 1).to } : null;
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (markdownLineKind(doc, line.number) !== "body") return null;
|
|
245
|
+
// A fenced block.
|
|
246
|
+
const open = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(text);
|
|
247
|
+
if (open && !(open[2][0] === "`" && open[3].includes("`"))) {
|
|
248
|
+
const close = new RegExp(`^ {0,3}${open[2][0]}{${open[2].length},}\\s*$`);
|
|
249
|
+
for (let number = line.number + 1; number <= doc.lines; number++) if (close.test(doc.line(number).text)) return number > line.number + 1 ? { from: line.to, to: doc.line(number - 1).to } : null;
|
|
250
|
+
return line.number < doc.lines ? { from: line.to, to: last(doc.lines) } : null;
|
|
251
|
+
}
|
|
252
|
+
// A slide: its first line (the one after the front matter or a separator, blank lines apart) folds the rest of the slide.
|
|
253
|
+
if (text.trim() === "" || SEPARATOR.test(text)) return null;
|
|
254
|
+
let before = line.number - 1;
|
|
255
|
+
while (before >= 1 && doc.line(before).text.trim() === "") before--;
|
|
256
|
+
const startsSlide = before < 1 || ((SEPARATOR.test(doc.line(before).text) || /^\.\.\.\s*$/.test(doc.line(before).text)) && markdownLineKind(doc, before) === "body");
|
|
257
|
+
if (!startsSlide) return null;
|
|
258
|
+
let end = line.number;
|
|
259
|
+
for (let number = line.number + 1; number <= doc.lines; number++) {
|
|
260
|
+
if (SEPARATOR.test(doc.line(number).text) && markdownLineKind(doc, number) === "body") break;
|
|
261
|
+
end = number;
|
|
262
|
+
}
|
|
263
|
+
while (end > line.number && doc.line(end).text.trim() === "") end--;
|
|
264
|
+
return end > line.number ? { from: line.to, to: doc.line(end).to } : null;
|
|
265
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
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 { analyzeYamlSyntax, scanMarkdown } from "@openpresentation/opf/source";
|
|
7
|
+
|
|
8
|
+
const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
|
|
9
|
+
const lineEnd = (text, offset) => { const at = text.indexOf("\n", offset); return at < 0 ? text.length : at; };
|
|
10
|
+
const nextLine = (text, offset) => { const end = lineEnd(text, offset); return end < text.length ? end + 1 : end; };
|
|
11
|
+
const fix = (id, title, edits, extra = {}) => ({ id, title, safe: false, edits, ...extra });
|
|
12
|
+
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 } : {}) });
|
|
13
|
+
|
|
14
|
+
// ---- YAML -------------------------------------------------------------------------------------------------------------------------
|
|
15
|
+
const yamlMemo = { text: null, problems: [] };
|
|
16
|
+
function yamlProblems(text) {
|
|
17
|
+
if (yamlMemo.text !== text) { yamlMemo.text = text; yamlMemo.problems = analyzeYamlSyntax(text); }
|
|
18
|
+
return yamlMemo.problems;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 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. */
|
|
22
|
+
function markdownYamlRegions(text) {
|
|
23
|
+
const scan = scanMarkdown(text);
|
|
24
|
+
const regions = [];
|
|
25
|
+
if (scan.frontMatter) regions.push({ from: scan.frontMatter.yamlFrom, to: scan.frontMatter.yamlTo });
|
|
26
|
+
for (const fence of scan.fences) if (fence.kind === "opf-slide" || fence.kind === "opf-block") regions.push({ from: fence.bodyFrom, to: fence.bodyTo });
|
|
27
|
+
return regions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The problem that starts where `finding` does, in the YAML `text` (`shift` is where `text` starts in the source). */
|
|
31
|
+
function yamlHit(problems, finding, shift) {
|
|
32
|
+
const at = (finding.location?.offset ?? finding.range?.from) - shift;
|
|
33
|
+
return problems.find((problem) => problem.range.offset === at);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function yamlSyntax(format, text, finding) {
|
|
37
|
+
if (format === "yaml") {
|
|
38
|
+
const hit = yamlHit(yamlProblems(text), finding, 0);
|
|
39
|
+
return hit ? { fixes: hit.fixes.map(fromCore), related: (hit.related ?? []).map((range) => ({ from: range.offset, to: range.offset + range.length })) } : null;
|
|
40
|
+
}
|
|
41
|
+
const at = finding.location?.offset ?? finding.range?.from;
|
|
42
|
+
const region = markdownYamlRegions(text).find((entry) => at >= entry.from && at <= entry.to);
|
|
43
|
+
if (!region) return null;
|
|
44
|
+
const slice = text.slice(region.from, region.to);
|
|
45
|
+
const hit = yamlHit(analyzeYamlSyntax(slice, { rootKind: "any" }), finding, region.from);
|
|
46
|
+
if (!hit) return null;
|
|
47
|
+
const shift = (offset) => offset + region.from;
|
|
48
|
+
return {
|
|
49
|
+
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) } : {}) })),
|
|
50
|
+
related: (hit.related ?? []).map((range) => ({ from: shift(range.offset), to: shift(range.offset + range.length) })),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Markdown ---------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** The end of the last non-blank line inside [from, to). */
|
|
57
|
+
function lastContentEnd(text, from, to) {
|
|
58
|
+
let end = Math.min(to, text.length);
|
|
59
|
+
while (end > from && /\s/.test(text[end - 1])) end--;
|
|
60
|
+
return end;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where a front matter that was never closed should end: before the first blank line or the first line that is not YAML-shaped. */
|
|
64
|
+
function frontMatterEnd(text) {
|
|
65
|
+
const YAMLISH = /^(\s*[A-Za-z_][\w.-]*\s*:|\s*-\s|\s+\S|\s*#(?!\s)|\s*#\s*$|\s*#\s.*$)/;
|
|
66
|
+
let at = nextLine(text, 0);
|
|
67
|
+
let seen = false;
|
|
68
|
+
while (at < text.length) {
|
|
69
|
+
const end = lineEnd(text, at);
|
|
70
|
+
const content = text.slice(at, end);
|
|
71
|
+
if (content.trim() === "" || (!YAMLISH.test(content) && seen)) return at;
|
|
72
|
+
if (/^#{1,6}\s/.test(content) && !/^#\s.*:\s/.test(content)) return at;
|
|
73
|
+
seen = true;
|
|
74
|
+
at = nextLine(text, at);
|
|
75
|
+
}
|
|
76
|
+
return text.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove the whole lines that [from, to) touches. */
|
|
80
|
+
const removeLines = (text, from, to) => ({ from: lineStart(text, from), to: nextLine(text, Math.max(from, to - 1)), insert: "" });
|
|
81
|
+
|
|
82
|
+
const markdownRules = {
|
|
83
|
+
"markdown/front-matter-unterminated": (text) => {
|
|
84
|
+
const at = frontMatterEnd(text);
|
|
85
|
+
const prefix = at > 0 && text[at - 1] !== "\n" ? "\n" : "";
|
|
86
|
+
return [fix("insert-front-matter-end", "Insert ---", [{ from: at, to: at, insert: `${prefix}---\n` }], { safe: true })];
|
|
87
|
+
},
|
|
88
|
+
"markdown/comment-unterminated": (text, finding) => {
|
|
89
|
+
const from = finding.location.offset;
|
|
90
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
91
|
+
return [fix("insert-comment-end", "Insert -->", [{ from: end, to: end, insert: " -->" }], { safe: true, caret: end + 4 })];
|
|
92
|
+
},
|
|
93
|
+
"markdown/fence-unterminated": (text, finding) => {
|
|
94
|
+
const from = finding.location.offset;
|
|
95
|
+
const opening = text.slice(lineStart(text, from), lineEnd(text, from));
|
|
96
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(opening)?.[1] ?? "```";
|
|
97
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
98
|
+
const at = nextLine(text, end > from ? end - 1 : from);
|
|
99
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
100
|
+
return [fix("insert-closing-fence", "Insert closing fence", [{ from: at, to: at, insert: `${prefix}${marker}\n` }], { safe: true })];
|
|
101
|
+
},
|
|
102
|
+
"markdown/options-unknown-key": (text, finding) => {
|
|
103
|
+
const key = /option "(.*)"\.$/.exec(finding.message)?.[1];
|
|
104
|
+
const { offset, length } = finding.location;
|
|
105
|
+
const found = key ? new RegExp(`(^|[\\s:])(${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?==|\\s|-->|$)`).exec(text.slice(offset, offset + length)) : null;
|
|
106
|
+
if (!found) return [];
|
|
107
|
+
const from = offset + found.index + found[1].length;
|
|
108
|
+
return (finding.suggestions ?? []).map((entry) => fix(`rename-to:${entry.value}`, `Rename to ${entry.value}`, [{ from, to: from + key.length, insert: String(entry.value) }]));
|
|
109
|
+
},
|
|
110
|
+
"markdown/options-value": (text, finding) => {
|
|
111
|
+
if (!/^hidden takes/.test(finding.message)) return [];
|
|
112
|
+
const { offset, length } = finding.location;
|
|
113
|
+
const found = /\bhidden=("[^"]*"|\S+?)(?=\s|-->)/.exec(text.slice(offset, offset + length));
|
|
114
|
+
if (!found) return [];
|
|
115
|
+
const from = offset + found.index;
|
|
116
|
+
return ["true", "false"].map((value) => fix(`hidden-${value}`, `Use hidden=${value}`, [{ from, to: from + found[0].length, insert: `hidden=${value}` }]));
|
|
117
|
+
},
|
|
118
|
+
"markdown/options-duplicate": (text, finding) => {
|
|
119
|
+
const key = /^Option (\w+) is given twice/.exec(finding.message)?.[1];
|
|
120
|
+
if (!key) return [];
|
|
121
|
+
const { offset, length } = finding.location;
|
|
122
|
+
const body = text.slice(offset, offset + length);
|
|
123
|
+
const pattern = new RegExp(`\\s${key}=("[^"]*"|[^\\s>]+)`, "g");
|
|
124
|
+
const matches = [...body.matchAll(pattern)];
|
|
125
|
+
if (matches.length < 2) return [];
|
|
126
|
+
const second = matches[1];
|
|
127
|
+
return [fix("remove-duplicate-option", `Remove the second ${key}`, [{ from: offset + second.index, to: offset + second.index + second[0].length, insert: "" }])];
|
|
128
|
+
},
|
|
129
|
+
"markdown/chart-type": (text, finding) => {
|
|
130
|
+
const open = lineStart(text, finding.location.offset);
|
|
131
|
+
const line = text.slice(open, lineEnd(text, open));
|
|
132
|
+
const found = /^(\s*(?:`{3,}|~{3,})\s*chart)/.exec(line);
|
|
133
|
+
if (!found) return [];
|
|
134
|
+
const at = open + found[1].length;
|
|
135
|
+
return (finding.suggestions ?? []).map((entry) => fix(`chart-type:${entry.value}`, `Insert ${entry.value}`, [{ from: at, to: at, insert: ` ${entry.value}` }]));
|
|
136
|
+
},
|
|
137
|
+
"markdown/front-matter-slides": (text, finding) => {
|
|
138
|
+
const { offset, length } = finding.location;
|
|
139
|
+
return [fix("remove-slides-key", "Remove the slides key", [removeLines(text, offset, offset + length)])];
|
|
140
|
+
},
|
|
141
|
+
"markdown/no-slides": (text) => {
|
|
142
|
+
const empty = text.trim() === "";
|
|
143
|
+
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 })];
|
|
144
|
+
},
|
|
145
|
+
"markdown/duplicate-title": (text, finding) => {
|
|
146
|
+
const open = lineStart(text, finding.location.offset);
|
|
147
|
+
return [
|
|
148
|
+
fix("start-new-slide", "Start a new slide here", [{ from: open, to: open, insert: "---\n\n" }]),
|
|
149
|
+
fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))]),
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
"markdown/duplicate-subtitle": (text, finding) => {
|
|
153
|
+
const open = lineStart(text, finding.location.offset);
|
|
154
|
+
return [fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
155
|
+
},
|
|
156
|
+
"markdown/empty-heading": (text, finding) => {
|
|
157
|
+
const open = lineStart(text, finding.location.offset);
|
|
158
|
+
return [fix("remove-empty-heading", "Remove the heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
159
|
+
},
|
|
160
|
+
"markdown/numbered-list": (text, finding) => {
|
|
161
|
+
const { offset, length } = finding.location;
|
|
162
|
+
const scan = scanMarkdown(text);
|
|
163
|
+
const slide = scan.slides.find((entry) => offset >= entry.from && offset <= entry.to);
|
|
164
|
+
// A slide that has an opf-slide fence already gets the field written into it, not a second fence.
|
|
165
|
+
if (slide && scan.fences.some((fence) => fence.kind === "opf-slide" && fence.from >= slide.from && fence.to <= slide.to + 1)) return [];
|
|
166
|
+
const end = lastContentEnd(text, offset, offset + length);
|
|
167
|
+
const at = nextLine(text, end > offset ? end - 1 : offset);
|
|
168
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
169
|
+
return [fix("add-numbering", "Add `numbering`", [{ from: at, to: at, insert: `${prefix}\n\`\`\`opf-slide\nnumbering: arabic\n\`\`\`\n` }])];
|
|
170
|
+
},
|
|
171
|
+
"markdown/metric-block": (text, finding) => {
|
|
172
|
+
if (finding.message !== "A metric needs a value.") return [];
|
|
173
|
+
const open = lineStart(text, finding.location.offset);
|
|
174
|
+
const at = nextLine(text, open);
|
|
175
|
+
return [fix("insert-metric-value", "Insert value:", [{ from: at, to: at, insert: "value: 0\n" }])];
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The quick fixes of a YAML or Markdown syntax finding, and the other places that belong to it: `{ fixes, related }` or null. */
|
|
180
|
+
export function syntaxFixesFor(format, text, finding) {
|
|
181
|
+
if (!finding?.location || format === "json") return null;
|
|
182
|
+
const id = String(finding.ruleId ?? "");
|
|
183
|
+
try {
|
|
184
|
+
if (id.startsWith("yaml/") || (format === "md" && (id === "markdown/front-matter" || id === "markdown/opf-slide" || id === "markdown/opf-block"))) return yamlSyntax(format, text, finding);
|
|
185
|
+
const make = markdownRules[id];
|
|
186
|
+
if (make) {
|
|
187
|
+
const fixes = make(text, finding).filter((entry) => entry.edits.length);
|
|
188
|
+
return fixes.length ? { fixes, related: [] } : null;
|
|
189
|
+
}
|
|
190
|
+
} catch { /* A fix that cannot be worked out is not offered. */ }
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Findings of a YAML or Markdown source with their quick fixes (`editFixes`) and related places (`related`) added, in the shape `normalizeFinding` and
|
|
196
|
+
* `buildProblems` read. Findings that have no fixes are returned as they are.
|
|
197
|
+
*/
|
|
198
|
+
export function withSyntaxFixes(format, text, findings) {
|
|
199
|
+
return findings.map((finding) => {
|
|
200
|
+
const made = syntaxFixesFor(format, text, finding);
|
|
201
|
+
if (!made) return finding;
|
|
202
|
+
return { ...finding, ...(made.fixes.length ? { editFixes: made.fixes } : {}), ...(made.related.length ? { related: made.related } : {}) };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Quick fixes for the findings of a YAML or Markdown source (artifacts/source-editor/VALIDATOR-HINTS.md, section 12): "Change to center", "Rename to
|
|
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
|
+
// 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
|
+
import { Document } from "yaml";
|
|
5
|
+
import { SLIDE_OPTION_KEYS, scanMarkdown } from "@openpresentation/opf/source";
|
|
6
|
+
import { replacementValues, segmentsOf } from "./source-messages.js";
|
|
7
|
+
import { locateText } from "./source-ranges.js";
|
|
8
|
+
|
|
9
|
+
const yamlScalar = (value) => {
|
|
10
|
+
if (typeof value !== "string") return String(value);
|
|
11
|
+
try { return new Document(value).toString({ lineWidth: 0 }).trimEnd() || '""'; } catch { return JSON.stringify(value); }
|
|
12
|
+
};
|
|
13
|
+
const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/.test(key) ? key : yamlScalar(key));
|
|
14
|
+
const BARE = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
|
|
16
|
+
/** Whether `segments` is an option of a slide's options comment in Markdown (`/slides/0/layout`), whose value is written as `key=value`. */
|
|
17
|
+
const isSlideOption = (format, segments) => format === "md" && segments[0] === "slides" && segments.length === 3 && SLIDE_OPTION_KEYS.includes(segments[2]);
|
|
18
|
+
|
|
19
|
+
/** How `value` is written in place of the token `old`: in the quoting style the author used. */
|
|
20
|
+
function written(format, segments, old, value) {
|
|
21
|
+
if (isSlideOption(format, segments)) return typeof value === "string" && BARE.test(value) ? value : JSON.stringify(value);
|
|
22
|
+
if (old.startsWith('"')) return JSON.stringify(value);
|
|
23
|
+
if (old.startsWith("'")) return `'${String(value).replaceAll("'", "''")}'`;
|
|
24
|
+
return yamlScalar(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Only YAML written as block text (and the front matter and fences of Markdown, which are block YAML) can have a whole entry removed or added by lines.
|
|
28
|
+
const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
|
|
29
|
+
const lineEnd = (text, offset) => { const at = text.indexOf("\n", offset); return at < 0 ? text.length : at; };
|
|
30
|
+
|
|
31
|
+
/** Remove the entry at `segments` (its lines), when it starts its line and is not the first key after a list dash. */
|
|
32
|
+
function removeEntry(format, text, segments) {
|
|
33
|
+
const entry = locateText(format, text, segments, "entry");
|
|
34
|
+
if (!entry?.exact) return null;
|
|
35
|
+
const start = lineStart(text, entry.from);
|
|
36
|
+
if (text.slice(start, entry.from).trim() !== "") return null;
|
|
37
|
+
const end = lineEnd(text, Math.max(entry.from, entry.to - 1));
|
|
38
|
+
return [{ from: start, to: Math.min(text.length, end + 1), insert: "" }];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Add `key: value` to the mapping at `segments`, after its last line, indented like its first key. */
|
|
42
|
+
function addProperty(format, text, segments, key, value) {
|
|
43
|
+
const parent = segments.length ? locateText(format, text, segments, "value") : null;
|
|
44
|
+
const first = parent?.exact ? locateText(format, text, segments, "open") : null;
|
|
45
|
+
let column = 0;
|
|
46
|
+
let at = text.length;
|
|
47
|
+
if (parent?.exact && first) {
|
|
48
|
+
if (/^[[{]/.test(text.slice(parent.from, parent.from + 1))) return null;
|
|
49
|
+
column = first.from - lineStart(text, first.from);
|
|
50
|
+
at = lineEnd(text, Math.max(parent.from, parent.to - 1));
|
|
51
|
+
} else if (segments.length) return null;
|
|
52
|
+
else if (text.trim() !== "") {
|
|
53
|
+
const root = locateText(format, text, [], "value");
|
|
54
|
+
if (!root) return null;
|
|
55
|
+
at = lineEnd(text, Math.max(root.from, root.to - 1));
|
|
56
|
+
}
|
|
57
|
+
const body = new Document({ [key]: value }).toString({ indent: 2, lineWidth: 0 }).trimEnd().split("\n").map((line) => `${" ".repeat(column)}${line}`).join("\n");
|
|
58
|
+
return [{ from: at, to: at, insert: text.trim() === "" ? `${body}\n` : `\n${body}` }];
|
|
59
|
+
}
|
|
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
|
+
|
|
125
|
+
/**
|
|
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,
|
|
127
|
+
* property) }` (the value `Add` inserts). Returns `[]` for what the text cannot express as a plain edit.
|
|
128
|
+
*/
|
|
129
|
+
export function textFixesForFinding(format, finding, text, context = {}) {
|
|
130
|
+
if (!finding.path && finding.validation?.keyword !== "required") return [];
|
|
131
|
+
const segments = segmentsOf(finding.path);
|
|
132
|
+
const keyword = finding.validation?.keyword;
|
|
133
|
+
const fixes = [];
|
|
134
|
+
const push = (id, title, edits) => { if (edits?.length) fixes.push({ id, title, edits, safe: false }); };
|
|
135
|
+
if (keyword === "enum" || finding.ruleId === "opf/unresolved-reference") {
|
|
136
|
+
const where = locateText(format, text, segments, "value");
|
|
137
|
+
if (!where?.exact) return [];
|
|
138
|
+
const old = text.slice(where.from, where.to);
|
|
139
|
+
for (const value of replacementValues(finding, context)) push(`change-to:${value}`, `Change to ${value}`, [{ from: where.from, to: where.to, insert: written(format, segments, old, value) }]);
|
|
140
|
+
} else if (keyword === "additionalProperties" && !isSlideOption(format, segments)) {
|
|
141
|
+
const key = locateText(format, text, segments, "key");
|
|
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));
|
|
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));
|
|
153
|
+
} else if (keyword === "required") {
|
|
154
|
+
const property = finding.validation.params?.missingProperty;
|
|
155
|
+
const starter = property ? context.starter?.(segments, property) : undefined;
|
|
156
|
+
// In Markdown only the front matter is a mapping this can add to; a slide's fields are written as Markdown.
|
|
157
|
+
if (property && starter !== undefined && (format === "yaml" || segments[0] !== "slides")) push(`add:${property}`, `Add \`${property}\``, addProperty(format, text, segments, property, starter));
|
|
158
|
+
}
|
|
159
|
+
return fixes.filter((fix) => fix.edits.length);
|
|
160
|
+
}
|