@openpresentation/opf-editor 0.26.1 → 0.26.2
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 +4 -3
- package/dist/json-editor.d.ts +65 -3
- package/dist/json-editor.js +177 -45
- package/dist/json-options.js +4 -18
- package/dist/schema.js +4 -121
- package/dist/source-a11y.js +109 -0
- package/dist/source-completions.js +362 -0
- package/dist/source-diagnostics.js +211 -0
- package/dist/source-docs.js +87 -0
- package/dist/source-findings.js +11 -0
- package/dist/source-fixes.js +106 -0
- package/dist/source-json.js +139 -0
- package/dist/source-messages.js +231 -0
- package/dist/source-pane.d.ts +24 -2
- package/dist/source-pane.js +213 -37
- package/dist/source-problems.js +81 -0
- package/dist/source-ranges.js +32 -0
- package/dist/source-snippets.js +93 -0
- package/dist/source-syntax.js +81 -0
- package/dist/styles.css +76 -5
- package/package.json +8 -7
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Problems in the text (artifacts/source-editor/SPEC.md, section 9): every finding the pane has a range for is a squiggle with its message, what
|
|
2
|
+
// could come next and its quick fixes; F8 and Shift+F8 walk them; Cmd/Ctrl+. lists the fixes at the caret. The pane decides what the problems
|
|
3
|
+
// are (`control.setProblems`); this module only draws them and acts on them. Ranges are original-source offsets (CRLF counts as two).
|
|
4
|
+
import { ChangeSet, EditorSelection, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
|
|
5
|
+
import { EditorView, activateHover, keymap, showTooltip } from "@codemirror/view";
|
|
6
|
+
import { forEachDiagnostic, linter, setDiagnostics } from "@codemirror/lint";
|
|
7
|
+
import { isolateHistory } from "@codemirror/commands";
|
|
8
|
+
import { textInputMap } from "./text-input.js";
|
|
9
|
+
import { setJsonSource } from "./json-source.js";
|
|
10
|
+
import { applySourceEdits } from "./source-fixes.js";
|
|
11
|
+
|
|
12
|
+
const SEVERITY_WORD = { error: "Error", warning: "Warning", info: "Note" };
|
|
13
|
+
const MAX_FIXES = 7;
|
|
14
|
+
const el = (tag, className, text) => {
|
|
15
|
+
const node = document.createElement(tag);
|
|
16
|
+
if (className) node.className = className;
|
|
17
|
+
if (text !== undefined) node.textContent = text;
|
|
18
|
+
return node;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The platform's name for the quick fix chord, for the hint in a problem's tooltip. */
|
|
22
|
+
const isMac = () => /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? "");
|
|
23
|
+
export const quickFixChord = () => (isMac() ? "Cmd+." : "Ctrl+.");
|
|
24
|
+
|
|
25
|
+
// ---- the diagnostics ---------------------------------------------------------------------------------------------------------------
|
|
26
|
+
/** One problem as CodeMirror's lint state keeps it (`problem` is the pane's object, with source offsets as of when it was set). */
|
|
27
|
+
function toDiagnostic(problem, map, length, applyFix) {
|
|
28
|
+
const clamp = (offset) => Math.max(0, Math.min(offset, length));
|
|
29
|
+
const from = map.toInput(clamp(problem.from)), to = map.toInput(clamp(problem.to));
|
|
30
|
+
return {
|
|
31
|
+
from, to: Math.max(from, to),
|
|
32
|
+
severity: problem.severity === "warning" || problem.severity === "info" ? problem.severity : "error",
|
|
33
|
+
message: problem.message,
|
|
34
|
+
source: problem.rule,
|
|
35
|
+
problem,
|
|
36
|
+
renderMessage: () => renderProblem(problem),
|
|
37
|
+
actions: (problem.fixes ?? []).slice(0, MAX_FIXES).map((fix) => ({ name: fix.title, apply: () => applyFix(fix, problem) })),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The tooltip body: the message, what could come next, and the quick fix hint. */
|
|
41
|
+
function renderProblem(problem) {
|
|
42
|
+
const body = el("div", "opf-problem");
|
|
43
|
+
body.append(el("div", "opf-problem-message", problem.message));
|
|
44
|
+
if (problem.next) body.append(el("div", "opf-problem-next", `Next: ${problem.next}`));
|
|
45
|
+
if (problem.fixes?.length) body.append(el("div", "opf-problem-hint", `Quick Fix (${quickFixChord()}): ${problem.fixes.length} ${problem.fixes.length === 1 ? "action" : "actions"}`));
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The problems now in the text, in position order, with their current source offsets. */
|
|
50
|
+
export function currentProblems(state, source) {
|
|
51
|
+
const map = textInputMap(source), out = [];
|
|
52
|
+
forEachDiagnostic(state, (diagnostic, from, to) => {
|
|
53
|
+
if (!diagnostic.problem) return;
|
|
54
|
+
out.push({ ...diagnostic.problem, from: map.toSource(Math.min(from, map.text.length)), to: map.toSource(Math.min(to, map.text.length)), diagnostic });
|
|
55
|
+
});
|
|
56
|
+
return out.sort((a, b) => a.from - b.from || a.to - b.to || a.message.localeCompare(b.message));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- quick fix menu ----------------------------------------------------------------------------------------------------------------
|
|
60
|
+
const openFixes = StateEffect.define();
|
|
61
|
+
const closeFixes = StateEffect.define();
|
|
62
|
+
const moveFixes = StateEffect.define();
|
|
63
|
+
const fixMenu = StateField.define({
|
|
64
|
+
create: () => null,
|
|
65
|
+
update(value, tr) {
|
|
66
|
+
for (const effect of tr.effects) {
|
|
67
|
+
if (effect.is(openFixes)) value = { pos: effect.value.pos, items: effect.value.items, selected: 0 };
|
|
68
|
+
else if (effect.is(closeFixes)) value = null;
|
|
69
|
+
else if (effect.is(moveFixes) && value) value = { ...value, selected: (value.selected + effect.value + value.items.length) % value.items.length };
|
|
70
|
+
}
|
|
71
|
+
// Any change of the text or the caret closes the menu.
|
|
72
|
+
if (value && !tr.effects.some((effect) => effect.is(openFixes) || effect.is(moveFixes)) && (tr.docChanged || tr.selection)) value = null;
|
|
73
|
+
return value;
|
|
74
|
+
},
|
|
75
|
+
provide: (field) => showTooltip.from(field, (value) => value && {
|
|
76
|
+
pos: value.pos,
|
|
77
|
+
above: false,
|
|
78
|
+
strictSide: false,
|
|
79
|
+
arrow: false,
|
|
80
|
+
create() {
|
|
81
|
+
const dom = el("div", "cm-tooltip-section opf-fixes");
|
|
82
|
+
dom.setAttribute("role", "listbox");
|
|
83
|
+
dom.setAttribute("aria-label", "Quick fixes");
|
|
84
|
+
value.items.forEach((fix, index) => {
|
|
85
|
+
const item = el("div", "opf-fix", fix.title);
|
|
86
|
+
item.setAttribute("role", "option");
|
|
87
|
+
item.setAttribute("aria-selected", String(index === value.selected));
|
|
88
|
+
item.dataset.fix = fix.id;
|
|
89
|
+
if (fix.safe) item.dataset.safe = "true";
|
|
90
|
+
dom.append(item);
|
|
91
|
+
});
|
|
92
|
+
return { dom };
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** The fixes of the problems at the selection (or, when none touches it, on its line), the safest first. */
|
|
98
|
+
export function fixesAtCaret(state, source) {
|
|
99
|
+
const { from, to } = state.selection.main;
|
|
100
|
+
const map = textInputMap(source);
|
|
101
|
+
const problems = currentProblems(state, source);
|
|
102
|
+
const caretFrom = map.toSource(from), caretTo = map.toSource(to);
|
|
103
|
+
const touching = problems.filter((problem) => problem.from <= caretTo && problem.to >= caretFrom);
|
|
104
|
+
let chosen = touching;
|
|
105
|
+
if (!chosen.length) {
|
|
106
|
+
const line = state.doc.lineAt(from);
|
|
107
|
+
chosen = problems.filter((problem) => state.doc.lineAt(map.toInput(Math.min(problem.from, source.length))).number === line.number);
|
|
108
|
+
}
|
|
109
|
+
const fixes = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const problem of chosen) for (const fix of problem.fixes ?? []) {
|
|
112
|
+
const key = `${fix.id}|${fix.title}`;
|
|
113
|
+
if (seen.has(key)) continue;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
fixes.push({ ...fix, problem });
|
|
116
|
+
}
|
|
117
|
+
fixes.sort((a, b) => Number(Boolean(b.safe)) - Number(Boolean(a.safe)));
|
|
118
|
+
return fixes.slice(0, MAX_FIXES);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- the extension -----------------------------------------------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
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
|
+
* the live region. Returns `{ extension, draw(view, problems), problems(view) }`.
|
|
125
|
+
*/
|
|
126
|
+
export function createDiagnostics({ getSource, applyFix, say }) {
|
|
127
|
+
let announced = null;
|
|
128
|
+
const stepProblem = (forward) => (view) => {
|
|
129
|
+
const source = getSource(), map = textInputMap(source), list = currentProblems(view.state, source);
|
|
130
|
+
if (!list.length) { say("No problems"); return true; }
|
|
131
|
+
const sel = view.state.selection.main, here = map.toSource(sel.from), end = map.toSource(sel.to);
|
|
132
|
+
let target;
|
|
133
|
+
if (forward) target = list.find((problem) => problem.from > here || (problem.from === here && problem.to > end)) ?? list[0];
|
|
134
|
+
else target = [...list].reverse().find((problem) => problem.from < here || (problem.from === here && problem.to < end)) ?? list[list.length - 1];
|
|
135
|
+
selectProblem(view, target, map);
|
|
136
|
+
return true;
|
|
137
|
+
};
|
|
138
|
+
const selectProblem = (view, problem, map) => {
|
|
139
|
+
const anchor = map.toInput(problem.from), head = map.toInput(problem.to);
|
|
140
|
+
view.dispatch({ selection: EditorSelection.single(anchor, head), scrollIntoView: true, userEvent: "select.problem" });
|
|
141
|
+
announced = problem.diagnostic;
|
|
142
|
+
say(`${SEVERITY_WORD[problem.severity] ?? "Error"}: ${problem.message}${problem.next ? ` Next: ${problem.next}` : ""}`);
|
|
143
|
+
activateHover(view, anchor, 1, { until: (tr) => tr.docChanged || (tr.selection !== undefined && !tr.isUserEvent("select.problem")) });
|
|
144
|
+
};
|
|
145
|
+
const openMenu = (view) => {
|
|
146
|
+
const fixes = fixesAtCaret(view.state, getSource());
|
|
147
|
+
if (!fixes.length) { say("No quick fixes available"); return true; }
|
|
148
|
+
view.dispatch({ effects: openFixes.of({ pos: view.state.selection.main.from, items: fixes }) });
|
|
149
|
+
say(`${fixes.length} quick ${fixes.length === 1 ? "fix" : "fixes"}. ${fixes[0].title}`);
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
const menuOpen = (view) => view.state.field(fixMenu, false) !== null;
|
|
153
|
+
const whenOpen = (run) => (view) => (menuOpen(view) ? (run(view), true) : false);
|
|
154
|
+
const move = (by) => (view) => {
|
|
155
|
+
view.dispatch({ effects: moveFixes.of(typeof by === "function" ? by(view.state.field(fixMenu)) : by) });
|
|
156
|
+
const menu = view.state.field(fixMenu);
|
|
157
|
+
say(menu.items[menu.selected].title);
|
|
158
|
+
};
|
|
159
|
+
const keys = Prec.highest(keymap.of([
|
|
160
|
+
{ key: "ArrowDown", run: whenOpen(move(1)) },
|
|
161
|
+
{ key: "ArrowUp", run: whenOpen(move(-1)) },
|
|
162
|
+
{ key: "Home", run: whenOpen(move((menu) => -menu.selected)) },
|
|
163
|
+
{ key: "End", run: whenOpen(move((menu) => menu.items.length - 1 - menu.selected)) },
|
|
164
|
+
{ key: "Enter", run: whenOpen((view) => { const menu = view.state.field(fixMenu), fix = menu.items[menu.selected]; view.dispatch({ effects: closeFixes.of(null) }); applyFix(fix, fix.problem); }) },
|
|
165
|
+
{ key: "Escape", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
|
|
166
|
+
{ key: "Tab", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
|
|
167
|
+
]));
|
|
168
|
+
const plain = keymap.of([
|
|
169
|
+
{ key: "F8", run: stepProblem(true), shift: stepProblem(false) },
|
|
170
|
+
{ key: "Mod-.", run: openMenu },
|
|
171
|
+
]);
|
|
172
|
+
// The caret entering a squiggle says what the problem is, once per problem (a screen reader user finds it without looking for it).
|
|
173
|
+
const caretWatch = EditorView.updateListener.of((update) => {
|
|
174
|
+
if (!update.selectionSet || update.docChanged || update.transactions.some((tr) => tr.isUserEvent("select.problem"))) return;
|
|
175
|
+
const { head } = update.state.selection.main;
|
|
176
|
+
let found = null;
|
|
177
|
+
forEachDiagnostic(update.state, (diagnostic, from, to) => { if (!found && diagnostic.problem && head >= from && head <= to && from !== to) found = diagnostic; });
|
|
178
|
+
if (found && found !== announced) say(`${SEVERITY_WORD[found.severity] ?? "Error"}: ${found.message}`);
|
|
179
|
+
announced = found;
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
extension: [linter(null), fixMenu, keys, plain, caretWatch],
|
|
183
|
+
/** Replace the problems drawn in the text. `problems` carry source offsets into `getSource()`. */
|
|
184
|
+
draw(view, problems) {
|
|
185
|
+
const source = getSource(), map = textInputMap(source);
|
|
186
|
+
announced = null;
|
|
187
|
+
view.dispatch(setDiagnostics(view.state, problems
|
|
188
|
+
.filter((problem) => Number.isInteger(problem.from) && Number.isInteger(problem.to) && problem.from <= source.length)
|
|
189
|
+
.map((problem) => toDiagnostic(problem, map, source.length, applyFix))));
|
|
190
|
+
},
|
|
191
|
+
problems: (view) => currentProblems(view.state, getSource()),
|
|
192
|
+
openMenu,
|
|
193
|
+
next: stepProblem(true),
|
|
194
|
+
previous: stepProblem(false),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The edit of a fix as one CodeMirror transaction spec (source offsets in, editor offsets out), or null when an edit splits a CRLF pair. */
|
|
199
|
+
export function editsTransaction(state, source, edits, { caret, select, userEvent = "input.fix" } = {}) {
|
|
200
|
+
const map = textInputMap(source);
|
|
201
|
+
const sorted = edits.map((edit, index) => ({ edit, index })).sort((a, b) => a.edit.from - b.edit.from || a.index - b.index).map((entry) => entry.edit);
|
|
202
|
+
const next = applySourceEdits(source, edits);
|
|
203
|
+
const changes = ChangeSet.of(sorted.map((edit) => ({ from: map.toInput(edit.from), to: map.toInput(edit.to), insert: textInputMap(edit.insert).text })), state.doc.length);
|
|
204
|
+
const spec = { changes, effects: setJsonSource.of(next), annotations: [Transaction.userEvent.of(userEvent), isolateHistory.of("full")], scrollIntoView: true };
|
|
205
|
+
const nextMap = textInputMap(next);
|
|
206
|
+
// A caret or selection is given as offsets in the new text; otherwise the selection follows the edits.
|
|
207
|
+
if (select) spec.selection = EditorSelection.single(nextMap.toInput(Math.min(select[0], next.length)), nextMap.toInput(Math.min(select[1], next.length)));
|
|
208
|
+
else if (caret !== undefined) spec.selection = EditorSelection.single(nextMap.toInput(Math.min(caret, next.length)));
|
|
209
|
+
else spec.selection = state.selection.map(changes, 1);
|
|
210
|
+
return spec;
|
|
211
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The documentation cards of the source editor: the panel beside the selected suggestion and the hover over a key or a value. One model, two uses,
|
|
2
|
+
// plain DOM (no markup is parsed from the schema: its text is shown as text).
|
|
3
|
+
|
|
4
|
+
const el = (tag, className, text) => {
|
|
5
|
+
const node = document.createElement(tag);
|
|
6
|
+
if (className) node.className = className;
|
|
7
|
+
if (text !== undefined) node.textContent = text;
|
|
8
|
+
return node;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} DocModel
|
|
13
|
+
* @property {string} title the key, the value or the record name
|
|
14
|
+
* @property {string} [type] "string", "array of Slide", "layout"
|
|
15
|
+
* @property {boolean} [required]
|
|
16
|
+
* @property {boolean} [deprecated]
|
|
17
|
+
* @property {string} [description]
|
|
18
|
+
* @property {{ value: unknown, description?: string }[]} [values] allowed values, shown as chips
|
|
19
|
+
* @property {unknown[]} [examples]
|
|
20
|
+
* @property {string} [definition] the schema definition's name
|
|
21
|
+
* @property {string[]} [notes] extra lines (regions of a layout, where a record comes from)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MAX_CHIPS = 12;
|
|
25
|
+
const show = (value) => (typeof value === "string" ? value : JSON.stringify(value));
|
|
26
|
+
|
|
27
|
+
/** The card as DOM. */
|
|
28
|
+
export function docNode(model) {
|
|
29
|
+
const root = el("div", "opf-doc");
|
|
30
|
+
const head = el("div", "opf-doc-head");
|
|
31
|
+
head.append(el("code", "opf-doc-title", model.title));
|
|
32
|
+
if (model.type) head.append(el("span", "opf-doc-type", model.type));
|
|
33
|
+
if (model.required) head.append(el("span", "opf-doc-badge", "required"));
|
|
34
|
+
if (model.deprecated) head.append(el("span", "opf-doc-badge", "deprecated"));
|
|
35
|
+
root.append(head);
|
|
36
|
+
if (model.description) root.append(el("p", "opf-doc-text", model.description));
|
|
37
|
+
for (const note of model.notes ?? []) root.append(el("p", "opf-doc-note", note));
|
|
38
|
+
if (model.values?.length) {
|
|
39
|
+
const chips = el("div", "opf-doc-values");
|
|
40
|
+
for (const entry of model.values.slice(0, MAX_CHIPS)) {
|
|
41
|
+
const chip = el("code", "opf-doc-chip", show(entry.value));
|
|
42
|
+
if (entry.description) chip.title = entry.description;
|
|
43
|
+
chips.append(chip);
|
|
44
|
+
}
|
|
45
|
+
if (model.values.length > MAX_CHIPS) chips.append(el("span", "opf-doc-more", `and ${model.values.length - MAX_CHIPS} more`));
|
|
46
|
+
root.append(chips);
|
|
47
|
+
}
|
|
48
|
+
const example = model.examples?.find((value) => value !== undefined && !(typeof value === "string" && value.includes("...")));
|
|
49
|
+
if (example !== undefined) {
|
|
50
|
+
const line = el("p", "opf-doc-example");
|
|
51
|
+
line.append("Example: ", el("code", undefined, typeof example === "string" ? JSON.stringify(example) : JSON.stringify(example).slice(0, 80)));
|
|
52
|
+
root.append(line);
|
|
53
|
+
}
|
|
54
|
+
if (model.definition) root.append(el("p", "opf-doc-source", `Schema: ${model.definition}`));
|
|
55
|
+
return root;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The card as plain text (what a screen reader hears and what the tests read). */
|
|
59
|
+
export function docText(model) {
|
|
60
|
+
return [model.title, model.type, model.required ? "required" : "", model.description, ...(model.notes ?? [])].filter(Boolean).join(". ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The model of a field described by `describeField` (`info`), named `title`. */
|
|
64
|
+
export function fieldDoc(info, title) {
|
|
65
|
+
return {
|
|
66
|
+
title,
|
|
67
|
+
type: typeOfInfo(info),
|
|
68
|
+
required: info.required,
|
|
69
|
+
deprecated: info.deprecated,
|
|
70
|
+
description: info.description,
|
|
71
|
+
values: info.enum,
|
|
72
|
+
examples: info.examples,
|
|
73
|
+
definition: info.definition,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function typeOfInfo(info) {
|
|
78
|
+
if (info.catalogKind) return info.catalogKind.replace(/s$/, "").replace(/([A-Z])/g, " $1").toLowerCase() + " id";
|
|
79
|
+
if (info.enum?.length > 1) return "enum";
|
|
80
|
+
const kinds = info.types.map((type) => (type === "array" && info.itemInfo ? `array of ${info.itemInfo.definition ?? info.itemInfo.types.join(" | ")}` : type));
|
|
81
|
+
return [...new Set(kinds)].join(" | ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The model of one property of an object (a `FieldChild` of `describeField`): the card beside a key suggestion. */
|
|
85
|
+
export function childDoc(child) {
|
|
86
|
+
return { title: child.key, type: child.typeLabel, required: child.required, deprecated: child.deprecated, description: child.description };
|
|
87
|
+
}
|
package/dist/source-findings.js
CHANGED
|
@@ -7,9 +7,20 @@ export function normalizeFinding(input, kind) {
|
|
|
7
7
|
const out = { path: String(finding.path ?? ""), message: String(finding.message ?? ""), severity };
|
|
8
8
|
const line = finding.line ?? finding.location?.line;
|
|
9
9
|
if (Number.isInteger(line) && line > 0) out.line = line;
|
|
10
|
+
const column = finding.column ?? finding.location?.column;
|
|
11
|
+
if (Number.isInteger(column) && column > 0) out.column = column;
|
|
10
12
|
const own = finding.kind ?? kind;
|
|
11
13
|
if (own) out.kind = own;
|
|
12
14
|
if (finding.ruleId) out.ruleId = finding.ruleId;
|
|
15
|
+
// What core knows about a finding beyond the sentence: the help, the source range, the schema keyword and parameters, the suggested
|
|
16
|
+
// values and the fixes. The pane writes the problem text and its quick fixes from them.
|
|
17
|
+
for (const key of ["help", "category", "location", "validation", "suggestions", "fixes", "lookup", "definition", "slide", "slideId", "measured", "next"]) {
|
|
18
|
+
if (finding[key] !== undefined && finding[key] !== null) out[key] = finding[key];
|
|
19
|
+
}
|
|
20
|
+
// A problem with a range of its own (the JSON syntax analyzer) and the quick fixes that go with it.
|
|
21
|
+
if (finding.range && Number.isInteger(finding.range.from) && Number.isInteger(finding.range.to)) out.range = finding.range;
|
|
22
|
+
if (finding.editFixes) out.editFixes = finding.editFixes;
|
|
23
|
+
if (Array.isArray(finding.related)) out.related = finding.related;
|
|
13
24
|
return out;
|
|
14
25
|
}
|
|
15
26
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Quick fixes for the findings of the source pane (artifacts/source-editor/VALIDATOR-HINTS.md, section 12): the text edits behind "Change to center",
|
|
2
|
+
// "Rename to slides", "Remove property", "Add `slides`" and the like, for a JSON text that parses. Pure functions over the source text:
|
|
3
|
+
// each returns `{ id, title, safe, edits: [{ from, to, insert }], caret? }`, source offsets in, so the editor applies it as one undoable change.
|
|
4
|
+
import { findNodeAtLocation, modify, parseTree } from "jsonc-parser";
|
|
5
|
+
import { replacementValues, segmentsOf } from "./source-messages.js";
|
|
6
|
+
|
|
7
|
+
const toPath = (segments) => segments.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment));
|
|
8
|
+
|
|
9
|
+
/** jsonc-parser's edits as `{ from, to, insert }`. */
|
|
10
|
+
const asEdits = (edits) => edits.map((edit) => ({ from: edit.offset, to: edit.offset + edit.length, insert: edit.content }));
|
|
11
|
+
|
|
12
|
+
function formatting(unit, text) {
|
|
13
|
+
return { insertSpaces: unit !== "\t", tabSize: unit === "\t" ? 4 : unit.length, eol: /\r\n/.test(text) ? "\r\n" : "\n" };
|
|
14
|
+
}
|
|
15
|
+
/** The text after `edits` (source offsets, not overlapping; edits at one offset keep their order). */
|
|
16
|
+
export function applySourceEdits(text, edits) {
|
|
17
|
+
const ordered = edits.map((edit, index) => ({ edit, index })).sort((a, b) => a.edit.from - b.edit.from || a.index - b.index);
|
|
18
|
+
let next = text;
|
|
19
|
+
for (const { edit } of ordered.reverse()) next = next.slice(0, edit.from) + edit.insert + next.slice(edit.to);
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The node at `segments`: `{ node, parent }` or null. */
|
|
24
|
+
function nodeAt(text, segments) {
|
|
25
|
+
const root = parseTree(text, [], { allowTrailingComma: true });
|
|
26
|
+
if (!root) return null;
|
|
27
|
+
const node = findNodeAtLocation(root, toPath(segments));
|
|
28
|
+
return node ? { root, node } : null;
|
|
29
|
+
}
|
|
30
|
+
/** The property node (`"key": value`) whose value sits at `segments`, and its key node. */
|
|
31
|
+
function propertyAt(text, segments) {
|
|
32
|
+
const found = nodeAt(text, segments);
|
|
33
|
+
const property = found?.node.parent;
|
|
34
|
+
if (!property || property.type !== "property") return null;
|
|
35
|
+
return { property, key: property.children[0], value: property.children[1] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Replace the value at `segments` with `value` (any JSON value). */
|
|
39
|
+
export function changeValue(text, segments, value) {
|
|
40
|
+
const found = nodeAt(text, segments);
|
|
41
|
+
if (!found) return null;
|
|
42
|
+
return [{ from: found.node.offset, to: found.node.offset + found.node.length, insert: JSON.stringify(value) }];
|
|
43
|
+
}
|
|
44
|
+
/** Rename the property whose value is at `segments`. */
|
|
45
|
+
export function renameKey(text, segments, name) {
|
|
46
|
+
const found = propertyAt(text, segments);
|
|
47
|
+
if (!found) return null;
|
|
48
|
+
return [{ from: found.key.offset, to: found.key.offset + found.key.length, insert: JSON.stringify(name) }];
|
|
49
|
+
}
|
|
50
|
+
/** Remove the property (or list item) at `segments`, with the comma that belonged to it. */
|
|
51
|
+
export function removeAt(text, segments, unit = " ") {
|
|
52
|
+
const edits = modify(text, toPath(segments), undefined, { formattingOptions: formatting(unit, text) });
|
|
53
|
+
return edits.length ? asEdits(edits) : null;
|
|
54
|
+
}
|
|
55
|
+
/** Add `key: value` to the object at `segments`, after its last property. */
|
|
56
|
+
export function addProperty(text, segments, key, value, unit = " ") {
|
|
57
|
+
const edits = modify(text, [...toPath(segments), key], value, { formattingOptions: formatting(unit, text) });
|
|
58
|
+
return edits.length ? asEdits(edits) : null;
|
|
59
|
+
}
|
|
60
|
+
/** Add `value` at the end of the list at `segments`. */
|
|
61
|
+
export function addItem(text, segments, value, unit = " ") {
|
|
62
|
+
const found = nodeAt(text, segments);
|
|
63
|
+
const length = found?.node.type === "array" ? (found.node.children?.length ?? 0) : 0;
|
|
64
|
+
const edits = modify(text, [...toPath(segments), length], value, { formattingOptions: formatting(unit, text), isArrayInsertion: true });
|
|
65
|
+
return edits.length ? asEdits(edits) : null;
|
|
66
|
+
}
|
|
67
|
+
/** Wrap the value at `segments` in a one-item list. */
|
|
68
|
+
export function wrapInList(text, segments) {
|
|
69
|
+
const found = nodeAt(text, segments);
|
|
70
|
+
if (!found) return null;
|
|
71
|
+
const { offset, length } = found.node;
|
|
72
|
+
return [{ from: offset, to: offset, insert: "[" }, { from: offset + length, to: offset + length, insert: "]" }];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 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
|
+
* `{ unit, starter(segments, property) }` (the value `Add` inserts).
|
|
78
|
+
*/
|
|
79
|
+
export function fixesForFinding(finding, text, context = {}) {
|
|
80
|
+
if (!finding.path && finding.validation?.keyword !== "required") return [];
|
|
81
|
+
const segments = segmentsOf(finding.path);
|
|
82
|
+
const keyword = finding.validation?.keyword;
|
|
83
|
+
const unit = context.unit ?? " ";
|
|
84
|
+
const fixes = [];
|
|
85
|
+
const push = (id, title, edits, extra = {}) => { if (edits?.length) fixes.push({ id, title, edits, safe: false, ...extra }); };
|
|
86
|
+
if (keyword === "enum" || finding.ruleId === "opf/unresolved-reference") {
|
|
87
|
+
for (const value of replacementValues(finding, context)) push(`change-to:${value}`, `Change to ${value}`, changeValue(text, segments, value));
|
|
88
|
+
} else if (keyword === "additionalProperties") {
|
|
89
|
+
const name = finding.validation.params?.additionalProperty ?? segments.at(-1);
|
|
90
|
+
for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, renameKey(text, segments, value));
|
|
91
|
+
push("remove-property", "Remove property", removeAt(text, segments, unit));
|
|
92
|
+
void name;
|
|
93
|
+
} else if (keyword === "required") {
|
|
94
|
+
const property = finding.validation.params?.missingProperty;
|
|
95
|
+
const starter = context.starter?.(segments, property);
|
|
96
|
+
if (property && starter !== undefined) push(`add:${property}`, `Add \`${property}\``, addProperty(text, segments, property, starter, unit));
|
|
97
|
+
} else if (keyword === "type" && finding.validation.params?.type === "array") {
|
|
98
|
+
push("wrap-in-list", "Wrap in a list", wrapInList(text, segments));
|
|
99
|
+
} else if (keyword === "minItems" && finding.path === "/slides") {
|
|
100
|
+
push("add-slide", "Add a slide", addItem(text, segments, { title: "Slide title" }, unit));
|
|
101
|
+
} else if (finding.ruleId === "opf/duplicate-key") {
|
|
102
|
+
// The pane marks the second key; removing either one leaves the other.
|
|
103
|
+
push("remove-duplicate", "Remove the second", removeAt(text, segments, unit));
|
|
104
|
+
}
|
|
105
|
+
return fixes;
|
|
106
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The JSON text adapter of the source editor (artifacts/source-editor/SPEC.md, section 3.1): what a position in a JSON draft means. Everything the
|
|
2
|
+
// completion list, the hover and the hints know about the caret comes from `contextAt`: the token being typed, whether it sits where a key, a value
|
|
3
|
+
// or a list item belongs, the path of the node in the document, the keys already there, and what the text around needs when something is inserted
|
|
4
|
+
// (a colon, a comma). It reads drafts that do not parse: the token under the caret is swapped for a placeholder string and the rest is read with
|
|
5
|
+
// the tolerant parser.
|
|
6
|
+
import { findNodeAtOffset, getNodePath, getNodeValue, parseTree } from "jsonc-parser";
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER = "__opf_completion__";
|
|
9
|
+
const WORD = /[A-Za-z0-9_.-]/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The token being typed at `position`: inside a string (a quote opened earlier on this line), or a bare word, or nothing yet where a value or key
|
|
13
|
+
* would go. JSON strings never span lines. Returns `{ quoted, closed, start, end, from, to }` (`start..end` is the whole token with its quotes,
|
|
14
|
+
* `from..to` the text between them), or null when no token can be completed here. `explicit` (Ctrl+Space) also takes the empty place after `{`,
|
|
15
|
+
* `,`, `:` and `[`, the string just closed, and a blank document; typing `:` or the space after it takes the empty place after the colon.
|
|
16
|
+
*/
|
|
17
|
+
export function tokenAt(text, position, explicit) {
|
|
18
|
+
const lineStart = Math.max(text.lastIndexOf("\n", position - 1), text.lastIndexOf("\r", position - 1)) + 1;
|
|
19
|
+
let open = -1, last = null;
|
|
20
|
+
for (let index = lineStart; index < position; index++) {
|
|
21
|
+
if (open >= 0) { if (text[index] === "\\") index++; else if (text[index] === '"') { last = { start: open, end: index }; open = -1; } }
|
|
22
|
+
else if (text[index] === '"') open = index;
|
|
23
|
+
}
|
|
24
|
+
// Asked for explicitly (Ctrl+Space) right after a closing quote: the string just ended is the token, as in VS Code.
|
|
25
|
+
if (open < 0 && explicit && last?.end === position - 1) return { quoted: true, closed: true, start: last.start, end: position, from: last.start + 1, to: position - 1 };
|
|
26
|
+
if (open >= 0) {
|
|
27
|
+
let close = -1;
|
|
28
|
+
for (let index = position; index < text.length && text[index] !== "\n" && text[index] !== "\r"; index++) {
|
|
29
|
+
if (text[index] === "\\") index++;
|
|
30
|
+
else if (text[index] === '"') { close = index; break; }
|
|
31
|
+
}
|
|
32
|
+
return { quoted: true, closed: close >= 0, start: open, end: close >= 0 ? close + 1 : position, from: open + 1, to: close >= 0 ? close : position };
|
|
33
|
+
}
|
|
34
|
+
let start = position, end = position;
|
|
35
|
+
while (start > lineStart && WORD.test(text[start - 1])) start--;
|
|
36
|
+
while (end < text.length && WORD.test(text[end])) end++;
|
|
37
|
+
if (start === position && !explicit && !/:[ \t]?$/.test(text.slice(lineStart, position))) return null;
|
|
38
|
+
let before = start - 1;
|
|
39
|
+
while (before >= lineStart && /\s/.test(text[before])) before--;
|
|
40
|
+
// A bare word starts a key or a value only after `{`, `,`, `:` or `[` (or at the start of an empty line).
|
|
41
|
+
const previous = before >= lineStart ? text[before] : (text.slice(0, lineStart).trimEnd().at(-1) ?? "");
|
|
42
|
+
const blank = previous === "" && text.slice(0, start).trim() === "" && text.slice(end).trim() === "";
|
|
43
|
+
if (!["{", ",", ":", "["].includes(previous) && !blank) return null;
|
|
44
|
+
return { quoted: false, closed: false, start, end, from: start, to: end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The first non-blank character after `offset` (skipping whitespace and line breaks), or "". */
|
|
48
|
+
export function nextSignificant(text, offset) {
|
|
49
|
+
for (let index = offset; index < text.length; index++) if (!/\s/.test(text[index])) return text[index];
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A copy of the document with only what choosing the schema form along `path` needs: the nodes on the path in full, every other value cut to its
|
|
55
|
+
* type (an object to `{}`, a list to an empty list as long as it is), so the cost does not grow with the deck. `catalogs` is kept in full because
|
|
56
|
+
* embedded records are offered as values.
|
|
57
|
+
*/
|
|
58
|
+
export function skeleton(root, path, skip) {
|
|
59
|
+
const shallow = (node) => {
|
|
60
|
+
if (node.type === "object") return {};
|
|
61
|
+
if (node.type === "array") return new Array((node.children ?? []).length);
|
|
62
|
+
return node.value;
|
|
63
|
+
};
|
|
64
|
+
const build = (node, rest, skip) => {
|
|
65
|
+
if (node.type === "object") {
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const property of node.children ?? []) {
|
|
68
|
+
const key = property.children?.[0]?.value, value = property.children?.[1];
|
|
69
|
+
if (typeof key !== "string" || !value) continue;
|
|
70
|
+
if (rest.length && String(key) === String(rest[0])) {
|
|
71
|
+
if (rest.length === 1 && skip) continue;
|
|
72
|
+
out[key] = build(value, rest.slice(1), skip);
|
|
73
|
+
} else out[key] = key === "catalogs" && rest.length === 0 ? getNodeValue(value) : shallow(value);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
if (node.type === "array") {
|
|
78
|
+
const out = new Array((node.children ?? []).length);
|
|
79
|
+
(node.children ?? []).forEach((child, index) => {
|
|
80
|
+
if (rest.length && String(index) === String(rest[0])) { if (!(rest.length === 1 && skip)) out[index] = build(child, rest.slice(1), skip); }
|
|
81
|
+
else out[index] = shallow(child);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
return node.value;
|
|
86
|
+
};
|
|
87
|
+
const document = build(root, path, skip);
|
|
88
|
+
// The embedded catalogs, whole, whatever the path (their records are offered as values).
|
|
89
|
+
const catalogs = (root.children ?? []).find((property) => property.children?.[0]?.value === "catalogs")?.children?.[1];
|
|
90
|
+
if (catalogs && root.type === "object" && path[0] !== "catalogs") document.catalogs = getNodeValue(catalogs);
|
|
91
|
+
return document;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the draft means at `position`, or null: `{ mode, token, path, key, existing, siblingsAfter, document, node }`.
|
|
96
|
+
* `mode` is "key" (a property name goes here; `path` is the object), "value" (the value of the property at `path`; `key` is its name) or "item" (an
|
|
97
|
+
* entry of the list at `path`, which ends with the entry's index). `existing` holds the other property names of the object. `document` is the
|
|
98
|
+
* skeleton that `describeField` reads. `node` is the placeholder node of the draft.
|
|
99
|
+
*/
|
|
100
|
+
export function contextAt(text, position, { explicit = false } = {}) {
|
|
101
|
+
const token = tokenAt(text, position, explicit);
|
|
102
|
+
if (!token) return null;
|
|
103
|
+
const draft = text.slice(0, token.start) + JSON.stringify(PLACEHOLDER) + text.slice(token.end);
|
|
104
|
+
const root = parseTree(draft, [], { allowTrailingComma: true });
|
|
105
|
+
if (!root) return null;
|
|
106
|
+
const node = findNodeAtOffset(root, token.start + 1, true);
|
|
107
|
+
if (node?.type !== "string" || node.value !== PLACEHOLDER) return null;
|
|
108
|
+
const parent = node.parent;
|
|
109
|
+
if (!parent) return { mode: "value", token, path: [], key: undefined, existing: new Set(), document: {}, node, root };
|
|
110
|
+
if (parent.type === "property" && parent.children?.[0] === node) {
|
|
111
|
+
const object = parent.parent;
|
|
112
|
+
if (!object || object.type !== "object") return null;
|
|
113
|
+
const path = getNodePath(object);
|
|
114
|
+
const existing = new Set((object.children ?? []).filter((property) => property !== parent).map((property) => property.children?.[0]?.value));
|
|
115
|
+
return { mode: "key", token, path, key: undefined, existing, document: skeleton(root, path, false), node, root, object };
|
|
116
|
+
}
|
|
117
|
+
const path = getNodePath(node);
|
|
118
|
+
if (parent.type === "property") return { mode: "value", token, path, key: String(path.at(-1)), existing: new Set(), document: skeleton(root, path, true), node, root };
|
|
119
|
+
if (parent.type === "array") return { mode: "item", token, path, key: undefined, existing: new Set(), document: skeleton(root, path, true), node, root };
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The node under `position` in a JSON text for hover: `{ kind: "key" | "value", path, from, to, value }` (offsets of the key or value token), or
|
|
125
|
+
* null. Works on text that does not parse.
|
|
126
|
+
*/
|
|
127
|
+
export function nodeAt(text, position) {
|
|
128
|
+
const root = parseTree(text, [], { allowTrailingComma: true, disallowComments: false });
|
|
129
|
+
if (!root) return null;
|
|
130
|
+
const node = findNodeAtOffset(root, position, true);
|
|
131
|
+
if (!node) return null;
|
|
132
|
+
const parent = node.parent;
|
|
133
|
+
if (node.type === "string" && parent?.type === "property" && parent.children?.[0] === node) {
|
|
134
|
+
return { kind: "key", path: [...getNodePath(parent), node.value], from: node.offset, to: node.offset + node.length, value: node.value, root, node };
|
|
135
|
+
}
|
|
136
|
+
if (node.type === "property") return null;
|
|
137
|
+
if (!["string", "number", "boolean", "object", "array", "null"].includes(node.type)) return null;
|
|
138
|
+
return { kind: "value", path: getNodePath(node), from: node.offset, to: node.offset + node.length, value: node.type === "object" || node.type === "array" ? undefined : node.value, nodeType: node.type, root, node };
|
|
139
|
+
}
|