@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.
@@ -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, findDuplicateKey, updateJsonSource } from "./exact-source.js";
6
- import { lineForPath, lineOfOffset, offsetOfLine } from "./source-lines.js";
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
- try {
77
- const deck = JSON.parse(text);
78
- if (!isObject(deck)) return { syntax: true, findings: [normalizeFinding({ message: "An OPF document is a JSON object.", line: 1 }, "syntax")] };
79
- const duplicate = findDuplicateKey(text);
80
- if (duplicate) {
81
- const path = pointer([...duplicate.path, duplicate.key]);
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.map((finding, index) => ({ finding, index })).sort((a, b) => SEVERITY[a.finding.severity] - SEVERITY[b.finding.severity] || a.index - b.index).map((entry) => entry.finding);
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 all = collect();
702
- const rows = all.map((finding) => ({ finding, line: lineOf(finding) }));
703
- invalid = rows.some((row) => row.finding.severity === "error");
704
- const key = JSON.stringify([format, collapsed, rows.map(({ finding, line }) => [finding.severity, finding.path, finding.message, finding.kind, line])]);
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.finding.severity === "error");
711
- const warnings = rows.filter((row) => row.finding.severity === "warning");
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(({ finding, line }) => {
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": finding.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 path = finding.path ? el("code", "opf-source-finding-path") : null;
729
- if (path) path.textContent = finding.path;
730
- const message = Object.assign(el("span", "opf-source-finding-message"), { textContent: finding.message });
731
- const where = Object.assign(el("span", "opf-source-finding-line"), { textContent: line ? `Ln ${line}` : "" });
732
- button.append(...(path ? [path] : []), message, where);
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 = focusPath(finding.path, finding);
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
+ }