@openpresentation/opf-editor 0.26.1 → 0.26.3
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
package/dist/source-pane.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
2
1
|
import { fromYaml, scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
|
|
3
2
|
import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
|
|
4
3
|
import { mountJsonCodeEditor } from "./json-editor.js";
|
|
5
|
-
import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory,
|
|
6
|
-
import { lineForPath,
|
|
4
|
+
import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, updateJsonSource } from "./exact-source.js";
|
|
5
|
+
import { lineForPath, offsetOfLine } from "./source-lines.js";
|
|
7
6
|
import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
|
|
8
7
|
import { formatSource } from "./source-format.js";
|
|
8
|
+
import { buildProblems, summarize } from "./source-problems.js";
|
|
9
|
+
import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
|
|
10
|
+
import { createSchemaValue, schemaAtPath, schemaVariants } from "./schema.js";
|
|
11
|
+
import { catalogKindAt } from "@openpresentation/opf/schema";
|
|
12
|
+
import { locateJson } from "./source-ranges.js";
|
|
13
|
+
import { listCatalogRecords } from "./catalogs.js";
|
|
9
14
|
|
|
10
15
|
// The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
|
|
11
16
|
// and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
|
|
@@ -68,27 +73,15 @@ function same(a, b) {
|
|
|
68
73
|
const SEVERITY = { error: 0, warning: 1, info: 2 };
|
|
69
74
|
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
70
75
|
|
|
71
|
-
const JSON_ERRORS = { InvalidSymbol: "Invalid symbol", InvalidNumberFormat: "Invalid number", PropertyNameExpected: "Property name expected", ValueExpected: "Value expected", ColonExpected: "Colon expected", CommaExpected: "Comma expected", CloseBraceExpected: "Closing brace expected", CloseBracketExpected: "Closing bracket expected", EndOfFileExpected: "End of file expected", InvalidCommentToken: "Comments are not allowed in JSON", UnexpectedEndOfComment: "Unexpected end of comment", UnexpectedEndOfString: "Unexpected end of string", UnexpectedEndOfNumber: "Unexpected end of number", InvalidUnicode: "Invalid unicode escape", InvalidEscapeCharacter: "Invalid escape character", InvalidCharacter: "Invalid character" };
|
|
72
|
-
|
|
73
76
|
/** Read the text of one format. `{ deck }` on success; `findings` carry the syntax errors (`syntax: true`) or the OPF findings of the parsed deck. */
|
|
74
77
|
function readText(format, text, catalogs) {
|
|
75
78
|
if (format === "json") {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return { syntax: true, findings: [normalizeFinding({ path, message: `Duplicate key "${duplicate.key}". Remove the repeated key.` }, "syntax")] };
|
|
83
|
-
}
|
|
84
|
-
return { deck, findings: [] };
|
|
85
|
-
} catch (error) {
|
|
86
|
-
const errors = [];
|
|
87
|
-
parseJsonc(text, errors, { allowTrailingComma: false, disallowComments: true });
|
|
88
|
-
const first = errors[0];
|
|
89
|
-
const message = first ? `${JSON_ERRORS[printParseErrorCode(first.error)] ?? printParseErrorCode(first.error)}.` : `${error.message}.`;
|
|
90
|
-
return { syntax: true, findings: [normalizeFinding({ message, line: first ? lineOfOffset(text, first.offset) : 1 }, "syntax")] };
|
|
91
|
-
}
|
|
79
|
+
const problems = analyzeSyntax(text);
|
|
80
|
+
if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
|
|
81
|
+
const deck = JSON.parse(text);
|
|
82
|
+
const duplicates = findDuplicateKeys(text);
|
|
83
|
+
if (duplicates.length) return { syntax: true, findings: duplicates };
|
|
84
|
+
return { deck, findings: [] };
|
|
92
85
|
}
|
|
93
86
|
const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
|
|
94
87
|
const syntax = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
|
|
@@ -128,6 +121,10 @@ export function createSourcePane(container, options = {}) {
|
|
|
128
121
|
// Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
|
|
129
122
|
let invalid = false, shownInvalid = null;
|
|
130
123
|
const lineCache = { key: "", values: new Map() };
|
|
124
|
+
// The problems now shown (rows of source-problems.js), the document they were checked as, and the pause timers of the count announcement and the
|
|
125
|
+
// syntax pass.
|
|
126
|
+
const rowOf = new WeakMap();
|
|
127
|
+
let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
|
|
131
128
|
// The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
|
|
132
129
|
// (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
|
|
133
130
|
const exactTexts = new Map();
|
|
@@ -240,6 +237,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
240
237
|
describedBy: `${id}-findings`,
|
|
241
238
|
onChange: () => schedule(),
|
|
242
239
|
onFormat: () => formatDocument(),
|
|
240
|
+
onFocusProblems: () => focusProblems(),
|
|
241
|
+
// The pane supplies the problems itself (every finding, with ranges and fixes).
|
|
242
|
+
syntaxProblems: false,
|
|
243
243
|
historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
|
|
244
244
|
onError: (error) => options.onError?.(error),
|
|
245
245
|
});
|
|
@@ -247,8 +247,25 @@ export function createSourcePane(container, options = {}) {
|
|
|
247
247
|
// ---- Applying edits -----------------------------------------------------------------------------------------------
|
|
248
248
|
function schedule() {
|
|
249
249
|
clearTimeout(timer);
|
|
250
|
+
clearTimeout(syntaxTimer);
|
|
250
251
|
if (delay === 0) { flush(); return; }
|
|
251
252
|
timer = setTimeout(flush, delay);
|
|
253
|
+
// Syntax problems come first, 150 ms after the last key: the rest of the checking waits for the apply delay.
|
|
254
|
+
if (delay > 150) syntaxTimer = setTimeout(checkSyntax, 150);
|
|
255
|
+
}
|
|
256
|
+
/** Show or clear the syntax problems of the text now, without applying it to the document. */
|
|
257
|
+
function checkSyntax() {
|
|
258
|
+
syntaxTimer = null;
|
|
259
|
+
if (destroyed || format !== "json") return;
|
|
260
|
+
const problems = analyzeSyntax(control.api.getValue());
|
|
261
|
+
if (!problems.length) {
|
|
262
|
+
if (!syntaxFindings.length) return;
|
|
263
|
+
syntaxFindings = [];
|
|
264
|
+
renderFindings();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
syntaxFindings = problems.map((problem) => normalizeFinding(problem, "syntax"));
|
|
268
|
+
renderFindings();
|
|
252
269
|
}
|
|
253
270
|
/** Apply any edit still waiting for its delay. */
|
|
254
271
|
function flush() {
|
|
@@ -272,6 +289,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
272
289
|
}
|
|
273
290
|
syntaxFindings = [];
|
|
274
291
|
baseline = source;
|
|
292
|
+
lastDraft = read.deck;
|
|
275
293
|
const before = deck();
|
|
276
294
|
if (!same(read.deck, before)) {
|
|
277
295
|
applying = true;
|
|
@@ -319,6 +337,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
319
337
|
rejectionFindings = [];
|
|
320
338
|
transientNotes = [];
|
|
321
339
|
dirty = false;
|
|
340
|
+
lastDraft = null;
|
|
322
341
|
let written;
|
|
323
342
|
try { written = write(format, deck()); }
|
|
324
343
|
catch (error) {
|
|
@@ -504,6 +523,28 @@ export function createSourcePane(container, options = {}) {
|
|
|
504
523
|
return true;
|
|
505
524
|
}
|
|
506
525
|
|
|
526
|
+
/** Read the text as `next` (it is that format already: a YAML deck pasted into the JSON pane) without rewriting it. */
|
|
527
|
+
function switchFormatKeepingText(next) {
|
|
528
|
+
const target = formatOf(next);
|
|
529
|
+
if (destroyed || target.id === format) return;
|
|
530
|
+
flush();
|
|
531
|
+
const previous = format;
|
|
532
|
+
format = target.id;
|
|
533
|
+
transientNotes = [];
|
|
534
|
+
rejectionFindings = [];
|
|
535
|
+
syntaxFindings = [];
|
|
536
|
+
noteFindings = [];
|
|
537
|
+
control.setLanguage(target.language);
|
|
538
|
+
updateBar();
|
|
539
|
+
baseline = null;
|
|
540
|
+
flush();
|
|
541
|
+
renderedKey = "";
|
|
542
|
+
renderFindings();
|
|
543
|
+
syncAttributes();
|
|
544
|
+
control.api.focus();
|
|
545
|
+
options.onFormatChange?.(format, previous);
|
|
546
|
+
}
|
|
547
|
+
|
|
507
548
|
// ---- Menu ---------------------------------------------------------------------------------------------------------
|
|
508
549
|
const menuOpen = () => !menu.hidden;
|
|
509
550
|
function openMenu(focus = "checked") {
|
|
@@ -674,14 +715,14 @@ export function createSourcePane(container, options = {}) {
|
|
|
674
715
|
for (const group of [syntaxFindings, rejectionFindings, transientNotes, noteFindings, host]) {
|
|
675
716
|
const keys = [];
|
|
676
717
|
for (const finding of group) {
|
|
677
|
-
const key = `${finding.severity}|${finding.path}|${finding.message}`;
|
|
718
|
+
const key = `${finding.severity}|${finding.path}|${finding.message}|${finding.range?.from ?? ""}`;
|
|
678
719
|
if (seen.has(key)) continue;
|
|
679
720
|
keys.push(key);
|
|
680
721
|
all.push(finding);
|
|
681
722
|
}
|
|
682
723
|
for (const key of keys) seen.add(key);
|
|
683
724
|
}
|
|
684
|
-
return all
|
|
725
|
+
return all;
|
|
685
726
|
}
|
|
686
727
|
function lineOf(finding) {
|
|
687
728
|
const value = control.api.getValue();
|
|
@@ -692,23 +733,75 @@ export function createSourcePane(container, options = {}) {
|
|
|
692
733
|
if (!lineCache.values.has(cacheKey)) lineCache.values.set(cacheKey, finding.path === "" ? null : lineForPath(format, value, finding.path));
|
|
693
734
|
return lineCache.values.get(cacheKey) ?? explicit ?? null;
|
|
694
735
|
}
|
|
736
|
+
// What the messages and the quick fixes read: the document being checked (the typed draft, else the session's) and its schema.
|
|
737
|
+
const attempt = (run, fallback) => { try { return run(); } catch { return fallback; } };
|
|
738
|
+
function problemContext() {
|
|
739
|
+
const draft = lastDraft ?? deck();
|
|
740
|
+
return {
|
|
741
|
+
unit: control.api.indentUnit(),
|
|
742
|
+
valueAt: (segments) => segments.reduce((value, key) => (value !== null && typeof value === "object" ? value[key] : undefined), draft),
|
|
743
|
+
schemaAt: (segments) => attempt(() => schemaAtPath(draft, segments), undefined),
|
|
744
|
+
keysOf: (segments) => attempt(() => [...new Set(schemaVariants(schemaAtPath(draft, segments)).flatMap((variant) => Object.keys(variant.properties ?? {})))], []),
|
|
745
|
+
catalogKind: (segments) => catalogKindAt(segments),
|
|
746
|
+
catalogCount: (kind) => attempt(() => listCatalogRecords(draft, kind, { catalogs: catalogs() }).length, 0),
|
|
747
|
+
starter: (segments, property) => attempt(() => (property === "slides" ? [{ title: "Slide title" }] : createSchemaValue(schemaAtPath(draft, [...segments, property]))), undefined),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** Select the field a core "focus" fix names: its path, else the field inside it, else the nearest place there is one. */
|
|
751
|
+
function focusField(focus) {
|
|
752
|
+
flush();
|
|
753
|
+
const text = control.api.getValue();
|
|
754
|
+
const segments = String(focus.path ?? "").replace(/^\//, "").split("/").filter(Boolean).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
755
|
+
if (format === "json") {
|
|
756
|
+
const wanted = focus.field ? locateJson(text, [...segments, focus.field], "value") : null;
|
|
757
|
+
const where = wanted?.exact ? wanted : locateJson(text, segments, "value");
|
|
758
|
+
if (where) { control.api.setSelection(where.from, where.to); control.api.focus(); return; }
|
|
759
|
+
}
|
|
760
|
+
focusPath(focus.path ?? "");
|
|
761
|
+
}
|
|
762
|
+
const fixActions = {
|
|
763
|
+
applyPatch(patch) {
|
|
764
|
+
flush();
|
|
765
|
+
try { editor.applyPatch(patch, { source: "source-pane", format }); } catch (error) { rejectionFindings = findingsOfError(error); renderedKey = ""; renderFindings(); }
|
|
766
|
+
control.api.focus();
|
|
767
|
+
},
|
|
768
|
+
focusField,
|
|
769
|
+
switchFormat(next) { switchFormatKeepingText(next); },
|
|
770
|
+
};
|
|
771
|
+
/** Problems the author is in the middle of typing (a key with a list of keys open next to it) are not shown yet. */
|
|
772
|
+
function withheld(row) {
|
|
773
|
+
if (row.finding.kind !== "syntax" || row.from === undefined || !control.isCompleting()) return false;
|
|
774
|
+
const [caret] = control.api.getSelection();
|
|
775
|
+
return caret >= row.from - 1 && caret <= row.to + 1;
|
|
776
|
+
}
|
|
695
777
|
function renderFindings() {
|
|
696
778
|
paintFindings();
|
|
697
779
|
if (!destroyed && invalid !== shownInvalid) syncAttributes();
|
|
698
780
|
}
|
|
699
781
|
function paintFindings() {
|
|
700
782
|
if (destroyed) return;
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
783
|
+
const text = control.api.getValue();
|
|
784
|
+
let rows = buildProblems({ format, text, findings: collect(), context: problemContext(), actions: fixActions, lineOf });
|
|
785
|
+
const hidden = rows.filter(withheld);
|
|
786
|
+
if (hidden.length) {
|
|
787
|
+
rows = rows.filter((row) => !hidden.includes(row));
|
|
788
|
+
clearTimeout(recheckTimer);
|
|
789
|
+
recheckTimer = setTimeout(() => { if (!destroyed) { renderedKey = ""; renderFindings(); } }, 600);
|
|
790
|
+
}
|
|
791
|
+
problemRows = rows;
|
|
792
|
+
invalid = rows.some((row) => row.severity === "error");
|
|
793
|
+
const key = JSON.stringify([format, collapsed, rows.map((row) => [row.severity, row.path, row.message, row.next, row.finding.kind, row.line, row.column, row.from, row.to, row.fixes.map((fix) => fix.title)])]);
|
|
705
794
|
if (key === renderedKey) return;
|
|
706
795
|
renderedKey = key;
|
|
796
|
+
// The same problems are drawn in the text, with their tooltips and quick fixes.
|
|
797
|
+
control.setProblems(rows.filter((row) => row.from !== undefined).flatMap((row) => [row, ...(row.finding.related ?? []).map((range) => ({ ...row, from: range.from, to: range.to }))])
|
|
798
|
+
.map((row) => ({ from: row.from, to: row.to, severity: row.severity, message: row.message, next: row.next, rule: row.rule, fixes: row.fixes })));
|
|
799
|
+
announceSummary(rows);
|
|
707
800
|
findingsRegion.hidden = rows.length === 0;
|
|
708
801
|
root.dataset.findings = String(rows.length);
|
|
709
802
|
if (!rows.length) { list.replaceChildren(); return; }
|
|
710
|
-
const errors = rows.filter((row) => row.
|
|
711
|
-
const warnings = rows.filter((row) => row.
|
|
803
|
+
const errors = rows.filter((row) => row.severity === "error");
|
|
804
|
+
const warnings = rows.filter((row) => row.severity === "warning");
|
|
712
805
|
const infos = rows.length - errors.length - warnings.length;
|
|
713
806
|
const parts = [];
|
|
714
807
|
if (errors.length) parts.push(plural(errors.length, errors.every((row) => row.finding.kind === "schema" || !row.finding.kind) ? "schema error" : "error"));
|
|
@@ -721,25 +814,88 @@ export function createSourcePane(container, options = {}) {
|
|
|
721
814
|
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
722
815
|
list.hidden = collapsed;
|
|
723
816
|
const shown = rows.slice(0, 200);
|
|
724
|
-
list.replaceChildren(...shown.map((
|
|
817
|
+
list.replaceChildren(...shown.map((row) => {
|
|
818
|
+
const { finding } = row;
|
|
725
819
|
const item = el("li", "opf-source-finding-item");
|
|
726
|
-
const button = el("button", "opf-source-finding", { type: "button", "data-severity":
|
|
820
|
+
const button = el("button", "opf-source-finding", { type: "button", "data-severity": row.severity });
|
|
727
821
|
if (finding.kind) button.dataset.kind = finding.kind;
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
822
|
+
const word = row.severity === "error" ? "Error" : row.severity === "warning" ? "Warning" : "Note";
|
|
823
|
+
const severity = Object.assign(el("span", "opf-source-finding-severity"), { textContent: `${word}: ` });
|
|
824
|
+
const path = row.path ? el("code", "opf-source-finding-path") : null;
|
|
825
|
+
if (path) path.textContent = row.path;
|
|
826
|
+
const message = Object.assign(el("span", "opf-source-finding-message"), { textContent: row.message });
|
|
827
|
+
const next = row.next ? Object.assign(el("span", "opf-source-finding-next"), { textContent: `Next: ${row.next}` }) : null;
|
|
828
|
+
const where = Object.assign(el("span", "opf-source-finding-line"), { textContent: row.line ? (row.column ? `Ln ${row.line}, Col ${row.column}` : `Ln ${row.line}`) : "" });
|
|
829
|
+
const rule = row.rule ? Object.assign(el("span", "opf-source-finding-rule"), { textContent: row.rule }) : null;
|
|
830
|
+
rowOf.set(button, row);
|
|
831
|
+
const head = el("span", "opf-source-finding-head");
|
|
832
|
+
head.append(...(path ? [path] : []), message);
|
|
833
|
+
button.append(severity, head, ...(next ? [next] : []), where, ...(rule ? [rule] : []));
|
|
733
834
|
button.addEventListener("click", () => {
|
|
734
|
-
const target =
|
|
835
|
+
const target = selectRow(row);
|
|
735
836
|
options.onFindingSelect?.(finding, target);
|
|
736
837
|
});
|
|
737
838
|
item.append(button);
|
|
839
|
+
if (row.fixes.length) {
|
|
840
|
+
const fixes = el("span", "opf-source-finding-fixes");
|
|
841
|
+
for (const fix of row.fixes.slice(0, 3)) {
|
|
842
|
+
const fixButton = Object.assign(el("button", "opf-source-finding-fix", { type: "button", "aria-label": `${fix.title}: ${row.message}` }), { textContent: fix.title });
|
|
843
|
+
fixButton.addEventListener("click", () => applyFix(fix, row));
|
|
844
|
+
fixes.append(fixButton);
|
|
845
|
+
}
|
|
846
|
+
item.append(fixes);
|
|
847
|
+
}
|
|
738
848
|
return item;
|
|
739
849
|
}));
|
|
740
850
|
if (rows.length > shown.length) list.append(Object.assign(el("li", "opf-source-finding-more"), { textContent: `${rows.length - shown.length} more not shown.` }));
|
|
741
851
|
}
|
|
742
852
|
toggle.addEventListener("click", () => { collapsed = !collapsed; renderedKey = ""; renderFindings(); });
|
|
853
|
+
/** "2 problems: 1 error, 1 warning" in the live region once the problems stop changing for a moment. */
|
|
854
|
+
function announceSummary(rows) {
|
|
855
|
+
const summary = summarize(rows);
|
|
856
|
+
clearTimeout(summaryTimer);
|
|
857
|
+
if (summary === lastSummary) return;
|
|
858
|
+
summaryTimer = setTimeout(() => { if (!destroyed) { lastSummary = summary; control.api.say(summary); } }, 600);
|
|
859
|
+
}
|
|
860
|
+
/** Run a fix of a row: its edits as one undoable change in the text, or its own action. */
|
|
861
|
+
function applyFix(fix, row) {
|
|
862
|
+
if (readOnly) return;
|
|
863
|
+
control.applyFix(fix, row);
|
|
864
|
+
}
|
|
865
|
+
/** Put the caret on a row's place in the text, selecting the range when it has one. Returns the line. */
|
|
866
|
+
function selectRow(row) {
|
|
867
|
+
flush();
|
|
868
|
+
const text = control.api.getValue();
|
|
869
|
+
if (row.from !== undefined) {
|
|
870
|
+
control.api.setSelection(Math.min(row.from, text.length), Math.min(row.to, text.length));
|
|
871
|
+
control.api.focus();
|
|
872
|
+
return row.line ?? 1;
|
|
873
|
+
}
|
|
874
|
+
return focusPath(row.path, row.finding);
|
|
875
|
+
}
|
|
876
|
+
// The panel with the keyboard: arrows, Home and End move between rows, Escape returns to the code, Cmd/Ctrl+. applies the first fix of the row.
|
|
877
|
+
list.addEventListener("keydown", (event) => {
|
|
878
|
+
const rows = [...list.querySelectorAll(".opf-source-finding")];
|
|
879
|
+
const at = rows.indexOf(document.activeElement);
|
|
880
|
+
const move = (to) => { event.preventDefault(); rows[Math.max(0, Math.min(rows.length - 1, to))]?.focus(); };
|
|
881
|
+
if (event.key === "ArrowDown") move(at + 1);
|
|
882
|
+
else if (event.key === "ArrowUp") move(at - 1);
|
|
883
|
+
else if (event.key === "Home") move(0);
|
|
884
|
+
else if (event.key === "End") move(rows.length - 1);
|
|
885
|
+
else if (event.key === "Escape") { event.preventDefault(); control.api.focus(); }
|
|
886
|
+
else if (event.key === "." && (event.metaKey || event.ctrlKey) && at >= 0) {
|
|
887
|
+
const row = rowOf.get(rows[at]);
|
|
888
|
+
const fix = row?.fixes[0];
|
|
889
|
+
if (fix) { event.preventDefault(); applyFix(fix, row); } else control.api.say("No quick fixes available");
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
/** Move focus to the first problem of the panel (Cmd/Ctrl+Shift+M in the code). */
|
|
893
|
+
function focusProblems() {
|
|
894
|
+
if (findingsRegion.hidden) { control.api.say("No problems"); return true; }
|
|
895
|
+
if (collapsed) { collapsed = false; renderedKey = ""; renderFindings(); }
|
|
896
|
+
list.querySelector(".opf-source-finding")?.focus();
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
743
899
|
|
|
744
900
|
// ---- Public API ---------------------------------------------------------------------------------------------------
|
|
745
901
|
function focusPath(path, finding) {
|
|
@@ -792,6 +948,23 @@ export function createSourcePane(container, options = {}) {
|
|
|
792
948
|
formatAction.disabled = readOnly;
|
|
793
949
|
control.setReadOnly(readOnly);
|
|
794
950
|
},
|
|
951
|
+
/** The problems drawn in the text now, in position order: `{ from, to, severity, message, next, rule, fixes }` at source offsets. */
|
|
952
|
+
getProblems: () => control.getProblems(),
|
|
953
|
+
/** Select the next problem in the text (F8) and say what it is; wraps. */
|
|
954
|
+
nextProblem: () => control.nextProblem(),
|
|
955
|
+
/** Select the previous problem in the text (Shift+F8); wraps. */
|
|
956
|
+
previousProblem: () => control.previousProblem(),
|
|
957
|
+
/** Select problem number `index` (zero-based, position order) and say what it is. */
|
|
958
|
+
showProblem(index) {
|
|
959
|
+
const problems = control.getProblems(), problem = problems[index];
|
|
960
|
+
if (!problem) return false;
|
|
961
|
+
control.api.setSelection(problem.from, problem.to);
|
|
962
|
+
control.api.focus();
|
|
963
|
+
control.api.say(`${problem.severity === "error" ? "Error" : problem.severity === "warning" ? "Warning" : "Note"}: ${problem.message}`);
|
|
964
|
+
return true;
|
|
965
|
+
},
|
|
966
|
+
/** Open the suggestion list at the caret, as Ctrl+Space does. */
|
|
967
|
+
triggerCompletion: () => control.triggerCompletion(),
|
|
795
968
|
/** The code editor, for hosts that focus or measure it. */
|
|
796
969
|
editor: control,
|
|
797
970
|
destroy() {
|
|
@@ -800,6 +973,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
800
973
|
destroyed = true;
|
|
801
974
|
clearTimeout(timer);
|
|
802
975
|
clearTimeout(copyTimer);
|
|
976
|
+
clearTimeout(summaryTimer);
|
|
977
|
+
clearTimeout(syntaxTimer);
|
|
978
|
+
clearTimeout(recheckTimer);
|
|
803
979
|
unsubscribe();
|
|
804
980
|
document.removeEventListener("pointerdown", outside, true);
|
|
805
981
|
control.destroy();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The problems of the source pane: every finding with its plain-words message, what could come next, where it is in the text (a range, a line and a
|
|
2
|
+
// column) and the quick fixes that go with it. The pane lists them in the panel and draws them in the text with the same objects.
|
|
3
|
+
import { describeFinding, segmentsOf } from "./source-messages.js";
|
|
4
|
+
import { fixesForFinding } from "./source-fixes.js";
|
|
5
|
+
import { lineColumn, locateJson, partOf } from "./source-ranges.js";
|
|
6
|
+
|
|
7
|
+
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fix core ships (`fixes[]` of a finding) as a pane fix: a `patch` goes through the session, a `focus` selects the field.
|
|
11
|
+
* `actions`: `{ applyPatch(patch), focusField(focus) }`.
|
|
12
|
+
*/
|
|
13
|
+
function coreFix(fix, actions) {
|
|
14
|
+
if (!fix || typeof fix !== "object" || typeof fix.id !== "string") return null;
|
|
15
|
+
const base = { id: fix.id, title: String(fix.title ?? fix.id), safe: fix.safe === true };
|
|
16
|
+
if (fix.kind === "patch" && Array.isArray(fix.patch)) return { ...base, run: () => actions.applyPatch(fix.patch) };
|
|
17
|
+
if (fix.kind === "focus" && fix.focus) return { ...base, run: () => actions.focusField(fix.focus) };
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Edits from a syntax problem (`{ offset, length, insert }` or `{ from, to, insert }`) as `{ from, to, insert }`. */
|
|
22
|
+
const toEdits = (edits = []) => edits.map((edit) => (edit.from !== undefined ? edit : { from: edit.offset, to: edit.offset + edit.length, insert: edit.insert ?? edit.content ?? "" }));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The problems of `findings` in `text`. `context` is what source-messages.js takes plus `{ unit, starter }`; `actions` is what coreFix takes.
|
|
26
|
+
* Returns rows in position order (errors first at one place), each `{ finding, severity, message, next, rule, path, from?, to?, line?, column?, fixes }`.
|
|
27
|
+
*/
|
|
28
|
+
export function buildProblems({ format, text, findings, context = {}, actions = {}, lineOf }) {
|
|
29
|
+
const rows = findings.map((finding, index) => {
|
|
30
|
+
const { message, next } = describeFinding(finding, context);
|
|
31
|
+
let range = null;
|
|
32
|
+
if (finding.range) range = { from: finding.range.from, to: finding.range.to };
|
|
33
|
+
else if (format === "json" && finding.kind !== "syntax" && finding.path !== undefined && finding.ruleId?.startsWith("opf/")) {
|
|
34
|
+
const located = locateJson(text, segmentsOf(finding.path), partOf(finding));
|
|
35
|
+
if (located) range = { from: located.from, to: located.to };
|
|
36
|
+
}
|
|
37
|
+
if (!range && Number.isInteger(finding.location?.offset)) range = { from: finding.location.offset, to: finding.location.offset + Math.max(0, finding.location.length ?? 0) };
|
|
38
|
+
const fixes = [];
|
|
39
|
+
for (const fix of finding.editFixes ?? []) {
|
|
40
|
+
if (fix.action === "switch-format") fixes.push({ id: fix.id, title: fix.title, safe: fix.safe === true, run: () => actions.switchFormat?.(fix.format) });
|
|
41
|
+
else fixes.push({ ...fix, edits: toEdits(fix.edits) });
|
|
42
|
+
}
|
|
43
|
+
if (format === "json" && finding.kind !== "syntax") fixes.push(...fixesForFinding(finding, text, context));
|
|
44
|
+
for (const fix of finding.fixes ?? []) { const row = coreFix(fix, actions); if (row) fixes.push(row); }
|
|
45
|
+
const row = { finding, index, severity: finding.severity, message, next, rule: finding.ruleId, path: finding.path ?? "", fixes };
|
|
46
|
+
if (range) {
|
|
47
|
+
row.from = range.from;
|
|
48
|
+
row.to = Math.max(range.from, range.to);
|
|
49
|
+
const where = lineColumn(text, range.from);
|
|
50
|
+
row.line = where.line;
|
|
51
|
+
row.column = where.column;
|
|
52
|
+
} else {
|
|
53
|
+
const line = lineOf?.(finding) ?? finding.line;
|
|
54
|
+
if (line) {
|
|
55
|
+
row.line = line;
|
|
56
|
+
if (finding.column) row.column = finding.column;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return row;
|
|
60
|
+
});
|
|
61
|
+
return mergeRenames(rows).sort((a, b) => (a.line ?? Infinity) - (b.line ?? Infinity) || (a.column ?? 0) - (b.column ?? 0) || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.index - b.index);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The count line of the panel and the live region: "2 problems: 1 error, 1 warning". */
|
|
65
|
+
export function summarize(rows) {
|
|
66
|
+
const count = (severity) => rows.filter((row) => row.severity === severity).length;
|
|
67
|
+
const parts = [[count("error"), "error"], [count("warning"), "warning"], [count("info"), "note"]].filter(([n]) => n > 0).map(([n, word]) => `${n} ${word}${n === 1 ? "" : "s"}`);
|
|
68
|
+
return rows.length ? `${rows.length} problem${rows.length === 1 ? "" : "s"}: ${parts.join(", ")}` : "No problems";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// An unknown key that is a near miss of a missing required key says it once: the rename fixes both, so the "missing" row goes.
|
|
72
|
+
function mergeRenames(rows) {
|
|
73
|
+
const renamed = new Set();
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
if (row.finding.validation?.keyword !== "additionalProperties") continue;
|
|
76
|
+
const parent = segmentsOf(row.finding.path).slice(0, -1).join("/");
|
|
77
|
+
for (const fix of row.fixes) if (fix.id.startsWith("rename-to:")) renamed.add(`${parent}|${fix.id.slice("rename-to:".length)}`);
|
|
78
|
+
}
|
|
79
|
+
if (!renamed.size) return rows;
|
|
80
|
+
return rows.filter((row) => !(row.finding.validation?.keyword === "required" && renamed.has(`${segmentsOf(row.finding.path).join("/")}|${row.finding.validation.params?.missingProperty}`)));
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Where a finding is in the text (artifacts/source-editor/VALIDATOR-HINTS.md, section 4, rule 4): the smallest thing the message is about. A JSON
|
|
2
|
+
// Pointer becomes a range of the key, the value, the whole entry or the opening bracket of the container. Ranges are original-source offsets.
|
|
3
|
+
import { locate } from "@openpresentation/opf/source";
|
|
4
|
+
|
|
5
|
+
/** One-based line and column of a source offset (a CRLF pair is one line break). */
|
|
6
|
+
export function lineColumn(text, offset) {
|
|
7
|
+
let line = 1, start = 0;
|
|
8
|
+
const end = Math.min(offset, text.length);
|
|
9
|
+
for (let i = 0; i < end; i++) {
|
|
10
|
+
const code = text.charCodeAt(i);
|
|
11
|
+
if (code === 10 || (code === 13 && text.charCodeAt(i + 1) !== 10)) { line++; start = i + 1; }
|
|
12
|
+
}
|
|
13
|
+
return { line, column: offset - start + 1 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The range of `segments` in the JSON `text`, or null (core's `locate`, which reads text that does not parse). `part`: "value" (default), "key" (the
|
|
18
|
+
* property name), "entry" (key to the end of the value), "colon" (the key and its colon) or "open" (the first character of the value, the `{` of an
|
|
19
|
+
* object). A path that is not there gives the range of its nearest container, with `exact: false`.
|
|
20
|
+
*/
|
|
21
|
+
export function locateJson(text, segments, part = "value") {
|
|
22
|
+
const range = locate(text, "json", segments.map(String), { part });
|
|
23
|
+
return range ? { from: range.offset, to: range.offset + range.length, exact: range.exact !== false } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The part of the text a finding is about, by what it says (VALIDATOR-HINTS section 4). */
|
|
27
|
+
export function partOf(finding) {
|
|
28
|
+
const keyword = finding.validation?.keyword;
|
|
29
|
+
if (keyword === "additionalProperties" || finding.ruleId === "opf/duplicate-key") return "key";
|
|
30
|
+
if (keyword === "required") return "open";
|
|
31
|
+
return "value";
|
|
32
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Snippets of the source editor (artifacts/source-editor/SPEC.md, section 5): a new slide, a chart slide, a quote and the like. Each is written once as an
|
|
2
|
+
// OPF fragment with numbered placeholders and rendered per format (JSON here; YAML and Markdown follow in phase 2), then handed to CodeMirror's
|
|
3
|
+
// snippet engine, which walks the tab stops. Plain functions, no DOM.
|
|
4
|
+
|
|
5
|
+
const PLACEHOLDER = Symbol("opf-placeholder");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A placeholder inside a fragment: tab stop `index` (1 first; 0 is where the caret ends), `text` is what it holds until typed over, `raw` keeps it
|
|
9
|
+
* out of string quotes (a number), `choice` opens the list of the field's values when the tab stop is reached (`value` is what a document has
|
|
10
|
+
* when the stop is left empty, for tests).
|
|
11
|
+
*/
|
|
12
|
+
export const ph = (index, text = "", options = {}) => ({ [PLACEHOLDER]: true, index, text, ...options });
|
|
13
|
+
const isPlaceholder = (value) => value !== null && typeof value === "object" && value[PLACEHOLDER] === true;
|
|
14
|
+
|
|
15
|
+
const tabs = (depth) => "\t".repeat(depth);
|
|
16
|
+
// The snippet syntax treats braces and backslashes in a placeholder's text as markup.
|
|
17
|
+
const escapeText = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
|
|
18
|
+
const field = (placeholder) => `\${${placeholder.index}${placeholder.text ? `:${escapeText(JSON.stringify(placeholder.text).slice(1, -1))}` : ""}}`;
|
|
19
|
+
|
|
20
|
+
/** `{ text, fields }`: the fragment as JSON text with CodeMirror snippet markers (indentation as leading tabs, which become the editor's unit). */
|
|
21
|
+
export function renderJson(fragment, { comma = false } = {}) {
|
|
22
|
+
const fields = [];
|
|
23
|
+
const render = (value, depth) => {
|
|
24
|
+
if (isPlaceholder(value)) {
|
|
25
|
+
fields.push({ index: value.index, text: value.text, ...(value.choice ? { choice: true } : {}) });
|
|
26
|
+
return value.raw ? field(value) : `"${field(value)}"`;
|
|
27
|
+
}
|
|
28
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
if (!value.length) return "[]";
|
|
31
|
+
return `[\n${value.map((item) => `${tabs(depth + 1)}${render(item, depth + 1)}`).join(",\n")}\n${tabs(depth)}]`;
|
|
32
|
+
}
|
|
33
|
+
const entries = Object.entries(value);
|
|
34
|
+
if (!entries.length) return "{}";
|
|
35
|
+
return `{\n${entries.map(([key, item]) => `${tabs(depth + 1)}${JSON.stringify(key)}: ${render(item, depth + 1)}`).join(",\n")}\n${tabs(depth)}}`;
|
|
36
|
+
};
|
|
37
|
+
let text = render(fragment, 0);
|
|
38
|
+
if (comma) text += ",";
|
|
39
|
+
return { text: `${text}\${0}`, fields: fields.sort((a, b) => a.index - b.index) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The same fragment with every placeholder replaced by its text (what a document has when the placeholders are left as they are). */
|
|
43
|
+
export function fragmentValue(fragment, { empty = false, literal = false } = {}) {
|
|
44
|
+
const visit = (value) => {
|
|
45
|
+
if (isPlaceholder(value)) return value.raw ? Number(value.text || 0) : (empty ? "" : (literal ? value.text : (value.value ?? value.text)));
|
|
46
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
47
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
return visit(fragment);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const columns = (first, second) => [ph(first, "Category"), ph(second, "Value")];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The snippets. `where`: "slide" (an entry of `slides`), "reference" (an entry of `references`), "dataset" (a new key of `datasets`), "deck" (a blank
|
|
57
|
+
* document). `fragment` is the OPF fragment; for "dataset" `name` is the entry's key.
|
|
58
|
+
*/
|
|
59
|
+
export const SNIPPETS = Object.freeze([
|
|
60
|
+
{ prefix: "slide", where: "slide", description: "A slide with a title and a list", fragment: { title: ph(1, "Slide title"), items: [ph(2, "First point")] } },
|
|
61
|
+
{ prefix: "slide-text", where: "slide", description: "A slide with a title and a paragraph", fragment: { title: ph(1, "Title"), text: ph(2, "Body text") } },
|
|
62
|
+
{ prefix: "slide-image", where: "slide", description: "A slide with a picture and its alt text", fragment: { title: ph(1, "Title"), image: { src: ph(2, "asset:photo"), alt: ph(3, "Describe the picture") } } },
|
|
63
|
+
{ prefix: "slide-chart", where: "slide", description: "A slide with a chart", fragment: { title: ph(1, "Title"), chart: { type: ph(2, "", { choice: true, value: "column" }), data: { columns: columns(3, 4), rows: [[ph(5, "A"), ph(6, "1", { raw: true })]] } } } },
|
|
64
|
+
{ prefix: "slide-quote", where: "slide", description: "A slide with a quote", fragment: { quote: { text: ph(1, "Quote"), attribution: ph(2, "Name, Title") } } },
|
|
65
|
+
{ prefix: "slide-metric", where: "slide", description: "A slide with one big number", fragment: { metric: { value: ph(1, "42"), label: ph(2, "Label") } } },
|
|
66
|
+
{ prefix: "slide-table", where: "slide", description: "A slide with a table", fragment: { title: ph(1, "Title"), table: { columns: [ph(2, "Name"), ph(3, "Value")], rows: [[ph(4, "A"), ph(5, "1")]] } } },
|
|
67
|
+
{ prefix: "slide-code", where: "slide", description: "A slide with a piece of code", fragment: { code: { source: ph(1, "code"), language: ph(2, "js") } } },
|
|
68
|
+
{ prefix: "slide-two-column", where: "slide", description: "A slide with two columns", fragment: { title: ph(1, "Title"), layout: "two-column", left: { items: [ph(2, "Left")] }, right: { items: [ph(3, "Right")] } } },
|
|
69
|
+
{ prefix: "section", where: "slide", description: "A section divider", fragment: { layout: "section", title: ph(1, "Section title") } },
|
|
70
|
+
{ prefix: "reference", where: "reference", description: "A reference a slide can cite", fragment: { id: ph(1, "smith2020"), text: ph(2, "Citation") } },
|
|
71
|
+
{ prefix: "dataset", where: "dataset", description: "A table of data that charts and tables can share", name: ph(1, "sales"), fragment: { columns: [ph(2, "Category"), ph(3, "Value")], rows: [[ph(4, "A"), ph(5, "1", { raw: true })]] } },
|
|
72
|
+
{ prefix: "deck", where: "deck", description: "A starter deck", fragment: { name: ph(1, "Deck name"), slides: [{ title: ph(2, "Title") }] } },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/** The snippets that fit where the caret is: `where` and the path of the node (see source-json.js). */
|
|
76
|
+
export function snippetsFor(mode, path) {
|
|
77
|
+
const last = path.at(-1);
|
|
78
|
+
return SNIPPETS.filter((snippet) => {
|
|
79
|
+
if (snippet.where === "slide") return mode === "item" && path.length === 2 && path[0] === "slides" && typeof last === "number";
|
|
80
|
+
if (snippet.where === "reference") return mode === "item" && path.length === 2 && path[0] === "references" && typeof last === "number";
|
|
81
|
+
if (snippet.where === "dataset") return mode === "key" && path.length === 1 && path[0] === "datasets";
|
|
82
|
+
if (snippet.where === "deck") return mode === "value" && path.length === 0;
|
|
83
|
+
return false;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The JSON text of a snippet for insertion: `{ text, fields }`; a dataset is a `"name": { ... }` entry. */
|
|
88
|
+
export function snippetJson(snippet, { comma = false } = {}) {
|
|
89
|
+
if (snippet.where !== "dataset") return renderJson(snippet.fragment, { comma });
|
|
90
|
+
const body = renderJson(snippet.fragment, { comma });
|
|
91
|
+
const key = `"${field(snippet.name)}"`;
|
|
92
|
+
return { text: `${key}: ${body.text}`, fields: [{ index: snippet.name.index, text: snippet.name.text }, ...body.fields].sort((a, b) => a.index - b.index) };
|
|
93
|
+
}
|