@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.
@@ -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
- const context = contextAt(text, position, { explicit });
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 : WORD_TEXT).test(typed) && sameSet(typed),
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
- /** What accepting `completion` writes: `{ start, end, template, fields, chain, choices }` for the context of the caret. */
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 = contextAt(text, main.head, { explicit: true });
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, start, end);
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: start, to: end, insert }, selection: { anchor: start + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
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 = contextAt(text, range.head, { explicit: true });
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 found = nodeAt(state.doc.toString(), position);
336
- if (!found) return null;
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, skeleton(found.root, found.path, false)); } catch { return null; }
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(skeleton(found.root, found.path, false), info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
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
+ });