@openpresentation/opf-editor 0.26.1 → 0.26.3
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 +4 -3
- package/dist/json-editor.d.ts +65 -3
- package/dist/json-editor.js +177 -45
- package/dist/json-options.js +4 -18
- package/dist/schema.js +4 -121
- package/dist/source-a11y.js +109 -0
- package/dist/source-completions.js +362 -0
- package/dist/source-diagnostics.js +211 -0
- package/dist/source-docs.js +87 -0
- package/dist/source-findings.js +11 -0
- package/dist/source-fixes.js +106 -0
- package/dist/source-json.js +139 -0
- package/dist/source-messages.js +231 -0
- package/dist/source-pane.d.ts +24 -2
- package/dist/source-pane.js +213 -37
- package/dist/source-problems.js +81 -0
- package/dist/source-ranges.js +32 -0
- package/dist/source-snippets.js +93 -0
- package/dist/source-syntax.js +81 -0
- package/dist/styles.css +76 -5
- package/package.json +8 -7
package/dist/schema.js
CHANGED
|
@@ -1,122 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// The schema helpers moved to core (`@openpresentation/opf/schema`), where the CLI, agents and every editor read the schemas one way. This module
|
|
2
|
+
// keeps the editor's exports.
|
|
3
|
+
import { schemas } from '@openpresentation/opf';
|
|
4
|
+
export { activeSchema, createSchemaValue, listSchemaFields, resolveSchema, schemaAtPath, schemaLabel, schemaType, schemaVariants } from '@openpresentation/opf/schema';
|
|
3
5
|
export const opfSchemas = schemas;
|
|
4
|
-
const documents = new Map(Object.values(schemas).map(schema => [schema.$id, schema]));
|
|
5
|
-
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
6
|
-
// An allOf of shaping members (the SlideDesign definition is Design plus a `not` constraint) reads as one schema: members are
|
|
7
|
-
// merged, properties included; constraint-only members (`not`, `required`) shape nothing a picker can show and are dropped.
|
|
8
|
-
function mergeAllOf(schema, root, seen) {
|
|
9
|
-
if (!Array.isArray(schema.allOf)) return schema;
|
|
10
|
-
const {allOf, ...base} = schema;
|
|
11
|
-
let merged = base;
|
|
12
|
-
for (const member of allOf) {
|
|
13
|
-
const resolved = resolveSchema(member, root, new Set(seen));
|
|
14
|
-
if (!resolved.properties && !resolved.type && !resolved.oneOf && !resolved.anyOf) continue;
|
|
15
|
-
merged = {...resolved, ...merged, ...(resolved.properties || merged.properties ? {properties: {...resolved.properties, ...merged.properties}} : {})};
|
|
16
|
-
}
|
|
17
|
-
return merged;
|
|
18
|
-
}
|
|
19
|
-
export function resolveSchema(schema, root = schemas.presentation, seen = new Set()) {
|
|
20
|
-
if (!schema || typeof schema !== 'object') return {};
|
|
21
|
-
if (!schema.$ref) return mergeAllOf(schema, root, seen);
|
|
22
|
-
const ref = schema.$ref;
|
|
23
|
-
if (seen.has(ref)) return {};
|
|
24
|
-
seen.add(ref);
|
|
25
|
-
const [id, fragment = ''] = ref.split('#');
|
|
26
|
-
const target = id ? documents.get(id) : root;
|
|
27
|
-
if (!target) throw new Error(`Unresolved OPF schema: ${ref}`);
|
|
28
|
-
const resolved = fragment ? getValueAtPath(target, fragment) : target;
|
|
29
|
-
if (!resolved) throw new Error(`Unresolved OPF schema: ${ref}`);
|
|
30
|
-
const { $ref, ...rest } = schema;
|
|
31
|
-
return mergeAllOf({...resolveSchema(resolved, target, seen), ...rest}, target, seen);
|
|
32
|
-
}
|
|
33
|
-
export function schemaVariants(schema, root = schemas.presentation) {
|
|
34
|
-
const resolved = resolveSchema(schema, root);
|
|
35
|
-
if (Array.isArray(resolved.type)) return resolved.type.flatMap(type=>schemaVariants({...resolved,type},root));
|
|
36
|
-
const variants = resolved.oneOf ?? resolved.anyOf;
|
|
37
|
-
if (!variants) return [resolved];
|
|
38
|
-
const {$ref, oneOf, anyOf, ...base} = resolved;
|
|
39
|
-
return variants.flatMap(child => schemaVariants(child, root).map(variant => {
|
|
40
|
-
// A branch that only names required fields (Watermark: exactly one of src and text) adds to the base's required list and is
|
|
41
|
-
// labelled by them, so the generic form offers "src" and "text" as two forms of one object.
|
|
42
|
-
const requiredOnly = !variant.type && !variant.properties && !variant.$ref && Array.isArray(variant.required);
|
|
43
|
-
return {...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined,
|
|
44
|
-
...(base.required || variant.required ? {required: [...new Set([...(base.required ?? []), ...(variant.required ?? [])])]} : {}),
|
|
45
|
-
...(requiredOnly && !variant.title ? {title: variant.required.join(' + ')} : {})};
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
|
-
export function schemaType(schema, value) {
|
|
49
|
-
return schema.type ?? (schema.properties || schema.additionalProperties ? 'object' : schema.items ? 'array' : schema.const !== undefined ? typeof schema.const : value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'undefined' ? 'string' : typeof value);
|
|
50
|
-
}
|
|
51
|
-
function score(schema, value) {
|
|
52
|
-
const type=schemaType(schema,value);
|
|
53
|
-
if (type === 'integer' ? !Number.isInteger(value) : type === 'array' ? !Array.isArray(value) : type === 'null' ? value !== null : typeof value !== type || (type === 'object' && (value === null || Array.isArray(value)))) return -100;
|
|
54
|
-
if (own(schema,'const')) return JSON.stringify(schema.const)===JSON.stringify(value)?100:-100;
|
|
55
|
-
if(schema.enum && !schema.enum.includes(value))return -100;
|
|
56
|
-
let result=1;
|
|
57
|
-
for(const [key,child] of Object.entries(schema.properties??{})) {
|
|
58
|
-
if(!value || !own(value,key))continue;
|
|
59
|
-
if(own(child,'const'))result+=value[key]===child.const?100:-1000;
|
|
60
|
-
else result+=1;
|
|
61
|
-
}
|
|
62
|
-
for(const key of schema.required??[])if(!value || !own(value,key))result-=2;
|
|
63
|
-
return result;
|
|
64
|
-
}
|
|
65
|
-
export function activeSchema(schema, value, root=schemas.presentation) {
|
|
66
|
-
return schemaVariants(schema,root).sort((a,b)=>score(b,value)-score(a,value))[0] ?? {};
|
|
67
|
-
}
|
|
68
|
-
export function schemaAtPath(presentation, path, root = schemas.presentation) {
|
|
69
|
-
const parts=splitOpfPath(path);let current=root,value=presentation;
|
|
70
|
-
for(let index=0; index<parts.length;index++) {
|
|
71
|
-
const key=parts[index], resolved=activeSchema(current,value,root);
|
|
72
|
-
if(parts[0]==='catalogs' && index===3 && catalogSchemaNames[parts[2]]) current=schemas[catalogSchemaNames[parts[2]]]??{};
|
|
73
|
-
else current=Array.isArray(value)||resolved.type==='array' ? resolved.items??{} : resolved.properties?.[key] ?? Object.entries(resolved.patternProperties??{}).find(([pattern])=>new RegExp(pattern).test(key))?.[1] ?? (typeof resolved.additionalProperties==='object'?resolved.additionalProperties:{});
|
|
74
|
-
value=value?.[key];
|
|
75
|
-
}
|
|
76
|
-
return resolveSchema(current,root);
|
|
77
|
-
}
|
|
78
|
-
export function schemaLabel(schema, index=0) {
|
|
79
|
-
const type=schemaType(schema);
|
|
80
|
-
return schema.title ?? schema.properties?.type?.const ?? (schema.const!==undefined?String(schema.const):`${type}${schema.properties ? ` · ${Object.keys(schema.properties).slice(0,3).join(', ')}` : ''}`) ?? `Option ${index+1}`;
|
|
81
|
-
}
|
|
82
|
-
export function createSchemaValue(schema, root=schemas.presentation, depth=0) {
|
|
83
|
-
if(depth>20)throw new Error('Choose a shallower initial value for this recursive structure.');
|
|
84
|
-
const variants=schemaVariants(schema,root), s=variants[0]??{};
|
|
85
|
-
if(own(s,'const'))return structuredClone(s.const);
|
|
86
|
-
if(own(s,'default'))return structuredClone(s.default);
|
|
87
|
-
if(s.enum?.length)return structuredClone(s.enum[0]);
|
|
88
|
-
const type=schemaType(s);
|
|
89
|
-
if(type==='object'){
|
|
90
|
-
const value={};
|
|
91
|
-
for(const key of new Set([...(s.required??[]),...Object.keys(s.properties??{}).filter(key=>own(s.properties[key],'const'))])) value[key]=createSchemaValue(s.properties?.[key]??{},root,depth+1);
|
|
92
|
-
if(s.anyOf || s.oneOf){ /* Variants have already been expanded. */ }
|
|
93
|
-
return value;
|
|
94
|
-
}
|
|
95
|
-
if(type==='array')return Array.from({length:Math.min(s.minItems??0,100)},()=>createSchemaValue(s.items??{},root,depth+1));
|
|
96
|
-
if(type==='boolean')return false;
|
|
97
|
-
if(type==='null')return null;
|
|
98
|
-
if(type==='number'||type==='integer')return s.minimum??(s.exclusiveMinimum!==undefined?s.exclusiveMinimum+1:0);
|
|
99
|
-
if(s.examples?.length && typeof s.examples[0]==='string' && !s.examples[0].includes('...'))return s.examples[0];
|
|
100
|
-
if(s.format==='uri')return 'https://example.com';
|
|
101
|
-
if(s.pattern?.includes('a-z'))return 'example';
|
|
102
|
-
return s.minLength?'Text':'';
|
|
103
|
-
}
|
|
104
|
-
export function listSchemaFields() {
|
|
105
|
-
const result=[];
|
|
106
|
-
for(const [name,root] of Object.entries(schemas)) {
|
|
107
|
-
const walk=(schema,path)=>{
|
|
108
|
-
if(!schema || typeof schema!=='object')return;
|
|
109
|
-
for(const [key,child]of Object.entries(schema.properties??{})){
|
|
110
|
-
result.push({schema:name,path:opfPathToJsonPointer([...path,'properties',key]),name:key,description:child.description??'',reference:child.$ref??null,type:child.type??null,values:child.enum??(own(child,'const')?[child.const]:null)});
|
|
111
|
-
walk(child,[...path,'properties',key]);
|
|
112
|
-
}
|
|
113
|
-
for(const [key,child]of Object.entries(schema.$defs??{}))walk(child,[...path,'$defs',key]);
|
|
114
|
-
for(const keyword of ['oneOf','anyOf','allOf']) (schema[keyword]??[]).forEach((child,index)=>walk(child,[...path,keyword,String(index)]));
|
|
115
|
-
if(schema.items)walk(schema.items,[...path,'items']);
|
|
116
|
-
if(typeof schema.additionalProperties==='object')walk(schema.additionalProperties,[...path,'additionalProperties']);
|
|
117
|
-
for(const [key,child]of Object.entries(schema.patternProperties??{}))walk(child,[...path,'patternProperties',key]);
|
|
118
|
-
};
|
|
119
|
-
walk(root,[]);
|
|
120
|
-
}
|
|
121
|
-
return result;
|
|
122
|
-
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Screen reader support for the source editor (artifacts/source-editor/SPEC.md, section 13): one polite live region next to the code that says
|
|
2
|
+
// what happened while the author types (how many suggestions, which one is selected, what was inserted, which tab stop a snippet is at), and an
|
|
3
|
+
// accessible name for every suggestion that carries its kind and its place in the list. Plain DOM, no network.
|
|
4
|
+
import { ViewPlugin } from "@codemirror/view";
|
|
5
|
+
import { completionStatus, currentCompletions, pickedCompletion, selectedCompletion } from "@codemirror/autocomplete";
|
|
6
|
+
|
|
7
|
+
const CLEAR_AFTER = 1500;
|
|
8
|
+
const SELECTION_DELAY = 100;
|
|
9
|
+
const MERGE_WITHIN = 400;
|
|
10
|
+
const KIND_WORDS = { property: "property", enum: "value", boolean: "value", catalog: "catalog id", reference: "reference", snippet: "snippet", keyword: "keyword", text: "text" };
|
|
11
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The live region: `say(message)` replaces its text (an identical message in a row is announced again), and the text goes after 1.5 seconds.
|
|
15
|
+
* `onSay` also receives every message (hosts mirror them into their own region; tests read them).
|
|
16
|
+
*/
|
|
17
|
+
export function createAnnouncer(parent, onSay) {
|
|
18
|
+
const document = parent.ownerDocument;
|
|
19
|
+
const region = document.createElement("div");
|
|
20
|
+
region.className = "opf-source-live";
|
|
21
|
+
region.setAttribute("role", "status");
|
|
22
|
+
region.setAttribute("aria-live", "polite");
|
|
23
|
+
region.setAttribute("aria-atomic", "true");
|
|
24
|
+
parent.append(region);
|
|
25
|
+
let timer = null, toggle = false;
|
|
26
|
+
return {
|
|
27
|
+
element: region,
|
|
28
|
+
say(message) {
|
|
29
|
+
if (!message) return;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
toggle = !toggle;
|
|
32
|
+
region.textContent = toggle ? message : `${message}`;
|
|
33
|
+
onSay?.(message);
|
|
34
|
+
timer = setTimeout(() => { region.textContent = ""; }, CLEAR_AFTER);
|
|
35
|
+
},
|
|
36
|
+
destroy() { clearTimeout(timer); region.remove(); },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The first sentence of a documentation text, for the selection announcement. */
|
|
41
|
+
export function firstSentence(text) {
|
|
42
|
+
if (typeof text !== "string") return "";
|
|
43
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
44
|
+
return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The accessible name of one suggestion: `label, kind[, required][, detail]. n of total`. */
|
|
48
|
+
export function optionName(completion, index, total) {
|
|
49
|
+
const kind = KIND_WORDS[completion.type] ?? completion.type ?? "";
|
|
50
|
+
const parts = [completion.label];
|
|
51
|
+
if (kind) parts.push(kind);
|
|
52
|
+
if (completion.opfRequired) parts.push("required");
|
|
53
|
+
if (completion.detail && completion.detail !== kind) parts.push(completion.detail);
|
|
54
|
+
return `${parts.join(", ")}. ${index + 1} of ${total}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The editor extension: announces the list opening (the count), the selection changing (label, kind and the first sentence of its
|
|
59
|
+
* documentation; after a short pause so arrowing through the list is not read item by item) and a suggestion being inserted; and names
|
|
60
|
+
* every rendered option for assistive technology. `describe(completion)` gives the documentation text of an item.
|
|
61
|
+
*/
|
|
62
|
+
export function a11yExtension(announcer, describe = () => "") {
|
|
63
|
+
return ViewPlugin.fromClass(class {
|
|
64
|
+
constructor(view) {
|
|
65
|
+
this.view = view;
|
|
66
|
+
this.open = false;
|
|
67
|
+
this.selected = null;
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.labelTimer = null;
|
|
70
|
+
}
|
|
71
|
+
update(update) {
|
|
72
|
+
const { state } = update;
|
|
73
|
+
for (const tr of update.transactions) {
|
|
74
|
+
const picked = tr.annotation(pickedCompletion);
|
|
75
|
+
if (picked) announcer.say(`Inserted ${picked.label}`);
|
|
76
|
+
}
|
|
77
|
+
const open = completionStatus(state) === "active" && currentCompletions(state).length > 0;
|
|
78
|
+
const selected = open ? selectedCompletion(state) : null;
|
|
79
|
+
if (open && !this.open) {
|
|
80
|
+
announcer.say(plural(currentCompletions(state).length, "suggestion"));
|
|
81
|
+
} else if (open && selected && selected !== this.selected) {
|
|
82
|
+
clearTimeout(this.timer);
|
|
83
|
+
this.timer = setTimeout(() => {
|
|
84
|
+
const text = firstSentence(describe(selected));
|
|
85
|
+
const kind = KIND_WORDS[selected.type] ?? selected.type ?? "";
|
|
86
|
+
announcer.say(`${selected.label}${kind ? `, ${kind}` : ""}${selected.opfRequired ? ", required" : ""}.${text ? ` ${text}` : ""}`);
|
|
87
|
+
}, SELECTION_DELAY);
|
|
88
|
+
}
|
|
89
|
+
if (!open) clearTimeout(this.timer);
|
|
90
|
+
this.open = open;
|
|
91
|
+
this.selected = selected;
|
|
92
|
+
if (open) {
|
|
93
|
+
clearTimeout(this.labelTimer);
|
|
94
|
+
this.labelTimer = setTimeout(() => this.label(), 0);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
label() {
|
|
98
|
+
const { view } = this, { state } = view;
|
|
99
|
+
const completions = currentCompletions(state);
|
|
100
|
+
if (!completions.length) return;
|
|
101
|
+
for (const node of view.dom.querySelectorAll(".cm-tooltip-autocomplete [role=option]")) {
|
|
102
|
+
const index = Number(node.id.slice(node.id.lastIndexOf("-") + 1));
|
|
103
|
+
const completion = completions[index];
|
|
104
|
+
if (completion) node.setAttribute("aria-label", optionName(completion, index, completions.length));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
destroy() { clearTimeout(this.timer); clearTimeout(this.labelTimer); }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// The completion engine of the source editor (artifacts/source-editor/SPEC.md, section 4): what the list offers at a position of a JSON draft, how it is
|
|
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
|
+
// 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
|
+
import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, startCompletion } from "@codemirror/autocomplete";
|
|
5
|
+
import { EditorSelection } from "@codemirror/state";
|
|
6
|
+
import { describeField } from "@openpresentation/opf/schema";
|
|
7
|
+
import { listCatalogRecords } from "./catalogs.js";
|
|
8
|
+
import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
|
|
9
|
+
import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
|
|
10
|
+
import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
|
|
11
|
+
import { snippetJson, snippetsFor } from "./source-snippets.js";
|
|
12
|
+
|
|
13
|
+
export { contextAt, tokenAt };
|
|
14
|
+
|
|
15
|
+
// A quoted token keeps its list for any text between the quotes; a bare word for word characters only.
|
|
16
|
+
const QUOTED_TEXT = /^[^"\\\n\r]*$/;
|
|
17
|
+
const WORD_TEXT = /^[A-Za-z0-9_.-]*$/;
|
|
18
|
+
const MIN_ADVANCED_PREFIX = 3;
|
|
19
|
+
|
|
20
|
+
const KIND_LABELS = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone" };
|
|
21
|
+
const SOURCE_LABELS = { document: "In this document", registered: "Provided by app" };
|
|
22
|
+
|
|
23
|
+
const brief = (text) => {
|
|
24
|
+
if (typeof text !== "string" || !text.trim()) return undefined;
|
|
25
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
26
|
+
return /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ---- ranking ---------------------------------------------------------------------------------------------------------------------------
|
|
30
|
+
// CodeMirror adds `boost` (-99 to 99) to the match score. Prefix matches differ by hundreds from other matches, so the boost orders the items that
|
|
31
|
+
// match equally: the position in the list below, from 99 down.
|
|
32
|
+
const boostAt = (index) => Math.max(-99, 99 - index);
|
|
33
|
+
|
|
34
|
+
// ---- templates -------------------------------------------------------------------------------------------------------------------------
|
|
35
|
+
const primaryVariant = (info) => info.variants[info.primary] ?? info.variants[0];
|
|
36
|
+
const variantKind = (info) => {
|
|
37
|
+
const variant = primaryVariant(info);
|
|
38
|
+
if (!variant) return "any";
|
|
39
|
+
if (variant.enum?.length || info.catalogKind) return "choice";
|
|
40
|
+
return variant.type;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The text inserted for a value of the field `info` describes, as a snippet template: `{ template, fields, chain }`. `first` is the number of the first
|
|
45
|
+
* tab stop. A string with a closed set of values (an enum, a catalog id) is an empty string between quotes with the list of values opened inside;
|
|
46
|
+
* a boolean leaves the place for `true` or `false` and opens that list; an object with required members writes them, one per line, each a tab stop.
|
|
47
|
+
*/
|
|
48
|
+
export function valueTemplate(info, first = 1) {
|
|
49
|
+
const kind = variantKind(info);
|
|
50
|
+
const variant = primaryVariant(info);
|
|
51
|
+
if (kind === "choice") return { template: `"\${${first}}"`, fields: 1, chain: true };
|
|
52
|
+
if (kind === "boolean") return { template: "", fields: 0, chain: true };
|
|
53
|
+
if (kind === "number" || kind === "integer") return { template: `\${${first}}`, fields: 1, chain: false };
|
|
54
|
+
if (kind === "array") return { template: `[\${${first}}]`, fields: 1, chain: false };
|
|
55
|
+
if (kind === "object") {
|
|
56
|
+
const required = variant?.requiredChildren?.length ? variant.requiredChildren : info.requiredChildren;
|
|
57
|
+
if (!required.length) return { template: `{\${${first}}}`, fields: 1, chain: false };
|
|
58
|
+
let next = first;
|
|
59
|
+
const lines = required.map((key) => {
|
|
60
|
+
const child = info.children.find((entry) => entry.key === key);
|
|
61
|
+
const type = child?.types[0] ?? "string";
|
|
62
|
+
const stop = next++;
|
|
63
|
+
const value = type === "boolean" ? "false" : type === "array" ? "[]" : type === "object" ? "{}" : type === "number" || type === "integer" ? `\${${stop}:0}` : `"\${${stop}}"`;
|
|
64
|
+
return `\t${JSON.stringify(key)}: ${value}`;
|
|
65
|
+
});
|
|
66
|
+
return { template: `{\n${lines.join(",\n")}\n}`, fields: next - first, chain: false };
|
|
67
|
+
}
|
|
68
|
+
return { template: `"\${${first}}"`, fields: 1, chain: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A snippet template as plain text, and where its first tab stop is (offset), for inserting at several cursors at once. */
|
|
72
|
+
function plainOf(template) {
|
|
73
|
+
let first = -1, output = "";
|
|
74
|
+
for (let index = 0; index < template.length;) {
|
|
75
|
+
if (template[index] === "\\" && /[{}\\]/.test(template[index + 1] ?? "")) { output += template[index + 1]; index += 2; continue; }
|
|
76
|
+
const match = /^[#$]\{(\d+)(?::((?:\\[{}\\]|[^{}\\])*))?\}/.exec(template.slice(index));
|
|
77
|
+
if (match) {
|
|
78
|
+
if (first < 0 && match[1] !== "0") first = output.length;
|
|
79
|
+
output += (match[2] ?? "").replace(/\\([{}\\])/g, "$1");
|
|
80
|
+
index += match[0].length;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
output += template[index++];
|
|
84
|
+
}
|
|
85
|
+
return { text: output, first: first < 0 ? output.length : first };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- the options -----------------------------------------------------------------------------------------------------------------------
|
|
89
|
+
function advancedShown(children, typed, explicit) {
|
|
90
|
+
if (!typed || typed.length < (explicit ? 1 : MIN_ADVANCED_PREFIX)) return false;
|
|
91
|
+
return children.some((child) => child.advanced && child.key.toLowerCase().startsWith(typed));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function keyOptions(context, text, caret, explicit) {
|
|
95
|
+
const info = describeField(context.path, context.document);
|
|
96
|
+
const typed = text.slice(context.token.from, caret).toLowerCase();
|
|
97
|
+
const options = [];
|
|
98
|
+
if (info.known) {
|
|
99
|
+
const free = info.children.filter((child) => !context.existing.has(child.key));
|
|
100
|
+
const withAdvanced = advancedShown(free, typed, explicit);
|
|
101
|
+
const shown = free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed)));
|
|
102
|
+
shown.forEach((child, index) => options.push({
|
|
103
|
+
label: child.key,
|
|
104
|
+
type: "property",
|
|
105
|
+
detail: child.required ? `${child.typeLabel} · required` : child.typeLabel,
|
|
106
|
+
boost: boostAt(index),
|
|
107
|
+
opfRequired: child.required,
|
|
108
|
+
opfDoc: child.description ?? "",
|
|
109
|
+
opf: { kind: "key", key: child.key },
|
|
110
|
+
info: () => docNode(describeChild(context, child)),
|
|
111
|
+
}));
|
|
112
|
+
for (const entry of snippetsFor("key", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
113
|
+
return { options, advanced: withAdvanced };
|
|
114
|
+
}
|
|
115
|
+
return { options, advanced: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describeChild(context, child) {
|
|
119
|
+
try {
|
|
120
|
+
const info = describeField([...context.path, child.key], context.document);
|
|
121
|
+
if (info.known) return { ...fieldDoc(info, child.key), description: child.description ?? info.description };
|
|
122
|
+
} catch {
|
|
123
|
+
// The card still shows what the list knows.
|
|
124
|
+
}
|
|
125
|
+
return childDoc(child);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function snippetOption(entry, boost) {
|
|
129
|
+
return { label: entry.prefix, type: "snippet", detail: "snippet", boost, opfDoc: entry.description, opf: { kind: "snippet", prefix: entry.prefix }, info: () => docNode({ title: entry.prefix, type: "snippet", description: entry.description }) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
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 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function catalogOptions(kind, context, catalogs) {
|
|
137
|
+
let records = [];
|
|
138
|
+
try { records = listCatalogRecords(context.document, kind, { catalogs }); } catch { records = []; }
|
|
139
|
+
const order = (record) => {
|
|
140
|
+
if (record.origin === "document") return -1;
|
|
141
|
+
if (kind !== "layouts") return 0;
|
|
142
|
+
const id = record.id.replace(/^default:/, "");
|
|
143
|
+
const group = LAYOUT_INTENT_GROUPS.findIndex((entry) => entry.ids.includes(id));
|
|
144
|
+
return group < 0 ? LAYOUT_INTENT_GROUPS.length * 100 : group * 100 + LAYOUT_INTENT_GROUPS[group].ids.indexOf(id);
|
|
145
|
+
};
|
|
146
|
+
records.sort((a, b) => order(a) - order(b) || a.label.localeCompare(b.label, "en", { numeric: true }) || a.id.localeCompare(b.id, "en"));
|
|
147
|
+
const intent = kind === "layouts";
|
|
148
|
+
return records.map((record, index) => {
|
|
149
|
+
const regions = kind === "layouts" ? layoutRegionSummary(record.record) : "";
|
|
150
|
+
const notes = [regions && `Regions: ${regions}`, SOURCE_LABELS[record.origin === "document" ? "document" : "registered"]].filter(Boolean);
|
|
151
|
+
const model = { title: record.id, type: KIND_LABELS[kind], description: record.description ?? undefined, notes: [record.label !== record.id ? record.label : "", ...notes].filter(Boolean) };
|
|
152
|
+
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"],
|
|
154
|
+
doc: [brief(record.description), regions].filter(Boolean).join(" "), info: () => docNode(model),
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function valueOptions(context, catalogs) {
|
|
160
|
+
const info = describeField(context.path, context.document);
|
|
161
|
+
const options = [];
|
|
162
|
+
if (!info.known) return { options };
|
|
163
|
+
const variant = primaryVariant(info);
|
|
164
|
+
if (info.catalogKind) options.push(...catalogOptions(info.catalogKind, context, catalogs));
|
|
165
|
+
else if (info.enum?.length) {
|
|
166
|
+
// In the order VS Code lists them: by label.
|
|
167
|
+
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,
|
|
169
|
+
})));
|
|
170
|
+
} 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")) {
|
|
173
|
+
// An object or a list goes in as a template: `{}` with its required members, or `[]`.
|
|
174
|
+
const label = variant.type === "object" ? "{}" : "[]";
|
|
175
|
+
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)) });
|
|
176
|
+
}
|
|
177
|
+
for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
178
|
+
return { options };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The completion result at `position` for CodeMirror, or null. `catalogs` are the host's.
|
|
183
|
+
*/
|
|
184
|
+
export function completionResult(state, position, { explicit, catalogs }) {
|
|
185
|
+
const text = state.doc.toString();
|
|
186
|
+
const context = contextAt(text, position, { explicit });
|
|
187
|
+
if (!context) return null;
|
|
188
|
+
const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit) : valueOptions(context, catalogs);
|
|
189
|
+
if (!options.length) return null;
|
|
190
|
+
const { token } = context;
|
|
191
|
+
const quoted = token.quoted;
|
|
192
|
+
// 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
|
+
// with the prefix).
|
|
194
|
+
const sameSet = context.mode !== "key" ? () => true : (typed) => advancedShown(options.length ? infoChildren(context) : [], typed.toLowerCase(), explicit) === Boolean(advanced);
|
|
195
|
+
return {
|
|
196
|
+
from: token.from,
|
|
197
|
+
// Filtered by the text before the caret, as VS Code does.
|
|
198
|
+
to: Math.max(token.from, Math.min(position, token.to)),
|
|
199
|
+
filter: true,
|
|
200
|
+
options,
|
|
201
|
+
validFor: (typed) => (quoted ? QUOTED_TEXT : WORD_TEXT).test(typed) && sameSet(typed),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function infoChildren(context) {
|
|
205
|
+
try { return describeField(context.path, context.document).children.filter((child) => !context.existing.has(child.key)); } catch { return []; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- accepting -------------------------------------------------------------------------------------------------------------------------
|
|
209
|
+
const snippetRuns = new WeakMap();
|
|
210
|
+
|
|
211
|
+
/** What accepting `completion` writes: `{ start, end, template, fields, chain, choices }` for the context of the caret. */
|
|
212
|
+
export function planCompletion(context, completion, text, end) {
|
|
213
|
+
const next = nextSignificant(text, end);
|
|
214
|
+
const needsComma = next !== "" && !",}]".includes(next);
|
|
215
|
+
const kind = completion.opf?.kind;
|
|
216
|
+
if (kind === "key") {
|
|
217
|
+
const key = completion.opf.key;
|
|
218
|
+
const quoted = JSON.stringify(key);
|
|
219
|
+
if (/^\s*:/.test(text.slice(end))) return { template: quoted, fields: 0, chain: false };
|
|
220
|
+
let info;
|
|
221
|
+
try { info = describeField([...context.path, key], context.document); } catch { info = null; }
|
|
222
|
+
const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
|
|
223
|
+
const body = `${quoted}: ${value.template}${needsComma ? "," : ""}`;
|
|
224
|
+
// The last stop is after the value: Tab leaves the string, the braces or the list.
|
|
225
|
+
return { template: value.fields ? `${body}\${0}` : body, fields: value.fields, chain: value.chain };
|
|
226
|
+
}
|
|
227
|
+
if (kind === "literal") return { template: `${completion.opf.literal.replace(/[\\{}]/g, (character) => `\\${character}`)}${needsComma ? "," : ""}`, fields: 0, chain: false };
|
|
228
|
+
if (kind === "template") {
|
|
229
|
+
let info;
|
|
230
|
+
try { info = describeField(context.path, context.document); } catch { info = null; }
|
|
231
|
+
const value = info?.known ? valueTemplate(info) : { template: "{${1}}", fields: 1, chain: false };
|
|
232
|
+
const template = value.template || "";
|
|
233
|
+
return { template: `${template}${needsComma ? "," : ""}${value.fields ? "\${0}" : ""}`, fields: value.fields, chain: false };
|
|
234
|
+
}
|
|
235
|
+
if (kind === "snippet") {
|
|
236
|
+
const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
|
|
237
|
+
if (!entry) return null;
|
|
238
|
+
const rendered = snippetJson(entry, { comma: needsComma && context.mode !== "key" });
|
|
239
|
+
return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 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) {
|
|
246
|
+
const { state } = view;
|
|
247
|
+
const text = state.doc.toString();
|
|
248
|
+
const main = state.selection.main;
|
|
249
|
+
const context = contextAt(text, main.head, { explicit: true });
|
|
250
|
+
const token = context?.token;
|
|
251
|
+
const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
|
|
252
|
+
const plan = context ? planCompletion(context, completion, text, end) : null;
|
|
253
|
+
if (!plan) {
|
|
254
|
+
view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (state.selection.ranges.length > 1) { applyAtEveryCursor(view, completion, plan); return; }
|
|
258
|
+
// 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
|
+
// 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('"')) {
|
|
261
|
+
const inner = completion.opf.literal.slice(1, -1);
|
|
262
|
+
const inSnippet = snippetRuns.has(view) && (hasNextSnippetField(state) || hasPrevSnippetField(state));
|
|
263
|
+
const next = nextSignificant(text, token.end);
|
|
264
|
+
const changes = [{ from: token.from, to: token.to, insert: inner }];
|
|
265
|
+
if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
|
|
266
|
+
const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
|
|
267
|
+
view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (plan.fields > 0 || plan.template.includes("${")) {
|
|
271
|
+
snippet(plan.template)(view, completion, start, end);
|
|
272
|
+
if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
|
|
273
|
+
else snippetRuns.delete(view);
|
|
274
|
+
if (plan.choices) snippetStep(view, true, { start: true, say });
|
|
275
|
+
} else {
|
|
276
|
+
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 });
|
|
278
|
+
snippetRuns.delete(view);
|
|
279
|
+
}
|
|
280
|
+
// A key whose value is one of a closed set opens that list at once.
|
|
281
|
+
if (plan.chain) startCompletion(view);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 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) {
|
|
286
|
+
const { state } = view;
|
|
287
|
+
const text = state.doc.toString();
|
|
288
|
+
const plain = plainOf(plan.template);
|
|
289
|
+
const changes = [], carets = [];
|
|
290
|
+
let shift = 0;
|
|
291
|
+
for (const range of [...state.selection.ranges].sort((a, b) => a.from - b.from)) {
|
|
292
|
+
const context = contextAt(text, range.head, { explicit: true });
|
|
293
|
+
const token = context?.token ?? { start: range.from, end: range.to };
|
|
294
|
+
changes.push({ from: token.start, to: token.end, insert: plain.text });
|
|
295
|
+
carets.push(token.start + shift + plain.first);
|
|
296
|
+
shift += plain.text.length - (token.end - token.start);
|
|
297
|
+
}
|
|
298
|
+
view.dispatch({ changes, selection: EditorSelection.create(carets.map((position) => EditorSelection.cursor(position)), 0), annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A tab stop of the snippet just accepted was reached (`forward`: Tab, else Shift+Tab). Says which one, and opens the list of values at a stop
|
|
303
|
+
* the snippet marks as a choice. `start` is the first stop. Returns whether a snippet is being walked.
|
|
304
|
+
*/
|
|
305
|
+
export function snippetStep(view, forward, { start = false, say } = {}) {
|
|
306
|
+
const run = snippetRuns.get(view);
|
|
307
|
+
if (!run) return false;
|
|
308
|
+
run.index = start ? 0 : run.index + (forward ? 1 : -1);
|
|
309
|
+
const total = run.fields.length;
|
|
310
|
+
const announce = say;
|
|
311
|
+
if (run.index >= total) {
|
|
312
|
+
snippetRuns.delete(view);
|
|
313
|
+
announce?.("End of snippet");
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (run.index < 0) { run.index = 0; return true; }
|
|
317
|
+
const stop = run.fields[run.index];
|
|
318
|
+
announce?.(`Tab stop ${run.index + 1} of ${total}${stop.text ? `: ${stop.text}` : ""}`);
|
|
319
|
+
if (stop.choice) startCompletion(view);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
export const snippetActive = (view) => snippetRuns.has(view);
|
|
323
|
+
export const forgetSnippet = (view) => snippetRuns.delete(view);
|
|
324
|
+
|
|
325
|
+
/** The plain description of an option for the live region. */
|
|
326
|
+
export const describeOption = (completion) => completion.opfDoc ?? "";
|
|
327
|
+
export { docText };
|
|
328
|
+
|
|
329
|
+
// ---- hover -----------------------------------------------------------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* 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.
|
|
333
|
+
*/
|
|
334
|
+
export function hoverModelAt(state, position, { catalogs }) {
|
|
335
|
+
const found = nodeAt(state.doc.toString(), position);
|
|
336
|
+
if (!found) return null;
|
|
337
|
+
let info;
|
|
338
|
+
try { info = describeField(found.path, skeleton(found.root, found.path, false)); } catch { return null; }
|
|
339
|
+
if (!info.known) return null;
|
|
340
|
+
const name = String(found.path.at(-1));
|
|
341
|
+
if (found.kind === "key") return { from: found.from, to: found.to, model: fieldDoc(info, name) };
|
|
342
|
+
if (typeof found.value === "string" && info.catalogKind) {
|
|
343
|
+
let record;
|
|
344
|
+
try { record = listCatalogRecords(skeleton(found.root, found.path, false), info.catalogKind, { catalogs }).find((entry) => entry.id === found.value); } catch { record = undefined; }
|
|
345
|
+
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
|
+
const regions = info.catalogKind === "layouts" ? layoutRegionSummary(record.record) : "";
|
|
347
|
+
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) } };
|
|
348
|
+
}
|
|
349
|
+
if (info.enum?.length && ["string", "number", "boolean"].includes(typeof found.value)) {
|
|
350
|
+
const entry = info.enum.find((candidate) => candidate.value === found.value);
|
|
351
|
+
return { from: found.from, to: found.to, model: { ...fieldDoc(info, name), description: entry?.description ?? info.description } };
|
|
352
|
+
}
|
|
353
|
+
if (info.freeText || found.nodeType === "object" || found.nodeType === "array") return null;
|
|
354
|
+
return { from: found.from, to: found.to, model: fieldDoc(info, name) };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A CodeMirror tooltip for `hoverModelAt`, or null. */
|
|
358
|
+
export function hoverAt(state, position, options) {
|
|
359
|
+
const found = hoverModelAt(state, position, options);
|
|
360
|
+
if (!found) return null;
|
|
361
|
+
return { pos: found.from, end: found.to, above: true, create: () => ({ dom: docNode(found.model) }) };
|
|
362
|
+
}
|