@openpresentation/opf-editor 0.26.5 → 0.26.7
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 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +9 -2
- package/dist/json-editor.d.ts +20 -0
- package/dist/json-editor.js +188 -89
- package/dist/layout-editor.js +6 -2
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +46 -10
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +138 -8
- package/dist/source-folding.js +173 -0
- package/dist/source-hints.js +376 -0
- package/dist/source-keymap.js +369 -0
- package/dist/source-markdown.js +42 -1
- package/dist/source-navigation-model.js +426 -0
- package/dist/source-navigation-read.js +269 -0
- package/dist/source-navigation-ui.js +244 -0
- package/dist/source-navigation.js +217 -0
- package/dist/source-outline.js +112 -0
- package/dist/source-pane.d.ts +63 -0
- package/dist/source-pane.js +201 -44
- package/dist/source-references.js +58 -11
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/source-wrap.js +55 -0
- package/dist/styles.css +539 -4
- package/package.json +11 -7
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
]);
|
package/dist/source-editing.js
CHANGED
|
@@ -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
|
-
*
|
|
232
|
-
* first line to the line before the next `---`). A `---` inside a
|
|
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
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|