@openpresentation/opf-editor 0.26.2 → 0.26.4
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 +6 -2
- package/dist/json-editor.d.ts +7 -2
- package/dist/json-editor.js +51 -24
- package/dist/source-completions.js +192 -26
- package/dist/source-editing.js +265 -0
- package/dist/source-fixes-text.js +87 -0
- package/dist/source-lines.js +4 -0
- package/dist/source-markdown.js +551 -0
- package/dist/source-pane.d.ts +10 -2
- package/dist/source-pane.js +78 -7
- package/dist/source-problems.js +6 -4
- package/dist/source-ranges.js +8 -5
- package/dist/source-snippets.js +139 -0
- package/dist/source-yaml.js +538 -0
- package/dist/styles.css +23 -0
- package/package.json +9 -7
package/dist/source-pane.js
CHANGED
|
@@ -9,7 +9,8 @@ import { buildProblems, summarize } from "./source-problems.js";
|
|
|
9
9
|
import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
|
|
10
10
|
import { createSchemaValue, schemaAtPath, schemaVariants } from "./schema.js";
|
|
11
11
|
import { catalogKindAt } from "@openpresentation/opf/schema";
|
|
12
|
-
import {
|
|
12
|
+
import { locateText } from "./source-ranges.js";
|
|
13
|
+
import { scanMarkdown } from "@openpresentation/opf/source";
|
|
13
14
|
import { listCatalogRecords } from "./catalogs.js";
|
|
14
15
|
|
|
15
16
|
// The source pane (RR-98): the OPF document as JSON, YAML or Markdown, with the file name, the format menu, Format, Copy
|
|
@@ -33,6 +34,7 @@ const ICONS = {
|
|
|
33
34
|
Check: '<path d="M20 6 9 17l-5-5"/>',
|
|
34
35
|
CircleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
|
35
36
|
Info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
37
|
+
Sparkles: '<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>',
|
|
36
38
|
};
|
|
37
39
|
function icon(name, size = 14) {
|
|
38
40
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
@@ -170,10 +172,18 @@ export function createSourcePane(container, options = {}) {
|
|
|
170
172
|
const formatAction = actionButton("Format (fix indentation)", "Braces", "format");
|
|
171
173
|
formatAction.title = "Format (fix indentation), Shift+Alt+F";
|
|
172
174
|
formatAction.setAttribute("aria-keyshortcuts", "Shift+Alt+F");
|
|
175
|
+
// The old Format (a rewrite through OPF's own writer) is its own command: it drops the comments of YAML and Markdown, so it asks first.
|
|
176
|
+
const canonicalAction = actionButton("Canonicalize with OPF", "Sparkles", "canonicalize");
|
|
177
|
+
canonicalAction.title = "Canonicalize with OPF: rewrite the text the way OPF writes it (drops comments)";
|
|
173
178
|
const copyAction = actionButton("Copy source", "Copy", "copy");
|
|
174
179
|
const downloadAction = actionButton("Download source", "Download", "download");
|
|
175
180
|
bar.append(file, actions);
|
|
176
181
|
|
|
182
|
+
const confirm = el("div", "opf-source-confirm", { role: "alertdialog", "aria-labelledby": `${id}-confirm-text`, hidden: "" });
|
|
183
|
+
const confirmText = el("p", "opf-source-confirm-text", { id: `${id}-confirm-text` });
|
|
184
|
+
const confirmAccept = Object.assign(el("button", "opf-source-confirm-accept", { type: "button" }), { textContent: "Canonicalize anyway" });
|
|
185
|
+
const confirmCancel = Object.assign(el("button", "opf-source-confirm-cancel", { type: "button" }), { textContent: "Cancel" });
|
|
186
|
+
confirm.append(confirmText, confirmAccept, confirmCancel);
|
|
177
187
|
const code = el("div", "opf-source-code");
|
|
178
188
|
const status = el("div", "opf-source-status", { role: "status", "aria-live": "polite" });
|
|
179
189
|
|
|
@@ -185,7 +195,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
185
195
|
const list = el("ul", "opf-source-findings-list", { id: `${id}-findings` });
|
|
186
196
|
findingsRegion.append(toggle, list);
|
|
187
197
|
|
|
188
|
-
root.append(bar, code, findingsRegion, status);
|
|
198
|
+
root.append(bar, confirm, code, findingsRegion, status);
|
|
189
199
|
container.append(root);
|
|
190
200
|
|
|
191
201
|
// ---- Document <-> text --------------------------------------------------------------------------------------------
|
|
@@ -233,6 +243,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
233
243
|
language: formatOf(format).language,
|
|
234
244
|
readOnly,
|
|
235
245
|
completions: options.completions !== false,
|
|
246
|
+
smartListEnter: options.smartListEnter,
|
|
236
247
|
catalogs: options.catalogs ?? editor.catalogs,
|
|
237
248
|
describedBy: `${id}-findings`,
|
|
238
249
|
onChange: () => schedule(),
|
|
@@ -246,6 +257,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
246
257
|
|
|
247
258
|
// ---- Applying edits -----------------------------------------------------------------------------------------------
|
|
248
259
|
function schedule() {
|
|
260
|
+
closeConfirm(false);
|
|
249
261
|
clearTimeout(timer);
|
|
250
262
|
clearTimeout(syntaxTimer);
|
|
251
263
|
if (delay === 0) { flush(); return; }
|
|
@@ -325,6 +337,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
325
337
|
|
|
326
338
|
/** Show `value` as the text of the current format, as an external change. */
|
|
327
339
|
function show(value) {
|
|
340
|
+
closeConfirm(false);
|
|
328
341
|
baseline = value;
|
|
329
342
|
shownInvalid = false;
|
|
330
343
|
control.update(value, false, null, `${id}-findings`);
|
|
@@ -632,6 +645,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
632
645
|
nameButton.setAttribute("aria-label", `Rename ${name}`);
|
|
633
646
|
formatLabel.textContent = `.opf.${current.extension}`;
|
|
634
647
|
root.dataset.format = current.id;
|
|
648
|
+
canonicalAction.hidden = current.id === "json";
|
|
635
649
|
for (const item of menuItems) item.setAttribute("aria-checked", String(item.dataset.format === current.id));
|
|
636
650
|
}
|
|
637
651
|
|
|
@@ -670,6 +684,61 @@ export function createSourcePane(container, options = {}) {
|
|
|
670
684
|
return true;
|
|
671
685
|
}
|
|
672
686
|
formatAction.addEventListener("click", () => { formatDocument(); });
|
|
687
|
+
|
|
688
|
+
// Canonicalize with OPF: the text rewritten through core's writer (the old Format). Unlike Format it drops comments, so it asks first and says how many.
|
|
689
|
+
function droppedComments(text) {
|
|
690
|
+
if (format === "yaml") return scanYamlComments(text).count;
|
|
691
|
+
if (format !== "md") return 0;
|
|
692
|
+
const scan = scanMarkdown(text);
|
|
693
|
+
let count = scan.comments.filter((comment) => comment.kind === "comment").length;
|
|
694
|
+
if (scan.frontMatter) count += scanYamlComments(text.slice(scan.frontMatter.yamlFrom, scan.frontMatter.yamlTo)).count;
|
|
695
|
+
for (const fence of scan.fences) if (fence.kind === "opf-slide" || fence.kind === "opf-block") count += scanYamlComments(text.slice(fence.bodyFrom, fence.bodyTo)).count;
|
|
696
|
+
return count;
|
|
697
|
+
}
|
|
698
|
+
let pendingCanonical = null;
|
|
699
|
+
function closeConfirm(returnFocus) {
|
|
700
|
+
if (confirm.hidden) return;
|
|
701
|
+
confirm.hidden = true;
|
|
702
|
+
pendingCanonical = null;
|
|
703
|
+
if (returnFocus) control.api.focus();
|
|
704
|
+
}
|
|
705
|
+
/** Rewrite the text through OPF's own writer. Asks first when that drops comments (`confirmed: true` has asked already). Returns "applied", "unchanged", "confirm" (waiting for the answer) or "refused". */
|
|
706
|
+
function canonicalize({ confirmed = false } = {}) {
|
|
707
|
+
flush();
|
|
708
|
+
if (readOnly || format === "json" || destroyed) return "refused";
|
|
709
|
+
if (dirty) { announce("Fix the errors first: the text cannot be read as a document."); control.api.say("Fix the errors first: the text cannot be read as a document."); return "refused"; }
|
|
710
|
+
let written;
|
|
711
|
+
try { written = write(format, deck()); } catch (error) { announce(`Cannot canonicalize: ${errorText(error)}`); return "refused"; }
|
|
712
|
+
const before = control.api.getValue();
|
|
713
|
+
if (written.text === before) { announce("Already canonical"); control.api.say("Already canonical"); return "unchanged"; }
|
|
714
|
+
const count = droppedComments(before);
|
|
715
|
+
if (count > 0 && !confirmed) {
|
|
716
|
+
pendingCanonical = written.text;
|
|
717
|
+
confirmText.textContent = `Canonicalizing rewrites the text the way OPF writes it, and drops ${plural(count, "comment")}. Undo brings ${count === 1 ? "it" : "them"} back.`;
|
|
718
|
+
confirm.hidden = false;
|
|
719
|
+
confirmAccept.focus();
|
|
720
|
+
control.api.say(confirmText.textContent);
|
|
721
|
+
return "confirm";
|
|
722
|
+
}
|
|
723
|
+
closeConfirm(false);
|
|
724
|
+
control.api.replaceDiff(written.text);
|
|
725
|
+
flush();
|
|
726
|
+
control.api.focus();
|
|
727
|
+
announce("Canonicalized");
|
|
728
|
+
control.api.say(count > 0 ? `Canonicalized. ${plural(count, "comment")} dropped.` : "Canonicalized");
|
|
729
|
+
return "applied";
|
|
730
|
+
}
|
|
731
|
+
canonicalAction.addEventListener("click", () => { canonicalize(); });
|
|
732
|
+
confirmAccept.addEventListener("click", () => { closeConfirm(false); canonicalize({ confirmed: true }); });
|
|
733
|
+
confirmCancel.addEventListener("click", () => closeConfirm(true));
|
|
734
|
+
confirm.addEventListener("keydown", (event) => {
|
|
735
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeConfirm(true); }
|
|
736
|
+
else if (event.key === "Tab") {
|
|
737
|
+
const order = [confirmAccept, confirmCancel], at = order.indexOf(document.activeElement);
|
|
738
|
+
event.preventDefault();
|
|
739
|
+
order[(at + (event.shiftKey ? order.length - 1 : 1)) % order.length].focus();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
673
742
|
copyAction.addEventListener("click", async () => {
|
|
674
743
|
const info = currentFile();
|
|
675
744
|
try {
|
|
@@ -752,11 +821,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
752
821
|
flush();
|
|
753
822
|
const text = control.api.getValue();
|
|
754
823
|
const segments = String(focus.path ?? "").replace(/^\//, "").split("/").filter(Boolean).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (where) { control.api.setSelection(where.from, where.to); control.api.focus(); return; }
|
|
759
|
-
}
|
|
824
|
+
const wanted = focus.field ? locateText(format, text, [...segments, focus.field], "value") : null;
|
|
825
|
+
const where = wanted?.exact ? wanted : locateText(format, text, segments, "value");
|
|
826
|
+
if (where) { control.api.setSelection(where.from, where.to); control.api.focus(); return; }
|
|
760
827
|
focusPath(focus.path ?? "");
|
|
761
828
|
}
|
|
762
829
|
const fixActions = {
|
|
@@ -913,6 +980,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
913
980
|
root.toggleAttribute("data-readonly", readOnly);
|
|
914
981
|
nameButton.disabled = readOnly;
|
|
915
982
|
formatAction.disabled = readOnly;
|
|
983
|
+
canonicalAction.disabled = readOnly;
|
|
916
984
|
renderFindings();
|
|
917
985
|
syncAttributes();
|
|
918
986
|
|
|
@@ -936,6 +1004,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
936
1004
|
getFormat: () => format,
|
|
937
1005
|
/** Move the cursor to the line of a JSON Pointer in the current format (best effort for YAML and Markdown). Returns the line. */
|
|
938
1006
|
focusPath: (path) => focusPath(path),
|
|
1007
|
+
/** Canonicalize with OPF (rewrite through core's writer; asks before dropping comments). See `canonicalize` in the pane. */
|
|
1008
|
+
canonicalize,
|
|
939
1009
|
/** The source text exactly as shown, with any edit still waiting applied first. */
|
|
940
1010
|
getText() { flush(); return control.api.getValue(); },
|
|
941
1011
|
getName: () => name,
|
|
@@ -946,6 +1016,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
946
1016
|
root.toggleAttribute("data-readonly", readOnly);
|
|
947
1017
|
nameButton.disabled = readOnly;
|
|
948
1018
|
formatAction.disabled = readOnly;
|
|
1019
|
+
canonicalAction.disabled = readOnly;
|
|
949
1020
|
control.setReadOnly(readOnly);
|
|
950
1021
|
},
|
|
951
1022
|
/** The problems drawn in the text now, in position order: `{ from, to, severity, message, next, rule, fixes }` at source offsets. */
|
package/dist/source-problems.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
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
3
|
import { describeFinding, segmentsOf } from "./source-messages.js";
|
|
4
4
|
import { fixesForFinding } from "./source-fixes.js";
|
|
5
|
-
import {
|
|
5
|
+
import { textFixesForFinding } from "./source-fixes-text.js";
|
|
6
|
+
import { lineColumn, locateText, partOf } from "./source-ranges.js";
|
|
6
7
|
|
|
7
8
|
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
8
9
|
|
|
@@ -30,8 +31,9 @@ export function buildProblems({ format, text, findings, context = {}, actions =
|
|
|
30
31
|
const { message, next } = describeFinding(finding, context);
|
|
31
32
|
let range = null;
|
|
32
33
|
if (finding.range) range = { from: finding.range.from, to: finding.range.to };
|
|
33
|
-
else if (
|
|
34
|
-
|
|
34
|
+
else if (finding.kind !== "syntax" && finding.path !== undefined && finding.ruleId?.startsWith("opf/")) {
|
|
35
|
+
// The finding names a field of the document: core finds it in the text of any format.
|
|
36
|
+
const located = locateText(format, text, segmentsOf(finding.path), partOf(finding));
|
|
35
37
|
if (located) range = { from: located.from, to: located.to };
|
|
36
38
|
}
|
|
37
39
|
if (!range && Number.isInteger(finding.location?.offset)) range = { from: finding.location.offset, to: finding.location.offset + Math.max(0, finding.location.length ?? 0) };
|
|
@@ -40,7 +42,7 @@ export function buildProblems({ format, text, findings, context = {}, actions =
|
|
|
40
42
|
if (fix.action === "switch-format") fixes.push({ id: fix.id, title: fix.title, safe: fix.safe === true, run: () => actions.switchFormat?.(fix.format) });
|
|
41
43
|
else fixes.push({ ...fix, edits: toEdits(fix.edits) });
|
|
42
44
|
}
|
|
43
|
-
if (
|
|
45
|
+
if (finding.kind !== "syntax") fixes.push(...(format === "json" ? fixesForFinding(finding, text, context) : textFixesForFinding(format, finding, text, context)));
|
|
44
46
|
for (const fix of finding.fixes ?? []) { const row = coreFix(fix, actions); if (row) fixes.push(row); }
|
|
45
47
|
const row = { finding, index, severity: finding.severity, message, next, rule: finding.ruleId, path: finding.path ?? "", fixes };
|
|
46
48
|
if (range) {
|
package/dist/source-ranges.js
CHANGED
|
@@ -14,15 +14,18 @@ export function lineColumn(text, offset) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* The range of `segments` in
|
|
18
|
-
* property name), "entry" (key to the end of the value), "colon" (the key and its colon) or "open" (the first character
|
|
19
|
-
* object). A path that is not there gives the range of its nearest container, with `exact: false`.
|
|
17
|
+
* The range of `segments` in `text` of `format` ("json", "yaml" or "md"), or null (core's `locate`, which reads text that does not parse). `part`:
|
|
18
|
+
* "value" (default), "key" (the property name), "entry" (key to the end of the value), "colon" (the key and its colon) or "open" (the first character
|
|
19
|
+
* of the value, the `{` of an object). A path that is not there gives the range of its nearest container, with `exact: false`.
|
|
20
20
|
*/
|
|
21
|
-
export function
|
|
22
|
-
const range = locate(text,
|
|
21
|
+
export function locateText(format, text, segments, part = "value") {
|
|
22
|
+
const range = locate(text, format, segments.map(String), { part });
|
|
23
23
|
return range ? { from: range.offset, to: range.offset + range.length, exact: range.exact !== false } : null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** `locateText` for JSON. */
|
|
27
|
+
export const locateJson = (text, segments, part = "value") => locateText("json", text, segments, part);
|
|
28
|
+
|
|
26
29
|
/** The part of the text a finding is about, by what it says (VALIDATOR-HINTS section 4). */
|
|
27
30
|
export function partOf(finding) {
|
|
28
31
|
const keyword = finding.validation?.keyword;
|
package/dist/source-snippets.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
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
3
|
// snippet engine, which walks the tab stops. Plain functions, no DOM.
|
|
4
4
|
|
|
5
|
+
import { Document as YamlDocument } from "yaml";
|
|
6
|
+
|
|
5
7
|
const PLACEHOLDER = Symbol("opf-placeholder");
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -70,6 +72,8 @@ export const SNIPPETS = Object.freeze([
|
|
|
70
72
|
{ prefix: "reference", where: "reference", description: "A reference a slide can cite", fragment: { id: ph(1, "smith2020"), text: ph(2, "Citation") } },
|
|
71
73
|
{ 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
74
|
{ prefix: "deck", where: "deck", description: "A starter deck", fragment: { name: ph(1, "Deck name"), slides: [{ title: ph(2, "Title") }] } },
|
|
75
|
+
// Speaker notes in Markdown: a `Note:` line (JSON and YAML have the `notes` property for it).
|
|
76
|
+
{ prefix: "notes", where: "markdown", key: "notes", description: "Speaker notes for the slide", fragment: ph(1, "Speaker notes") },
|
|
73
77
|
]);
|
|
74
78
|
|
|
75
79
|
/** The snippets that fit where the caret is: `where` and the path of the node (see source-json.js). */
|
|
@@ -86,8 +90,143 @@ export function snippetsFor(mode, path) {
|
|
|
86
90
|
|
|
87
91
|
/** The JSON text of a snippet for insertion: `{ text, fields }`; a dataset is a `"name": { ... }` entry. */
|
|
88
92
|
export function snippetJson(snippet, { comma = false } = {}) {
|
|
93
|
+
if (snippet.key !== undefined) {
|
|
94
|
+
const body = renderJson(snippet.fragment, { comma });
|
|
95
|
+
return { text: `${JSON.stringify(snippet.key)}: ${body.text}`, fields: body.fields };
|
|
96
|
+
}
|
|
89
97
|
if (snippet.where !== "dataset") return renderJson(snippet.fragment, { comma });
|
|
90
98
|
const body = renderJson(snippet.fragment, { comma });
|
|
91
99
|
const key = `"${field(snippet.name)}"`;
|
|
92
100
|
return { text: `${key}: ${body.text}`, fields: [{ index: snippet.name.index, text: snippet.name.text }, ...body.fields].sort((a, b) => a.index - b.index) };
|
|
93
101
|
}
|
|
102
|
+
|
|
103
|
+
// ---- YAML ------------------------------------------------------------------------------------------------------------------------------
|
|
104
|
+
const yamlText = (text) => {
|
|
105
|
+
try { return new YamlDocument(text).toString({ lineWidth: 0 }).trimEnd(); } catch { return JSON.stringify(text); }
|
|
106
|
+
};
|
|
107
|
+
// A placeholder as YAML: its text between the markers, quoted when plain YAML would read it as another type or as syntax (`42`, `a: b`).
|
|
108
|
+
function yamlField(placeholder) {
|
|
109
|
+
const marker = (text) => `\${${placeholder.index}${text ? `:${escapeText(text)}` : ""}}`;
|
|
110
|
+
const text = placeholder.text || (placeholder.choice ? (placeholder.value ?? "") : "");
|
|
111
|
+
if (placeholder.raw || !text) return marker(text);
|
|
112
|
+
return yamlText(text) === text ? marker(text) : `"${marker(JSON.stringify(text).slice(1, -1))}"`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The lines of a mapping as `[{ depth, text }]` (block style; lists of lists and `columns` in flow style), recording placeholders in `fields`. */
|
|
116
|
+
function yamlLines(mapping, depth, fields) {
|
|
117
|
+
const scalar = (value) => {
|
|
118
|
+
if (isPlaceholder(value)) {
|
|
119
|
+
fields.push({ index: value.index, text: value.text || (value.choice ? (value.value ?? "") : ""), ...(value.choice ? { choice: true } : {}) });
|
|
120
|
+
return yamlField(value);
|
|
121
|
+
}
|
|
122
|
+
if (value === null) return "null";
|
|
123
|
+
return typeof value === "string" ? yamlText(value) : String(value);
|
|
124
|
+
};
|
|
125
|
+
const flow = (value) => (Array.isArray(value) ? `[${value.map(flow).join(", ")}]` : scalar(value));
|
|
126
|
+
const isCollection = (value) => value !== null && typeof value === "object" && !isPlaceholder(value);
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (const [key, item] of Object.entries(mapping)) {
|
|
129
|
+
if (!isCollection(item)) { lines.push({ depth, text: `${key}: ${scalar(item)}` }); continue; }
|
|
130
|
+
if (Array.isArray(item)) {
|
|
131
|
+
if (!item.length) { lines.push({ depth, text: `${key}: []` }); continue; }
|
|
132
|
+
if (key === "columns") { lines.push({ depth, text: `${key}: ${flow(item)}` }); continue; }
|
|
133
|
+
lines.push({ depth, text: `${key}:` });
|
|
134
|
+
for (const entry of item) {
|
|
135
|
+
if (Array.isArray(entry)) lines.push({ depth: depth + 1, text: `- ${flow(entry)}` });
|
|
136
|
+
else if (isCollection(entry)) {
|
|
137
|
+
const inner = yamlLines(entry, depth + 2, fields);
|
|
138
|
+
if (inner.length) inner[0] = { depth: depth + 1, text: `- ${inner[0].text}` };
|
|
139
|
+
lines.push(...inner);
|
|
140
|
+
} else lines.push({ depth: depth + 1, text: `- ${scalar(entry)}` });
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!Object.keys(item).length) { lines.push({ depth, text: `${key}: {}` }); continue; }
|
|
145
|
+
lines.push({ depth, text: `${key}:` }, ...yamlLines(item, depth + 1, fields));
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `{ text, fields }`: a fragment as block-style YAML with CodeMirror snippet markers. The first line starts where the caret is (after a dash or
|
|
152
|
+
* at a key position); the others are indented relative to the line the caret is on, and `dash` more columns when the caret follows a list dash.
|
|
153
|
+
* `entry` writes the fragment as the value of that key; `name` (a placeholder) writes it under a key the author names.
|
|
154
|
+
*/
|
|
155
|
+
export function renderYaml(fragment, { dash = 0, entry, name } = {}) {
|
|
156
|
+
const fields = [];
|
|
157
|
+
let lines;
|
|
158
|
+
if (name !== undefined) {
|
|
159
|
+
fields.push({ index: name.index, text: name.text });
|
|
160
|
+
lines = [{ depth: 0, text: `${yamlField(name)}:` }, ...yamlLines(fragment, 1, fields)];
|
|
161
|
+
} else if (entry !== undefined) lines = yamlLines({ [entry]: fragment }, 0, fields);
|
|
162
|
+
else lines = yamlLines(fragment, 0, fields);
|
|
163
|
+
const pad = " ".repeat(dash);
|
|
164
|
+
const text = lines.map((line, index) => (index === 0 ? line.text : `${pad}${" ".repeat(line.depth)}${line.text}`)).join("\n");
|
|
165
|
+
return { text: `${text}\${0}`, fields: fields.sort((a, b) => a.index - b.index) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The YAML text of a snippet for insertion: `{ text, fields }`. `dash` is the width of the list dash the caret follows (2), else 0. */
|
|
169
|
+
export function snippetYaml(snippet, { dash = 0 } = {}) {
|
|
170
|
+
if (snippet.key !== undefined) return renderYaml(snippet.fragment, { dash, entry: snippet.key });
|
|
171
|
+
if (snippet.where === "dataset") return renderYaml(snippet.fragment, { dash, name: snippet.name });
|
|
172
|
+
return renderYaml(snippet.fragment, { dash });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- Markdown --------------------------------------------------------------------------------------------------------------------------
|
|
176
|
+
// A placeholder as Markdown text: the marker with its text (a choice starts with its default, `column`).
|
|
177
|
+
function mdField(placeholder, fields) {
|
|
178
|
+
const text = placeholder.text || (placeholder.choice ? (placeholder.value ?? "") : "");
|
|
179
|
+
fields.push({ index: placeholder.index, text, ...(placeholder.choice ? { choice: true } : {}) });
|
|
180
|
+
return `\${${placeholder.index}${text ? `:${escapeText(text)}` : ""}}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const SLIDE_OPTION_FIELDS = ["id", "type", "layout", "section", "tag"];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `{ text, fields }`: a slide fragment as the Markdown dialect writes it (options comment, `#` title, blocks, `Note:`), with CodeMirror snippet
|
|
187
|
+
* markers. `separator` puts `---` and a blank line first (a slide after another). A `notes` snippet is one `Note:` line. The shape follows
|
|
188
|
+
* core's writer, so the result reads back as the slide the fragment describes.
|
|
189
|
+
*/
|
|
190
|
+
export function renderMarkdown(fragment, { separator = false, entry } = {}) {
|
|
191
|
+
const fields = [];
|
|
192
|
+
const value = (item) => (isPlaceholder(item) ? mdField(item, fields) : item === null || item === undefined ? "" : String(item));
|
|
193
|
+
if (entry === "notes") return { text: `Note: ${value(fragment)}\${0}`, fields };
|
|
194
|
+
const blocks = [];
|
|
195
|
+
const options = [];
|
|
196
|
+
for (const key of SLIDE_OPTION_FIELDS) if (typeof fragment[key] === "string" || isPlaceholder(fragment[key])) options.push(`${key}=${value(fragment[key])}`);
|
|
197
|
+
// The options comment sits directly above what follows it, as core writes it.
|
|
198
|
+
const lead = options.length ? `<!-- slide: ${options.join(" ")} -->` : "";
|
|
199
|
+
if (fragment.title !== undefined) blocks.push(`${lead ? `${lead}\n` : ""}# ${value(fragment.title)}`);
|
|
200
|
+
else if (lead) blocks.push(lead);
|
|
201
|
+
if (fragment.subtitle !== undefined) blocks.push(`## ${value(fragment.subtitle)}`);
|
|
202
|
+
const bullets = (items) => items.map((item) => `- ${value(item)}`).join("\n");
|
|
203
|
+
const csv = (rows) => rows.map((row) => row.map((cell) => value(cell)).join(",")).join("\n");
|
|
204
|
+
if (fragment.text !== undefined) blocks.push(value(fragment.text));
|
|
205
|
+
if (Array.isArray(fragment.items)) blocks.push(bullets(fragment.items));
|
|
206
|
+
for (const region of ["left", "right"]) {
|
|
207
|
+
if (fragment[region]) blocks.push(`<!-- block: region=${region} -->\n${bullets(fragment[region].items ?? [])}`);
|
|
208
|
+
}
|
|
209
|
+
if (fragment.image) blocks.push(`})`);
|
|
210
|
+
if (fragment.chart) blocks.push(`\`\`\`chart ${value(fragment.chart.type)}\n${csv([fragment.chart.data.columns, ...fragment.chart.data.rows])}\n\`\`\``);
|
|
211
|
+
if (fragment.quote) blocks.push(`> ${value(fragment.quote.text)}${fragment.quote.attribution === undefined ? "" : `\n> — ${value(fragment.quote.attribution)}`}`);
|
|
212
|
+
if (fragment.metric) blocks.push(`\`\`\`metric\nvalue: ${value(fragment.metric.value)}\nlabel: ${value(fragment.metric.label)}\n\`\`\``);
|
|
213
|
+
if (fragment.table) {
|
|
214
|
+
const row = (cells) => `| ${cells.map((cell) => value(cell)).join(" | ")} |`;
|
|
215
|
+
blocks.push([row(fragment.table.columns), row(fragment.table.columns.map(() => "---")), ...fragment.table.rows.map(row)].join("\n"));
|
|
216
|
+
}
|
|
217
|
+
if (fragment.code) blocks.push(`\`\`\`${value(fragment.code.language)}\n${value(fragment.code.source)}\n\`\`\``);
|
|
218
|
+
if (fragment.notes !== undefined) blocks.push(`Note: ${value(fragment.notes)}`);
|
|
219
|
+
const body = blocks.join("\n\n");
|
|
220
|
+
return { text: `${separator ? "---\n\n" : ""}${body}\${0}`, fields: fields.sort((a, b) => a.index - b.index) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The Markdown text of a snippet for insertion at the start of a line: `{ text, fields }`. `separator`: a slide follows another. */
|
|
224
|
+
export function snippetMarkdown(snippet, { separator = false } = {}) {
|
|
225
|
+
if (snippet.key !== undefined) return renderMarkdown(snippet.fragment, { entry: snippet.key });
|
|
226
|
+
return renderMarkdown(snippet.fragment, { separator });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The snippets of the Markdown body: a slide of each kind and the speaker notes. */
|
|
230
|
+
export function snippetsForMarkdown() {
|
|
231
|
+
return SNIPPETS.filter((snippet) => snippet.where === "slide" || snippet.where === "markdown");
|
|
232
|
+
}
|