@openpresentation/opf-editor 0.26.3 → 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.
@@ -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 { locateJson } from "./source-ranges.js";
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
- 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
- }
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. */
@@ -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 { lineColumn, locateJson, partOf } from "./source-ranges.js";
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 (format === "json" && finding.kind !== "syntax" && finding.path !== undefined && finding.ruleId?.startsWith("opf/")) {
34
- const located = locateJson(text, segmentsOf(finding.path), partOf(finding));
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 (format === "json" && finding.kind !== "syntax") fixes.push(...fixesForFinding(finding, text, context));
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) {
@@ -14,15 +14,18 @@ export function lineColumn(text, offset) {
14
14
  }
15
15
 
16
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`.
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 locateJson(text, segments, part = "value") {
22
- const range = locate(text, "json", segments.map(String), { part });
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;
@@ -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(`![${value(fragment.image.alt)}](${value(fragment.image.src)})`);
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
+ }