@openpresentation/opf-editor 0.26.0 → 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.
@@ -1,10 +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";
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";
8
14
 
9
15
  // The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
10
16
  // and Download, and the findings under the code. JSON is canonical and keeps its authored bytes (exact-source); YAML and
@@ -67,27 +73,15 @@ function same(a, b) {
67
73
  const SEVERITY = { error: 0, warning: 1, info: 2 };
68
74
  const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
69
75
 
70
- 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" };
71
-
72
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. */
73
77
  function readText(format, text, catalogs) {
74
78
  if (format === "json") {
75
- try {
76
- const deck = JSON.parse(text);
77
- if (!isObject(deck)) return { syntax: true, findings: [normalizeFinding({ message: "An OPF document is a JSON object.", line: 1 }, "syntax")] };
78
- const duplicate = findDuplicateKey(text);
79
- if (duplicate) {
80
- const path = pointer([...duplicate.path, duplicate.key]);
81
- return { syntax: true, findings: [normalizeFinding({ path, message: `Duplicate key "${duplicate.key}". Remove the repeated key.` }, "syntax")] };
82
- }
83
- return { deck, findings: [] };
84
- } catch (error) {
85
- const errors = [];
86
- parseJsonc(text, errors, { allowTrailingComma: false, disallowComments: true });
87
- const first = errors[0];
88
- const message = first ? `${JSON_ERRORS[printParseErrorCode(first.error)] ?? printParseErrorCode(first.error)}.` : `${error.message}.`;
89
- return { syntax: true, findings: [normalizeFinding({ message, line: first ? lineOfOffset(text, first.offset) : 1 }, "syntax")] };
90
- }
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: [] };
91
85
  }
92
86
  const result = format === "yaml" ? (fromYaml(text, { catalogs })) : fromMarkdown(text, { catalogs });
93
87
  const syntax = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
@@ -127,6 +121,10 @@ export function createSourcePane(container, options = {}) {
127
121
  // Whether the findings include an error, and what the editor's `aria-invalid` currently says (null: not yet set).
128
122
  let invalid = false, shownInvalid = null;
129
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;
130
128
  // The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
131
129
  // (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
132
130
  const exactTexts = new Map();
@@ -170,6 +168,8 @@ export function createSourcePane(container, options = {}) {
170
168
  return button;
171
169
  };
172
170
  const formatAction = actionButton("Format (fix indentation)", "Braces", "format");
171
+ formatAction.title = "Format (fix indentation), Shift+Alt+F";
172
+ formatAction.setAttribute("aria-keyshortcuts", "Shift+Alt+F");
173
173
  const copyAction = actionButton("Copy source", "Copy", "copy");
174
174
  const downloadAction = actionButton("Download source", "Download", "download");
175
175
  bar.append(file, actions);
@@ -236,6 +236,10 @@ export function createSourcePane(container, options = {}) {
236
236
  catalogs: options.catalogs ?? editor.catalogs,
237
237
  describedBy: `${id}-findings`,
238
238
  onChange: () => schedule(),
239
+ onFormat: () => formatDocument(),
240
+ onFocusProblems: () => focusProblems(),
241
+ // The pane supplies the problems itself (every finding, with ranges and fixes).
242
+ syntaxProblems: false,
239
243
  historyFallback: { undo: () => stepReplace("undo"), redo: () => stepReplace("redo") },
240
244
  onError: (error) => options.onError?.(error),
241
245
  });
@@ -243,8 +247,25 @@ export function createSourcePane(container, options = {}) {
243
247
  // ---- Applying edits -----------------------------------------------------------------------------------------------
244
248
  function schedule() {
245
249
  clearTimeout(timer);
250
+ clearTimeout(syntaxTimer);
246
251
  if (delay === 0) { flush(); return; }
247
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();
248
269
  }
249
270
  /** Apply any edit still waiting for its delay. */
250
271
  function flush() {
@@ -268,6 +289,7 @@ export function createSourcePane(container, options = {}) {
268
289
  }
269
290
  syntaxFindings = [];
270
291
  baseline = source;
292
+ lastDraft = read.deck;
271
293
  const before = deck();
272
294
  if (!same(read.deck, before)) {
273
295
  applying = true;
@@ -315,6 +337,7 @@ export function createSourcePane(container, options = {}) {
315
337
  rejectionFindings = [];
316
338
  transientNotes = [];
317
339
  dirty = false;
340
+ lastDraft = null;
318
341
  let written;
319
342
  try { written = write(format, deck()); }
320
343
  catch (error) {
@@ -500,6 +523,28 @@ export function createSourcePane(container, options = {}) {
500
523
  return true;
501
524
  }
502
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
+
503
548
  // ---- Menu ---------------------------------------------------------------------------------------------------------
504
549
  const menuOpen = () => !menu.hidden;
505
550
  function openMenu(focus = "checked") {
@@ -601,30 +646,30 @@ export function createSourcePane(container, options = {}) {
601
646
  const current = formatOf(format);
602
647
  return { name, filename: `${name}.opf.${current.extension}`, text: control.api.getValue(), format: current.id, mime: current.mime };
603
648
  }
604
- formatAction.addEventListener("click", () => {
649
+ // Format document: the button and the keys (Shift+Alt+F, Cmd/Ctrl+Shift+F) do the same in every format. Whitespace only: JSON is laid out
650
+ // token by token (also while it does not parse), YAML keeps its comments, and a rewrite that would change the document is refused.
651
+ function formatDocument() {
605
652
  flush();
606
- if (readOnly) return;
607
- if (format === "json") { control.api.format(); control.api.focus(); return; }
608
- const read = readText(format, control.api.getValue(), catalogs());
609
- if (!read.deck) { renderFindings(); return; }
610
- let written;
611
- try { written = write(format, read.deck); }
612
- catch (error) {
613
- rejectionFindings = [normalizeFinding({ message: `Cannot rewrite the ${formatOf(format).label}: ${errorText(error)}`, kind: "rejected" })];
614
- renderFindings();
615
- return;
616
- }
653
+ if (readOnly) return false;
617
654
  const before = control.api.getValue();
618
- if (written.text !== before) {
619
- const comments = format === "yaml" ? scanYamlComments(before).count : 0;
620
- control.api.replace(0, before.length, written.text);
621
- flush();
622
- transientNotes = comments > 0 ? [normalizeFinding({ message: `${plural(comments, "comment")} in the YAML ${comments === 1 ? "was" : "were"} dropped when the text was rewritten.`, severity: "warning" }, "format")] : [];
623
- renderedKey = "";
624
- renderFindings();
625
- }
655
+ const result = formatSource(format, before, { unit: control.api.indentUnit() });
626
656
  control.api.focus();
627
- });
657
+ if (result.error) { announce(result.error); return false; }
658
+ if (result.text === before) { announce("Already formatted"); return true; }
659
+ if (format !== "json") {
660
+ let unchanged = true;
661
+ try {
662
+ const was = readText(format, before, catalogs()).deck;
663
+ unchanged = !was || same(was, readText(format, result.text, catalogs()).deck);
664
+ } catch { unchanged = false; }
665
+ if (!unchanged) { announce("Formatting would change the document, so the text was left as it is."); return false; }
666
+ }
667
+ control.api.replaceDiff(result.text);
668
+ flush();
669
+ announce("Formatted");
670
+ return true;
671
+ }
672
+ formatAction.addEventListener("click", () => { formatDocument(); });
628
673
  copyAction.addEventListener("click", async () => {
629
674
  const info = currentFile();
630
675
  try {
@@ -670,14 +715,14 @@ export function createSourcePane(container, options = {}) {
670
715
  for (const group of [syntaxFindings, rejectionFindings, transientNotes, noteFindings, host]) {
671
716
  const keys = [];
672
717
  for (const finding of group) {
673
- const key = `${finding.severity}|${finding.path}|${finding.message}`;
718
+ const key = `${finding.severity}|${finding.path}|${finding.message}|${finding.range?.from ?? ""}`;
674
719
  if (seen.has(key)) continue;
675
720
  keys.push(key);
676
721
  all.push(finding);
677
722
  }
678
723
  for (const key of keys) seen.add(key);
679
724
  }
680
- 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;
681
726
  }
682
727
  function lineOf(finding) {
683
728
  const value = control.api.getValue();
@@ -688,23 +733,75 @@ export function createSourcePane(container, options = {}) {
688
733
  if (!lineCache.values.has(cacheKey)) lineCache.values.set(cacheKey, finding.path === "" ? null : lineForPath(format, value, finding.path));
689
734
  return lineCache.values.get(cacheKey) ?? explicit ?? null;
690
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
+ }
691
777
  function renderFindings() {
692
778
  paintFindings();
693
779
  if (!destroyed && invalid !== shownInvalid) syncAttributes();
694
780
  }
695
781
  function paintFindings() {
696
782
  if (destroyed) return;
697
- const all = collect();
698
- const rows = all.map((finding) => ({ finding, line: lineOf(finding) }));
699
- invalid = rows.some((row) => row.finding.severity === "error");
700
- 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)])]);
701
794
  if (key === renderedKey) return;
702
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);
703
800
  findingsRegion.hidden = rows.length === 0;
704
801
  root.dataset.findings = String(rows.length);
705
802
  if (!rows.length) { list.replaceChildren(); return; }
706
- const errors = rows.filter((row) => row.finding.severity === "error");
707
- 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");
708
805
  const infos = rows.length - errors.length - warnings.length;
709
806
  const parts = [];
710
807
  if (errors.length) parts.push(plural(errors.length, errors.every((row) => row.finding.kind === "schema" || !row.finding.kind) ? "schema error" : "error"));
@@ -717,25 +814,88 @@ export function createSourcePane(container, options = {}) {
717
814
  toggle.setAttribute("aria-expanded", String(!collapsed));
718
815
  list.hidden = collapsed;
719
816
  const shown = rows.slice(0, 200);
720
- list.replaceChildren(...shown.map(({ finding, line }) => {
817
+ list.replaceChildren(...shown.map((row) => {
818
+ const { finding } = row;
721
819
  const item = el("li", "opf-source-finding-item");
722
- 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 });
723
821
  if (finding.kind) button.dataset.kind = finding.kind;
724
- const path = finding.path ? el("code", "opf-source-finding-path") : null;
725
- if (path) path.textContent = finding.path;
726
- const message = Object.assign(el("span", "opf-source-finding-message"), { textContent: finding.message });
727
- const where = Object.assign(el("span", "opf-source-finding-line"), { textContent: line ? `Ln ${line}` : "" });
728
- 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] : []));
729
834
  button.addEventListener("click", () => {
730
- const target = focusPath(finding.path, finding);
835
+ const target = selectRow(row);
731
836
  options.onFindingSelect?.(finding, target);
732
837
  });
733
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
+ }
734
848
  return item;
735
849
  }));
736
850
  if (rows.length > shown.length) list.append(Object.assign(el("li", "opf-source-finding-more"), { textContent: `${rows.length - shown.length} more not shown.` }));
737
851
  }
738
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
+ }
739
899
 
740
900
  // ---- Public API ---------------------------------------------------------------------------------------------------
741
901
  function focusPath(path, finding) {
@@ -788,6 +948,23 @@ export function createSourcePane(container, options = {}) {
788
948
  formatAction.disabled = readOnly;
789
949
  control.setReadOnly(readOnly);
790
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(),
791
968
  /** The code editor, for hosts that focus or measure it. */
792
969
  editor: control,
793
970
  destroy() {
@@ -796,6 +973,9 @@ export function createSourcePane(container, options = {}) {
796
973
  destroyed = true;
797
974
  clearTimeout(timer);
798
975
  clearTimeout(copyTimer);
976
+ clearTimeout(summaryTimer);
977
+ clearTimeout(syntaxTimer);
978
+ clearTimeout(recheckTimer);
799
979
  unsubscribe();
800
980
  document.removeEventListener("pointerdown", outside, true);
801
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
+ }