@openpresentation/opf-editor 0.26.5 → 0.26.6

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.
@@ -0,0 +1,90 @@
1
+ // The source pane's text checks as plain functions of their input (no DOM, no editor session), so the main thread and the Web Worker
2
+ // (`./source-worker.js`) run exactly the same code and give the same answer. `readSource` is what the pane has always done with a text
3
+ // (parse it, collect the syntax findings); `checkSource` adds the validation the editor session would run on the deck it reads, so a worker
4
+ // can hand both back and the session need not repeat it on the main thread. Everything returned is structured-cloneable data.
5
+ import { fromYaml } from "@openpresentation/opf/yaml";
6
+ import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
7
+ import { checkFormat } from "./checks.js";
8
+ import { normalizeFinding } from "./source-findings.js";
9
+ import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
10
+ import { withSyntaxFixes } from "./source-fixes-syntax.js";
11
+
12
+ /**
13
+ * Read the text of one format: `{ deck, findings, notes? }` on success, `{ syntax: true, findings }` when it cannot be read. `findings` carry the
14
+ * syntax errors (`kind: "syntax"`) or the OPF findings of the parsed deck; `notes` are the reader's warnings that come with a deck.
15
+ */
16
+ export function readSource(format, text, catalogs) {
17
+ if (format === "json") {
18
+ const problems = analyzeSyntax(text);
19
+ if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
20
+ const deck = JSON.parse(text);
21
+ const duplicates = findDuplicateKeys(text);
22
+ if (duplicates.length) return { syntax: true, findings: duplicates };
23
+ return { deck, findings: [] };
24
+ }
25
+ const result = format === "yaml" ? fromYaml(text, { catalogs }) : fromMarkdown(text, { catalogs });
26
+ // The reader's own findings: errors stop the document being read; its warnings (a numbered list that became bullets) come with the deck.
27
+ const readerFindings = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
28
+ const errors = readerFindings.filter((finding) => finding.severity === "error");
29
+ const syntax = (list) => withSyntaxFixes(format, text, list).map((finding) => normalizeFinding(finding, "syntax"));
30
+ if (errors.length) return { syntax: true, findings: syntax(errors) };
31
+ return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")), notes: syntax(readerFindings) };
32
+ }
33
+
34
+ /**
35
+ * `readSource` plus, when a deck was read, `validation`: the report the editor session computes for that deck (`checkFormat`, with the same
36
+ * catalogs). `validate: false` skips it (the main thread's own path, where the session validates the patch itself).
37
+ */
38
+ export function checkSource({ format, text, catalogs, validate = true }) {
39
+ const read = readSource(format, text, catalogs);
40
+ if (validate && read.deck) {
41
+ read.validation = checkFormat(read.deck, { catalogs });
42
+ // What the Markdown writer cannot hold for this deck: the pane lists it after every applied edit, and writing a big deck back is not cheap.
43
+ if (format === "md") read.markdownNotes = markdownNotesOf(read.deck);
44
+ }
45
+ return read;
46
+ }
47
+
48
+ /** The notes about what Markdown cannot hold, as findings, from the report of `toMarkdown`. */
49
+ export function markdownNotes(report) {
50
+ const notes = report.embedded.map((part) => normalizeFinding({ path: part.path, message: `Markdown has no syntax for this, so it is kept as YAML in a fenced block (${part.reason}).`, severity: "warning" }, "format"));
51
+ for (const entry of report.loss) {
52
+ const at = entry.indexOf(": ");
53
+ notes.push(normalizeFinding({ path: at > 0 ? entry.slice(0, at) : "", message: `Left out of the Markdown: ${at > 0 ? entry.slice(at + 2) : entry}.`, severity: "warning" }, "format"));
54
+ }
55
+ return notes;
56
+ }
57
+
58
+ /** The Markdown notes of `deck`; none when it cannot be written as Markdown. */
59
+ export function markdownNotesOf(deck) {
60
+ try { return markdownNotes(toMarkdown(deck).report); } catch { return []; }
61
+ }
62
+
63
+ /** The syntax problems of a JSON text as findings (the pane's quick pass, 150 ms after the last key). */
64
+ export function checkSyntaxOnly(text) {
65
+ return analyzeSyntax(text).map((problem) => normalizeFinding(problem, "syntax"));
66
+ }
67
+
68
+ /**
69
+ * The worker's side of the protocol, free of any worker API so it can be tested in Node. `handle(message)` returns the reply for one message
70
+ * (or null for a message with no reply). Messages: `{ id, type: "catalogs", catalogs }`, `{ id, type: "check", format, text }` and
71
+ * `{ id, type: "syntax", text }`; replies: `{ id, ok: true, result }` or `{ id, ok: false, error }`.
72
+ */
73
+ export function createCheckService() {
74
+ let catalogs;
75
+ return {
76
+ handle(message) {
77
+ const id = message?.id;
78
+ try {
79
+ switch (message?.type) {
80
+ case "catalogs": catalogs = message.catalogs; return { id, ok: true, result: null };
81
+ case "check": return { id, ok: true, result: checkSource({ format: message.format, text: message.text, catalogs }) };
82
+ case "syntax": return { id, ok: true, result: checkSyntaxOnly(message.text) };
83
+ default: return { id, ok: false, error: `Unknown message type ${String(message?.type)}.` };
84
+ }
85
+ } catch (error) {
86
+ return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
87
+ }
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,130 @@
1
+ // The client side of the source worker (`./source-worker.js`): it owns the worker, numbers the requests, keeps the worker's catalogs in step with the
2
+ // pane's, and falls back to the pane's own inline path for good, once, when anything about the worker goes wrong (it cannot start, a script URL is
3
+ // blocked or missing, a message cannot be cloned, the worker throws or stops answering). It never loses an edit: a failed request rejects with
4
+ // `error.workerFailed === true`, and the pane then does the same work inline.
5
+
6
+ const DEFAULT_TIMEOUT = 15000;
7
+
8
+ /** A failure of the worker as an error the caller can tell from an exception of the checked text. */
9
+ function failure(cause) {
10
+ const error = new Error(cause instanceof Error ? cause.message : String(cause ?? "The source worker failed."));
11
+ error.workerFailed = true;
12
+ error.cause = cause;
13
+ return error;
14
+ }
15
+
16
+ /** The worker the `worker` option stands for: a function is called, an object with `postMessage` is used as given, `true` starts the package's own. */
17
+ function makeWorker(option) {
18
+ if (typeof option === "function") return option();
19
+ if (option && typeof option === "object" && typeof option.postMessage === "function") return option;
20
+ if (option === true) {
21
+ if (typeof Worker !== "function") throw new Error("Web Workers are not available here.");
22
+ return new Worker(new URL("./source-worker.js", import.meta.url), { type: "module" });
23
+ }
24
+ throw new TypeError("The worker option is true, a Worker, or a function that returns one.");
25
+ }
26
+
27
+ /**
28
+ * @param {{ worker: boolean | object | (() => object), onError?: (error: unknown) => void, timeout?: number }} options
29
+ */
30
+ export function createSourceChecker(options = {}) {
31
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
32
+ let worker = null;
33
+ let failed = false;
34
+ let disposed = false;
35
+ let nextId = 0;
36
+ let sentCatalogs;
37
+ let catalogsSent = false;
38
+ const pending = new Map();
39
+ const latest = { check: 0, syntax: 0 };
40
+ const counts = { worker: 0 };
41
+
42
+ function fail(cause) {
43
+ if (failed || disposed) return;
44
+ failed = true;
45
+ const error = failure(cause);
46
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
47
+ pending.clear();
48
+ try { worker?.terminate?.(); } catch { /* Already gone. */ }
49
+ worker = null;
50
+ try { options.onError?.(cause instanceof Error ? cause : error); } catch { /* A host callback must not break the fallback. */ }
51
+ }
52
+
53
+ function reply(event) {
54
+ const data = event?.data;
55
+ const entry = pending.get(data?.id);
56
+ if (!entry) return;
57
+ pending.delete(data.id);
58
+ clearTimeout(entry.timer);
59
+ if (data.ok === false) { entry.reject(failure(data.error)); fail(new Error(String(data.error))); return; }
60
+ if (data.skipped || (entry.kind && latest[entry.kind] !== data.id)) { entry.resolve(null); return; }
61
+ if (entry.kind) counts.worker++;
62
+ entry.resolve(data.result);
63
+ }
64
+
65
+ function start() {
66
+ if (worker) return worker;
67
+ worker = makeWorker(options.worker);
68
+ if (!worker || typeof worker.postMessage !== "function") throw new TypeError("The worker option did not give a Worker.");
69
+ worker.onmessage = reply;
70
+ worker.onerror = (event) => { event?.preventDefault?.(); fail(new Error(event?.message || "The source worker failed to start.")); };
71
+ worker.onmessageerror = () => fail(new Error("The source worker sent a message that could not be read."));
72
+ return worker;
73
+ }
74
+
75
+ function post(message, kind) {
76
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
77
+ let target;
78
+ try { target = start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
79
+ const id = ++nextId;
80
+ if (kind) latest[kind] = id;
81
+ return new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => fail(new Error("The source worker did not answer in time.")), timeout);
83
+ pending.set(id, { resolve, reject, timer, kind });
84
+ try { target.postMessage({ id, ...message }); } catch (error) { fail(error); }
85
+ });
86
+ }
87
+
88
+ /** Send the catalogs when they are not the ones the worker already has (by identity), ahead of the request that needs them. */
89
+ function syncCatalogs(catalogs) {
90
+ if (catalogsSent && sentCatalogs === catalogs) return;
91
+ sentCatalogs = catalogs;
92
+ catalogsSent = true;
93
+ post({ type: "catalogs", catalogs }).catch(() => { /* `fail` already ran; the request after it rejects too. */ });
94
+ }
95
+
96
+ return {
97
+ /** Whether the worker can still be used (it may not have been started yet). */
98
+ get available() { return !failed && !disposed; },
99
+ /** Whether the worker is running now. */
100
+ get running() { return worker !== null && !failed && !disposed; },
101
+ /** The catalogs object the worker's next check will use (the last one sent). */
102
+ get catalogs() { return catalogsSent ? sentCatalogs : undefined; },
103
+ /** How many checks the worker answered. */
104
+ get answered() { return counts.worker; },
105
+ /**
106
+ * Check `text` as `format` in the worker, with `catalogs`. Resolves with the `checkSource` result, or with null when a newer check replaced this
107
+ * one. Rejects with `error.workerFailed` when the worker is not usable (and never again starts it).
108
+ */
109
+ check(format, text, catalogs) {
110
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
111
+ try { start(); syncCatalogs(catalogs); } catch (error) { fail(error); return Promise.reject(failure(error)); }
112
+ return post({ type: "check", format, text }, "check");
113
+ },
114
+ /** The syntax findings of a JSON text (null when a newer request replaced this one). */
115
+ syntax(text) {
116
+ if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
117
+ try { start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
118
+ return post({ type: "syntax", text }, "syntax");
119
+ },
120
+ /** Stop the worker and answer nothing more. */
121
+ dispose() {
122
+ if (disposed) return;
123
+ disposed = true;
124
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.resolve(null); }
125
+ pending.clear();
126
+ try { worker?.terminate?.(); } catch { /* Already gone. */ }
127
+ worker = null;
128
+ },
129
+ };
130
+ }
@@ -11,7 +11,7 @@ import { Document, parse as parseYamlDocument, parseDocument } from "yaml";
11
11
  import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
12
12
  import { findNodeAtLocation, getNodeValue } from "jsonc-parser";
13
13
  import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
14
- import { PROMOTED_REGIONS, beatsOf, columnSourceFor, factsOf, jsonDeckParts, layoutKeys, referenceSiteAt, regionNames, slidePath, valueAt, variableNames, yamlDeckParts } from "./source-references.js";
14
+ import { PROMOTED_REGIONS, beatsOf, builtinVariables, columnSourceFor, factsOf, jsonDeckParts, layoutKeys, referenceSiteAt, regionNames, slidePath, valueAt, variableNames, yamlDeckParts } from "./source-references.js";
15
15
  import { beginUndoGroup, finishUndoGroup, takeUndoGroup } from "./source-undo-groups.js";
16
16
  import { scalarValue, yamlContextAt } from "./source-yaml.js";
17
17
  import { markdownContextAt, markdownOptions, markdownPlan, markdownHoverAt } from "./source-markdown.js";
@@ -290,11 +290,20 @@ function referenceOptions(site, context, catalogs, scope) {
290
290
  return options;
291
291
  }
292
292
 
293
- // `{{name}}` in a text: the variables the document declares, offered once `{{` is typed. `before` is the text of the value before the caret.
293
+ // `{{name}}` in a text, offered once `{{` is typed: the variables the document declares first, then the built-in ones the engine resolves
294
+ // (`deck.*`, `speaker.*`, `organization.*`, `slide.*`), grouped by prefix, each saying whether the document sets it. `before` is the text of the value
295
+ // before the caret.
294
296
  const VARIABLE_OPEN = /\{\{([A-Za-z0-9._-]*)$/;
295
297
  function variableOptions(scope) {
298
+ const facts = scope.facts();
296
299
  const options = [];
297
- variableNames(scope.facts()).forEach((variable, index) => options.push({ label: variable.id, type: "reference", detail: `${variable.type} variable`, boost: boostAt(index), opfDoc: `The variable ${variable.id}`, opf: { kind: "variable", name: variable.id } }));
300
+ variableNames(facts).forEach((variable) => options.push({ label: variable.id, type: "reference", detail: `${variable.type} variable`, boost: boostAt(options.length), opfDoc: `The variable ${variable.id}`, opf: { kind: "variable", name: variable.id } }));
301
+ for (const builtin of builtinVariables(facts)) {
302
+ options.push({
303
+ label: builtin.name, type: "reference", detail: builtin.detail, boost: boostAt(options.length), opfDoc: builtin.doc, opf: { kind: "variable", name: builtin.name, builtin: true },
304
+ info: () => docNode({ title: builtin.name, type: "built-in variable", description: builtin.doc, notes: [builtin.available ? "" : "Not set in this document"].filter(Boolean) }),
305
+ });
306
+ }
298
307
  return options;
299
308
  }
300
309
 
@@ -533,13 +542,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
533
542
  const { state } = view;
534
543
  const text = state.doc.toString();
535
544
  const main = state.selection.main;
536
- if (completion.opf?.kind === "variable") {
537
- // `{{na|` becomes `{{name}}`: the name replaces what was typed after the braces and the closing braces are written unless they are there.
538
- const to = text.startsWith("}}", fallbackTo) ? fallbackTo + 2 : fallbackTo;
539
- const insert = `${completion.opf.name}}}`;
540
- view.dispatch({ changes: { from: fallbackFrom, to, insert }, selection: { anchor: fallbackFrom + insert.length }, annotations: [pickedCompletion.of(completion), isolateHistory.of("full")], userEvent: "input.complete", scrollIntoView: true });
541
- return;
542
- }
545
+ if (completion.opf?.kind === "variable") { applyVariable(view, completion, fallbackFrom, fallbackTo); return; }
543
546
  // A value accepted right after the key that opened its list is one undo step with that key (R-CMP-14).
544
547
  const group = takeUndoGroup(view);
545
548
  const context = contextFor(format, text, main.head, true);
@@ -593,6 +596,39 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
593
596
  finish(plan.chain);
594
597
  }
595
598
 
599
+ /**
600
+ * `{{na|` becomes `{{name}}`: the name replaces what was typed after the braces and the closing braces are written unless they are there. With
601
+ * several cursors it does so at each of them, in one undo step. A cursor with no `{{` before it replaces as many characters as the main cursor's typed
602
+ * name when they are the same text (VS Code applies the item at every cursor the same way), and adds the braces all the same.
603
+ */
604
+ function applyVariable(view, completion, fallbackFrom, fallbackTo) {
605
+ const { state } = view;
606
+ const text = state.doc.toString();
607
+ const insert = `${completion.opf.name}}}`;
608
+ const annotations = [pickedCompletion.of(completion), isolateHistory.of("full")];
609
+ if (state.selection.ranges.length <= 1) {
610
+ const to = text.startsWith("}}", fallbackTo) ? fallbackTo + 2 : fallbackTo;
611
+ view.dispatch({ changes: { from: fallbackFrom, to, insert }, selection: { anchor: fallbackFrom + insert.length }, annotations, userEvent: "input.complete", scrollIntoView: true });
612
+ return;
613
+ }
614
+ const typed = text.slice(fallbackFrom, fallbackTo);
615
+ const sorted = [...state.selection.ranges].sort((a, b) => a.from - b.from);
616
+ const changes = [], carets = [];
617
+ let shift = 0;
618
+ for (const range of sorted) {
619
+ const head = range.head;
620
+ const open = VARIABLE_OPEN.exec(text.slice(Math.max(0, head - 200), head));
621
+ const length = open ? open[1].length : text.slice(head - typed.length, head) === typed ? typed.length : 0;
622
+ const from = head - length;
623
+ const to = text.startsWith("}}", head) ? head + 2 : head;
624
+ changes.push({ from, to, insert });
625
+ carets.push(from + shift + insert.length);
626
+ shift += insert.length - (to - from);
627
+ }
628
+ const main = sorted.indexOf(state.selection.main);
629
+ view.dispatch({ changes, selection: EditorSelection.create(carets.map((position) => EditorSelection.cursor(position)), Math.max(0, main)), annotations, userEvent: "input.complete", scrollIntoView: true });
630
+ }
631
+
596
632
  // With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
597
633
  function applyAtEveryCursor(view, completion, plan, format) {
598
634
  const { state } = view;
@@ -123,7 +123,7 @@ export function fixesAtCaret(state, source) {
123
123
  * `getSource()` is the original text; `applyFix(fix, problem)` performs a fix (the editor's edits or the pane's own action); `say(message)` is
124
124
  * the live region. Returns `{ extension, draw(view, problems), problems(view) }`.
125
125
  */
126
- export function createDiagnostics({ getSource, applyFix, say }) {
126
+ export function createDiagnostics({ getSource, applyFix, say, km }) {
127
127
  let announced = null;
128
128
  const stepProblem = (forward) => (view) => {
129
129
  const source = getSource(), map = textInputMap(source), list = currentProblems(view.state, source);
@@ -165,7 +165,8 @@ export function createDiagnostics({ getSource, applyFix, say }) {
165
165
  { key: "Escape", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
166
166
  { key: "Tab", run: whenOpen((view) => view.dispatch({ effects: closeFixes.of(null) })) },
167
167
  ]));
168
- const plain = keymap.of([
168
+ // F8, Shift+F8 and the quick fix key are declared in source-keymap.js; the menu's own keys above are modal and not listed.
169
+ const plain = keymap.of(km ? [...km.bind("problem.next", stepProblem(true)), ...km.bind("problem.previous", stepProblem(false)), ...km.bind("problem.quickFix", openMenu)] : [
169
170
  { key: "F8", run: stepProblem(true), shift: stepProblem(false) },
170
171
  { key: "Mod-.", run: openMenu },
171
172
  ]);
@@ -227,39 +227,59 @@ export const markdownWrapSelection = EditorView.inputHandler.of((view, _from, _t
227
227
  // ---- Folding in Markdown -----------------------------------------------------------------------------------------------------------
228
228
  const SEPARATOR = /^-{3,}[ \t]*$/;
229
229
 
230
+ const FRONT_END = /^(---|\.\.\.)\s*$/;
231
+ const foldIndexes = new WeakMap();
232
+
230
233
  /**
231
- * What a line of Markdown can fold (SPEC 6.8): the front matter (from its opening line), a fenced block (from its opening fence), and a slide (from its
232
- * first line to the line before the next `---`). A `---` inside a fence or the front matter is not a separator.
234
+ * Every foldable range of a Markdown text, by the number of the line it starts on, from one pass over the lines (SPEC 6.8): the front matter (from its
235
+ * opening line), a fenced block (from its opening fence) and a slide (from its first line to the last line before the next `---`). A `---` inside a
236
+ * fence or the front matter is not a separator. A fence that is also the first line of a slide folds as the fence.
233
237
  */
234
- export const markdownFolding = foldService.of((state, lineStart) => {
235
- const { doc } = state;
236
- const line = doc.lineAt(lineStart);
237
- const text = line.text;
238
- const last = (number) => { const end = doc.line(number); return end.to; };
239
- // The front matter.
240
- if (line.number === 1 && /^---\s*$/.test(text)) {
241
- for (let number = 2; number <= doc.lines; number++) if (/^(---|\.\.\.)\s*$/.test(doc.line(number).text)) return number > 2 ? { from: line.to, to: doc.line(number - 1).to } : null;
242
- return null;
243
- }
244
- if (markdownLineKind(doc, line.number) !== "body") return null;
245
- // A fenced block.
246
- const open = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(text);
247
- if (open && !(open[2][0] === "`" && open[3].includes("`"))) {
248
- const close = new RegExp(`^ {0,3}${open[2][0]}{${open[2].length},}\\s*$`);
249
- for (let number = line.number + 1; number <= doc.lines; number++) if (close.test(doc.line(number).text)) return number > line.number + 1 ? { from: line.to, to: doc.line(number - 1).to } : null;
250
- return line.number < doc.lines ? { from: line.to, to: last(doc.lines) } : null;
251
- }
252
- // A slide: its first line (the one after the front matter or a separator, blank lines apart) folds the rest of the slide.
253
- if (text.trim() === "" || SEPARATOR.test(text)) return null;
254
- let before = line.number - 1;
255
- while (before >= 1 && doc.line(before).text.trim() === "") before--;
256
- const startsSlide = before < 1 || ((SEPARATOR.test(doc.line(before).text) || /^\.\.\.\s*$/.test(doc.line(before).text)) && markdownLineKind(doc, before) === "body");
257
- if (!startsSlide) return null;
258
- let end = line.number;
259
- for (let number = line.number + 1; number <= doc.lines; number++) {
260
- if (SEPARATOR.test(doc.line(number).text) && markdownLineKind(doc, number) === "body") break;
261
- end = number;
238
+ export function markdownFoldIndex(doc) {
239
+ const cached = foldIndexes.get(doc);
240
+ if (cached) return cached;
241
+ const index = new Map();
242
+ const range = (line, endLine) => ({ from: doc.line(line).to, to: doc.line(endLine).to });
243
+ let front = false, fence = null, previous = null, slide = null, frontEnd = 0;
244
+ const closeSlide = () => { if (slide && slide.last > slide.start) index.set(slide.start, range(slide.start, slide.last)); slide = null; };
245
+ for (let number = 1; number <= doc.lines; number++) {
246
+ const text = doc.line(number).text;
247
+ // What this line is, from the state before it (as markdownLineKind): the opening line of the front matter is body.
248
+ let kind = "body";
249
+ if (front) kind = FRONT_END.test(text) ? "body" : "front";
250
+ else if (fence) kind = new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text) ? "body" : fence.yaml ? "yaml-fence" : "fence";
251
+ const blank = text.trim() === "";
252
+ if (kind === "body" && !blank) {
253
+ if (SEPARATOR.test(text)) closeSlide();
254
+ else {
255
+ const startsSlide = !previous || ((SEPARATOR.test(previous.text) || /^\.\.\.\s*$/.test(previous.text)) && previous.kind === "body");
256
+ if (!slide && startsSlide) slide = { start: number, last: number };
257
+ }
258
+ }
259
+ if (slide && !blank && !(kind === "body" && SEPARATOR.test(text))) slide.last = number;
260
+ if (!blank) previous = { text, kind };
261
+ // The state after this line.
262
+ if (number === 1 && /^---\s*$/.test(text)) {
263
+ front = true;
264
+ for (let at = 2; at <= doc.lines; at++) if (FRONT_END.test(doc.line(at).text)) { frontEnd = at; break; }
265
+ if (frontEnd > 2) index.set(1, range(1, frontEnd - 1));
266
+ } else if (front) {
267
+ if (FRONT_END.test(text)) front = false;
268
+ } else if (fence) {
269
+ if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text)) {
270
+ if (number > fence.start + 1) index.set(fence.start, range(fence.start, number - 1));
271
+ fence = null;
272
+ }
273
+ } else {
274
+ const open = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(text);
275
+ if (open && !(open[2][0] === "`" && open[3].includes("`"))) fence = { char: open[2][0], length: open[2].length, yaml: /^\s*opf-(?:slide|block)\b/.test(open[3]), start: number };
276
+ }
262
277
  }
263
- while (end > line.number && doc.line(end).text.trim() === "") end--;
264
- return end > line.number ? { from: line.to, to: doc.line(end).to } : null;
265
- });
278
+ // A fence never closed folds to the end of the text.
279
+ if (fence && fence.start < doc.lines) index.set(fence.start, range(fence.start, doc.lines));
280
+ closeSlide();
281
+ foldIndexes.set(doc, index);
282
+ return index;
283
+ }
284
+
285
+ export const markdownFolding = foldService.of((state, lineStart) => markdownFoldIndex(state.doc).get(state.doc.lineAt(lineStart).number) ?? null);
@@ -3,6 +3,7 @@
3
3
  // the like. The YAML ones are core's (`analyzeYamlSyntax` returns the edits with the problem); the Markdown ones are found here from the finding's range and
4
4
  // core's `scanMarkdown`. Pure functions over the source text: each fix is `{ id, title, safe, edits: [{ from, to, insert }], caret? }`, source offsets in, so
5
5
  // the editor applies it as one undoable change that keeps the author's comments and layout.
6
+ import { parseDocument } from "yaml";
6
7
  import { analyzeYamlSyntax, scanMarkdown } from "@openpresentation/opf/source";
7
8
 
8
9
  const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
@@ -79,6 +80,55 @@ function frontMatterEnd(text) {
79
80
  /** Remove the whole lines that [from, to) touches. */
80
81
  const removeLines = (text, from, to) => ({ from: lineStart(text, from), to: nextLine(text, Math.max(from, to - 1)), insert: "" });
81
82
 
83
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ const isBlankLine = (text, start) => text.slice(start, lineEnd(text, start)).trim() === "";
85
+ /** A name as the finding message writes it (a JSON string without its quotes). */
86
+ const unquote = (value) => { try { return JSON.parse(`"${value}"`); } catch { return value; } };
87
+
88
+ /**
89
+ * The lines of the top-level entry `key` of the YAML in text[from, to) (the key through its value, whole lines), as an edit; null when the key is
90
+ * not a block entry at the start of a line (a flow mapping, an entry the reader cannot place). The comments above and below stay.
91
+ */
92
+ function yamlEntryEdit(text, from, to, key) {
93
+ const slice = text.slice(from, to);
94
+ let document;
95
+ try { document = parseDocument(slice, { strict: false, uniqueKeys: false }); } catch { return null; }
96
+ const pairs = document.contents?.items;
97
+ if (!Array.isArray(pairs) || document.contents.flow) return null;
98
+ const pair = pairs.find((entry) => String(entry.key?.value ?? entry.key) === key);
99
+ const keyRange = pair?.key?.range;
100
+ if (!keyRange) return null;
101
+ const start = lineStart(slice, keyRange[0]);
102
+ if (slice.slice(start, keyRange[0]).trim() !== "") return null;
103
+ const valueEnd = Math.max(keyRange[1], pair.value?.range?.[1] ?? 0);
104
+ const end = nextLine(slice, Math.max(keyRange[1], valueEnd - 1));
105
+ // The last line of a body has no line break of its own (the closing fence's follows it): the one before it goes instead.
106
+ const lead = end >= slice.length && !slice.endsWith("\n") && start > 0 ? (slice.slice(Math.max(0, start - 2), start) === "\r\n" ? 2 : 1) : 0;
107
+ return { from: from + start - lead, to: from + end, insert: "" };
108
+ }
109
+
110
+ /** True when the YAML text[from, to) has only blank and comment lines once `edit` is applied. */
111
+ function emptyAfter(text, from, to, edit) {
112
+ const rest = text.slice(from, edit.from) + text.slice(edit.to, to);
113
+ return rest.split(/\r?\n/).every((line) => line.trim() === "" || line.trim().startsWith("#"));
114
+ }
115
+
116
+ /** The span of `key` (a flag, `key=bare` or `key="quoted"`) in an info line or an options comment text[from, to), with the space before it, or null. */
117
+ function attributeSpan(text, from, to, key) {
118
+ const found = new RegExp(`[ \t]+${escapeRegExp(key)}(?:=(?:"(?:[^"\\\\]|\\\\.)*"|[^\\s,"]+))?(?=[\\s,]|-->|$)`).exec(text.slice(from, to));
119
+ return found ? { from: from + found.index, to: from + found.index + found[0].length } : null;
120
+ }
121
+
122
+ /** An attribute the fence does not take (`Unknown chart attribute "foo".`) is dropped from its info line; a known one with a bad value is left to the author. */
123
+ function unknownAttribute(text, finding, kind, known) {
124
+ const quoted = new RegExp(`^Unknown ${kind} attribute ("(?:[^"\\\\]|\\\\.)*")\\.$`).exec(finding.message)?.[1];
125
+ const key = quoted ? unquote(quoted.slice(1, -1)) : "";
126
+ if (!key || known.includes(key)) return [];
127
+ const open = lineStart(text, finding.location.offset);
128
+ const span = attributeSpan(text, open, lineEnd(text, open), key);
129
+ return span ? [fix(`remove-attribute:${key}`, `Remove ${key}`, [{ ...span, insert: "" }])] : [];
130
+ }
131
+
82
132
  const markdownRules = {
83
133
  "markdown/front-matter-unterminated": (text) => {
84
134
  const at = frontMatterEnd(text);
@@ -108,12 +158,59 @@ const markdownRules = {
108
158
  return (finding.suggestions ?? []).map((entry) => fix(`rename-to:${entry.value}`, `Rename to ${entry.value}`, [{ from, to: from + key.length, insert: String(entry.value) }]));
109
159
  },
110
160
  "markdown/options-value": (text, finding) => {
111
- if (!/^hidden takes/.test(finding.message)) return [];
112
161
  const { offset, length } = finding.location;
113
- const found = /\bhidden=("[^"]*"|\S+?)(?=\s|-->)/.exec(text.slice(offset, offset + length));
114
- if (!found) return [];
115
- const from = offset + found.index;
116
- return ["true", "false"].map((value) => fix(`hidden-${value}`, `Use hidden=${value}`, [{ from, to: from + found[0].length, insert: `hidden=${value}` }]));
162
+ const body = text.slice(offset, offset + length);
163
+ if (/^hidden takes/.test(finding.message)) {
164
+ const found = /\bhidden=("[^"]*"|\S+?)(?=\s|-->)/.exec(body);
165
+ if (!found) return [];
166
+ const from = offset + found.index;
167
+ return ["true", "false"].map((value) => fix(`hidden-${value}`, `Use hidden=${value}`, [{ from, to: from + found[0].length, insert: `hidden=${value}` }]));
168
+ }
169
+ if (/^as takes/.test(finding.message)) {
170
+ const found = /\bas=("[^"]*"|\S+?)(?=\s|-->)/.exec(body);
171
+ if (!found) return [];
172
+ const from = offset + found.index;
173
+ return ["bullets", "video"].map((value) => fix(`as-${value}`, `Use as=${value}`, [{ from, to: from + found[0].length, insert: `as=${value}` }]));
174
+ }
175
+ // A key written as a flag (`layout`) where it needs a value: the safest repair is to drop it, not to guess the value.
176
+ const key = /^Option (\w+) needs a value/.exec(finding.message)?.[1];
177
+ const span = key ? attributeSpan(text, offset, offset + length, key) : null;
178
+ return span ? [fix(`remove-option:${key}`, `Remove ${key}`, [{ ...span, insert: "" }])] : [];
179
+ },
180
+ "markdown/options-syntax": (text, finding) => {
181
+ const { offset, length } = finding.location;
182
+ const body = text.slice(offset, offset + length);
183
+ const open = /^Unterminated quoted value for ([A-Za-z][\w-]*)\./.exec(finding.message)?.[1];
184
+ if (open) {
185
+ // The quote is closed after the last character before `-->`.
186
+ const quote = new RegExp(`(?:^|[\\s:,])${escapeRegExp(open)}="`).exec(body);
187
+ if (!quote) return [];
188
+ const valueStart = quote.index + quote[0].length;
189
+ const arrow = body.indexOf("-->", valueStart);
190
+ if (arrow < 0) return [];
191
+ let end = arrow;
192
+ while (end > valueStart && /\s/.test(body[end - 1])) end--;
193
+ return [fix("insert-closing-quote", "Insert closing quote", [{ from: offset + end, to: offset + end, insert: '"' }])];
194
+ }
195
+ const missing = /^Missing value for ([A-Za-z][\w-]*)\./.exec(finding.message)?.[1];
196
+ if (missing) {
197
+ const found = new RegExp(`[ \t]+${escapeRegExp(missing)}=(?=[\\s,]|-->|$)`).exec(body);
198
+ return found ? [fix(`remove-option:${missing}`, `Remove ${missing}=`, [{ from: offset + found.index, to: offset + found.index + found[0].length, insert: "" }])] : [];
199
+ }
200
+ return [];
201
+ },
202
+ "markdown/options-trailing": (text, finding) => {
203
+ const { offset, length } = finding.location;
204
+ const body = text.slice(offset, offset + length);
205
+ const arrow = body.indexOf("-->");
206
+ if (arrow < 0) return [];
207
+ const gap = /^[ \t]*/.exec(body.slice(arrow + 3))[0];
208
+ if (body.slice(arrow + 3 + gap.length).trim() === "") return [];
209
+ return [fix("move-text-down", "Move the text to its own line", [{ from: offset + arrow + 3, to: offset + arrow + 3 + gap.length, insert: /\r\n/.test(text) ? "\r\n" : "\n" }])];
210
+ },
211
+ "markdown/options-orphan": (text, finding) => {
212
+ const { offset, length } = finding.location;
213
+ return [fix("remove-options-comment", "Remove the options comment", [removeLines(text, offset, offset + length)])];
117
214
  },
118
215
  "markdown/options-duplicate": (text, finding) => {
119
216
  const key = /^Option (\w+) is given twice/.exec(finding.message)?.[1];
@@ -168,11 +265,44 @@ const markdownRules = {
168
265
  const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
169
266
  return [fix("add-numbering", "Add `numbering`", [{ from: at, to: at, insert: `${prefix}\n\`\`\`opf-slide\nnumbering: arabic\n\`\`\`\n` }])];
170
267
  },
268
+ "markdown/empty-quote": (text, finding) => {
269
+ const { offset, length } = finding.location;
270
+ const edit = removeLines(text, offset, offset + length);
271
+ // The blank line after the quote goes too when the line before is blank as well, so no double gap is left.
272
+ if (edit.from > 0 && isBlankLine(text, lineStart(text, edit.from - 1)) && edit.to < text.length && isBlankLine(text, edit.to)) edit.to = nextLine(text, edit.to);
273
+ return [fix("remove-empty-quote", "Remove the empty quote", [edit])];
274
+ },
275
+ "markdown/slide-property-conflict": (text, finding) => {
276
+ const key = unquote(/property "(.*)" is set twice/.exec(finding.message)?.[1] ?? "");
277
+ const slide = Number(/^\/slides\/(\d+)\//.exec(finding.path ?? "")?.[1]);
278
+ if (!key || !Number.isInteger(slide)) return [];
279
+ for (const fence of scanMarkdown(text).fences) {
280
+ if (fence.kind !== "opf-slide" || fence.slide !== slide) continue;
281
+ const edit = yamlEntryEdit(text, fence.bodyFrom, fence.bodyTo, key);
282
+ if (!edit) continue;
283
+ if (!emptyAfter(text, fence.bodyFrom, fence.bodyTo, edit)) return [fix(`remove-from-fence:${key}`, `Remove ${key} from the fence`, [edit])];
284
+ // The fence held nothing else: it goes with its blank line above.
285
+ let from = lineStart(text, fence.from);
286
+ const to = nextLine(text, fence.to);
287
+ if (from > 0 && isBlankLine(text, lineStart(text, from - 1)) && (to >= text.length || isBlankLine(text, to))) from = lineStart(text, from - 1);
288
+ return [fix(`remove-from-fence:${key}`, `Remove ${key} from the fence`, [{ from, to, insert: "" }])];
289
+ }
290
+ return [];
291
+ },
292
+ "markdown/chart-attributes": (text, finding) => unknownAttribute(text, finding, "chart", ["alt"]),
293
+ "markdown/timeline-attributes": (text, finding) => unknownAttribute(text, finding, "timeline", ["name", "description"]),
171
294
  "markdown/metric-block": (text, finding) => {
172
- if (finding.message !== "A metric needs a value.") return [];
173
295
  const open = lineStart(text, finding.location.offset);
174
- const at = nextLine(text, open);
175
- return [fix("insert-metric-value", "Insert value:", [{ from: at, to: at, insert: "value: 0\n" }])];
296
+ if (finding.message === "A metric needs a value.") {
297
+ const at = nextLine(text, open);
298
+ return [fix("insert-metric-value", "Insert value:", [{ from: at, to: at, insert: "value: 0\n" }])];
299
+ }
300
+ // The offending line itself is the finding's range: a key given twice, or a key with nothing after the colon.
301
+ const { offset, length } = finding.location;
302
+ const twice = /^Metric key (\w+) is given twice\./.exec(finding.message)?.[1];
303
+ if (twice) return [fix("remove-duplicate-metric-key", `Remove the second ${twice}`, [removeLines(text, offset, offset + length)])];
304
+ const empty = /^Metric key (\w+) has no value\./.exec(finding.message)?.[1];
305
+ return empty && empty !== "value" ? [fix(`remove-metric-key:${empty}`, `Remove ${empty}:`, [removeLines(text, offset, offset + length)])] : [];
176
306
  },
177
307
  };
178
308