@openpresentation/opf-editor 0.26.3 → 0.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/json-editor.d.ts +7 -2
- package/dist/json-editor.js +51 -24
- package/dist/source-completions.js +192 -26
- package/dist/source-editing.js +265 -0
- package/dist/source-fixes-text.js +87 -0
- package/dist/source-lines.js +4 -0
- package/dist/source-markdown.js +551 -0
- package/dist/source-pane.d.ts +10 -2
- package/dist/source-pane.js +78 -7
- package/dist/source-problems.js +6 -4
- package/dist/source-ranges.js +8 -5
- package/dist/source-snippets.js +139 -0
- package/dist/source-yaml.js +538 -0
- package/dist/styles.css +23 -0
- package/package.json +9 -7
|
@@ -2,16 +2,39 @@
|
|
|
2
2
|
// ranked, and what accepting an item writes. Keys come from the schema (`describeField` of core), values from the schema's enums, booleans and the
|
|
3
3
|
// catalogs, templates for objects and lists, snippets for slides and the like. Loaded lazily by the code editor with the schema and the catalogs.
|
|
4
4
|
import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, startCompletion } from "@codemirror/autocomplete";
|
|
5
|
+
import { isolateHistory } from "@codemirror/commands";
|
|
5
6
|
import { EditorSelection } from "@codemirror/state";
|
|
6
7
|
import { describeField } from "@openpresentation/opf/schema";
|
|
7
8
|
import { listCatalogRecords } from "./catalogs.js";
|
|
8
9
|
import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
|
|
10
|
+
import { Document, parse as parseYamlDocument } from "yaml";
|
|
11
|
+
import { pathAt } from "@openpresentation/opf/source";
|
|
9
12
|
import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
|
|
13
|
+
import { scalarValue, yamlContextAt } from "./source-yaml.js";
|
|
14
|
+
import { markdownContextAt, markdownOptions, markdownPlan, markdownHoverAt } from "./source-markdown.js";
|
|
10
15
|
import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
|
|
11
|
-
import { snippetJson, snippetsFor } from "./source-snippets.js";
|
|
16
|
+
import { snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
|
|
12
17
|
|
|
13
18
|
export { contextAt, tokenAt };
|
|
14
19
|
|
|
20
|
+
/** The context of the caret in a text of `format` ("json", "yaml" or "md"), or null: what the list offers, and the token it replaces. */
|
|
21
|
+
export function contextFor(format, text, position, explicit) {
|
|
22
|
+
if (format === "yaml") {
|
|
23
|
+
const context = yamlContextAt(text, position, { explicit });
|
|
24
|
+
// A blank document starts with a deck.
|
|
25
|
+
return context && context.mode === "key" && context.path.length === 0 && text.trim() === "" ? { ...context, blank: true } : context;
|
|
26
|
+
}
|
|
27
|
+
if (format === "md") return markdownContextAt(text, position, { explicit });
|
|
28
|
+
return contextAt(text, position, { explicit });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The token being typed at `position` in a text of `format`, or null. */
|
|
32
|
+
export function tokenFor(format, text, position) {
|
|
33
|
+
if (format === "json") return tokenAt(text, position, false);
|
|
34
|
+
const context = contextFor(format, text, position, false);
|
|
35
|
+
return context?.token ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
// A quoted token keeps its list for any text between the quotes; a bare word for word characters only.
|
|
16
39
|
const QUOTED_TEXT = /^[^"\\\n\r]*$/;
|
|
17
40
|
const WORD_TEXT = /^[A-Za-z0-9_.-]*$/;
|
|
@@ -99,17 +122,21 @@ function keyOptions(context, text, caret, explicit) {
|
|
|
99
122
|
const free = info.children.filter((child) => !context.existing.has(child.key));
|
|
100
123
|
const withAdvanced = advancedShown(free, typed, explicit);
|
|
101
124
|
const shown = free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed)));
|
|
125
|
+
// A blank document starts with a deck: its snippet comes first.
|
|
126
|
+
if (context.blank) for (const entry of snippetsFor("value", [])) options.push(snippetOption(entry, 99));
|
|
102
127
|
shown.forEach((child, index) => options.push({
|
|
103
128
|
label: child.key,
|
|
104
129
|
type: "property",
|
|
105
130
|
detail: child.required ? `${child.typeLabel} · required` : child.typeLabel,
|
|
106
|
-
boost: boostAt(index),
|
|
131
|
+
boost: boostAt(index + (context.blank ? 1 : 0)),
|
|
107
132
|
opfRequired: child.required,
|
|
108
133
|
opfDoc: child.description ?? "",
|
|
109
134
|
opf: { kind: "key", key: child.key },
|
|
110
135
|
info: () => docNode(describeChild(context, child)),
|
|
111
136
|
}));
|
|
112
137
|
for (const entry of snippetsFor("key", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
138
|
+
// A key right after a list dash is also the start of the item: the item snippets (a slide, a reference) fit there.
|
|
139
|
+
if (context.itemStart) for (const entry of snippetsFor("item", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
113
140
|
return { options, advanced: withAdvanced };
|
|
114
141
|
}
|
|
115
142
|
return { options, advanced: false };
|
|
@@ -130,7 +157,7 @@ function snippetOption(entry, boost) {
|
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
function literalOption(label, literal, extra) {
|
|
133
|
-
return { label, type: extra.type ?? "enum", boost: extra.boost, detail: extra.detail, opfDoc: extra.doc ?? "", opf: { kind: "literal", literal }, info: extra.info };
|
|
160
|
+
return { label, type: extra.type ?? "enum", boost: extra.boost, detail: extra.detail, opfDoc: extra.doc ?? "", opf: { kind: "literal", literal, value: extra.value }, info: extra.info };
|
|
134
161
|
}
|
|
135
162
|
|
|
136
163
|
function catalogOptions(kind, context, catalogs) {
|
|
@@ -150,7 +177,7 @@ function catalogOptions(kind, context, catalogs) {
|
|
|
150
177
|
const notes = [regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean);
|
|
151
178
|
const model = { title: record.id, type: KIND_LABELS[kind], description: record.description ?? undefined, notes: [record.label !== record.id ? record.label : "", ...notes].filter(Boolean) };
|
|
152
179
|
return literalOption(record.id, JSON.stringify(record.id), {
|
|
153
|
-
type: "catalog", boost: intent || record.origin === "document" ? boostAt(index) : 0, detail: record.label !== record.id ? record.label : SOURCE_LABELS[record.origin === "document" ? "document" : "registered"],
|
|
180
|
+
value: record.id, type: "catalog", boost: intent || record.origin === "document" ? boostAt(index) : 0, detail: record.label !== record.id ? record.label : SOURCE_LABELS[record.origin === "document" ? "document" : "registered"],
|
|
154
181
|
doc: [brief(record.description), regions].filter(Boolean).join(" "), info: () => docNode(model),
|
|
155
182
|
});
|
|
156
183
|
});
|
|
@@ -165,11 +192,11 @@ function valueOptions(context, catalogs) {
|
|
|
165
192
|
else if (info.enum?.length) {
|
|
166
193
|
// In the order VS Code lists them: by label.
|
|
167
194
|
info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
|
|
168
|
-
doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "value", description: entry.description }) : undefined,
|
|
195
|
+
value: entry.value, doc: entry.description ?? "", info: entry.description ? () => docNode({ title: String(entry.value), type: "value", description: entry.description }) : undefined,
|
|
169
196
|
})));
|
|
170
197
|
} else if (info.types.includes("boolean")) {
|
|
171
|
-
for (const value of ["true", "false"]) options.push(literalOption(value, value, { type: "boolean" }));
|
|
172
|
-
} else if (variant && !info.freeText && (variant.type === "object" || variant.type === "array")) {
|
|
198
|
+
for (const value of ["true", "false"]) options.push(literalOption(value, value, { type: "boolean", value: value === "true" }));
|
|
199
|
+
} else if (variant && !info.freeText && (variant.type === "object" || variant.type === "array") && context.format !== "yaml") {
|
|
173
200
|
// An object or a list goes in as a template: `{}` with its required members, or `[]`.
|
|
174
201
|
const label = variant.type === "object" ? "{}" : "[]";
|
|
175
202
|
options.push({ label, type: "keyword", detail: variant.type === "object" ? "object" : "list", boost: 99, opfDoc: info.description ?? "", opf: { kind: "template" }, info: () => docNode(fieldDoc(info, context.key ?? label)) });
|
|
@@ -178,17 +205,49 @@ function valueOptions(context, catalogs) {
|
|
|
178
205
|
return { options };
|
|
179
206
|
}
|
|
180
207
|
|
|
208
|
+
// A word of a key, a value or an id: what the list stays valid for while the author types within the same token.
|
|
209
|
+
const YAML_KEY_TEXT = /^[A-Za-z0-9_.+-]*$/;
|
|
210
|
+
const YAML_VALUE_TEXT = /^[A-Za-z0-9_.:+-]*$/;
|
|
211
|
+
|
|
212
|
+
// In a YAML list the dash may start a mapping (a slide) or hold a scalar (an id, a line of text): the schema of the item tells.
|
|
213
|
+
function settleYamlContext(context, text) {
|
|
214
|
+
if (context.mode === "key" && context.seq) {
|
|
215
|
+
let info;
|
|
216
|
+
try { info = describeField(context.path, context.document); } catch { info = null; }
|
|
217
|
+
const primary = info?.known ? info.variants[info.primary] : undefined;
|
|
218
|
+
if (!info?.known || !primary) return null;
|
|
219
|
+
if (primary.type !== "object") {
|
|
220
|
+
// The item is a scalar, not the mapping the line reader pictured: the schema reads the document with an empty string there.
|
|
221
|
+
let parent = context.document;
|
|
222
|
+
for (const step of context.path.slice(0, -1)) parent = parent?.[step];
|
|
223
|
+
if (parent && typeof parent === "object") parent[context.path.at(-1)] = "";
|
|
224
|
+
return { ...context, mode: "item", key: undefined, itemStart: false };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return context;
|
|
228
|
+
}
|
|
229
|
+
|
|
181
230
|
/**
|
|
182
|
-
* The completion result at `position` for CodeMirror, or null. `catalogs` are the host's.
|
|
231
|
+
* The completion result at `position` for CodeMirror, or null. `catalogs` are the host's; `format` is "json" (the default), "yaml" or "md".
|
|
183
232
|
*/
|
|
184
|
-
export function completionResult(state, position, { explicit, catalogs }) {
|
|
233
|
+
export function completionResult(state, position, { explicit, catalogs, format = "json" }) {
|
|
185
234
|
const text = state.doc.toString();
|
|
186
|
-
|
|
235
|
+
let context = contextFor(format, text, position, explicit);
|
|
187
236
|
if (!context) return null;
|
|
237
|
+
if (context.format === "md") return markdownResult(context, text, position, { explicit, catalogs });
|
|
238
|
+
if (context.format === "yaml") {
|
|
239
|
+
context = settleYamlContext(context, text);
|
|
240
|
+
if (!context) return null;
|
|
241
|
+
}
|
|
188
242
|
const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
|
|
189
243
|
if (!options.length) return null;
|
|
244
|
+
return listResult(context, options, advanced, { position, explicit });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function listResult(context, options, advanced, { position, explicit }) {
|
|
190
248
|
const { token } = context;
|
|
191
249
|
const quoted = token.quoted;
|
|
250
|
+
const plainText = context.format !== "yaml" ? WORD_TEXT : context.mode === "value" || context.mode === "item" ? YAML_VALUE_TEXT : YAML_KEY_TEXT;
|
|
192
251
|
// The result stays valid while the author types within the same token and the set of keys it shows does not change (advanced keys come and go
|
|
193
252
|
// with the prefix).
|
|
194
253
|
const sameSet = context.mode !== "key" ? () => true : (typed) => advancedShown(options.length ? infoChildren(context) : [], typed.toLowerCase(), explicit) === Boolean(advanced);
|
|
@@ -198,7 +257,20 @@ export function completionResult(state, position, { explicit, catalogs }) {
|
|
|
198
257
|
to: Math.max(token.from, Math.min(position, token.to)),
|
|
199
258
|
filter: true,
|
|
200
259
|
options,
|
|
201
|
-
validFor: (typed) => (quoted ? QUOTED_TEXT :
|
|
260
|
+
validFor: (typed) => (quoted ? QUOTED_TEXT : plainText).test(typed) && sameSet(typed),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function markdownResult(context, text, position, { explicit, catalogs }) {
|
|
265
|
+
const built = markdownOptions(context, { catalogs, explicit, text, position, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
|
|
266
|
+
if (!built?.options.length) return null;
|
|
267
|
+
const { token } = context;
|
|
268
|
+
return {
|
|
269
|
+
from: token.from,
|
|
270
|
+
to: Math.max(token.from, Math.min(position, token.to)),
|
|
271
|
+
filter: true,
|
|
272
|
+
options: built.options,
|
|
273
|
+
validFor: (typed) => (token.quoted ? QUOTED_TEXT : built.validFor ?? YAML_VALUE_TEXT).test(typed),
|
|
202
274
|
};
|
|
203
275
|
}
|
|
204
276
|
function infoChildren(context) {
|
|
@@ -208,8 +280,64 @@ function infoChildren(context) {
|
|
|
208
280
|
// ---- accepting -------------------------------------------------------------------------------------------------------------------------
|
|
209
281
|
const snippetRuns = new WeakMap();
|
|
210
282
|
|
|
211
|
-
|
|
283
|
+
// A YAML value as text: a plain scalar when it reads back as the same value, else quoted (the `yaml` library knows the cases: `true`, `1`, `a: b`).
|
|
284
|
+
function yamlScalar(value) {
|
|
285
|
+
if (typeof value !== "string") return String(value);
|
|
286
|
+
try { return new Document(value).toString({ lineWidth: 0 }).trimEnd() || '""'; } catch { return JSON.stringify(value); }
|
|
287
|
+
}
|
|
288
|
+
const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/.test(key) ? key : yamlScalar(key));
|
|
289
|
+
const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
|
|
290
|
+
|
|
291
|
+
/** What accepting a key writes in YAML: `key: ` for a scalar, `key:` and a new line for a mapping or a list. */
|
|
292
|
+
function yamlKeyPlan(context, key, text, end) {
|
|
293
|
+
const written = yamlKey(key);
|
|
294
|
+
if (/^[ \t]*:/.test(text.slice(end))) return { template: escapeTemplate(written), fields: 0, chain: false };
|
|
295
|
+
let info;
|
|
296
|
+
try { info = describeField([...context.path, key], context.document); } catch { info = null; }
|
|
297
|
+
const kind = info?.known ? variantKind(info) : "string";
|
|
298
|
+
if (context.flow || kind === "choice" || kind === "boolean" || !["array", "object"].includes(kind)) {
|
|
299
|
+
return { template: `${escapeTemplate(written)}: `, fields: 0, chain: kind === "choice" || kind === "boolean" };
|
|
300
|
+
}
|
|
301
|
+
// A mapping or a list goes on the next line, one level in; after a dash the line's content starts two columns in, so the new lines do too.
|
|
302
|
+
const dash = " ".repeat(Math.max(0, context.column - context.indent));
|
|
303
|
+
const unit = " ";
|
|
304
|
+
const lead = `\n${dash}${unit}`;
|
|
305
|
+
if (kind === "array") return { template: `${escapeTemplate(written)}:${lead}- \${0}`, fields: 1, chain: false };
|
|
306
|
+
const variant = primaryVariant(info);
|
|
307
|
+
const required = variant?.requiredChildren?.length ? variant.requiredChildren : info.requiredChildren;
|
|
308
|
+
if (!required.length) return { template: `${escapeTemplate(written)}:${lead}\${0}`, fields: 1, chain: false };
|
|
309
|
+
const lines = required.map((name, index) => `${yamlKey(name)}: \${${index + 1}}`);
|
|
310
|
+
return { template: `${escapeTemplate(written)}:${lead}${lines.join(lead)}\${0}`, fields: required.length + 1, chain: false };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** What accepting `completion` writes in YAML: `{ start, end, template, fields, chain, choices }`. */
|
|
314
|
+
function planYaml(context, completion, text, end) {
|
|
315
|
+
const kind = completion.opf?.kind;
|
|
316
|
+
if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end);
|
|
317
|
+
if (kind === "literal") {
|
|
318
|
+
const value = completion.opf.value;
|
|
319
|
+
const prefix = context.needsSpace ? " " : "";
|
|
320
|
+
if (context.token.quoted) {
|
|
321
|
+
const inner = typeof value === "string" ? value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : String(value);
|
|
322
|
+
return { template: escapeTemplate(inner), fields: 0, chain: false, inner: true };
|
|
323
|
+
}
|
|
324
|
+
return { template: `${prefix}${escapeTemplate(yamlScalar(value))}`, fields: 0, chain: false };
|
|
325
|
+
}
|
|
326
|
+
if (kind === "snippet") {
|
|
327
|
+
const mode = context.itemStart ? "item" : context.blank ? "value" : context.mode;
|
|
328
|
+
const entry = snippetsFor(mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
|
|
329
|
+
if (!entry) return null;
|
|
330
|
+
const dash = " ".repeat(Math.max(0, context.column - context.indent));
|
|
331
|
+
const rendered = snippetYaml(entry, { dash: context.seq ? dash.length : 0, blank: context.blank });
|
|
332
|
+
return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What accepting `completion` writes: `{ template, fields, chain, choices }` for the context of the caret. */
|
|
212
338
|
export function planCompletion(context, completion, text, end) {
|
|
339
|
+
if (context.format === "yaml") return planYaml(context, completion, text, end);
|
|
340
|
+
if (context.format === "md") return markdownPlan(context, completion, text, end);
|
|
213
341
|
const next = nextSignificant(text, end);
|
|
214
342
|
const needsComma = next !== "" && !",}]".includes(next);
|
|
215
343
|
const kind = completion.opf?.kind;
|
|
@@ -242,11 +370,11 @@ export function planCompletion(context, completion, text, end) {
|
|
|
242
370
|
}
|
|
243
371
|
|
|
244
372
|
/** Accept `completion`: write its text over the token under the caret (quotes included) and, if it has tab stops, start the snippet. */
|
|
245
|
-
export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say) {
|
|
373
|
+
export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say, format = "json") {
|
|
246
374
|
const { state } = view;
|
|
247
375
|
const text = state.doc.toString();
|
|
248
376
|
const main = state.selection.main;
|
|
249
|
-
const context =
|
|
377
|
+
const context = contextFor(format, text, main.head, true);
|
|
250
378
|
const token = context?.token;
|
|
251
379
|
const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
|
|
252
380
|
const plan = context ? planCompletion(context, completion, text, end) : null;
|
|
@@ -254,10 +382,10 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say)
|
|
|
254
382
|
view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
|
|
255
383
|
return;
|
|
256
384
|
}
|
|
257
|
-
if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan); return; }
|
|
385
|
+
if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan, format); return; }
|
|
258
386
|
// A string between quotes that are already there keeps them: only what is inside is replaced. In the stop of a snippet the caret stays inside
|
|
259
387
|
// the quotes, so Tab goes on to the next stop; elsewhere it ends after the closing quote.
|
|
260
|
-
if (completion.opf?.kind === "literal" && token?.quoted && token.closed && completion.opf.literal.startsWith('"')) {
|
|
388
|
+
if (format === "json" && completion.opf?.kind === "literal" && token?.quoted && token.closed && completion.opf.literal.startsWith('"')) {
|
|
261
389
|
const inner = completion.opf.literal.slice(1, -1);
|
|
262
390
|
const inSnippet = snippetRuns.has(view) && (hasNextSnippetField(state) || hasPrevSnippetField(state));
|
|
263
391
|
const next = nextSignificant(text, token.end);
|
|
@@ -267,29 +395,39 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say)
|
|
|
267
395
|
view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
268
396
|
return;
|
|
269
397
|
}
|
|
398
|
+
// YAML and Markdown values inside quotes: only the inside changes, and the caret ends after the closing quote.
|
|
399
|
+
if (plan.inner && token) {
|
|
400
|
+
const insert = plan.template.replace(/\\([{}\\])/g, "$1");
|
|
401
|
+
const caret = token.closed ? token.end + insert.length - (token.to - token.from) : token.from + insert.length;
|
|
402
|
+
view.dispatch({ changes: { from: token.from, to: token.to, insert }, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const from = plan.replaceFrom ?? start, to = plan.replaceTo ?? end;
|
|
270
406
|
if (plan.fields > 0 || plan.template.includes("${")) {
|
|
271
|
-
snippet(plan.template)(view, completion,
|
|
407
|
+
snippet(plan.template)(view, completion, from, to);
|
|
272
408
|
if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
|
|
273
409
|
else snippetRuns.delete(view);
|
|
274
410
|
if (plan.choices) snippetStep(view, true, { start: true, say });
|
|
275
411
|
} else {
|
|
276
412
|
const insert = plan.template.replace(/\\([{}\\])/g, "$1");
|
|
277
|
-
view.dispatch({ changes: { from
|
|
413
|
+
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
278
414
|
snippetRuns.delete(view);
|
|
279
415
|
}
|
|
416
|
+
// Accepting is one undo step: what is typed next starts another.
|
|
417
|
+
view.dispatch({ annotations: isolateHistory.of("full") });
|
|
280
418
|
// A key whose value is one of a closed set opens that list at once.
|
|
281
419
|
if (plan.chain) startCompletion(view);
|
|
282
420
|
}
|
|
283
421
|
|
|
284
422
|
// With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
|
|
285
|
-
function applyAtEveryCursor(view, completion, plan) {
|
|
423
|
+
function applyAtEveryCursor(view, completion, plan, format) {
|
|
286
424
|
const { state } = view;
|
|
287
425
|
const text = state.doc.toString();
|
|
288
426
|
const plain = plainOf(plan.template);
|
|
289
427
|
const changes = [], carets = [];
|
|
290
428
|
let shift = 0;
|
|
291
429
|
for (const range of [...state.selection.ranges].sort((a, b) => a.from - b.from)) {
|
|
292
|
-
const context =
|
|
430
|
+
const context = contextFor(format, text, range.head, true);
|
|
293
431
|
const token = context?.token ?? { start: range.from, end: range.to };
|
|
294
432
|
changes.push({ from: token.start, to: token.end, insert: plain.text });
|
|
295
433
|
carets.push(token.start + shift + plain.first);
|
|
@@ -327,21 +465,49 @@ export const describeOption = (completion) => completion.opfDoc ?? "";
|
|
|
327
465
|
export { docText };
|
|
328
466
|
|
|
329
467
|
// ---- hover -----------------------------------------------------------------------------------------------------------------------------
|
|
468
|
+
// The key or value under a position of a YAML text, from core's `pathAt` (which reads text with errors as far as it can): the same shape
|
|
469
|
+
// as source-json.js's `nodeAt`, with the whole document read for the schema (hover waits 300 ms, so the cost does not matter).
|
|
470
|
+
function yamlNodeAt(text, position) {
|
|
471
|
+
const found = pathAt(text, "yaml", position);
|
|
472
|
+
if (!found?.range || (found.part !== "key" && found.part !== "value")) return null;
|
|
473
|
+
const { offset, length } = found.range;
|
|
474
|
+
const source = text.slice(offset, offset + length);
|
|
475
|
+
const base = { path: found.path, from: offset, to: offset + length };
|
|
476
|
+
let document = {};
|
|
477
|
+
try { document = parseYamlDocument(text) ?? {}; } catch { document = {}; }
|
|
478
|
+
if (found.part === "key") return { ...base, kind: "key", value: source, document };
|
|
479
|
+
const collection = /^[[{]/.test(source);
|
|
480
|
+
return { ...base, kind: "value", value: collection ? undefined : scalarValue(source), nodeType: collection ? (source[0] === "[" ? "array" : "object") : "scalar", document };
|
|
481
|
+
}
|
|
482
|
+
|
|
330
483
|
/**
|
|
331
484
|
* What the schema and the catalogs say about the key or the value under `position`: `{ from, to, model }` (offsets of the token; `model` is a
|
|
332
|
-
* source-docs.js card), or null over free text, punctuation and whitespace.
|
|
485
|
+
* source-docs.js card), or null over free text, punctuation and whitespace. `format` is "json", "yaml" or "md".
|
|
333
486
|
*/
|
|
334
|
-
export function hoverModelAt(state, position, { catalogs }) {
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
487
|
+
export function hoverModelAt(state, position, { catalogs, format = "json" }) {
|
|
488
|
+
const text = state.doc.toString();
|
|
489
|
+
if (format === "md") return markdownHoverAt(text, position, { catalogs, describeFound });
|
|
490
|
+
let found, document;
|
|
491
|
+
if (format === "yaml") {
|
|
492
|
+
found = yamlNodeAt(text, position);
|
|
493
|
+
document = found?.document;
|
|
494
|
+
} else {
|
|
495
|
+
found = nodeAt(text, position);
|
|
496
|
+
document = found ? skeleton(found.root, found.path, false) : undefined;
|
|
497
|
+
}
|
|
498
|
+
return found ? describeFound(found, document, catalogs) : null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The card for a found node `{ kind, path, from, to, value, nodeType }` of a document (shared by the three formats). */
|
|
502
|
+
export function describeFound(found, document, catalogs) {
|
|
337
503
|
let info;
|
|
338
|
-
try { info = describeField(found.path,
|
|
504
|
+
try { info = describeField(found.path, document); } catch { return null; }
|
|
339
505
|
if (!info.known) return null;
|
|
340
506
|
const name = String(found.path.at(-1));
|
|
341
507
|
if (found.kind === "key") return { from: found.from, to: found.to, model: fieldDoc(info, name) };
|
|
342
508
|
if (typeof found.value === "string" && info.catalogKind) {
|
|
343
509
|
let record;
|
|
344
|
-
try { record = listCatalogRecords(
|
|
510
|
+
try { record = listCatalogRecords(document, info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
|
|
345
511
|
if (!record) return { from: found.from, to: found.to, model: { title: found.value, type: `${KIND_LABELS[info.catalogKind]} id`, description: `No ${KIND_LABELS[info.catalogKind]} named ${found.value} is available here.` } };
|
|
346
512
|
const regions = info.catalogKind === "layouts" ? layoutRegionSummary(record.record) : "";
|
|
347
513
|
return { from: found.from, to: found.to, model: { title: record.id, type: KIND_LABELS[info.catalogKind], description: record.description, notes: [record.label !== record.id ? record.label : "", regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean) } };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Editing rules of the YAML and Markdown source (artifacts/source-editor/SPEC.md, sections 6.1 to 6.6): Enter indents the way the text is built (one
|
|
2
|
+
// level in after `key:`, aligned under the key after `- key: value`, a list item continued, an empty one ended), Tab nests a Markdown list item, Backspace
|
|
3
|
+
// takes a list marker away, Cmd/Ctrl+/ comments a line, and the characters that pair or wrap a selection. Commands over a CodeMirror view; no network.
|
|
4
|
+
// They read the text line by line, so they need no parser and work on half-typed text; the main bundle carries them (a few kilobytes).
|
|
5
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
6
|
+
import { EditorView } from "@codemirror/view";
|
|
7
|
+
import { foldService } from "@codemirror/language";
|
|
8
|
+
import { indentMore } from "@codemirror/commands";
|
|
9
|
+
import { flowDepth, readEntry, readMarkers } from "./source-yaml.js";
|
|
10
|
+
|
|
11
|
+
const UNIT = " ";
|
|
12
|
+
const BLOCK_HEADER = /^[|>][+-]?\d*[+-]?\s*(?:#.*)?$/;
|
|
13
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])([ \t]+)(.*)$/;
|
|
14
|
+
const LIST_EMPTY = /^(\s*)([-*+]|\d{1,9}[.)])[ \t]*$/;
|
|
15
|
+
const QUOTE = /^(\s{0,3})>([ \t]?)(.*)$/;
|
|
16
|
+
|
|
17
|
+
// ---- language data -----------------------------------------------------------------------------------------------------------------
|
|
18
|
+
/** Pairs per language (SPEC 6.3): YAML pairs quotes and flow brackets, Markdown pairs `(`, `[`, `{` and backticks (not `"` or `<`), JSON keeps its own. */
|
|
19
|
+
export function languageDataFor(name) {
|
|
20
|
+
if (name === "yaml") return EditorState.languageData.of(() => [{ closeBrackets: { brackets: ["[", "{", '"', "'"] } }]);
|
|
21
|
+
if (name === "markdown") return EditorState.languageData.of(() => [{ closeBrackets: { brackets: ["(", "[", "{", "`"] }, commentTokens: { block: { open: "<!--", close: "-->" } } }]);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---- what a line of Markdown is in -------------------------------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Where line `number` (1-based) of a Markdown text is: "front" (inside the front matter), "fence" (inside a code fence), "yaml-fence" (inside an
|
|
28
|
+
* `opf-slide` or `opf-block` fence) or "body". Scans from the top, which is cheap next to a key press.
|
|
29
|
+
*/
|
|
30
|
+
export function markdownLineKind(doc, number) {
|
|
31
|
+
let front = false;
|
|
32
|
+
let fence = null;
|
|
33
|
+
for (let index = 1; index < number; index++) {
|
|
34
|
+
const text = doc.line(index).text;
|
|
35
|
+
if (index === 1 && /^---\s*$/.test(text)) { front = true; continue; }
|
|
36
|
+
if (front) { if (/^(---|\.\.\.)\s*$/.test(text)) front = false; continue; }
|
|
37
|
+
if (fence) {
|
|
38
|
+
if (new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(text)) fence = null;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(text);
|
|
42
|
+
if (open && !(open[1][0] === "`" && open[2].includes("`"))) fence = { char: open[1][0], length: open[1].length, yaml: /^\s*opf-(?:slide|block)\b/.test(open[2]) };
|
|
43
|
+
}
|
|
44
|
+
const here = doc.line(number).text;
|
|
45
|
+
if (front) return /^(---|\.\.\.)\s*$/.test(here) ? "body" : "front";
|
|
46
|
+
if (fence) return new RegExp(`^ {0,3}${fence.char}{${fence.length},}\\s*$`).test(here) ? "body" : fence.yaml ? "yaml-fence" : "fence";
|
|
47
|
+
return "body";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---- Enter -------------------------------------------------------------------------------------------------------------------------
|
|
51
|
+
/** The indentation (columns) of the new line after `before`, the text of the line up to the caret, in YAML; `"leave"` for an empty list item. */
|
|
52
|
+
export function yamlNewlineIndent(before) {
|
|
53
|
+
const parsed = readMarkers(before);
|
|
54
|
+
if (parsed.markers.length && parsed.rest.trim() === "") return "leave";
|
|
55
|
+
const entry = readEntry(parsed.rest);
|
|
56
|
+
const content = parsed.markers.length ? parsed.restStart : parsed.indent;
|
|
57
|
+
if (!entry) return content;
|
|
58
|
+
const value = parsed.rest.slice(entry.valueStart).replace(/\s+#.*$/, "").trim();
|
|
59
|
+
if (value === "" || BLOCK_HEADER.test(value)) return parsed.restStart + entry.keyStart + UNIT.length;
|
|
60
|
+
// An unclosed flow collection continues one level in.
|
|
61
|
+
let depth = 0;
|
|
62
|
+
for (const character of value) depth += character === "[" || character === "{" ? 1 : character === "]" || character === "}" ? -1 : 0;
|
|
63
|
+
return depth > 0 ? parsed.restStart + entry.keyStart + UNIT.length : parsed.markers.length || entry.keyStart > 0 ? parsed.restStart + entry.keyStart : content;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Replace each selection range with a line break and what `decide` says goes after it: `{ indent }` columns, `{ insert }` text, or `{ clear, text }` to
|
|
67
|
+
// replace the whole line (an empty list item loses its marker) instead of breaking it.
|
|
68
|
+
function newline(view, decide) {
|
|
69
|
+
const { state } = view;
|
|
70
|
+
if (state.readOnly) return false;
|
|
71
|
+
const update = state.changeByRange((range) => {
|
|
72
|
+
const line = state.doc.lineAt(range.from);
|
|
73
|
+
const result = decide(line.text.slice(0, range.from - line.from), line.text, line);
|
|
74
|
+
if (result.clear) return { changes: { from: line.from, to: Math.max(range.to, line.to), insert: result.text }, range: EditorSelection.cursor(line.from + result.text.length) };
|
|
75
|
+
const insert = `\n${result.insert ?? " ".repeat(result.indent ?? 0)}`;
|
|
76
|
+
return { changes: { from: range.from, to: range.to, insert }, range: EditorSelection.cursor(range.from + insert.length) };
|
|
77
|
+
});
|
|
78
|
+
view.dispatch(state.update(update, { scrollIntoView: true, userEvent: "input" }));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The YAML rules for the line before the caret: `{ indent }` of the new line, or `{ clear, text }` for an empty list item.
|
|
83
|
+
function yamlDecision(before, lists = true) {
|
|
84
|
+
// A line with only spaces before the caret keeps them: the new line gets the same indentation.
|
|
85
|
+
if (before.trim() === "") return { indent: before.length };
|
|
86
|
+
const indent = yamlNewlineIndent(before);
|
|
87
|
+
if (indent === "leave") return { clear: true, text: " ".repeat(Math.max(0, readMarkers(before).markers[0] - UNIT.length)) };
|
|
88
|
+
// After a list item that is a value (`- board`) the list goes on with the next dash; after `- key: value` the new line aligns under the key.
|
|
89
|
+
const parsed = readMarkers(before);
|
|
90
|
+
if (!lists && parsed.markers.length && !readEntry(parsed.rest)) return { indent: parsed.indent };
|
|
91
|
+
if (lists && parsed.markers.length && !readEntry(parsed.rest)) {
|
|
92
|
+
const value = parsed.rest.replace(/\s+#.*$/, "").trim();
|
|
93
|
+
if (value !== "" && !BLOCK_HEADER.test(value) && !/^[&*!]/.test(value) && flowDepth(value) === 0) return { insert: `${" ".repeat(parsed.markers.at(-1))}- ` };
|
|
94
|
+
}
|
|
95
|
+
return { indent };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Enter in YAML (SPEC 6.2). */
|
|
99
|
+
export function yamlEnter(view, { lists = true } = {}) {
|
|
100
|
+
return newline(view, (before) => yamlDecision(before, lists));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Enter in Markdown (SPEC 6.2): lists and quotes continue, an empty marker ends them, a fence keeps its indentation, front matter follows YAML.
|
|
105
|
+
* `lists: false` (the `smartListEnter` option) keeps only the indentation.
|
|
106
|
+
*/
|
|
107
|
+
export function markdownEnter(view, { lists = true } = {}) {
|
|
108
|
+
const { state } = view;
|
|
109
|
+
return newline(view, (before, full, line) => {
|
|
110
|
+
const kind = markdownLineKind(state.doc, line.number);
|
|
111
|
+
if (kind === "front" || kind === "yaml-fence") return yamlDecision(before, lists);
|
|
112
|
+
const lead = /^\s*/.exec(before)[0];
|
|
113
|
+
if (kind === "fence" || !lists) return { indent: lead.length };
|
|
114
|
+
const atEnd = before.length === full.length;
|
|
115
|
+
// An empty item ends the list: a nested one moves out one level, a top-level one becomes an empty line.
|
|
116
|
+
const empty = LIST_EMPTY.exec(before);
|
|
117
|
+
if (empty && atEnd) return { clear: true, text: empty[1].length >= UNIT.length ? `${" ".repeat(empty[1].length - UNIT.length)}${empty[2]} ` : "" };
|
|
118
|
+
const item = LIST_ITEM.exec(before);
|
|
119
|
+
// Inside the marker itself the line just breaks.
|
|
120
|
+
if (item && before.length >= item[1].length + item[2].length + item[3].length) {
|
|
121
|
+
const marker = /^\d/.test(item[2]) ? `${Number.parseInt(item[2], 10) + 1}${item[2].slice(-1)}` : item[2];
|
|
122
|
+
return { insert: `${item[1]}${marker} ` };
|
|
123
|
+
}
|
|
124
|
+
const quote = QUOTE.exec(before);
|
|
125
|
+
if (quote) {
|
|
126
|
+
if (quote[3].trim() === "" && atEnd) return { clear: true, text: "" };
|
|
127
|
+
return { insert: `${quote[1]}> ` };
|
|
128
|
+
}
|
|
129
|
+
return { indent: lead.length };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Tab and Backspace in Markdown lists -------------------------------------------------------------------------------------------
|
|
134
|
+
/** Tab on a list item nests it one level (two spaces); false elsewhere. */
|
|
135
|
+
export function markdownTab(view) {
|
|
136
|
+
const { state } = view;
|
|
137
|
+
if (state.readOnly || state.selection.ranges.some((range) => !range.empty)) return false;
|
|
138
|
+
if (!state.selection.ranges.every((range) => LIST_ITEM.test(state.doc.lineAt(range.head).text) || LIST_EMPTY.test(state.doc.lineAt(range.head).text))) return false;
|
|
139
|
+
if (state.selection.ranges.some((range) => markdownLineKind(state.doc, state.doc.lineAt(range.head).number) !== "body")) return false;
|
|
140
|
+
return indentMore(view);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Backspace right after a list marker takes the marker away (`- |one` becomes `|one`); false elsewhere. */
|
|
144
|
+
export function markdownBackspace(view) {
|
|
145
|
+
const { state } = view;
|
|
146
|
+
if (state.readOnly || state.selection.ranges.length !== 1 || !state.selection.main.empty) return false;
|
|
147
|
+
const head = state.selection.main.head;
|
|
148
|
+
const line = state.doc.lineAt(head);
|
|
149
|
+
if (markdownLineKind(state.doc, line.number) !== "body") return false;
|
|
150
|
+
const item = LIST_ITEM.exec(line.text);
|
|
151
|
+
const empty = LIST_EMPTY.exec(line.text);
|
|
152
|
+
const markerEnd = item ? item[1].length + item[2].length + item[3].length : empty ? line.text.length : -1;
|
|
153
|
+
if (markerEnd < 0 || head - line.from !== markerEnd) return false;
|
|
154
|
+
const start = line.from + (item ? item[1].length : empty[1].length);
|
|
155
|
+
view.dispatch({ changes: { from: start, to: head }, selection: { anchor: start }, userEvent: "delete.backward", scrollIntoView: true });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Comments ----------------------------------------------------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Cmd/Ctrl+/ in Markdown: front matter and YAML fences toggle `# `; a line of the body is wrapped in `<!-- -->` or unwrapped; an options comment
|
|
162
|
+
* says it already is one. `say` announces what could not be done.
|
|
163
|
+
*/
|
|
164
|
+
export function markdownToggleComment(view, say) {
|
|
165
|
+
const { state } = view;
|
|
166
|
+
if (state.readOnly) return false;
|
|
167
|
+
const lines = [];
|
|
168
|
+
for (const range of state.selection.ranges) {
|
|
169
|
+
const first = state.doc.lineAt(range.from).number;
|
|
170
|
+
let last = state.doc.lineAt(range.to).number;
|
|
171
|
+
if (!range.empty && range.to === state.doc.line(last).from && last > first) last--;
|
|
172
|
+
for (let number = first; number <= last; number++) if (!lines.includes(number)) lines.push(number);
|
|
173
|
+
}
|
|
174
|
+
const kinds = lines.map((number) => markdownLineKind(state.doc, number));
|
|
175
|
+
const texts = lines.map((number) => state.doc.line(number).text);
|
|
176
|
+
if (kinds.some((kind) => kind === "fence")) { say?.("Code in a fence has no comment syntax."); return true; }
|
|
177
|
+
if (texts.some((text) => /^\s*<!--\s*(slide|block)\b/.test(text)) && texts.length === 1) { say?.("Slide options are already a comment. Delete the line to remove them."); return true; }
|
|
178
|
+
const changes = [];
|
|
179
|
+
const yamlStyle = kinds.every((kind) => kind === "front" || kind === "yaml-fence");
|
|
180
|
+
const blank = (text) => text.trim() === "";
|
|
181
|
+
const real = lines.filter((number, index) => !blank(texts[index]));
|
|
182
|
+
if (!real.length) return true;
|
|
183
|
+
if (yamlStyle) {
|
|
184
|
+
const all = real.every((number) => /^\s*#/.test(state.doc.line(number).text));
|
|
185
|
+
const column = Math.min(...real.map((number) => /^\s*/.exec(state.doc.line(number).text)[0].length));
|
|
186
|
+
for (const number of real) {
|
|
187
|
+
const line = state.doc.line(number);
|
|
188
|
+
if (all) {
|
|
189
|
+
const match = /^(\s*)# ?/.exec(line.text);
|
|
190
|
+
changes.push({ from: line.from + match[1].length, to: line.from + match[0].length });
|
|
191
|
+
} else changes.push({ from: line.from + column, insert: "# " });
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
const all = real.every((number) => /^\s*<!--.*-->\s*$/.test(state.doc.line(number).text));
|
|
195
|
+
for (const number of real) {
|
|
196
|
+
const line = state.doc.line(number);
|
|
197
|
+
if (all) {
|
|
198
|
+
const match = /^(\s*)<!-- ?(.*?) ?-->(\s*)$/.exec(line.text);
|
|
199
|
+
changes.push({ from: line.from + match[1].length, to: line.to, insert: match[2] });
|
|
200
|
+
} else {
|
|
201
|
+
const lead = /^\s*/.exec(line.text)[0].length;
|
|
202
|
+
changes.push({ from: line.from + lead, insert: "<!-- " }, { from: line.to, insert: " -->" });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// The selection takes in the comment marks that were added or removed at its ends.
|
|
207
|
+
const set = state.changes(changes);
|
|
208
|
+
const selection = EditorSelection.create(state.selection.ranges.map((range) => EditorSelection.range(set.mapPos(range.anchor, 1), set.mapPos(range.head, 1))), state.selection.mainIndex);
|
|
209
|
+
view.dispatch(state.update({ changes: set, selection, userEvent: "input.comment", scrollIntoView: true }));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Typing over a selection in Markdown -------------------------------------------------------------------------------------------
|
|
214
|
+
const WRAPPERS = { "*": "*", _: "_", "~": "~", '"': '"' };
|
|
215
|
+
|
|
216
|
+
/** Typing `*`, `_`, `~` or `"` over a selection wraps it (typing `*` twice makes bold); the selection stays on the text. */
|
|
217
|
+
export const markdownWrapSelection = EditorView.inputHandler.of((view, _from, _to, text) => {
|
|
218
|
+
if (text.length !== 1 || !(text in WRAPPERS) || view.state.readOnly) return false;
|
|
219
|
+
const { selection } = view.state;
|
|
220
|
+
if (selection.ranges.every((range) => range.empty)) return false;
|
|
221
|
+
const closer = WRAPPERS[text];
|
|
222
|
+
const changes = view.state.changes(selection.ranges.filter((range) => !range.empty).flatMap((range) => [{ from: range.from, insert: text }, { from: range.to, insert: closer }]));
|
|
223
|
+
view.dispatch({ changes, selection: selection.map(changes), userEvent: "input.type", scrollIntoView: true });
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- Folding in Markdown -----------------------------------------------------------------------------------------------------------
|
|
228
|
+
const SEPARATOR = /^-{3,}[ \t]*$/;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What a line of Markdown can fold (SPEC 6.8): the front matter (from its opening line), a fenced block (from its opening fence), and a slide (from its
|
|
232
|
+
* first line to the line before the next `---`). A `---` inside a fence or the front matter is not a separator.
|
|
233
|
+
*/
|
|
234
|
+
export const markdownFolding = foldService.of((state, lineStart) => {
|
|
235
|
+
const { doc } = state;
|
|
236
|
+
const line = doc.lineAt(lineStart);
|
|
237
|
+
const text = line.text;
|
|
238
|
+
const last = (number) => { const end = doc.line(number); return end.to; };
|
|
239
|
+
// The front matter.
|
|
240
|
+
if (line.number === 1 && /^---\s*$/.test(text)) {
|
|
241
|
+
for (let number = 2; number <= doc.lines; number++) if (/^(---|\.\.\.)\s*$/.test(doc.line(number).text)) return number > 2 ? { from: line.to, to: doc.line(number - 1).to } : null;
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (markdownLineKind(doc, line.number) !== "body") return null;
|
|
245
|
+
// A fenced block.
|
|
246
|
+
const open = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(text);
|
|
247
|
+
if (open && !(open[2][0] === "`" && open[3].includes("`"))) {
|
|
248
|
+
const close = new RegExp(`^ {0,3}${open[2][0]}{${open[2].length},}\\s*$`);
|
|
249
|
+
for (let number = line.number + 1; number <= doc.lines; number++) if (close.test(doc.line(number).text)) return number > line.number + 1 ? { from: line.to, to: doc.line(number - 1).to } : null;
|
|
250
|
+
return line.number < doc.lines ? { from: line.to, to: last(doc.lines) } : null;
|
|
251
|
+
}
|
|
252
|
+
// A slide: its first line (the one after the front matter or a separator, blank lines apart) folds the rest of the slide.
|
|
253
|
+
if (text.trim() === "" || SEPARATOR.test(text)) return null;
|
|
254
|
+
let before = line.number - 1;
|
|
255
|
+
while (before >= 1 && doc.line(before).text.trim() === "") before--;
|
|
256
|
+
const startsSlide = before < 1 || ((SEPARATOR.test(doc.line(before).text) || /^\.\.\.\s*$/.test(doc.line(before).text)) && markdownLineKind(doc, before) === "body");
|
|
257
|
+
if (!startsSlide) return null;
|
|
258
|
+
let end = line.number;
|
|
259
|
+
for (let number = line.number + 1; number <= doc.lines; number++) {
|
|
260
|
+
if (SEPARATOR.test(doc.line(number).text) && markdownLineKind(doc, number) === "body") break;
|
|
261
|
+
end = number;
|
|
262
|
+
}
|
|
263
|
+
while (end > line.number && doc.line(end).text.trim() === "") end--;
|
|
264
|
+
return end > line.number ? { from: line.to, to: doc.line(end).to } : null;
|
|
265
|
+
});
|