feat(studio): rebuild config editor on CodeMirror 6

The hand-rolled completion engine could not complete YAML key
positions: its cursor-marker probe produced invalid YAML mid-document,
the parser folded the marker into the next line's key, and the popup
never opened. Instead of patching the probe, replace the editor
foundation: CodeMirror 6 provides the popup, gutter, hover, and
tooltip machinery natively.

codemirror-json-schema was evaluated for the schema features and
rejected: enum completion through $ref resolves to nothing, $ref
nodes with sibling keywords crash its resolver, and both its published
ESM and CJS builds are broken under strict module resolution. The
proven schema resolver from the previous engine stays, rewired as a
native completion/hover source that walks the lezer syntax tree
instead of scanning text - which makes mid-edit states (blank lines,
dangling keys, open strings) work in both JSON and YAML, including
per-segment-type options completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 9045963ce7e2
This commit is contained in:
Jan De Dobbeleer
2026-08-02 12:37:05 +02:00
committed by Jan De Dobbeleer
co-authored by Claude Fable 5
parent db6ea0effc
commit c53ff0de67
10 changed files with 1696 additions and 1754 deletions
+184 -11
View File
@@ -8,14 +8,23 @@
"name": "website",
"version": "0.0.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@docusaurus/core": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-search-algolia": "^3.10.2",
"@lezer/highlight": "^1.2.3",
"@mdx-js/react": "^3.1.1",
"classnames": "^2.5.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-simple-code-editor": "^0.14.1",
"smol-toml": "^1.7.0",
"yaml": "^2.9.0"
},
@@ -1994,6 +2003,110 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
"integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -4343,6 +4456,58 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
@@ -6762,6 +6927,12 @@
}
}
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -15000,16 +15171,6 @@
"react": ">=15"
}
},
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -16392,6 +16553,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -17253,6 +17420,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/watchpack": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz",
+10 -1
View File
@@ -14,14 +14,23 @@
"clear": "docusaurus clear"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/lint": "^6.9.7",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@docusaurus/core": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-search-algolia": "^3.10.2",
"@lezer/highlight": "^1.2.3",
"@mdx-js/react": "^3.1.1",
"classnames": "^2.5.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-simple-code-editor": "^0.14.1",
"smol-toml": "^1.7.0",
"yaml": "^2.9.0"
},
@@ -1,789 +0,0 @@
import schema from '../../../../themes/schema.json';
const ROOT_SCHEMA = schema;
// The main scanner in getCompletionContext only walks text up to the cursor, so its
// per-frame usedKeys only ever sees sibling keys that appear BEFORE the cursor. When
// completing inside an already-populated object (the common "loaded an existing config"
// case), sibling keys placed AFTER the cursor are just as real and must be excluded too.
// This does a small forward-only scan from the cursor to the end of the current object
// (its matching closing `}`), collecting any key strings it passes at the same depth.
function collectForwardKeys(text, cursorOffset, insideOpenString) {
const keys = [];
let i = cursorOffset;
// If the cursor sits inside a string we (or the user) already opened, find where that
// string would close - but only treat it as a genuine closer if what follows it looks
// like a key delimiter (`:`). Otherwise the "next quote" is really the start of an
// unrelated sibling key sitting right next to our not-yet-closed string (e.g. the user
// just typed a bare opening `"` for a brand new key immediately before an existing
// one), and skipping past it would swallow that sibling's own opening quote.
if (insideOpenString) {
let closeAt = i;
while (closeAt < text.length && text[closeAt] !== '"') {
if (text[closeAt] === '\\') {
closeAt += 1;
}
closeAt += 1;
}
if (closeAt < text.length) {
let k = closeAt + 1;
while (k < text.length && /\s/.test(text[k])) {
k += 1;
}
if (text[k] === ':') {
i = closeAt + 1;
}
}
}
let depth = 0;
let inString = false;
while (i < text.length) {
const char = text[i];
if (inString) {
if (char === '\\') {
i += 2;
continue;
}
if (char === '"') {
inString = false;
}
i += 1;
continue;
}
if (char === '"') {
const keyStart = i + 1;
let j = keyStart;
while (j < text.length && text[j] !== '"') {
if (text[j] === '\\') {
j += 1;
}
j += 1;
}
if (depth === 0) {
// Only a string immediately followed by `:` (ignoring whitespace) is a key -
// a plain string value at this depth must not be collected as one.
let k = j + 1;
while (k < text.length && /\s/.test(text[k])) {
k += 1;
}
if (text[k] === ':') {
keys.push(text.slice(keyStart, j));
}
}
i = j + 1;
continue;
}
if (char === '{' || char === '[') {
depth += 1;
i += 1;
continue;
}
if (char === '}' || char === ']') {
if (depth === 0) {
break;
}
depth -= 1;
i += 1;
continue;
}
i += 1;
}
return keys;
}
function resolveSchema(node, root = ROOT_SCHEMA) {
if (!node || typeof node !== 'object') {
return {};
}
if (node.$ref) {
const ref = node.$ref;
if (ref.startsWith('#/')) {
const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root);
const resolved = resolveSchema(target, root);
// Draft 2020-12 allows keywords alongside $ref; sibling keys (e.g. a
// description overriding the target's) must win over the target's own.
const siblings = { ...node };
delete siblings.$ref;
return { ...resolved, ...siblings };
}
}
// anyOf/oneOf branches are alternatives (e.g. "enum or free string") rather than
// required composition, so completion only needs the union of their enum values.
if (node.anyOf || node.oneOf) {
const branches = node.anyOf || node.oneOf;
const merged = { ...node };
delete merged.anyOf;
delete merged.oneOf;
branches.forEach((branch) => {
const resolvedBranch = resolveSchema(branch, root);
if (resolvedBranch.enum) {
merged.enum = [...(merged.enum || []), ...resolvedBranch.enum];
}
if (resolvedBranch.type && !merged.type) {
merged.type = resolvedBranch.type;
}
});
return merged;
}
if (node.allOf) {
return node.allOf.reduce((acc, child) => mergeSchema(acc, resolveSchema(child, root)), {
...node,
});
}
return { ...node };
}
function mergeSchema(base, incoming) {
const merged = { ...base };
if (incoming.properties) {
merged.properties = {
...(base.properties || {}),
...(incoming.properties || {}),
};
}
if (incoming.type && !merged.type) {
merged.type = incoming.type;
}
if (incoming.enum && !merged.enum) {
merged.enum = incoming.enum;
}
if (incoming.default !== undefined && merged.default === undefined) {
merged.default = incoming.default;
}
if (incoming.description && !merged.description) {
merged.description = incoming.description;
}
if (incoming.title && !merged.title) {
merged.title = incoming.title;
}
return merged;
}
function getDefinitionSchema(ref, root = ROOT_SCHEMA) {
if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) {
return {};
}
const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root);
return resolveSchema(target, root);
}
function deriveValueSchema(parentSchema, propertyName, root = ROOT_SCHEMA) {
const resolvedParent = resolveSchema(parentSchema, root);
const propertySchema = resolvedParent.properties?.[propertyName];
if (!propertySchema) {
return {};
}
return resolveSchema(propertySchema, root);
}
// Only `definitions.segment` uses conditional `if.properties.type.const` / `then`
// branches today, but this stays generic so any similarly-shaped schema benefits.
function mergeTypeBranch(schema, typeValue) {
const resolved = resolveSchema(schema);
if (!resolved.allOf || !typeValue) {
return resolved;
}
const merged = { ...resolved };
resolved.allOf.forEach((branch) => {
if (branch.if?.properties?.type?.const === typeValue) {
const thenSchema = resolveSchema(branch.then, ROOT_SCHEMA);
const nextProperties = { ...(merged.properties || {}) };
Object.entries(thenSchema.properties || {}).forEach(([key, branchProperty]) => {
const baseProperty = nextProperties[key];
// A segment's own override (e.g. "options") replaces the base property's shape,
// but shouldn't lose the base's description/title if the override doesn't set one.
nextProperties[key] = baseProperty
? { description: baseProperty.description, title: baseProperty.title, ...branchProperty }
: branchProperty;
});
merged.properties = nextProperties;
}
});
return merged;
}
function getItemSchema(parentValueSchema) {
const resolved = resolveSchema(parentValueSchema);
if (!resolved.items) {
return {};
}
return resolveSchema(resolved.items);
}
// A resolved schema's own "type" keyword is the most direct source, but per-segment-type
// conditionals (schema.json's "if type === X then properties: { options: {...} }" branches)
// routinely narrow a property down to just "properties"/"items"/"unevaluatedProperties"
// without repeating "type": "object" (JSON Schema doesn't require it for validation to work).
// Completion still needs a concrete type to know what to chain into, so fall back to
// inferring it from shape - "properties" implies object, "items" implies array - before
// falling back further to a bare enum implying string.
function inferSchemaType(resolvedSchema) {
if (resolvedSchema.type) {
return resolvedSchema.type;
}
if (resolvedSchema.properties) {
return 'object';
}
if (resolvedSchema.items) {
return 'array';
}
return resolvedSchema.enum ? 'string' : null;
}
function createObjectFrame(schema) {
return {
kind: 'object',
schema: resolveSchema(schema),
lastPropertyName: null,
inValue: false,
expectingKey: true,
pendingValueSchema: null,
usedKeys: [],
};
}
function createArrayFrame(itemSchema) {
return {
kind: 'array',
schema: itemSchema,
lastPropertyName: null,
inValue: false,
expectingKey: false,
pendingValueSchema: null,
// Array items are never keys, so this never gains entries, but it must exist -
// getCompletionContext spreads every frame's usedKeys regardless of frame kind.
usedKeys: [],
};
}
// Walks the raw text char-by-char (not a full parse — configs mid-edit are rarely
// valid JSON) tracking a stack of object/array frames so we know which schema
// applies at the cursor. Handles the "still typing, string not closed yet" case
// explicitly since that's the state completion is actually triggered in.
// Which schema governs the outermost `{` of the text being edited. The studio edits a full
// theme config (ROOT_SCHEMA fits directly); a segment doc page's sample editor only ever
// contains a single bare segment object (see website/src/components/Config.js), so completion
// there needs to start from #/definitions/segment instead - otherwise every top-level
// property/hover lookup resolves against the wrong schema entirely (root config fields like
// "final_space" instead of segment fields like "foreground").
function getScopedRootSchema(schemaScope) {
if (schemaScope === 'segment') {
return getDefinitionSchema('#/definitions/segment', ROOT_SCHEMA);
}
return ROOT_SCHEMA;
}
function getCompletionContext(text, cursorOffset, schemaScope = 'config') {
const scopedRootSchema = getScopedRootSchema(schemaScope);
const beforeCursor = text.slice(0, cursorOffset);
const stack = [];
let currentFrame = null;
let inString = false;
let escapeNext = false;
let currentString = '';
let stringStartedAsKey = false;
for (let index = 0; index < beforeCursor.length; index += 1) {
const char = beforeCursor[index];
if (inString) {
if (escapeNext) {
escapeNext = false;
currentString += char;
continue;
}
if (char === '\\') {
escapeNext = true;
currentString += char;
continue;
}
if (char === '"') {
inString = false;
if (stringStartedAsKey) {
currentFrame.lastPropertyName = currentString;
currentFrame.expectingKey = false;
currentFrame.inValue = false;
if (!currentFrame.usedKeys.includes(currentString)) {
currentFrame.usedKeys.push(currentString);
}
} else if (currentFrame?.kind === 'object' && currentFrame.lastPropertyName === 'type') {
currentFrame.schema = mergeTypeBranch(currentFrame.schema, currentString);
}
currentString = '';
} else {
currentString += char;
}
continue;
}
if (char === '"') {
inString = true;
currentString = '';
stringStartedAsKey = !!currentFrame && currentFrame.kind === 'object' && currentFrame.expectingKey;
continue;
}
if (char === '{') {
// The very first `{` opens the config's own root object — there's no
// enclosing property to derive a value schema from, so it uses ROOT_SCHEMA
// directly instead of a (nonexistent) parent's pending value schema.
let frameSchema;
if (!currentFrame) {
frameSchema = scopedRootSchema;
} else if (currentFrame.kind === 'object') {
frameSchema = currentFrame.pendingValueSchema || {};
} else {
frameSchema = currentFrame.schema;
}
const frame = createObjectFrame(frameSchema);
stack.push(frame);
currentFrame = frame;
continue;
}
if (char === '[') {
const parentValueSchema = !currentFrame
? {}
: currentFrame.kind === 'object'
? currentFrame.pendingValueSchema
: currentFrame.schema;
const frame = createArrayFrame(getItemSchema(parentValueSchema));
stack.push(frame);
currentFrame = frame;
continue;
}
if (char === '}' || char === ']') {
if (stack.length > 0) {
stack.pop();
currentFrame = stack[stack.length - 1] || null;
}
continue;
}
if (char === ':') {
if (currentFrame?.kind === 'object' && currentFrame.lastPropertyName) {
currentFrame.pendingValueSchema = deriveValueSchema(currentFrame.schema, currentFrame.lastPropertyName);
currentFrame.inValue = true;
currentFrame.expectingKey = false;
}
continue;
}
if (char === ',') {
if (currentFrame?.kind === 'object') {
currentFrame.expectingKey = true;
currentFrame.inValue = false;
currentFrame.lastPropertyName = null;
currentFrame.pendingValueSchema = null;
}
continue;
}
}
if (!currentFrame) {
return {
schema: scopedRootSchema,
currentPropertyName: null,
partialText: inString ? currentString : '',
inValue: false,
insideOpenString: inString,
usedKeys: [],
};
}
const activeFrame = currentFrame;
if (inString) {
if (stringStartedAsKey) {
return {
schema: activeFrame.schema,
currentPropertyName: null,
partialText: currentString,
inValue: false,
insideOpenString: true,
usedKeys: [...new Set([...activeFrame.usedKeys, ...collectForwardKeys(text, cursorOffset, true)])],
};
}
return {
schema: activeFrame.pendingValueSchema || {},
currentPropertyName: activeFrame.lastPropertyName,
partialText: currentString,
inValue: true,
insideOpenString: true,
usedKeys: activeFrame.usedKeys,
};
}
if (activeFrame.kind === 'object' && activeFrame.inValue) {
// Unlike a string value (tracked char-by-char above via currentString), a bare literal
// like a boolean's "true"/"false" leaves no trace in the walk above - so re-triggering
// completion right after one (e.g. to swap a just-seeded default, see applyCompletion's
// boolean chain) needs its own backward scan to find how much of it is replaceable.
// Deliberately NOT reflected in partialText itself: unlike a string value, filtering
// "true"/"false" by the literal that's already there would just filter the other one
// out, defeating the point of offering both.
const bareTokenMatch = /[^\s{}[\],"]+$/.exec(beforeCursor);
return {
schema: activeFrame.pendingValueSchema || {},
currentPropertyName: activeFrame.lastPropertyName,
partialText: '',
replaceLength: bareTokenMatch ? bareTokenMatch[0].length : 0,
insideOpenString: false,
inValue: true,
usedKeys: activeFrame.usedKeys,
};
}
return {
schema: activeFrame.schema,
currentPropertyName: activeFrame.lastPropertyName,
partialText: '',
inValue: false,
insideOpenString: false,
usedKeys: [...new Set([...activeFrame.usedKeys, ...collectForwardKeys(text, cursorOffset, false)])],
};
}
function normalizeCompletionItem(item) {
return {
label: item.label,
kind: item.kind,
// Raw text only — the caller decides whether to wrap it in quotes, since
// that depends on whether the cursor is already inside an open string.
insertText: item.insertText,
needsQuotes: !!item.needsQuotes,
detail: item.detail || '',
// Only set on `property` items - tells the caller what kind of value slot follows,
// so it can chain straight into the next completion cycle instead of waiting for
// the user to type the opening character themselves. See applyCompletion (index.js).
chainValueType: item.chainValueType || null,
// Only meaningful when chainValueType === 'array' - the resolved type of the array's
// own items, so the caller can seed a useful first element (e.g. `[""]` for an array
// of strings) instead of leaving a bare `[]`.
chainItemType: item.chainItemType || null,
// Only set for scalar types (boolean/integer/number) that have a schema default -
// seeds that literal instead of leaving the value slot empty/invalid.
chainDefault: item.chainDefault !== undefined ? item.chainDefault : null,
// Richer text for the hover tooltip (index.js) - kept separate from `detail` (the
// short title already shown inline in the popup row) so the tooltip can show real
// prose without repeating it. Always plain explanatory text - schema.json no longer
// carries bare doc-link descriptions (see buildPropertyHint).
description: item.description || '',
// A short list of sample values worth showing under the description - either authored
// directly in schema.json (the "examples" keyword) or, absent that, a small enum's own
// values (see buildPropertyHint). Null when there's nothing worth showing.
examples: item.examples || null,
};
}
// Schema.json's "examples" keyword, when present, is the most direct source of sample
// values for a tooltip. Absent that, a small enum is itself a good stand-in - but only
// when it's short enough to be a helpful hint rather than a wall of text (large enums,
// e.g. every segment type, are already fully browsable via the completion dropdown itself).
const MAX_ENUM_HINT_SIZE = 8;
function buildPropertyHint(resolvedProp) {
const description = resolvedProp.description && resolvedProp.description !== resolvedProp.title
? resolvedProp.description
: '';
let examples = Array.isArray(resolvedProp.examples) && resolvedProp.examples.length
? resolvedProp.examples
: null;
if (!examples) {
const enumSource = resolvedProp.enum
|| (inferSchemaType(resolvedProp) === 'array' ? getItemSchema(resolvedProp).enum : null);
if (Array.isArray(enumSource) && enumSource.length > 0 && enumSource.length <= MAX_ENUM_HINT_SIZE) {
examples = enumSource;
}
}
return { description, examples };
}
// Finds the property-key string (if any) whose quoted range spans `offset`, scanning the
// WHOLE text rather than just up to a cursor - unlike getCompletionContext, a hover target can
// sit anywhere in the document, including after the caret. Mirrors getCompletionContext's own
// object/array frame tracking, but only needs to know "is this string a key, and if so what
// object was it a key of" rather than resolve a full schema chain.
function findKeyTokenAt(text, offset) {
const stack = [];
let inString = false;
let escapeNext = false;
let stringStart = -1;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (inString) {
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"') {
inString = false;
const top = stack[stack.length - 1];
if (top && top.kind === 'object' && top.expectingKey) {
let k = i + 1;
while (k < text.length && /\s/.test(text[k])) {
k += 1;
}
if (text[k] === ':') {
if (offset >= stringStart && offset <= i + 1) {
return { keyName: text.slice(stringStart + 1, i), contextOffset: stringStart };
}
top.expectingKey = false;
}
}
}
continue;
}
if (char === '"') {
inString = true;
stringStart = i;
continue;
}
if (char === '{') {
stack.push({ kind: 'object', expectingKey: true });
continue;
}
if (char === '[') {
stack.push({ kind: 'array', expectingKey: false });
continue;
}
if (char === '}' || char === ']') {
stack.pop();
continue;
}
if (char === ',') {
const top = stack[stack.length - 1];
if (top && top.kind === 'object') {
top.expectingKey = true;
}
continue;
}
}
return null;
}
// Powers the editor's own hover tooltip (index.js) - hovering directly over a key already
// typed in the config, not just an entry in the completion popup. Resolves the same schema
// info getPropertySuggestions would have offered for that key, using getCompletionContext at
// the offset right before the key's own opening quote so the surrounding object's schema
// still has that key's own definition.
export function getHoverInfo(text, format, offset, schemaScope = 'config') {
if (format !== 'json') {
return null;
}
const token = findKeyTokenAt(text, offset);
if (!token) {
return null;
}
const context = getCompletionContext(text, token.contextOffset, schemaScope);
const resolvedSchema = resolveSchema(context.schema || getScopedRootSchema(schemaScope), ROOT_SCHEMA);
const propSchema = resolvedSchema.properties?.[token.keyName];
if (!propSchema) {
return null;
}
const resolvedProp = resolveSchema(propSchema, ROOT_SCHEMA);
const title = resolvedProp.title || token.keyName;
const { description, examples } = buildPropertyHint(resolvedProp);
return {
title,
text: description,
examples,
};
}
function getPropertySuggestions(context) {
const resolvedSchema = resolveSchema(context.schema || ROOT_SCHEMA, ROOT_SCHEMA);
if (!resolvedSchema.properties) {
return [];
}
const usedKeys = context.usedKeys || [];
return Object.entries(resolvedSchema.properties)
.filter(([name]) => !usedKeys.includes(name))
.map(([name, propSchema]) => {
const resolvedProp = resolveSchema(propSchema, ROOT_SCHEMA);
const detail = resolvedProp.title || resolvedProp.description || '';
// Only worth surfacing in the tooltip when it says something detail doesn't already -
// skip it when description IS what detail fell back to (bare title-less properties).
const { description, examples } = detail === resolvedProp.description
? { description: '', examples: null }
: buildPropertyHint(resolvedProp);
// A string-typed enum (e.g. "style") still resolves with type: "string" today because
// of how the schema expresses it (an anyOf branch with a bare "type": "string" fallback -
// see resolveSchema's anyOf handling), but inferSchemaType covers any schema shape that
// omits an explicit type (e.g. per-segment-type conditionals that only narrow
// "properties"/"items" without repeating "type": "object"/"array").
const chainValueType = inferSchemaType(resolvedProp);
const chainItemType = chainValueType === 'array' ? inferSchemaType(getItemSchema(resolvedProp)) : null;
// Every chainable value type needs *some* concrete literal to seed so the completion
// always leaves valid JSON behind, even when the schema itself doesn't author a
// "default" - false/0 are the same harmless placeholders the editor already relies on
// elsewhere (e.g. an empty "" for strings, an empty {} for objects).
const chainDefault = chainValueType === 'boolean'
? (resolvedProp.default !== undefined ? resolvedProp.default : false)
: chainValueType === 'integer' || chainValueType === 'number'
? (resolvedProp.default !== undefined ? resolvedProp.default : 0)
: undefined;
return normalizeCompletionItem({
label: name,
kind: 'property',
insertText: name,
needsQuotes: true,
detail,
description,
examples,
chainValueType,
chainItemType,
chainDefault,
});
});
}
function getEnumSuggestions(context) {
const resolvedSchema = resolveSchema(context.schema || ROOT_SCHEMA, ROOT_SCHEMA);
if (resolvedSchema.enum) {
const description = resolvedSchema.description && resolvedSchema.description !== resolvedSchema.title
? resolvedSchema.description
: '';
return resolvedSchema.enum.map((value) => normalizeCompletionItem({
label: value,
kind: 'value',
insertText: typeof value === 'string' ? value : JSON.stringify(value),
needsQuotes: typeof value === 'string',
detail: `Enum value for ${resolvedSchema.title || 'property'}`,
description,
}));
}
// A boolean has exactly two possible values, so it's just as pickable as a small enum -
// offer both instead of leaving the reader to type "true"/"false" by hand.
if (inferSchemaType(resolvedSchema) === 'boolean') {
const description = resolvedSchema.description && resolvedSchema.description !== resolvedSchema.title
? resolvedSchema.description
: '';
return [true, false].map((value) => normalizeCompletionItem({
label: String(value),
kind: 'value',
insertText: String(value),
needsQuotes: false,
detail: `Boolean value for ${resolvedSchema.title || 'property'}`,
description,
}));
}
return [];
}
function getTypeSuggestions() {
const segmentSchema = getDefinitionSchema('#/definitions/segment', ROOT_SCHEMA);
const typeSchema = segmentSchema.properties?.type;
return getEnumSuggestions({ schema: resolveSchema(typeSchema, ROOT_SCHEMA) });
}
function getSuggestionItems(context) {
const partialText = context.partialText || '';
if (context.inValue) {
if (context.currentPropertyName === 'type') {
return getTypeSuggestions().filter((item) => item.label.startsWith(partialText));
}
return getEnumSuggestions(context).filter((item) => item.label.startsWith(partialText));
}
return getPropertySuggestions(context).filter((item) => item.label.startsWith(partialText));
}
export function getCompletions(text, format, cursorOffset, schemaScope = 'config') {
if (format !== 'json') {
return [];
}
const context = getCompletionContext(text, cursorOffset, schemaScope);
return getSuggestionItems(context);
}
// Companion to getCompletions() giving the caller precise replacement bounds: how many
// already-typed characters to replace, and whether the cursor sits inside an already-open
// string (so insertText for a needsQuotes item must NOT be re-wrapped in quotes). Normally
// that's just partialText.length, but a bare-literal value slot (see getCompletionContext's
// object/inValue branch) reports a separate replaceLength - the token to discard is there,
// but deliberately isn't the same text used to filter which suggestions to show.
export function getCompletionReplacement(text, format, cursorOffset, schemaScope = 'config') {
if (format !== 'json') {
return { start: cursorOffset, insideOpenString: false };
}
const context = getCompletionContext(text, cursorOffset, schemaScope);
const partialText = context.partialText || '';
const replaceLength = context.replaceLength !== undefined ? context.replaceLength : partialText.length;
return {
start: cursorOffset - replaceLength,
insideOpenString: !!context.insideOpenString,
};
}
@@ -0,0 +1,45 @@
// Language + schema-completion wiring for the editor, kept free of React so it can be unit
// tested (in principle) and reasoned about independently from the mount/update lifecycle in
// index.js. json/yaml get schema-aware completion and hover from schemaCompletion.js's own
// syntax-tree walker (see that file's header for the schema-resolution library workaround it
// replaced); toml has no such support available, so it stays a plain syntax-only mode - it was
// never covered by the old hand-rolled completion.js engine either.
import { json, jsonLanguage } from '@codemirror/lang-json';
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
import { StreamLanguage } from '@codemirror/language';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { schemaCompletionSource, schemaHoverTooltip, schemaHintTheme } from './schemaCompletion';
import { getScopedRootSchema } from './schemaResolution';
// 'segment' (a segment doc's sample editor) completes against the bare segment shape; 'config'
// (the studio, and the default) completes against the full theme schema. Delegates to
// schemaResolution.js's getScopedRootSchema, which resolves the scoped shape against the schema's
// own `definitions` so its `#/definitions/...` refs still resolve regardless of scope.
export function getScopedSchema(schemaScope) {
return getScopedRootSchema(schemaScope);
}
// One extension array per format, swapped into index.js's language Compartment whenever the
// format or schemaScope changes.
export function getLanguageExtensions(format, schemaScope) {
switch (format) {
case 'json':
return [
json(),
jsonLanguage.data.of({ autocomplete: schemaCompletionSource(schemaScope) }),
schemaHoverTooltip(schemaScope),
schemaHintTheme,
];
case 'yaml':
return [
yaml(),
yamlLanguage.data.of({ autocomplete: schemaCompletionSource(schemaScope) }),
schemaHoverTooltip(schemaScope),
schemaHintTheme,
];
case 'toml':
return [StreamLanguage.define(toml)];
default:
return [];
}
}
@@ -0,0 +1,105 @@
// The editor's colors, kept independent of the language extensions (editorExtensions.js) and
// swapped into index.js's own theme Compartment whenever @docusaurus/theme-common's colorMode
// flips - the same two palettes the old prism-react-renderer setup used (see the removed
// DARK_CODE_THEME/LIGHT_CODE_THEME in index.js's git history: palenight for dark, github for
// light), reproduced as a CodeMirror HighlightStyle/EditorView.theme pair so a doc's fenced code
// (still prism-react-renderer, via @theme/CodeBlock) and this editor keep reading as the same
// family even though the two no longer share a tokenizer.
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags } from '@lezer/highlight';
const DARK_COLORS = {
background: '#292d3e',
foreground: '#bfc7d5',
property: '#c792ea',
string: '#c3e88d',
literal: '#f78c6c',
comment: '#697098',
punctuation: '#89ddff',
};
const LIGHT_COLORS = {
background: '#f6f8fa',
foreground: '#24292e',
property: '#005cc5',
string: '#032f62',
literal: '#005cc5',
comment: '#6a737d',
punctuation: '#24292e',
};
// Shared between both palettes: only the color values differ, not which tags map to which
// role. YAML keys can tokenize as either a bare propertyName or, depending on the grammar's own
// node naming, a `definition(propertyName)` wrapper - both are mapped so a yaml key gets the
// same color a json key does regardless of which one @codemirror/lang-yaml happens to emit.
function buildHighlightStyle(colors) {
return HighlightStyle.define([
{ tag: [tags.propertyName, tags.definition(tags.propertyName), tags.attributeName], color: colors.property },
{ tag: tags.string, color: colors.string },
{ tag: [tags.number, tags.bool, tags.null], color: colors.literal },
{ tag: tags.keyword, color: colors.property },
{ tag: tags.comment, color: colors.comment, fontStyle: 'italic' },
{ tag: tags.punctuation, color: colors.punctuation },
]);
}
const DARK_HIGHLIGHT_STYLE = buildHighlightStyle(DARK_COLORS);
const LIGHT_HIGHLIGHT_STYLE = buildHighlightStyle(LIGHT_COLORS);
// Mirrors EDITOR_PADDING/.gutter's own padding from the removed react-simple-code-editor setup,
// so line 1 still sits flush with the top of the frame instead of gaining a visible gap now that
// CodeMirror owns its own gutter/content layout.
const CONTENT_PADDING = '0.75rem';
function buildThemeExtension(colors, dark) {
return EditorView.theme(
{
'&': {
backgroundColor: colors.background,
color: colors.foreground,
},
'.cm-content': {
fontFamily: 'var(--ifm-font-family-monospace)',
caretColor: colors.foreground,
padding: CONTENT_PADDING,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: colors.foreground,
},
// Matches the old .gutter's own background/border-less look (styles.module.css) - CM's
// default gutter otherwise draws a visible seam against `.cm-content` above.
'.cm-gutters': {
backgroundColor: colors.background,
color: colors.comment,
border: 'none',
},
'.cm-activeLineGutter, .cm-activeLine': {
backgroundColor: 'transparent',
},
'.cm-tooltip': {
backgroundColor: 'var(--omp-card-background)',
border: '1px solid var(--omp-card-border-color)',
borderRadius: 'var(--omp-card-radius)',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--ifm-color-primary)',
color: '#fff',
},
},
{ dark },
);
}
const DARK_THEME_EXTENSION = buildThemeExtension(DARK_COLORS, true);
const LIGHT_THEME_EXTENSION = buildThemeExtension(LIGHT_COLORS, false);
// One array per color mode, swapped whole into index.js's theme Compartment - keeping the
// EditorView.theme (chrome colors) and the HighlightStyle (token colors) paired together here
// means a caller only ever has to think about "dark or light", not keep two separate pieces in
// sync by hand.
export function getThemeExtensions(colorMode) {
return colorMode === 'dark'
? [DARK_THEME_EXTENSION, syntaxHighlighting(DARK_HIGHLIGHT_STYLE)]
: [LIGHT_THEME_EXTENSION, syntaxHighlighting(LIGHT_HIGHLIGHT_STYLE)];
}
@@ -0,0 +1,113 @@
// Draws the caller-supplied syntax-error squiggle (errorLocation/errorMessage - see index.js's
// own prop doc comments) as a decoration, entirely separate from @codemirror/lint's diagnostic
// state (there is no schema-validation linter wired in at all - see editorExtensions.js - so
// today that state is simply empty, but the separation still matters): errorLocation is derived
// by the CALLER (Studio/index.js, Config.js) from a client-side re-parse that can catch a
// slightly different, more specific error than whatever CodeMirror-native diagnostics a future
// linter might add - piggybacking on the lint StateField would mean whichever ran last silently
// overwrote the other instead of both being able to show what they know.
import { StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, hoverTooltip } from '@codemirror/view';
const setExternalErrorEffect = StateEffect.define();
// Turns a { line, column, endColumn } (all 1-based, matching errorPosition.js's own convention)
// into a document offset range, or null if the location no longer exists in the current
// document - the error came from a re-parse of a PREVIOUS value of the text (see the callers'
// own debounce), so by the time this runs the doc may already have grown/shrunk past it.
function toDocRange(doc, location) {
if (!location || location.line < 1 || location.line > doc.lines) {
return null;
}
const line = doc.line(location.line);
const from = Math.min(line.to, Math.max(line.from, line.from + location.column - 1));
const to = Math.min(line.to, Math.max(from, line.from + location.endColumn - 1));
if (to <= from) {
return null;
}
return { from, to, message: location.message };
}
const errorMarkDeco = Decoration.mark({ class: 'cm-omp-external-error' });
// Stores the raw { line, column, endColumn, message } location the caller last supplied, not a
// pre-computed doc range - a plain keystroke elsewhere in the document changes what offset "line
// N" maps to without changing the location itself, so the range is re-derived from the CURRENT
// doc on every read (see the decorations/hover providers below) instead of tracked incrementally.
const externalErrorField = StateField.define({
create() {
return null;
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setExternalErrorEffect)) {
value = effect.value;
}
}
return value;
},
provide: (field) =>
EditorView.decorations.of((view) => {
const range = toDocRange(view.state.doc, view.state.field(field));
return range ? Decoration.set([errorMarkDeco.range(range.from, range.to)]) : Decoration.none;
}),
});
// Mirrors ErrorIndicator.js's own tooltip title/copy ("Config error" + the raw message) so
// hovering the squiggle and hovering the warning triangle in the actions row read as the same
// message surfaced two ways, not two different explanations of the same problem.
const externalErrorHover = hoverTooltip((view, pos) => {
const range = toDocRange(view.state.doc, view.state.field(externalErrorField, false));
if (!range || pos < range.from || pos > range.to) {
return null;
}
return {
pos: range.from,
end: range.to,
above: true,
create() {
const dom = document.createElement('div');
dom.className = 'cm-omp-external-error-tooltip';
const title = document.createElement('div');
title.className = 'cm-omp-external-error-tooltip-title';
title.textContent = 'Config error';
const text = document.createElement('div');
text.textContent = range.message;
dom.append(title, text);
return { dom };
},
};
});
// baseTheme rather than a styles.module.css rule: this module has no CSS Module of its own, and
// a plain global class here would leak into every consumer of the site's stylesheet - baseTheme
// scopes it to CodeMirror's own generated stylesheet the same way the rest of the theme
// extensions (editorTheme.js) do.
const externalErrorBaseTheme = EditorView.baseTheme({
'.cm-omp-external-error': {
textDecoration: 'underline wavy var(--omp-error-color, #f07178)',
textUnderlineOffset: '2px',
},
'.cm-omp-external-error-tooltip': {
padding: '0.5rem 0.65rem',
maxWidth: '18rem',
fontSize: '0.85rem',
lineHeight: '1.4',
},
'.cm-omp-external-error-tooltip-title': {
fontWeight: '600',
marginBottom: '0.25rem',
},
});
export const externalErrorExtension = [externalErrorField, externalErrorHover, externalErrorBaseTheme];
// Dispatched from index.js whenever the errorLocation/errorMessage props change - `error` is
// either { line, column, endColumn, message } or null/undefined for "clear the squiggle".
export function setExternalError(view, error) {
view.dispatch({ effects: setExternalErrorEffect.of(error || null) });
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,805 @@
// CodeMirror 6 completion + hover, built directly on the lezer syntax tree instead of the old
// completion.js's char-by-char text scanners (see schemaResolution.js's own header comment) or
// the third-party json-schema-library resolver a prior version of this editor delegated to,
// which was broken on this schema (see schemaResolution.js's own git-history reference).
// @codemirror/lang-json and @codemirror/lang-yaml both keep producing a real (if partial/error-
// flagged) tree through mid-edit states, which is exactly the case this whole rewrite exists for:
// a blank line inside an object, a dangling unterminated key, a value slot that's just a bare
// `key:`. Walking the tree handles all of those as a side effect of how lezer's incremental
// parser already recovers from them, rather than needing bespoke recovery logic of our own for
// each shape.
import { syntaxTree } from '@codemirror/language';
import { startCompletion } from '@codemirror/autocomplete';
import { EditorView, hoverTooltip } from '@codemirror/view';
import {
resolveSchema,
deriveValueSchema,
mergeTypeBranch,
getItemSchema,
inferSchemaType,
buildPropertyHint,
getScopedRootSchema,
} from './schemaResolution';
function children(node) {
const out = [];
for (let child = node.firstChild; child; child = child.nextSibling) {
out.push(child);
}
return out;
}
function textOf(state, node) {
return state.doc.sliceString(node.from, node.to);
}
// Column of `offset` on its own line - used only to compare indentation between a blank line and
// a dangling `key:` above it (see the BlockMapping branch in walkYaml), never to scan content.
function lineIndent(doc, offset) {
return offset - doc.lineAt(offset).from;
}
// @codemirror/lang-json's PropertyName/String nodes always include their surrounding quotes in
// their own text (an unterminated one becomes an error node instead - see below) - strip them
// for schema/property-name lookups without caring whether the closing quote is even there yet.
function stripJsonQuotes(text) {
if (text.startsWith('"')) {
return text.endsWith('"') && text.length > 1 ? text.slice(1, -1) : text.slice(1);
}
return text;
}
function yamlLiteralText(state, node) {
const text = textOf(state, node);
if (node.name === 'QuotedLiteral') {
if (text.length > 1 && (text.startsWith('"') || text.startsWith("'")) && text.endsWith(text[0])) {
return text.slice(1, -1);
}
return text.replace(/^['"]/, '');
}
return text;
}
// Collects the property/pair names directly inside a container node - the same "sibling keys
// already present" exclusion the old engine tracked by hand (see completion.js's
// collectForwardKeys), but trivial here: the tree already has every sibling as a direct child
// regardless of which side of the cursor it's on, unlike a forward-only text scan.
function siblingKeys(state, containerNode, isJson) {
const entryName = isJson ? 'Property' : 'Pair';
const keys = [];
children(containerNode).forEach((kid) => {
if (kid.name !== entryName) {
return;
}
const kids = children(kid);
if (isJson) {
const nameNode = kids.find((k) => k.name === 'PropertyName');
if (nameNode) {
keys.push(stripJsonQuotes(textOf(state, nameNode)));
}
} else {
const keyNode = kids.find((k) => k.name === 'Key');
const literal = keyNode && children(keyNode)[0];
if (keyNode) {
keys.push(literal ? yamlLiteralText(state, literal) : textOf(state, keyNode));
}
}
});
return keys;
}
// mergeTypeBranch only does something for a schema that actually declares per-`type`
// conditionals (definitions.segment, today) - reading every object/mapping's OWN "type"
// property here and applying it unconditionally is what makes a segment's `options:` mapping
// offer its type-specific properties (e.g. path's `folder_icon`) without this walker needing to
// know which schemas are "the kind with branches" and which aren't.
function applyOwnTypeBranch(state, containerNode, schemaNode, isJson) {
const entryName = isJson ? 'Property' : 'Pair';
for (const kid of children(containerNode)) {
if (kid.name !== entryName) {
continue;
}
const kids = children(kid);
if (isJson) {
const nameNode = kids.find((k) => k.name === 'PropertyName');
if (!nameNode || stripJsonQuotes(textOf(state, nameNode)) !== 'type') {
continue;
}
const valueNode = kids.find((k) => k !== nameNode && k.name !== ':');
if (!valueNode) {
continue;
}
const value = valueNode.name === 'String' ? stripJsonQuotes(textOf(state, valueNode)) : textOf(state, valueNode);
return mergeTypeBranch(schemaNode, value);
}
const keyNode = kids.find((k) => k.name === 'Key');
if (!keyNode) {
continue;
}
const keyLiteral = children(keyNode)[0];
const keyName = keyLiteral ? yamlLiteralText(state, keyLiteral) : textOf(state, keyNode);
if (keyName !== 'type') {
continue;
}
const valueNode = kids.find((k) => k.name === 'Literal' || k.name === 'QuotedLiteral');
if (!valueNode) {
continue;
}
return mergeTypeBranch(schemaNode, yamlLiteralText(state, valueNode));
}
return schemaNode;
}
// A blank/dangling line at the very end of the document sits past the range of every real node
// (lezer never extends a block container's range to cover trailing whitespace it hasn't seen
// content resume after) - `resolveInner` lands on Stream/Document instead of the mapping the
// reader is actually inside. Falls back to the deepest still-open mapping/sequence by following
// the tree's own rightmost spine, so typing at the true end of the file still resolves against
// *some* real container instead of nothing. (A blank line that also dedents back out to a
// shallower level at the very end of the file is the one shape this doesn't recover - the tree
// simply carries no signal for it beyond raw indentation, which is exactly the kind of char-by-
// char scanning this rewrite exists to avoid re-introducing.)
function findDeepestOpenYamlContainer(tree) {
let candidate = null;
let cur = tree.topNode.lastChild;
while (cur) {
if (cur.name === 'BlockMapping' || cur.name === 'BlockSequence') {
candidate = cur;
cur = cur.lastChild;
} else if (cur.name === 'Document' || cur.name === 'Pair' || cur.name === 'Item') {
cur = cur.lastChild;
} else {
break;
}
}
return candidate;
}
// Walks from the document root down to `pos`, resolving the schema at each level (object
// property steps via deriveValueSchema, array element steps via getItemSchema, each object's
// own `type` sibling via applyOwnTypeBranch) and classifying what's at `pos` itself: a property
// KEY being typed, or a property VALUE. Returns null when `pos` doesn't land anywhere completion
// can make sense of (e.g. inside a comment, or - see findDeepestOpenYamlContainer - a dedent back
// out to a shallower level at the very end of the file).
function walkYaml(state, pos, rootSchema) {
const tree = syntaxTree(state);
let node = tree.resolveInner(pos, -1);
if (node.name === 'Stream' || node.name === 'Document') {
node = findDeepestOpenYamlContainer(tree) || node;
}
if (node.name === 'Stream' || node.name === 'Document') {
return null;
}
const path = [];
for (let n = node; n; n = n.parent) {
path.unshift(n);
}
let containerSchema = rootSchema;
let containerNode = null;
path.forEach((cur) => {
if (cur.name === 'BlockMapping') {
containerSchema = applyOwnTypeBranch(state, cur, containerSchema, false);
containerNode = cur;
} else if (cur.name === 'Pair') {
const keyNode = children(cur).find((k) => k.name === 'Key');
// Strictly BEFORE pos: with the cursor sitting exactly at the end of a key still being
// typed (lezer wraps even a dangling colon-less line into a Pair/Key when it recovers),
// `<=` would descend into that half-typed name as if it were a real property and clobber
// the container schema to {} - the key branches below then have nothing to suggest from.
// A genuine value position always has at least the `:` between key end and cursor.
if (keyNode && keyNode.to < pos) {
const literal = children(keyNode)[0];
const propertyName = literal ? yamlLiteralText(state, literal) : textOf(state, keyNode);
containerSchema = deriveValueSchema(containerSchema, propertyName);
}
} else if (cur.name === 'BlockSequence') {
containerSchema = getItemSchema(containerSchema);
}
});
const final = path[path.length - 1];
const parent = final.parent;
// An existing, fully-typed key - re-triggering completion (or hovering) over it. containerSchema
// is still the ENCLOSING mapping's own schema (the Pair step above only advances past a key
// whose range ends at/before `pos`, which isn't true while `pos` is still inside it). A Key
// node's own range is IDENTICAL to its Literal/QuotedLiteral child's (there's no extra
// punctuation between them, unlike a Pair which also owns the trailing `:`), so `resolveInner`
// always descends past Key into that child when `pos` sits inside it - `final` here is
// therefore the literal, not the Key wrapper, whenever `pos` isn't sitting exactly on Key's own
// (identical) boundary.
const keyLiteralChild = (final.name === 'Literal' || final.name === 'QuotedLiteral') && parent?.name === 'Key';
if (final.name === 'Key' || keyLiteralChild) {
const keyNode = keyLiteralChild ? parent : final;
const literal = keyLiteralChild ? final : children(final)[0];
const raw = literal ? textOf(state, literal) : textOf(state, keyNode);
const quoted = literal?.name === 'QuotedLiteral';
const tokenFrom = quoted ? literal.from + 1 : keyNode.from;
return {
kind: 'key',
schema: containerSchema,
partialText: state.doc.sliceString(tokenFrom, pos),
from: tokenFrom,
quoteOpen: quoted,
usedKeys: containerNode ? siblingKeys(state, containerNode, false) : [],
keyToken: { from: tokenFrom, to: quoted ? literal.to - 1 : keyNode.to, name: raw.replace(/^['"]|['"]$/g, '') },
};
}
// A dangling bare key with no `:` yet (` f`) - lezer-yaml can't attach it to a Pair, so
// it surfaces as an error node directly under the enclosing mapping, a sibling of the real Pairs.
if (final.type.isError && parent?.name === 'BlockMapping') {
return {
kind: 'key',
schema: containerSchema,
partialText: state.doc.sliceString(final.from, pos),
from: final.from,
usedKeys: siblingKeys(state, parent, false),
};
}
// A blank indented line inside a mapping - resolveInner lands on the mapping itself, since the
// gap between two Pair children (or before the first/after the last) belongs to no child node.
// BUT a `key:` with nothing typed after it yet collapses into this exact same shape, whether
// `pos` sits right after the colon on the SAME line (`style: `) or on a deeper-indented blank
// line below it that was clearly meant to become that key's own nested mapping (`options:`
// followed by a blank indented line, before anything under it exists yet) - the Pair's own
// range ends at the `:` either way, leaving everything after it as unclaimed mapping content.
// Both are told apart from a genuinely blank line (nothing dangling immediately above `pos`)
// by hand, and from each other by comparing indentation and the dangling key's own value type.
if (final.name === 'BlockMapping') {
const danglingPair = children(final).slice().reverse().find((k) => k.name === 'Pair' && k.to <= pos);
const dkids = danglingPair ? children(danglingPair) : [];
const keyNode = dkids.find((k) => k.name === 'Key');
const hasValue = dkids.some((k) => k.name !== 'Key' && k.name !== ':');
if (danglingPair && keyNode && !hasValue) {
const literal = children(keyNode)[0];
const propertyName = literal ? yamlLiteralText(state, literal) : null;
const sameLine = !state.doc.sliceString(danglingPair.to, pos).includes('\n');
const nested = lineIndent(state.doc, pos) > lineIndent(state.doc, keyNode.from);
if (propertyName && (sameLine || nested)) {
const valueSchema = deriveValueSchema(containerSchema, propertyName);
const isObjectValue = inferSchemaType(resolveSchema(valueSchema)) === 'object';
if (nested && isObjectValue) {
return { kind: 'key', schema: valueSchema, partialText: '', from: pos, usedKeys: [] };
}
return { kind: 'value', schema: valueSchema, propertyName, partialText: '', from: pos };
}
}
return {
kind: 'key',
schema: containerSchema,
partialText: '',
from: pos,
usedKeys: siblingKeys(state, final, false),
};
}
// `key:` with nothing typed after it yet (not even a space) - the Pair has no value child at
// all, so `pos` resolves to the Pair itself. containerSchema already advanced past this pair's
// own key (the Pair step above ran, since the key necessarily ends before `pos` here).
if (final.name === 'Pair') {
const keyNode = children(final).find((k) => k.name === 'Key');
const literal = keyNode && children(keyNode)[0];
return {
kind: 'value',
schema: containerSchema,
propertyName: literal ? yamlLiteralText(state, literal) : null,
partialText: '',
from: pos,
};
}
// A scalar value already/being typed (`style: pl`, `foreground: p:blue`) - containerSchema was
// already advanced to this property's own value schema by the Pair step above.
if ((final.name === 'Literal' || final.name === 'QuotedLiteral') && parent?.name === 'Pair') {
const quoted = final.name === 'QuotedLiteral';
const tokenFrom = quoted ? final.from + 1 : final.from;
const keyNode = children(parent).find((k) => k.name === 'Key');
const literal = keyNode && children(keyNode)[0];
// Same bare-literal rule as walkJson: filtering true/false by the literal already sitting
// there would filter the other one out - YAML has no distinct node names for booleans, so
// the token's own text decides.
const bareLiteral = !quoted && /^(true|false|null|\d+(\.\d+)?)$/.test(textOf(state, final));
return {
kind: 'value',
schema: containerSchema,
propertyName: literal ? yamlLiteralText(state, literal) : null,
partialText: bareLiteral ? '' : state.doc.sliceString(tokenFrom, pos),
from: tokenFrom,
quoteOpen: quoted,
};
}
// Right after `- ` in a block sequence, before any content of the new item exists yet -
// containerSchema is already this sequence's item schema (the BlockSequence step above).
// Segment-shaped items (an object) offer property names; anything else offers a bare value.
if (final.name === 'BlockSequence' || final.name === 'Item') {
const isObjectItem = inferSchemaType(resolveSchema(containerSchema)) === 'object';
return {
kind: isObjectItem ? 'key' : 'value',
schema: containerSchema,
propertyName: null,
partialText: '',
from: pos,
usedKeys: [],
};
}
return null;
}
function walkJson(state, pos, rootSchema) {
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, -1);
const path = [];
for (let n = node; n; n = n.parent) {
path.unshift(n);
}
let containerSchema = rootSchema;
let containerNode = null;
path.forEach((cur) => {
if (cur.name === 'Object') {
containerSchema = applyOwnTypeBranch(state, cur, containerSchema, true);
containerNode = cur;
} else if (cur.name === 'Property') {
const nameNode = children(cur).find((k) => k.name === 'PropertyName');
// Strictly BEFORE pos - same off-by-one as walkYaml's Pair step: at pos == nameNode.to
// (cursor right on the name's closing quote) this is still a KEY position, and stepping
// into the possibly-partial name would resolve the container schema to {}.
if (nameNode && nameNode.to < pos) {
containerSchema = deriveValueSchema(containerSchema, stripJsonQuotes(textOf(state, nameNode)));
}
} else if (cur.name === 'Array') {
containerSchema = getItemSchema(containerSchema);
}
});
const final = path[path.length - 1];
const parent = final.parent;
// An existing, fully-closed property name being re-visited.
if (final.name === 'PropertyName') {
return {
kind: 'key',
schema: containerSchema,
partialText: state.doc.sliceString(final.from + 1, pos),
from: final.from + 1,
quoteOpen: true,
usedKeys: containerNode ? siblingKeys(state, containerNode, true) : [],
keyToken: { from: final.from + 1, to: final.to - 1, name: stripJsonQuotes(textOf(state, final)) },
};
}
// An error node under Object/Property: either an unterminated `"partial` key/value string, or
// (a trailing comma then nothing before the closing brace) a genuinely empty, zero-width node.
// Only the former has a leading quote to skip - the zero-width case has no text to skip at all.
if (final.type.isError) {
const text = textOf(state, final);
const hasQuote = text.startsWith('"');
const tokenFrom = hasQuote ? final.from + 1 : final.from;
if (parent?.name === 'Object') {
return {
kind: 'key',
schema: containerSchema,
partialText: state.doc.sliceString(tokenFrom, pos),
from: tokenFrom,
quoteOpen: hasQuote,
usedKeys: siblingKeys(state, parent, true),
};
}
if (parent?.name === 'Property') {
const nameNode = children(parent).find((k) => k.name === 'PropertyName');
return {
kind: 'value',
schema: containerSchema,
propertyName: nameNode ? stripJsonQuotes(textOf(state, nameNode)) : null,
partialText: state.doc.sliceString(tokenFrom, pos),
from: tokenFrom,
quoteOpen: hasQuote,
};
}
}
// A blank/comma-adjacent position inside an object with no partial token at all yet.
if (final.name === 'Object') {
return {
kind: 'key',
schema: containerSchema,
partialText: '',
from: pos,
quoteOpen: false,
usedKeys: siblingKeys(state, final, true),
};
}
// `"prop":` with no value yet - Property has only PropertyName + ':' as children.
if (final.name === 'Property') {
const nameNode = children(final).find((k) => k.name === 'PropertyName');
return {
kind: 'value',
schema: containerSchema,
propertyName: nameNode ? stripJsonQuotes(textOf(state, nameNode)) : null,
partialText: '',
from: pos,
quoteOpen: false,
};
}
// Inside an (empty) array - e.g. right after a seeded `[]`'s opening bracket. The path loop
// above already stepped containerSchema down to the array's ITEM schema, so string-enum items
// get their value popup here; anything else yields no options and the popup simply stays shut.
if (final.name === 'Array') {
return {
kind: 'value',
schema: containerSchema,
propertyName: null,
partialText: '',
from: pos,
quoteOpen: false,
};
}
// An already-closed string/number/bool value being re-visited. A bare literal (true/false/
// null/number - typically one this editor just seeded as a default) deliberately reports an
// EMPTY partialText while still replacing the whole token: filtering true/false by the literal
// that's already there would just filter the other one out, defeating the point of offering
// both. Strings keep prefix filtering - there, narrowing as the reader types is the point.
if (parent?.name === 'Property' && final.name !== 'PropertyName') {
const nameNode = children(parent).find((k) => k.name === 'PropertyName');
const quoted = final.name === 'String';
const bareLiteral = ['True', 'False', 'Null', 'Number'].includes(final.name);
const tokenFrom = quoted ? final.from + 1 : final.from;
return {
kind: 'value',
schema: containerSchema,
propertyName: nameNode ? stripJsonQuotes(textOf(state, nameNode)) : null,
partialText: bareLiteral ? '' : state.doc.sliceString(tokenFrom, pos),
from: bareLiteral ? final.from : tokenFrom,
quoteOpen: quoted,
};
}
return null;
}
function detectIsJson(state) {
return syntaxTree(state).topNode.name === 'JsonText';
}
export const schemaHintTheme = EditorView.baseTheme({
'.cm-omp-schema-hint': {
padding: '0.5rem 0.65rem',
maxWidth: '18rem',
fontSize: '0.85rem',
lineHeight: '1.4',
},
'.cm-omp-schema-hint-title': {
fontWeight: '600',
marginBottom: '0.25rem',
},
'.cm-omp-schema-hint-examples': {
marginTop: '0.25rem',
opacity: '0.75',
},
});
function buildHintDom(title, resolvedProp) {
const { description, examples } = buildPropertyHint(resolvedProp);
const dom = document.createElement('div');
dom.className = 'cm-omp-schema-hint';
const titleEl = document.createElement('div');
titleEl.className = 'cm-omp-schema-hint-title';
titleEl.textContent = title;
dom.append(titleEl);
if (description) {
const text = document.createElement('div');
text.textContent = description;
dom.append(text);
}
if (examples) {
const ex = document.createElement('div');
ex.className = 'cm-omp-schema-hint-examples';
ex.textContent = `Examples: ${examples.join(', ')}`;
dom.append(ex);
}
return dom;
}
// closeBrackets auto-pairs a typed quote, so when the walker says the cursor sits inside an open
// string (quoteOpen), the quote right after the replaced range is OUR token's auto-inserted
// closer, not the next token's opener - it must be consumed by the replacement or it survives as
// a stray `"` after the inserted text (`"maps": "`). Only ever done under quoteOpen: without it,
// a quote at `end` really is an unrelated neighbour's opening quote and must stay.
function consumeAutoClosedQuote(doc, start, end) {
const quote = doc.sliceString(start - 1, start);
if ((quote === '"' || quote === "'") && doc.sliceString(end, end + 1) === quote) {
return end + 1;
}
return end;
}
// Whether a `,` must follow whatever gets inserted at `end` for the JSON to stay valid: yes
// whenever the next non-whitespace thing is another member/element, no when it's a closer, an
// existing comma, or the end of the document (a trailing comma there would itself be invalid).
function missingJsonComma(doc, end) {
const rest = doc.sliceString(end, Math.min(doc.length, end + 500));
const next = rest.match(/^\s*(\S)/);
return next && !/[,}\]]/.test(next[1]) ? ',' : '';
}
// What to put in the value slot of a just-completed JSON property so the document is valid the
// moment the completion lands, even if the reader stops right there - the old editor's chain
// seeding, rebuilt. cursorStart/cursorEnd are offsets into `text`: equal for a plain caret (e.g.
// inside the seeded `""`), a real span for a seeded default literal so overtyping it (`true` over
// a defaulted `false`) is a single keystroke. `chain` opens the value popup right away - only
// when it would actually have something to show.
function buildJsonValueSeed(resolvedProp) {
// Chained seeds (a value popup opens right away) must leave a plain CARET at the end of the
// literal, never a range selection over it: @codemirror/autocomplete anchors both its query
// position and its replace range at selection.main.FROM (see its `cur()` helper), so a
// selection spanning the literal collapses the accept-range to zero width and the picked
// value gets INSERTED next to the seeded one instead of replacing it. The walker's
// whole-token rule for bare literals is what makes accepting over the caret replace the
// seed. Only the unchained number seed below keeps the overtype selection - no popup is
// involved there, so the quirk never triggers.
if (Array.isArray(resolvedProp.enum)) {
if (typeof resolvedProp.enum[0] === 'string') {
return { text: '""', cursorStart: 1, cursorEnd: 1, chain: true };
}
const literal = JSON.stringify(resolvedProp.default !== undefined ? resolvedProp.default : resolvedProp.enum[0]);
return { text: literal, cursorStart: literal.length, cursorEnd: literal.length, chain: true };
}
const type = inferSchemaType(resolvedProp);
if (type === 'boolean') {
const literal = JSON.stringify(resolvedProp.default !== undefined ? resolvedProp.default : false);
return { text: literal, cursorStart: literal.length, cursorEnd: literal.length, chain: true };
}
if (type === 'integer' || type === 'number') {
const literal = JSON.stringify(resolvedProp.default !== undefined ? resolvedProp.default : 0);
return { text: literal, cursorStart: 0, cursorEnd: literal.length, chain: false };
}
if (type === 'object') {
// Chaining straight into the nested object's own property popup only makes sense when the
// schema actually names properties to offer.
return { text: '{}', cursorStart: 1, cursorEnd: 1, chain: Object.keys(resolvedProp.properties || {}).length > 0 };
}
if (type === 'array') {
// Item completion inside the seeded `[]` comes from walkJson's Array branch; chain only for
// string-enum items, where the popup has values to offer immediately.
return { text: '[]', cursorStart: 1, cursorEnd: 1, chain: Array.isArray(getItemSchema(resolvedProp).enum) };
}
// string, and the untyped/anyOf leftovers where a string is the least-wrong valid placeholder.
return { text: '""', cursorStart: 1, cursorEnd: 1, chain: false };
}
function buildPropertyOption(name, propSchema, isJson, quoteOpen) {
const resolvedProp = resolveSchema(propSchema);
const title = resolvedProp.title || name;
const chainType = inferSchemaType(resolvedProp);
return {
label: name,
type: 'property',
detail: resolvedProp.title || '',
info: () => buildHintDom(title, resolvedProp),
apply(view, _completion, from, to) {
const doc = view.state.doc;
let start = from;
let end = to;
if (quoteOpen) {
end = consumeAutoClosedQuote(doc, start, end);
if (!isJson) {
// A YAML key the reader started quoting: schema property names never need quotes, so
// swallow the opening quote too instead of leaving `"maps: `.
start -= 1;
}
}
// Re-completing over a key whose `:` already exists (or whose value slot the caller
// seeded) must not double the separator - and must not seed a second value either.
const hasColon = /^[ \t]*:/.test(doc.sliceString(end, end + 8));
const base = isJson ? (quoteOpen ? `${name}"` : `"${name}"`) : name;
let insertText;
let anchor;
let head;
let shouldChain = false;
if (isJson && !hasColon) {
const seed = buildJsonValueSeed(resolvedProp);
const comma = missingJsonComma(doc, end);
const valueStart = start + base.length + 2;
insertText = `${base}: ${seed.text}${comma}`;
anchor = valueStart + seed.cursorStart;
head = valueStart + seed.cursorEnd;
shouldChain = seed.chain;
} else {
insertText = `${base}${hasColon ? '' : ': '}`;
anchor = start + insertText.length;
head = anchor;
// YAML stays valid with an empty value slot (`key: ` is a null), so no seeding - just
// open the value popup when it has something to show.
shouldChain = !hasColon && (chainType === 'boolean' || Array.isArray(resolvedProp.enum));
}
view.dispatch({
changes: { from: start, to: end, insert: insertText },
selection: { anchor, head },
});
if (shouldChain) {
startCompletion(view);
}
},
};
}
function buildEnumOption(value, isJson, quoteOpen) {
const label = String(value);
const isString = typeof value === 'string';
return {
label,
type: 'keyword',
apply(view, _completion, from, to) {
const doc = view.state.doc;
let start = from;
let end = to;
let insertText;
if (quoteOpen) {
const quote = doc.sliceString(start - 1, start);
end = consumeAutoClosedQuote(doc, start, end);
if (isString) {
// The opening quote the reader typed stays; re-close it ourselves since the
// auto-paired closer (if any) was consumed above.
insertText = `${label}${quote === "'" ? "'" : '"'}`;
} else {
// A bare literal (true/false/number) inside a hand-opened string would end up a
// STRING (`"true"`) - drop the opening quote along with the closer.
start -= 1;
insertText = label;
}
} else {
insertText = isJson && isString ? `"${label}"` : label;
}
// A completed value followed by another member needs its separator right away, or the
// document is invalid the moment the popup closes. Cursor stays before the comma - the
// value is what the reader was placing, the comma is just bookkeeping.
const comma = isJson ? missingJsonComma(doc, end) : '';
const anchor = start + insertText.length;
view.dispatch({
changes: { from: start, to: end, insert: insertText + comma },
selection: { anchor },
});
},
};
}
function buildValueOptions(resolvedSchema, isJson, quoteOpen) {
if (Array.isArray(resolvedSchema.enum)) {
return resolvedSchema.enum.map((value) => buildEnumOption(value, isJson, quoteOpen));
}
// A boolean has exactly two possible values, so it's just as pickable as a small enum - offer
// both instead of leaving the reader to type "true"/"false" by hand.
if (inferSchemaType(resolvedSchema) === 'boolean') {
return [true, false].map((value) => buildEnumOption(value, isJson, quoteOpen));
}
return [];
}
// CompletionSource for the language's own `data.of({ autocomplete })` facet (see
// editorExtensions.js) - one instance per schemaScope, reused across keystrokes.
export function schemaCompletionSource(schemaScope) {
const rootSchema = getScopedRootSchema(schemaScope);
return (context) => {
const { state, pos } = context;
const isJson = detectIsJson(state);
const info = isJson ? walkJson(state, pos, rootSchema) : walkYaml(state, pos, rootSchema);
if (!info) {
return null;
}
const resolved = resolveSchema(info.schema);
let options;
if (info.kind === 'key') {
const used = new Set(info.usedKeys || []);
options = Object.entries(resolved.properties || {})
.filter(([name]) => !used.has(name) && name.startsWith(info.partialText))
// Deprecated keys (e.g. the segment's v3 `properties` alias for `options`) stay out of
// the popup - suggesting them would steer new configs onto them. Hover over one already
// typed in a legacy config still works and shows the schema's own deprecation notice.
// The `options` key itself only means something for a segment type whose schema branch
// actually defines options (see mergeTypeBranch) - for a type without any, the resolved
// schema is the base bare object, and suggesting the key would only lead the reader
// into an empty mapping with an empty popup inside it.
.filter(([name, propSchema]) => {
const resolvedProp = resolveSchema(propSchema);
if (resolvedProp.deprecated) {
return false;
}
return name !== 'options' || Object.keys(resolvedProp.properties || {}).length > 0;
})
.map(([name, propSchema]) => buildPropertyOption(name, propSchema, isJson, !!info.quoteOpen));
} else {
options = buildValueOptions(resolved, isJson, !!info.quoteOpen)
.filter((option) => option.label.startsWith(info.partialText));
}
if (!options.length) {
return null;
}
// filter: false - the source already prefix-filtered against partialText, and CodeMirror's
// own second filtering pass would re-match options against the raw doc text between `from`
// and the caret, which breaks the bare-literal case: a seeded `false` under the caret would
// filter `true` right back out of the popup the walker deliberately left it in for. No
// validFor either - without CodeMirror's filter, narrowing as the reader types has to come
// from re-running this source on every keystroke (a tree walk; cheap at typing speed).
return { from: info.from, options, filter: false };
};
}
// The hoverTooltip() source itself, kept separate from the extension it's wrapped into below so
// it can be exercised directly (it only ever reads `view.state`, so a plain `{ state }` stands in
// for a real EditorView in a test). Hovering a property KEY already in the doc. Reuses the same
// walker as completion: at the hover position the walker naturally resolves the ENCLOSING
// container's schema (it only advances past a key once `pos` is past its end, which isn't true
// while the mouse sits inside it), so `containerSchema.properties[keyToken.name]` is exactly this
// key's own definition.
export function schemaHoverSource(schemaScope) {
const rootSchema = getScopedRootSchema(schemaScope);
return (view, pos) => {
const { state } = view;
const isJson = detectIsJson(state);
const info = isJson ? walkJson(state, pos, rootSchema) : walkYaml(state, pos, rootSchema);
if (!info || info.kind !== 'key' || !info.keyToken) {
return null;
}
const resolved = resolveSchema(info.schema);
const propSchema = resolved.properties?.[info.keyToken.name];
if (!propSchema) {
return null;
}
const resolvedProp = resolveSchema(propSchema);
const title = resolvedProp.title || info.keyToken.name;
return {
pos: info.keyToken.from,
end: info.keyToken.to,
above: true,
create() {
return { dom: buildHintDom(title, resolvedProp) };
},
};
};
}
// hoverTooltip extension for the language Compartment (see editorExtensions.js).
export function schemaHoverTooltip(schemaScope) {
return hoverTooltip(schemaHoverSource(schemaScope));
}
@@ -0,0 +1,214 @@
// Schema-resolution engine ported unchanged (behaviourally) from the pre-CodeMirror-6 editor's
// own completion.js (see `git show 04a7124a:website/src/components/ConfigEditor/completion.js` -
// the commit before this file was deleted in favour of a third-party schema-completion package,
// and the one before THAT deprecated it in favour of this hand-rolled walker again). It correctly
// resolves this schema's own `$ref`-with-sibling-keywords nodes (draft 2020-12 composition, not
// something that package's own json-schema-library v9 resolver ever supported - it threw
// "Mutiple typeIds matched" on several of schema.json's own segments) and its per-segment-type
// `if`/`then` branches. Kept free of CodeMirror imports so it can be reasoned about (and in
// principle unit tested) independently of the syntax-tree walker that now drives it
// (schemaCompletion.js) instead of the old char-by-char text scanners.
import schema from '../../../../themes/schema.json';
// Walks a schema node, resolving `$ref` and composing `anyOf`/`oneOf`/`allOf` into one plain
// object completion can read `.properties`/`.enum`/`.type` off of directly.
export function resolveSchema(node, root = schema) {
if (!node || typeof node !== 'object') {
return {};
}
if (node.$ref) {
const ref = node.$ref;
if (ref.startsWith('#/')) {
const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root);
const resolved = resolveSchema(target, root);
// Draft 2020-12 allows keywords alongside $ref; sibling keys (e.g. a
// description overriding the target's) must win over the target's own.
const siblings = { ...node };
delete siblings.$ref;
return { ...resolved, ...siblings };
}
}
// anyOf/oneOf branches are alternatives (e.g. "enum or free string") rather than
// required composition, so completion only needs the union of their enum values.
if (node.anyOf || node.oneOf) {
const branches = node.anyOf || node.oneOf;
const merged = { ...node };
delete merged.anyOf;
delete merged.oneOf;
branches.forEach((branch) => {
const resolvedBranch = resolveSchema(branch, root);
if (resolvedBranch.enum) {
merged.enum = [...(merged.enum || []), ...resolvedBranch.enum];
}
if (resolvedBranch.type && !merged.type) {
merged.type = resolvedBranch.type;
}
});
return merged;
}
if (node.allOf) {
return node.allOf.reduce((acc, child) => mergeSchema(acc, resolveSchema(child, root)), {
...node,
});
}
return { ...node };
}
export function mergeSchema(base, incoming) {
const merged = { ...base };
if (incoming.properties) {
merged.properties = {
...(base.properties || {}),
...(incoming.properties || {}),
};
}
if (incoming.type && !merged.type) {
merged.type = incoming.type;
}
if (incoming.enum && !merged.enum) {
merged.enum = incoming.enum;
}
if (incoming.default !== undefined && merged.default === undefined) {
merged.default = incoming.default;
}
if (incoming.description && !merged.description) {
merged.description = incoming.description;
}
if (incoming.title && !merged.title) {
merged.title = incoming.title;
}
return merged;
}
export function getDefinitionSchema(ref, root = schema) {
if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) {
return {};
}
const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root);
return resolveSchema(target, root);
}
export function deriveValueSchema(parentSchema, propertyName, root = schema) {
const resolvedParent = resolveSchema(parentSchema, root);
const propertySchema = resolvedParent.properties?.[propertyName];
if (!propertySchema) {
return {};
}
return resolveSchema(propertySchema, root);
}
// Only `definitions.segment` uses conditional `if.properties.type.const` / `then`
// branches today, but this stays generic so any similarly-shaped schema benefits.
export function mergeTypeBranch(schemaNode, typeValue) {
const resolved = resolveSchema(schemaNode);
if (!resolved.allOf || !typeValue) {
return resolved;
}
const merged = { ...resolved };
resolved.allOf.forEach((branch) => {
if (branch.if?.properties?.type?.const === typeValue) {
const thenSchema = resolveSchema(branch.then, schema);
const nextProperties = { ...(merged.properties || {}) };
Object.entries(thenSchema.properties || {}).forEach(([key, branchProperty]) => {
const baseProperty = nextProperties[key];
// A segment's own override (e.g. "options") replaces the base property's shape,
// but shouldn't lose the base's description/title if the override doesn't set one.
nextProperties[key] = baseProperty
? { description: baseProperty.description, title: baseProperty.title, ...branchProperty }
: branchProperty;
});
merged.properties = nextProperties;
}
});
return merged;
}
export function getItemSchema(parentValueSchema) {
const resolved = resolveSchema(parentValueSchema);
if (!resolved.items) {
return {};
}
return resolveSchema(resolved.items);
}
// A resolved schema's own "type" keyword is the most direct source, but per-segment-type
// conditionals (schema.json's "if type === X then properties: { options: {...} }" branches)
// routinely narrow a property down to just "properties"/"items"/"unevaluatedProperties"
// without repeating "type": "object" (JSON Schema doesn't require it for validation to work).
// Completion still needs a concrete type to know what to offer, so fall back to inferring it
// from shape - "properties" implies object, "items" implies array - before falling back
// further to a bare enum implying string.
export function inferSchemaType(resolvedSchema) {
if (resolvedSchema.type) {
return resolvedSchema.type;
}
if (resolvedSchema.properties) {
return 'object';
}
if (resolvedSchema.items) {
return 'array';
}
return resolvedSchema.enum ? 'string' : null;
}
// Schema.json's "examples" keyword, when present, is the most direct source of sample
// values for a tooltip. Absent that, a small enum is itself a good stand-in - but only
// when it's short enough to be a helpful hint rather than a wall of text (large enums,
// e.g. every segment type, are already fully browsable via the completion dropdown itself).
const MAX_ENUM_HINT_SIZE = 8;
export function buildPropertyHint(resolvedProp) {
const description = resolvedProp.description && resolvedProp.description !== resolvedProp.title
? resolvedProp.description
: '';
let examples = Array.isArray(resolvedProp.examples) && resolvedProp.examples.length
? resolvedProp.examples
: null;
if (!examples) {
const enumSource = resolvedProp.enum
|| (inferSchemaType(resolvedProp) === 'array' ? getItemSchema(resolvedProp).enum : null);
if (Array.isArray(enumSource) && enumSource.length > 0 && enumSource.length <= MAX_ENUM_HINT_SIZE) {
examples = enumSource;
}
}
return { description, examples };
}
// Which schema governs the outermost container of the document being edited. The studio edits
// a full theme config (schema fits directly); a segment doc page's sample editor only ever
// contains a single bare segment object (see website/src/components/Config.js), so completion
// there needs to start from #/definitions/segment instead - otherwise every top-level
// property/hover lookup resolves against the wrong schema entirely (root config fields like
// "final_space" instead of segment fields like "foreground").
export function getScopedRootSchema(schemaScope) {
if (schemaScope === 'segment') {
return getDefinitionSchema('#/definitions/segment', schema);
}
return schema;
}
@@ -47,9 +47,9 @@ ul.formatTabs li + li {
gap: var(--omp-space-1);
}
/* Visually hidden but still in the accessibility tree - the syntax-highlighted editor's real
input is react-simple-code-editor's own <textarea>, which can't take a plain aria-label prop,
so a proper associated <label> stands in for it instead. */
/* Visually hidden but still in the accessibility tree - CodeMirror's own editable surface (a
contenteditable div, not a <textarea>) can't take a plain aria-label prop, so a proper
associated <label> stands in for it instead - see index.js's EditorView.contentAttributes. */
.srOnly {
position: absolute;
width: 1px;
@@ -65,11 +65,11 @@ ul.formatTabs li + li {
/* The window chrome around the editor: same border/radius recipe as the preview pane beside it
(both trace back to the shared --omp-card-* tokens in src/css/custom.css), so the two read as
one family instead of the preview looking "finished" and the editor looking like a bare
textarea. overflow: hidden clips the chrome bar and the editor body to the rounded corners. */
textarea. Overflow stays visible so completion popups can escape the editor frame. */
.editorWindow {
border: 1px solid var(--omp-card-border-color);
border-radius: var(--omp-card-radius);
overflow: hidden;
overflow: visible;
}
.editorWindow:focus-within {
@@ -85,6 +85,7 @@ ul.formatTabs li + li {
padding: var(--omp-space-1) var(--omp-space-2);
background: var(--omp-card-background);
border-bottom: 1px solid var(--omp-card-border-color);
border-radius: var(--omp-card-radius) var(--omp-card-radius) 0 0;
}
.chromeDot {
@@ -94,169 +95,50 @@ ul.formatTabs li + li {
background: var(--ifm-color-emphasis-300);
}
/* position: relative makes this the gutter's containing block - see .gutter. */
/* No position: relative needed here any more - CodeMirror's own gutter is a child of
.cm-editor/.editorHost below, not a sibling positioned against .editorBody the way the old
hand-rolled line-number gutter was. align-items: stretch still gives .editorHost the row's
full height. */
.editorBody {
--omp-gutter-width: 2.75rem;
position: relative;
display: flex;
align-items: stretch;
}
/* The line-number gutter. It is a sibling of .editor, not nested inside it, specifically so it
never shares .editor's own horizontal scrollbar (see .noWrap below) - long lines scroll the
code without dragging the numbers sideways with them.
Taken out of flow, pinned top-to-bottom, rather than left as a flex item: its height then comes
from .editorBody, which is .editor's height, however that height was arrived at - grown by a
keystroke or dragged down by the resize handle. As an in-flow item it did the opposite, since a
row's height is its tallest item: shrink the editor to 200px with 48 lines of numbers beside it
and the gutter kept the row 1176px tall. Being out of flow also means nothing it does can
resize .editor, which is what made the earlier ResizeObserver-driven height sync (see index.js)
feed back into itself. .editor's margin-left reserves the column it now floats over.
Font, size and line-height are identical to .editor's so line N's number lands next to code
row N; padding-top/bottom match .editor's padding so the first number aligns with the first
code line. Vertical scrolling is mirrored from .editor in index.js. */
.gutter {
position: absolute;
top: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
width: var(--omp-gutter-width);
overflow: hidden;
padding: var(--omp-space-2) var(--omp-space-2) var(--omp-space-2) 0;
font-family: var(--ifm-font-family-monospace);
font-size: var(--omp-text-body);
line-height: 1.5;
text-align: right;
color: var(--omp-text-secondary);
border-right: 1px solid var(--omp-card-border-color);
user-select: none;
}
.gutterLine {
font-variant-numeric: tabular-nums;
}
/* No min-height: react-simple-code-editor's <pre> stays in normal flow (only the <textarea> is
absolutely positioned over it), so the element is already exactly as tall as the code it holds.
A fixed 22rem left a short segment config - most of them are under ten lines - sitting above
four or five empty rows. Every config text now ends in exactly one newline (see serialize.js's
stringifyConfig), which renders as the single blank row under the last character. The resize
handle still lets a reader make it taller. overflow: auto is set inline (see index.js) so
scrolling still works the way it did on a bare textarea. Background and text
color come from the active Prism theme instead of the card tokens (see index.js's
DARK_CODE_THEME/LIGHT_CODE_THEME) - matching a real code block's own colors rather than the
surrounding card chrome, the same way the docs' code fences do. Border and radius live on
.editorWindow instead, since the gutter sits inside the same frame and a border around .editor
alone would draw a seam between the two. */
/* No padding here on purpose: it is passed to the Editor's own `padding` prop instead (see
index.js), because this element's children are absolutely positioned and would ignore it. */
.editor {
/* The flex host CodeMirror mounts its own <div class="cm-editor"> into (see index.js's `new
EditorView({ parent: hostRef.current })`) - min-width: 0 lets it shrink below its content's
natural width instead of overflowing the pane, same as every other flex child in this file. */
.editorHost {
flex: 1;
min-width: 0;
margin-left: var(--omp-gutter-width);
box-sizing: border-box;
}
/* CodeMirror sizes .cm-editor to its own content by default (no min-height set here), matching
the old editor's behaviour: a short segment config - most are under ten lines - doesn't sit
above a wall of empty rows. font-size/line-height reproduce the old .editor's own values;
border-radius keeps the bottom corners matching .editorWindow's frame now that CodeMirror's
gutter+content pair replaces the old .gutter/.editor sibling pair sharing that frame. */
.editorHost :global(.cm-editor) {
font-family: var(--ifm-font-family-monospace);
font-size: var(--omp-text-body);
line-height: 1.5;
resize: vertical;
border-radius: 0 0 var(--omp-card-radius) var(--omp-card-radius);
}
.editor:focus-within {
.editorHost :global(.cm-editor.cm-focused) {
outline: none;
}
/* react-simple-code-editor's textarea keeps the browser's native focus outline even though
.editor:focus-within above resets it - outline does not cascade from a wrapper to a
descendant, and this is the actual focusable element. Left alone, it renders as a second,
inner ring that changes color on focus alongside .editorWindow's intended outer border. */
.editorTextarea:focus {
outline: none;
}
/* text-decoration (rather than a background-image squiggle) so it rides along with the text -
no separate positioning/scroll-sync logic needed. Drawn by ConfigEditor/index.js's
highlightConfig splitting the offending token, driven by the errorLocation prop. */
.errorSquiggle {
text-decoration: underline wavy var(--ifm-color-danger);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
/* Forces one logical line to one visual row (react-simple-code-editor's default whitespace is
pre-wrap), which is what lets the gutter map a line number to a fixed pixel offset instead of
having to measure wrapped-row heights. Applied via preClassName/textareaClassName (index.js), so
it lands on the actual pre/textarea elements rather than the outer .editor container - the
library sets white-space inline on both, hence !important to win over that. Long lines scroll
horizontally within .editor instead (its own overflow: auto, set inline in index.js). */
.noWrap {
white-space: pre !important;
}
.completionPopup {
position: absolute;
z-index: 10;
min-width: 16rem;
max-width: 24rem;
max-height: 16rem;
padding: 0.25rem;
border: 1px solid var(--omp-card-border-color);
border-radius: var(--omp-card-radius);
background: var(--omp-card-background);
box-shadow: var(--ifm-global-shadow-md);
display: flex;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
}
.completionOption {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--omp-space-2);
padding: 0.4rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
font-family: var(--ifm-font-family-monospace);
font-size: 0.9rem;
color: var(--ifm-font-color-base);
}
.completionOption:hover {
background: var(--ifm-hover-overlay);
}
.completionOptionActive,
.completionOptionActive:hover {
background: var(--ifm-color-primary);
color: #fff;
}
.completionLabel {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.completionDetail {
color: var(--omp-text-secondary);
font-size: 0.8rem;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 3;
}
.completionOptionActive .completionDetail {
color: inherit;
opacity: 0.85;
/* Long lines scroll horizontally instead of wrapping - EditorView.lineWrapping is deliberately
never added (see index.js), so this is the only place that behaviour needs stating; CodeMirror
would otherwise still let a scroller overflow silently without it. */
.editorHost :global(.cm-scroller) {
overflow-x: auto;
}
/* Despite the name, this is no longer the editor's own completion/hover tooltip (CodeMirror
draws those itself now - see editorTheme.js's .cm-tooltip rule) - only ErrorIndicator.js's
warning-triangle bubble still uses it, kept under its original name rather than renamed, since
renaming would touch ErrorIndicator.js for no behavioural benefit. */
.completionTooltip {
position: absolute;
z-index: 11;
@@ -283,13 +165,6 @@ ul.formatTabs li + li {
color: var(--omp-text-secondary);
}
.completionTooltipExamples {
margin-top: 0.35rem;
color: var(--omp-text-secondary);
font-size: 0.85em;
font-family: var(--ifm-font-family-monospace);
}
/* ErrorIndicator.js's warning triangle, rendered into ConfigEditor's actions slot alongside the
download/copy/add-to-studio buttons. position: relative so its own tooltip bubble (absolute)
anchors to the triangle itself rather than some further-up ancestor. */
@@ -332,17 +207,11 @@ ul.formatTabs li + li {
}
/* Left as a literal size rather than --omp-text-body: this is a mobile-only reduction on top of
the editor's own --omp-text-body, not one of the shared scale's steps. */
the editor's own --omp-text-body, not one of the shared scale's steps. Only one rule needed
now (against .cm-editor) - CodeMirror's own gutter inherits font-size from it rather than
needing the separate --omp-gutter-width/.gutter overrides the old hand-rolled gutter did. */
@media (max-width: 700px) {
.editor {
font-size: 0.75rem;
}
.editorBody {
--omp-gutter-width: 2.25rem;
}
.gutter {
.editorHost :global(.cm-editor) {
font-size: 0.75rem;
}
}