@openpresentation/opf-editor 0.26.4 → 0.26.5
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 +2 -1
- package/dist/json-editor.js +3 -2
- package/dist/source-a11y.js +18 -1
- package/dist/source-completions.js +196 -22
- package/dist/source-details.js +17 -0
- package/dist/source-fixes-syntax.js +204 -0
- package/dist/source-fixes-text.js +75 -2
- package/dist/source-fixes.js +25 -1
- package/dist/source-markdown.js +11 -16
- package/dist/source-messages.js +21 -205
- package/dist/source-pane.js +50 -11
- package/dist/source-problems.js +3 -0
- package/dist/source-references.js +227 -0
- package/dist/source-snippets.js +20 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-yaml.js +94 -9
- package/dist/styles.css +26 -0
- package/package.json +8 -7
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Quick fixes for the syntax problems of a YAML or Markdown source (artifacts/source-editor/VALIDATOR-HINTS.md, sections 9, 10 and 12): "Convert tabs to
|
|
2
|
+
// spaces", "Insert closing quote", "Insert ]", "Add a colon", "Insert ---", "Insert closing fence", "Insert -->", "Rename to layout", "Insert column" and
|
|
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
|
+
// 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
|
+
// the editor applies it as one undoable change that keeps the author's comments and layout.
|
|
6
|
+
import { analyzeYamlSyntax, scanMarkdown } from "@openpresentation/opf/source";
|
|
7
|
+
|
|
8
|
+
const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
|
|
9
|
+
const lineEnd = (text, offset) => { const at = text.indexOf("\n", offset); return at < 0 ? text.length : at; };
|
|
10
|
+
const nextLine = (text, offset) => { const end = lineEnd(text, offset); return end < text.length ? end + 1 : end; };
|
|
11
|
+
const fix = (id, title, edits, extra = {}) => ({ id, title, safe: false, edits, ...extra });
|
|
12
|
+
const fromCore = (entry) => ({ id: entry.id, title: entry.title, safe: entry.safe === true, edits: entry.edits.map((edit) => ({ from: edit.offset, to: edit.offset + edit.length, insert: edit.insert })), ...(entry.caret !== undefined ? { caret: entry.caret } : {}) });
|
|
13
|
+
|
|
14
|
+
// ---- YAML -------------------------------------------------------------------------------------------------------------------------
|
|
15
|
+
const yamlMemo = { text: null, problems: [] };
|
|
16
|
+
function yamlProblems(text) {
|
|
17
|
+
if (yamlMemo.text !== text) { yamlMemo.text = text; yamlMemo.problems = analyzeYamlSyntax(text); }
|
|
18
|
+
return yamlMemo.problems;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The YAML regions of a Markdown text: its front matter and its `opf-slide` and `opf-block` fences, as `{ from, to }` of the YAML itself. */
|
|
22
|
+
function markdownYamlRegions(text) {
|
|
23
|
+
const scan = scanMarkdown(text);
|
|
24
|
+
const regions = [];
|
|
25
|
+
if (scan.frontMatter) regions.push({ from: scan.frontMatter.yamlFrom, to: scan.frontMatter.yamlTo });
|
|
26
|
+
for (const fence of scan.fences) if (fence.kind === "opf-slide" || fence.kind === "opf-block") regions.push({ from: fence.bodyFrom, to: fence.bodyTo });
|
|
27
|
+
return regions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The problem that starts where `finding` does, in the YAML `text` (`shift` is where `text` starts in the source). */
|
|
31
|
+
function yamlHit(problems, finding, shift) {
|
|
32
|
+
const at = (finding.location?.offset ?? finding.range?.from) - shift;
|
|
33
|
+
return problems.find((problem) => problem.range.offset === at);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function yamlSyntax(format, text, finding) {
|
|
37
|
+
if (format === "yaml") {
|
|
38
|
+
const hit = yamlHit(yamlProblems(text), finding, 0);
|
|
39
|
+
return hit ? { fixes: hit.fixes.map(fromCore), related: (hit.related ?? []).map((range) => ({ from: range.offset, to: range.offset + range.length })) } : null;
|
|
40
|
+
}
|
|
41
|
+
const at = finding.location?.offset ?? finding.range?.from;
|
|
42
|
+
const region = markdownYamlRegions(text).find((entry) => at >= entry.from && at <= entry.to);
|
|
43
|
+
if (!region) return null;
|
|
44
|
+
const slice = text.slice(region.from, region.to);
|
|
45
|
+
const hit = yamlHit(analyzeYamlSyntax(slice, { rootKind: "any" }), finding, region.from);
|
|
46
|
+
if (!hit) return null;
|
|
47
|
+
const shift = (offset) => offset + region.from;
|
|
48
|
+
return {
|
|
49
|
+
fixes: hit.fixes.map(fromCore).map((entry) => ({ ...entry, edits: entry.edits.map((edit) => ({ ...edit, from: shift(edit.from), to: shift(edit.to) })), ...(entry.caret !== undefined ? { caret: shift(entry.caret) } : {}) })),
|
|
50
|
+
related: (hit.related ?? []).map((range) => ({ from: shift(range.offset), to: shift(range.offset + range.length) })),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Markdown ---------------------------------------------------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** The end of the last non-blank line inside [from, to). */
|
|
57
|
+
function lastContentEnd(text, from, to) {
|
|
58
|
+
let end = Math.min(to, text.length);
|
|
59
|
+
while (end > from && /\s/.test(text[end - 1])) end--;
|
|
60
|
+
return end;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where a front matter that was never closed should end: before the first blank line or the first line that is not YAML-shaped. */
|
|
64
|
+
function frontMatterEnd(text) {
|
|
65
|
+
const YAMLISH = /^(\s*[A-Za-z_][\w.-]*\s*:|\s*-\s|\s+\S|\s*#(?!\s)|\s*#\s*$|\s*#\s.*$)/;
|
|
66
|
+
let at = nextLine(text, 0);
|
|
67
|
+
let seen = false;
|
|
68
|
+
while (at < text.length) {
|
|
69
|
+
const end = lineEnd(text, at);
|
|
70
|
+
const content = text.slice(at, end);
|
|
71
|
+
if (content.trim() === "" || (!YAMLISH.test(content) && seen)) return at;
|
|
72
|
+
if (/^#{1,6}\s/.test(content) && !/^#\s.*:\s/.test(content)) return at;
|
|
73
|
+
seen = true;
|
|
74
|
+
at = nextLine(text, at);
|
|
75
|
+
}
|
|
76
|
+
return text.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove the whole lines that [from, to) touches. */
|
|
80
|
+
const removeLines = (text, from, to) => ({ from: lineStart(text, from), to: nextLine(text, Math.max(from, to - 1)), insert: "" });
|
|
81
|
+
|
|
82
|
+
const markdownRules = {
|
|
83
|
+
"markdown/front-matter-unterminated": (text) => {
|
|
84
|
+
const at = frontMatterEnd(text);
|
|
85
|
+
const prefix = at > 0 && text[at - 1] !== "\n" ? "\n" : "";
|
|
86
|
+
return [fix("insert-front-matter-end", "Insert ---", [{ from: at, to: at, insert: `${prefix}---\n` }], { safe: true })];
|
|
87
|
+
},
|
|
88
|
+
"markdown/comment-unterminated": (text, finding) => {
|
|
89
|
+
const from = finding.location.offset;
|
|
90
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
91
|
+
return [fix("insert-comment-end", "Insert -->", [{ from: end, to: end, insert: " -->" }], { safe: true, caret: end + 4 })];
|
|
92
|
+
},
|
|
93
|
+
"markdown/fence-unterminated": (text, finding) => {
|
|
94
|
+
const from = finding.location.offset;
|
|
95
|
+
const opening = text.slice(lineStart(text, from), lineEnd(text, from));
|
|
96
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(opening)?.[1] ?? "```";
|
|
97
|
+
const end = lastContentEnd(text, from, from + finding.location.length);
|
|
98
|
+
const at = nextLine(text, end > from ? end - 1 : from);
|
|
99
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
100
|
+
return [fix("insert-closing-fence", "Insert closing fence", [{ from: at, to: at, insert: `${prefix}${marker}\n` }], { safe: true })];
|
|
101
|
+
},
|
|
102
|
+
"markdown/options-unknown-key": (text, finding) => {
|
|
103
|
+
const key = /option "(.*)"\.$/.exec(finding.message)?.[1];
|
|
104
|
+
const { offset, length } = finding.location;
|
|
105
|
+
const found = key ? new RegExp(`(^|[\\s:])(${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?==|\\s|-->|$)`).exec(text.slice(offset, offset + length)) : null;
|
|
106
|
+
if (!found) return [];
|
|
107
|
+
const from = offset + found.index + found[1].length;
|
|
108
|
+
return (finding.suggestions ?? []).map((entry) => fix(`rename-to:${entry.value}`, `Rename to ${entry.value}`, [{ from, to: from + key.length, insert: String(entry.value) }]));
|
|
109
|
+
},
|
|
110
|
+
"markdown/options-value": (text, finding) => {
|
|
111
|
+
if (!/^hidden takes/.test(finding.message)) return [];
|
|
112
|
+
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}` }]));
|
|
117
|
+
},
|
|
118
|
+
"markdown/options-duplicate": (text, finding) => {
|
|
119
|
+
const key = /^Option (\w+) is given twice/.exec(finding.message)?.[1];
|
|
120
|
+
if (!key) return [];
|
|
121
|
+
const { offset, length } = finding.location;
|
|
122
|
+
const body = text.slice(offset, offset + length);
|
|
123
|
+
const pattern = new RegExp(`\\s${key}=("[^"]*"|[^\\s>]+)`, "g");
|
|
124
|
+
const matches = [...body.matchAll(pattern)];
|
|
125
|
+
if (matches.length < 2) return [];
|
|
126
|
+
const second = matches[1];
|
|
127
|
+
return [fix("remove-duplicate-option", `Remove the second ${key}`, [{ from: offset + second.index, to: offset + second.index + second[0].length, insert: "" }])];
|
|
128
|
+
},
|
|
129
|
+
"markdown/chart-type": (text, finding) => {
|
|
130
|
+
const open = lineStart(text, finding.location.offset);
|
|
131
|
+
const line = text.slice(open, lineEnd(text, open));
|
|
132
|
+
const found = /^(\s*(?:`{3,}|~{3,})\s*chart)/.exec(line);
|
|
133
|
+
if (!found) return [];
|
|
134
|
+
const at = open + found[1].length;
|
|
135
|
+
return (finding.suggestions ?? []).map((entry) => fix(`chart-type:${entry.value}`, `Insert ${entry.value}`, [{ from: at, to: at, insert: ` ${entry.value}` }]));
|
|
136
|
+
},
|
|
137
|
+
"markdown/front-matter-slides": (text, finding) => {
|
|
138
|
+
const { offset, length } = finding.location;
|
|
139
|
+
return [fix("remove-slides-key", "Remove the slides key", [removeLines(text, offset, offset + length)])];
|
|
140
|
+
},
|
|
141
|
+
"markdown/no-slides": (text) => {
|
|
142
|
+
const empty = text.trim() === "";
|
|
143
|
+
return [fix("insert-title", "Insert # Title", [{ from: text.length, to: text.length, insert: empty ? "# Title\n" : `${text.endsWith("\n") ? "" : "\n"}\n# Title\n` }], { safe: true })];
|
|
144
|
+
},
|
|
145
|
+
"markdown/duplicate-title": (text, finding) => {
|
|
146
|
+
const open = lineStart(text, finding.location.offset);
|
|
147
|
+
return [
|
|
148
|
+
fix("start-new-slide", "Start a new slide here", [{ from: open, to: open, insert: "---\n\n" }]),
|
|
149
|
+
fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))]),
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
"markdown/duplicate-subtitle": (text, finding) => {
|
|
153
|
+
const open = lineStart(text, finding.location.offset);
|
|
154
|
+
return [fix("remove-extra-heading", "Remove the extra heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
155
|
+
},
|
|
156
|
+
"markdown/empty-heading": (text, finding) => {
|
|
157
|
+
const open = lineStart(text, finding.location.offset);
|
|
158
|
+
return [fix("remove-empty-heading", "Remove the heading", [removeLines(text, open, lineEnd(text, open))])];
|
|
159
|
+
},
|
|
160
|
+
"markdown/numbered-list": (text, finding) => {
|
|
161
|
+
const { offset, length } = finding.location;
|
|
162
|
+
const scan = scanMarkdown(text);
|
|
163
|
+
const slide = scan.slides.find((entry) => offset >= entry.from && offset <= entry.to);
|
|
164
|
+
// A slide that has an opf-slide fence already gets the field written into it, not a second fence.
|
|
165
|
+
if (slide && scan.fences.some((fence) => fence.kind === "opf-slide" && fence.from >= slide.from && fence.to <= slide.to + 1)) return [];
|
|
166
|
+
const end = lastContentEnd(text, offset, offset + length);
|
|
167
|
+
const at = nextLine(text, end > offset ? end - 1 : offset);
|
|
168
|
+
const prefix = at === text.length && !text.endsWith("\n") ? "\n" : "";
|
|
169
|
+
return [fix("add-numbering", "Add `numbering`", [{ from: at, to: at, insert: `${prefix}\n\`\`\`opf-slide\nnumbering: arabic\n\`\`\`\n` }])];
|
|
170
|
+
},
|
|
171
|
+
"markdown/metric-block": (text, finding) => {
|
|
172
|
+
if (finding.message !== "A metric needs a value.") return [];
|
|
173
|
+
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" }])];
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The quick fixes of a YAML or Markdown syntax finding, and the other places that belong to it: `{ fixes, related }` or null. */
|
|
180
|
+
export function syntaxFixesFor(format, text, finding) {
|
|
181
|
+
if (!finding?.location || format === "json") return null;
|
|
182
|
+
const id = String(finding.ruleId ?? "");
|
|
183
|
+
try {
|
|
184
|
+
if (id.startsWith("yaml/") || (format === "md" && (id === "markdown/front-matter" || id === "markdown/opf-slide" || id === "markdown/opf-block"))) return yamlSyntax(format, text, finding);
|
|
185
|
+
const make = markdownRules[id];
|
|
186
|
+
if (make) {
|
|
187
|
+
const fixes = make(text, finding).filter((entry) => entry.edits.length);
|
|
188
|
+
return fixes.length ? { fixes, related: [] } : null;
|
|
189
|
+
}
|
|
190
|
+
} catch { /* A fix that cannot be worked out is not offered. */ }
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Findings of a YAML or Markdown source with their quick fixes (`editFixes`) and related places (`related`) added, in the shape `normalizeFinding` and
|
|
196
|
+
* `buildProblems` read. Findings that have no fixes are returned as they are.
|
|
197
|
+
*/
|
|
198
|
+
export function withSyntaxFixes(format, text, findings) {
|
|
199
|
+
return findings.map((finding) => {
|
|
200
|
+
const made = syntaxFixesFor(format, text, finding);
|
|
201
|
+
if (!made) return finding;
|
|
202
|
+
return { ...finding, ...(made.fixes.length ? { editFixes: made.fixes } : {}), ...(made.related.length ? { related: made.related } : {}) };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// slides", "Remove property", "Add `id`". The same fixes source-fixes.js writes for JSON, as text edits found with core's `locate`, so each one is a
|
|
3
3
|
// change of the text itself (one undoable change) that keeps the author's comments and layout. Pure functions: `{ id, title, safe, edits: [{ from, to, insert }] }`.
|
|
4
4
|
import { Document } from "yaml";
|
|
5
|
-
import { SLIDE_OPTION_KEYS } from "@openpresentation/opf/source";
|
|
5
|
+
import { SLIDE_OPTION_KEYS, scanMarkdown } from "@openpresentation/opf/source";
|
|
6
6
|
import { replacementValues, segmentsOf } from "./source-messages.js";
|
|
7
7
|
import { locateText } from "./source-ranges.js";
|
|
8
8
|
|
|
@@ -58,6 +58,70 @@ function addProperty(format, text, segments, key, value) {
|
|
|
58
58
|
return [{ from: at, to: at, insert: text.trim() === "" ? `${body}\n` : `\n${body}` }];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Replace the value at `segments` by `make(old)`, when the text has it exactly. */
|
|
62
|
+
function replaceValue(format, text, segments, make) {
|
|
63
|
+
const where = locateText(format, text, segments, "value");
|
|
64
|
+
if (!where?.exact || where.from === where.to) return null;
|
|
65
|
+
const insert = make(text.slice(where.from, where.to));
|
|
66
|
+
return insert === null ? null : [{ from: where.from, to: where.to, insert }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A scalar as the one item of a flow list: quotes stay, a plain scalar that flow syntax would misread is quoted. */
|
|
70
|
+
function inList(old, value) {
|
|
71
|
+
if (/^[|>&*!%@`]/.test(old) || /^[[{]/.test(old)) return null;
|
|
72
|
+
if (old.startsWith('"') || old.startsWith("'")) return `[${old}]`;
|
|
73
|
+
return /[,[\]{}#:]/.test(old) || typeof value !== "string" ? `[${JSON.stringify(value)}]` : `[${old}]`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `slides: []` (or `slides:` with nothing) becomes a block list with one slide. */
|
|
77
|
+
function addSlide(format, text) {
|
|
78
|
+
if (format !== "yaml") return null;
|
|
79
|
+
const key = locateText(format, text, ["slides"], "colon");
|
|
80
|
+
const value = locateText(format, text, ["slides"], "value");
|
|
81
|
+
if (!key?.exact) return null;
|
|
82
|
+
const empty = !value?.exact || text.slice(value.from, value.to).trim() === "" || /^\[\s*\]$/.test(text.slice(value.from, value.to));
|
|
83
|
+
if (!empty) return null;
|
|
84
|
+
const indent = " ".repeat(key.from - lineStart(text, key.from));
|
|
85
|
+
const end = value?.exact ? Math.max(value.to, key.to) : key.to;
|
|
86
|
+
return [{ from: key.to, to: end, insert: `\n${indent} - title: Slide title` }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The indentation of the first key of the block mapping at `segments` ("" for none), and where its last line ends. */
|
|
90
|
+
function blockMapping(format, text, segments) {
|
|
91
|
+
// The root of a Markdown deck is its front matter, which ends where the closing `---` line starts.
|
|
92
|
+
if (format === "md" && segments.length === 0) {
|
|
93
|
+
const matter = scanMarkdown(text).frontMatter;
|
|
94
|
+
return matter?.closed ? { column: 0, end: matter.yamlTo } : null;
|
|
95
|
+
}
|
|
96
|
+
const value = segments.length ? locateText(format, text, segments, "value") : locateText(format, text, [], "value");
|
|
97
|
+
const open = locateText(format, text, segments, "open");
|
|
98
|
+
if (!value?.exact || !open?.exact || /^[[{]/.test(text[value.from] ?? "")) return null;
|
|
99
|
+
return { column: open.from - lineStart(text, open.from), end: nextLine(text, Math.max(value.from, value.to - 1)) };
|
|
100
|
+
}
|
|
101
|
+
const nextLine = (text, offset) => { const end = lineEnd(text, offset); return end < text.length ? end + 1 : end; };
|
|
102
|
+
|
|
103
|
+
/** Move the entry at `segments` into the `extensions` mapping of its parent, creating it at the end of the parent when there is none. */
|
|
104
|
+
function moveIntoExtensions(format, text, segments) {
|
|
105
|
+
if (segments.length === 0 || (format === "md" && segments[0] === "slides")) return null;
|
|
106
|
+
const entry = locateText(format, text, segments, "entry");
|
|
107
|
+
if (!entry?.exact) return null;
|
|
108
|
+
const start = lineStart(text, entry.from);
|
|
109
|
+
if (text.slice(start, entry.from).trim() !== "") return null;
|
|
110
|
+
const end = nextLine(text, Math.max(entry.from, entry.to - 1));
|
|
111
|
+
const parent = segments.slice(0, -1);
|
|
112
|
+
const base = entry.from - start;
|
|
113
|
+
const reindent = (column) => text.slice(start, end).replace(/\n$/, "").split("\n").map((line) => `${" ".repeat(column)}${line.slice(Math.min(base, line.length - line.trimStart().length))}`).join("\n");
|
|
114
|
+
const owner = blockMapping(format, text, parent);
|
|
115
|
+
if (!owner) return null;
|
|
116
|
+
const existing = blockMapping(format, text, [...parent, "extensions"]);
|
|
117
|
+
const prefix = (at) => (at === text.length && !text.endsWith("\n") ? "\n" : "");
|
|
118
|
+
const edits = [{ from: start, to: end, insert: "" }];
|
|
119
|
+
if (existing) edits.push({ from: existing.end, to: existing.end, insert: `${prefix(existing.end)}${reindent(existing.column)}\n` });
|
|
120
|
+
else if (locateText(format, text, [...parent, "extensions"], "key")?.exact) return null;
|
|
121
|
+
else edits.push({ from: owner.end, to: owner.end, insert: `${prefix(owner.end)}${" ".repeat(owner.column)}extensions:\n${reindent(owner.column + 2)}\n` });
|
|
122
|
+
return edits;
|
|
123
|
+
}
|
|
124
|
+
|
|
61
125
|
/**
|
|
62
126
|
* The quick fixes of a schema or reference finding in a YAML or Markdown text. `context` is the one source-messages.js takes plus `{ starter(segments,
|
|
63
127
|
* property) }` (the value `Add` inserts). Returns `[]` for what the text cannot express as a plain edit.
|
|
@@ -76,12 +140,21 @@ export function textFixesForFinding(format, finding, text, context = {}) {
|
|
|
76
140
|
} else if (keyword === "additionalProperties" && !isSlideOption(format, segments)) {
|
|
77
141
|
const key = locateText(format, text, segments, "key");
|
|
78
142
|
if (key?.exact) for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, [{ from: key.from, to: key.to, insert: yamlKey(value) }]);
|
|
143
|
+
if (segments.at(-1) !== "extensions" && context.keysOf?.(segments.slice(0, -1))?.includes("extensions")) push("move-to-extensions", "Move into extensions", moveIntoExtensions(format, text, segments));
|
|
79
144
|
push("remove-property", "Remove property", removeEntry(format, text, segments));
|
|
145
|
+
} else if (keyword === "type" && finding.validation.params?.type === "array") {
|
|
146
|
+
const value = context.valueAt?.(segments);
|
|
147
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") push("wrap-in-list", "Wrap in a list", replaceValue(format, text, segments, (old) => inList(old, value)));
|
|
148
|
+
} else if ((keyword === "type" && finding.validation.params?.type === "string") || ((keyword === "oneOf" || keyword === "anyOf") && (finding.validation.alternatives ?? []).some((entry) => String(entry).startsWith("string")))) {
|
|
149
|
+
const value = context.valueAt?.(segments);
|
|
150
|
+
if (typeof value === "number" || typeof value === "boolean") fixes.push({ id: "convert-to-string", title: "Convert to string", safe: true, edits: replaceValue(format, text, segments, () => JSON.stringify(String(value))) ?? [] });
|
|
151
|
+
} else if (keyword === "minItems" && finding.path === "/slides") {
|
|
152
|
+
push("add-slide", "Add a slide", addSlide(format, text));
|
|
80
153
|
} else if (keyword === "required") {
|
|
81
154
|
const property = finding.validation.params?.missingProperty;
|
|
82
155
|
const starter = property ? context.starter?.(segments, property) : undefined;
|
|
83
156
|
// In Markdown only the front matter is a mapping this can add to; a slide's fields are written as Markdown.
|
|
84
157
|
if (property && starter !== undefined && (format === "yaml" || segments[0] !== "slides")) push(`add:${property}`, `Add \`${property}\``, addProperty(format, text, segments, property, starter));
|
|
85
158
|
}
|
|
86
|
-
return fixes;
|
|
159
|
+
return fixes.filter((fix) => fix.edits.length);
|
|
87
160
|
}
|
package/dist/source-fixes.js
CHANGED
|
@@ -72,6 +72,27 @@ export function wrapInList(text, segments) {
|
|
|
72
72
|
return [{ from: offset, to: offset, insert: "[" }, { from: offset + length, to: offset + length, insert: "]" }];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** One edit that turns `before` into `after` (the span between their common start and common end), for a change several `modify` calls make together. */
|
|
76
|
+
export function diffEdit(before, after) {
|
|
77
|
+
let start = 0;
|
|
78
|
+
while (start < before.length && start < after.length && before[start] === after[start]) start++;
|
|
79
|
+
let end = 0;
|
|
80
|
+
while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;
|
|
81
|
+
return [{ from: start, to: before.length - end, insert: after.slice(start, after.length - end) }];
|
|
82
|
+
}
|
|
83
|
+
/** Move the property whose value is at `segments` into the `extensions` object of its parent (created when there is none). */
|
|
84
|
+
export function moveIntoExtensions(text, segments, unit = " ") {
|
|
85
|
+
const found = nodeAt(text, segments);
|
|
86
|
+
if (!found || segments.length === 0) return null;
|
|
87
|
+
const parent = segments.slice(0, -1), name = segments.at(-1);
|
|
88
|
+
let value;
|
|
89
|
+
try { value = JSON.parse(text.slice(found.node.offset, found.node.offset + found.node.length)); } catch { return null; }
|
|
90
|
+
const options = { formattingOptions: formatting(unit, text) };
|
|
91
|
+
const removed = applySourceEdits(text, asEdits(modify(text, toPath(segments), undefined, options)));
|
|
92
|
+
const added = applySourceEdits(removed, asEdits(modify(removed, [...toPath(parent), "extensions", name], value, options)));
|
|
93
|
+
return added === text ? null : diffEdit(text, added);
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/**
|
|
76
97
|
* The quick fixes of a schema or reference finding in a JSON text that parses. `context` is the one source-messages.js takes plus
|
|
77
98
|
* `{ unit, starter(segments, property) }` (the value `Add` inserts).
|
|
@@ -88,14 +109,17 @@ export function fixesForFinding(finding, text, context = {}) {
|
|
|
88
109
|
} else if (keyword === "additionalProperties") {
|
|
89
110
|
const name = finding.validation.params?.additionalProperty ?? segments.at(-1);
|
|
90
111
|
for (const value of replacementValues(finding, context)) push(`rename-to:${value}`, `Rename to ${value}`, renameKey(text, segments, value));
|
|
112
|
+
if (name !== "extensions" && context.keysOf?.(segments.slice(0, -1))?.includes("extensions")) push("move-to-extensions", "Move into extensions", moveIntoExtensions(text, segments, unit));
|
|
91
113
|
push("remove-property", "Remove property", removeAt(text, segments, unit));
|
|
92
|
-
void name;
|
|
93
114
|
} else if (keyword === "required") {
|
|
94
115
|
const property = finding.validation.params?.missingProperty;
|
|
95
116
|
const starter = context.starter?.(segments, property);
|
|
96
117
|
if (property && starter !== undefined) push(`add:${property}`, `Add \`${property}\``, addProperty(text, segments, property, starter, unit));
|
|
97
118
|
} else if (keyword === "type" && finding.validation.params?.type === "array") {
|
|
98
119
|
push("wrap-in-list", "Wrap in a list", wrapInList(text, segments));
|
|
120
|
+
} else if ((keyword === "type" && finding.validation.params?.type === "string") || ((keyword === "oneOf" || keyword === "anyOf") && (finding.validation.alternatives ?? []).some((entry) => String(entry).startsWith("string")))) {
|
|
121
|
+
const value = context.valueAt?.(segments);
|
|
122
|
+
if (typeof value === "number" || typeof value === "boolean") push("convert-to-string", "Convert to string", changeValue(text, segments, String(value)), { safe: true });
|
|
99
123
|
} else if (keyword === "minItems" && finding.path === "/slides") {
|
|
100
124
|
push("add-slide", "Add a slide", addItem(text, segments, { title: "Slide title" }, unit));
|
|
101
125
|
} else if (finding.ruleId === "opf/duplicate-key") {
|
package/dist/source-markdown.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fromMarkdown } from "@openpresentation/opf/markdown";
|
|
|
10
10
|
import { yamlContextAt } from "./source-yaml.js";
|
|
11
11
|
import { snippetMarkdown, snippetsForMarkdown } from "./source-snippets.js";
|
|
12
12
|
import { fieldDoc } from "./source-docs.js";
|
|
13
|
+
import { regionNames } from "./source-references.js";
|
|
13
14
|
|
|
14
15
|
const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
15
16
|
const BARE = /^[A-Za-z0-9_-]+$/;
|
|
@@ -270,6 +271,12 @@ function bodyContext(text, position, where, explicit) {
|
|
|
270
271
|
const start = position - span[2].length;
|
|
271
272
|
return { format: "md", region: "span", token: { quoted: false, closed: false, start, end, from: start, to: end }, existing: used, document: {}, path: [], explicit, opened: span[2] === "" };
|
|
272
273
|
}
|
|
274
|
+
// `{{` in a line of text: the variables of the document.
|
|
275
|
+
const variable = /\{\{([A-Za-z0-9._-]*)$/.exec(before);
|
|
276
|
+
if (variable) {
|
|
277
|
+
const start = position - variable[1].length;
|
|
278
|
+
return { format: "md", region: "variable", token: { quoted: false, closed: false, start, end: position, from: start, to: position }, document: {}, path: [] };
|
|
279
|
+
}
|
|
273
280
|
if (!/^[A-Za-z][A-Za-z-]*$|^$/.test(before)) return null;
|
|
274
281
|
if (before === "" && !explicit) return null;
|
|
275
282
|
// Typing prose is not interrupted: a list opens by itself from the third letter on.
|
|
@@ -289,20 +296,8 @@ function bodyContext(text, position, where, explicit) {
|
|
|
289
296
|
// ---- the options -------------------------------------------------------------------------------------------------------------------
|
|
290
297
|
const kindOption = (label, type, detail, doc, boost, kind, extra = {}) => ({ label, type, detail, boost, opfDoc: doc, opf: { kind, ...extra } });
|
|
291
298
|
|
|
292
|
-
function regionNames(record) {
|
|
293
|
-
const names = [];
|
|
294
|
-
const walk = (regions) => {
|
|
295
|
-
for (const region of Array.isArray(regions) ? regions : []) {
|
|
296
|
-
if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
|
|
297
|
-
if (region?.regions) walk(region.regions);
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
walk(record?.regions);
|
|
301
|
-
return names;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
299
|
/** The completion options of a Markdown context: `{ options, validFor? }`, or null. `makers` are the option builders of the completion engine. */
|
|
305
|
-
export function markdownOptions(context, { catalogs, text, explicit, makers }) {
|
|
300
|
+
export function markdownOptions(context, { catalogs, text, explicit, makers, scope }) {
|
|
306
301
|
const { literalOption, boostAt, docNode } = makers;
|
|
307
302
|
const options = [];
|
|
308
303
|
if (context.region === "comment-start") {
|
|
@@ -310,7 +305,7 @@ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
|
|
|
310
305
|
options.push(kindOption("block:", "keyword", "options", "Options of the next block: id, type, as, region.", boostAt(1), "comment", { word: "block:" }));
|
|
311
306
|
return { options, validFor: /^[a-z]*$/ };
|
|
312
307
|
}
|
|
313
|
-
if (context.region === "options") return optionsOptions(context, { catalogs, makers });
|
|
308
|
+
if (context.region === "options") return optionsOptions(context, { catalogs, makers, scope });
|
|
314
309
|
if (context.region === "fence-info") {
|
|
315
310
|
if (context.mode === "kind") {
|
|
316
311
|
let order = 0;
|
|
@@ -368,7 +363,7 @@ export function markdownOptions(context, { catalogs, text, explicit, makers }) {
|
|
|
368
363
|
return null;
|
|
369
364
|
}
|
|
370
365
|
|
|
371
|
-
function optionsOptions(context, { catalogs, makers }) {
|
|
366
|
+
function optionsOptions(context, { catalogs, makers, scope }) {
|
|
372
367
|
const { literalOption, boostAt, docNode } = makers;
|
|
373
368
|
const options = [];
|
|
374
369
|
if (context.mode === "key") {
|
|
@@ -399,7 +394,7 @@ function optionsOptions(context, { catalogs, makers }) {
|
|
|
399
394
|
}
|
|
400
395
|
if (!SLIDE_OPTION_KEYS.includes(key)) return null;
|
|
401
396
|
const pseudo = { format: "md", mode: "value", path: ["slides", context.slide, key], document: context.document, key, token: context.token };
|
|
402
|
-
const built = makers.valueOptions(pseudo, catalogs);
|
|
397
|
+
const built = makers.valueOptions(pseudo, catalogs, scope);
|
|
403
398
|
options.push(...built.options);
|
|
404
399
|
return { options, validFor: /^[A-Za-z0-9_:+-]*$/ };
|
|
405
400
|
}
|