@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.
@@ -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
  }
@@ -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") {
@@ -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
  }