@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.
- package/README.md +10 -7
- package/dist/json-editor.d.ts +83 -4
- package/dist/json-editor.js +319 -79
- package/dist/json-format.js +54 -0
- package/dist/json-menu.js +8 -1
- package/dist/json-options.js +8 -20
- package/dist/presenter.js +33 -9
- 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-format.js +92 -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 +238 -58
- 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 +10 -7
package/dist/source-pane.js
CHANGED
|
@@ -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,
|
|
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";
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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.
|
|
707
|
-
const warnings = rows.filter((row) => row.
|
|
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((
|
|
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":
|
|
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
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
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 =
|
|
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
|
+
}
|