Files
go2rtc/www/config.html
T
Sergey Krashevich 3bd433c950 Improve null checks and object handling in config.html
Replaces optional chaining with explicit null checks and more robust object property access throughout the file. Refactors destructuring and object spreading to use Object.assign for better compatibility. These changes improve code reliability and compatibility with older browsers or environments lacking support for newer JavaScript features.
2025-12-27 09:09:32 +03:00

1056 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>config - go2rtc</title>
<style>
html, body {
height: 100%;
}
#config {
flex: 1 1 auto;
border-top: 1px solid #ccc;
min-height: 300px;
}
</style>
</head>
<body>
<script src="main.js"></script>
<main>
<div>
<button id="save">Save & Restart</button>
</div>
</main>
<div id="config"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs/loader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script>
/* global require, monaco */
const monacoVersion = '0.55.1';
const monacoRoot = `https://cdn.jsdelivr.net/npm/monaco-editor@${monacoVersion}/min`;
const monacoBase = `${monacoRoot}/vs`;
window.MonacoEnvironment = {
getWorkerUrl: function () {
return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
self.MonacoEnvironment = { baseUrl: '${monacoRoot}/' };
importScripts('${monacoBase}/base/worker/workerMain.js');
`)}`;
}
};
require.config({ paths: { vs: monacoBase } });
require(['vs/editor/editor.main'], () => {
const container = document.getElementById('config');
container.textContent = '';
const ensureYamlLanguage = () => {
const languages =
(window.monaco &&
monaco.languages &&
typeof monaco.languages.getLanguages === 'function' &&
monaco.languages.getLanguages()) ||
[];
const hasYaml = languages.some((l) => l.id === 'yaml');
if (hasYaml) return;
monaco.languages.register({
id: 'yaml',
extensions: ['.yaml', '.yml'],
aliases: ['YAML', 'yaml'],
mimetypes: ['application/x-yaml', 'text/yaml'],
});
monaco.languages.setLanguageConfiguration('yaml', {
comments: {lineComment: '#'},
brackets: [['{', '}'], ['[', ']'], ['(', ')']],
autoClosingPairs: [
{open: '{', close: '}'},
{open: '[', close: ']'},
{open: '(', close: ')'},
{open: '"', close: '"'},
{open: '\'', close: '\''},
],
surroundingPairs: [
{open: '{', close: '}'},
{open: '[', close: ']'},
{open: '(', close: ')'},
{open: '"', close: '"'},
{open: '\'', close: '\''},
],
});
monaco.languages.setMonarchTokensProvider('yaml', {
tokenizer: {
root: [
[/^\s*(---|\.\.\.)\s*$/, 'delimiter'],
[/#.*$/, 'comment'],
[/^\s*-\s+/, 'delimiter'],
[/[A-Za-z0-9_-]+(?=\s*:)/, 'key'],
[/:/, 'delimiter'],
[/[{}\[\](),]/, 'delimiter'],
[/\b(true|false|null|~)\b/, 'keyword'],
[/-?\d+(\.\d+)?\b/, 'number'],
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/[^#\s{}\[\](),]+/, 'string'],
[/\s+/, ''],
],
string_double: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^']+/, 'string'],
[/'/, 'string', '@pop'],
],
},
});
};
ensureYamlLanguage();
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
monaco.editor.setTheme(prefersDark ? 'vs-dark' : 'vs');
const editor = monaco.editor.create(container, {
language: 'yaml',
minimap: { enabled: false },
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
wordBasedSuggestions: false,
suggest: { showWords: false },
scrollBeyondLastLine: false,
});
const layout = () => {
const top = container.getBoundingClientRect().top;
container.style.height = `${Math.max(200, window.innerHeight - top)}px`;
editor.layout();
};
window.addEventListener('resize', layout);
layout();
const stripInlineComment = (line) => {
let inSingle = false;
let inDouble = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '\'' && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (ch === '#' && !inSingle && !inDouble) {
if (i === 0 || /\s/.test(line[i - 1])) return line.slice(0, i);
}
}
return line;
};
const countIndent = (line) => {
let indent = 0;
for (let i = 0; i < line.length; i++) {
if (line[i] === ' ') {
indent++;
} else if (line[i] === '\t') {
indent += 2;
} else {
break;
}
}
return indent;
};
const parseListItem = (line) => {
const m = line.match(/^([ \t]*)-/);
if (!m) return null;
const dashIndex = m[1].length;
if (dashIndex + 1 < line.length && !/\s/.test(line[dashIndex + 1])) return null;
let afterDashIndex = dashIndex + 1;
while (afterDashIndex < line.length && /\s/.test(line[afterDashIndex])) afterDashIndex++;
const spacesAfterDash = Math.max(1, afterDashIndex - (dashIndex + 1));
return {
indent: countIndent(m[1]),
dashIndex,
afterDashIndex,
rest: line.slice(afterDashIndex),
contentIndent: countIndent(m[1]) + 1 + spacesAfterDash,
};
};
const parseKey = (line) => {
const m = line.match(/^([ \t]*)/);
const indentStr = m ? m[0] : '';
const indentIndex = indentStr.length;
const indent = countIndent(indentStr);
if (indentIndex >= line.length) return null;
const i = indentIndex;
let key = '';
let rawKey = '';
let isQuoted = false;
let keyStartIndex = i;
let keyEndIndex = i;
let colonIndex = -1;
const parseQuotedKey = (quoteChar) => {
isQuoted = true;
let j = i + 1;
if (quoteChar === '"') {
while (j < line.length) {
if (line[j] === '\\') {
j += 2;
continue;
}
if (line[j] === '"') break;
j++;
}
} else {
while (j < line.length) {
if (line[j] === '\'') {
if (line[j + 1] === '\'') {
j += 2;
continue;
}
break;
}
j++;
}
}
if (j >= line.length) return null;
rawKey = line.slice(i, j + 1);
if (quoteChar === '"') {
key = line.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
} else {
key = line.slice(i + 1, j).replace(/''/g, '\'');
}
keyStartIndex = i;
keyEndIndex = j + 1;
let k = j + 1;
while (k < line.length && /\s/.test(line[k])) k++;
if (k >= line.length || line[k] !== ':') return null;
colonIndex = k;
return colonIndex;
};
if (line[i] === '"' || line[i] === '\'') {
if (parseQuotedKey(line[i]) === null) return null;
} else {
let j = i;
while (j < line.length) {
if (line[j] === ':') {
if (j + 1 >= line.length || /\s/.test(line[j + 1])) {
colonIndex = j;
break;
}
}
j++;
}
if (colonIndex === -1) return null;
rawKey = line.slice(i, colonIndex).replace(/\s+$/, '');
if (!rawKey) return null;
key = rawKey;
keyStartIndex = i;
keyEndIndex = i + rawKey.length;
}
const after = line.slice(colonIndex + 1);
const isContainer = after.trim() === '' || after.trim().startsWith('#');
const valueStartIndex = colonIndex + 1;
return {indent, key, rawKey, isQuoted, isContainer, after, keyStartIndex, keyEndIndex, colonIndex, valueStartIndex};
};
const unique = (arr) => [...new Set(arr)];
const toYamlScalar = (v) => {
if (v === '') return '\'\'';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return v ? 'true' : 'false';
return JSON.stringify(v);
};
const createSchemaTools = (schemaRoot) => {
const resolveRef = (schema, seen = new Set()) => {
if (!schema || typeof schema !== 'object') return schema;
if (typeof schema.$ref === 'string') {
const ref = schema.$ref;
if (ref.startsWith('#/definitions/')) {
if (seen.has(ref)) return schema;
seen.add(ref);
const name = ref.slice('#/definitions/'.length);
const def = schemaRoot.definitions && schemaRoot.definitions[name];
if (!def) return schema;
const resolved = resolveRef(def, seen);
const rest = Object.assign({}, schema);
delete rest.$ref;
return Object.assign({}, resolved, rest);
}
}
return schema;
};
const mergeProps = (schemas) => {
const props = {};
for (const s of schemas) {
const schema = resolveRef(s);
if (schema && schema.properties && typeof schema.properties === 'object') {
Object.assign(props, schema.properties);
}
}
return props;
};
const getObjectProperties = (schema) => {
schema = resolveRef(schema);
if (!schema) return {};
if (schema.properties && typeof schema.properties === 'object') return schema.properties;
if (Array.isArray(schema.anyOf)) return mergeProps(schema.anyOf);
return {};
};
const getPropertySchema = (schema, key) => {
schema = resolveRef(schema);
if (!schema) return null;
if (schema.properties && schema.properties[key]) return resolveRef(schema.properties[key]);
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
return resolveRef(schema.additionalProperties);
}
if (Array.isArray(schema.anyOf)) {
for (const alt of schema.anyOf) {
const res = getPropertySchema(alt, key);
if (res) return res;
}
}
return null;
};
const getValueSuggestions = (schema) => {
schema = resolveRef(schema);
if (!schema) return [];
const values = [];
const addFrom = (s) => {
s = resolveRef(s);
if (!s) return;
if (Array.isArray(s.enum)) values.push(...s.enum);
if ('const' in s) values.push(s.const);
if (Array.isArray(s.examples)) values.push(...s.examples);
if ('default' in s) values.push(s.default);
};
if (Array.isArray(schema.anyOf)) {
for (const alt of schema.anyOf) addFrom(alt);
} else {
addFrom(schema);
}
return unique(values);
};
const getSchemaTypes = (schema, seen = new Set()) => {
schema = resolveRef(schema);
if (!schema || typeof schema !== 'object') return new Set();
if (Array.isArray(schema.anyOf)) {
const types = new Set();
for (const alt of schema.anyOf) {
for (const t of getSchemaTypes(alt, seen)) types.add(t);
}
return types;
}
if (Array.isArray(schema.oneOf)) {
const types = new Set();
for (const alt of schema.oneOf) {
for (const t of getSchemaTypes(alt, seen)) types.add(t);
}
return types;
}
if (Array.isArray(schema.type)) return new Set(schema.type);
if (typeof schema.type === 'string') return new Set([schema.type]);
if (schema.properties || schema.additionalProperties) return new Set(['object']);
if (schema.items) return new Set(['array']);
return new Set();
};
const schemaAllowsType = (schema, actualType) => {
const types = getSchemaTypes(schema);
if (actualType === 'integer' && types.has('number')) return true;
return types.has(actualType);
};
const schemaTypesLabel = (schema) => {
const types = Array.from(getSchemaTypes(schema));
if (types.length === 0) return 'any';
return types.sort().join(' | ');
};
const getArrayItemSchema = (schema) => {
schema = resolveRef(schema);
if (!schema) return null;
if (schema.type === 'array' && schema.items) return resolveRef(schema.items);
if (Array.isArray(schema.anyOf)) {
for (const alt of schema.anyOf) {
const item = getArrayItemSchema(alt);
if (item) return item;
}
}
return null;
};
return {
schemaRoot,
resolveRef,
getObjectProperties,
getPropertySchema,
getValueSuggestions,
getSchemaTypes,
schemaAllowsType,
schemaTypesLabel,
getArrayItemSchema,
};
};
const isIntLike = (s) => /^[+-]?\d+$/.test(s);
const isNumberLike = (s) => (
/^[+-]?(?:\d*\.\d+|\d+\.\d*)(?:[eE][+-]?\d+)?$/.test(s) ||
/^[+-]?\d+(?:[eE][+-]?\d+)$/.test(s) ||
isIntLike(s)
);
const classifyYamlScalar = (raw) => {
const v = raw.trim();
if (!v) return {type: 'null'};
if (/^\$\{[^}{]+\}$/.test(v)) return {type: 'dynamic'};
if (v.startsWith('[')) return {type: 'array'};
if (v.startsWith('{')) return {type: 'object'};
if (v.startsWith('"') || v.startsWith('\'')) return {type: 'string'};
if (v === 'true' || v === 'false') return {type: 'boolean'};
if (v === 'null' || v === '~') return {type: 'null'};
if (isIntLike(v)) return {type: 'integer'};
if (isNumberLike(v)) return {type: 'number'};
return {type: 'string'};
};
const lintYamlModel = (model, schemaTools) => {
const markers = [];
const markedLines = new Set();
let blockScalarParentIndent = null;
const isBlockScalarHeader = (text) => {
const rawText = (text == null) ? '' : text;
const t = rawText.trimStart ? rawText.trimStart() : rawText.replace(/^\s+/, '');
return t.startsWith('|') || t.startsWith('>');
};
const checkChildIndent = (ctx, childIndent, lineNumber) => {
if (!ctx) return;
if (ctx.childIndent == null) {
ctx.childIndent = childIndent;
return;
}
if (childIndent !== ctx.childIndent) {
markLineError(lineNumber, `YAML: inconsistent indentation (expected ${ctx.childIndent} spaces)`, 1);
}
};
const markLineError = (lineNumber, message, startColumn = 1) => {
if (markedLines.has(`${lineNumber}:${message}`)) return;
markedLines.add(`${lineNumber}:${message}`);
const lineText = model.getLineContent(lineNumber);
markers.push({
severity: monaco.MarkerSeverity.Error,
message,
startLineNumber: lineNumber,
startColumn,
endLineNumber: lineNumber,
endColumn: Math.max(startColumn + 1, lineText.length + 1),
});
};
if (window.jsyaml && window.jsyaml.load) {
try {
window.jsyaml.load(model.getValue());
} catch (e) {
const mark = (e && e.mark) || {};
const line = typeof mark.line === 'number' ? mark.line + 1 : 1;
const column = typeof mark.column === 'number' ? mark.column + 1 : 1;
const reason = e && e.reason;
const messageText = e && e.message;
markLineError(line, reason ? `YAML: ${reason}` : `YAML: ${messageText || 'Invalid YAML'}`, column);
}
}
const pushMarker = (m) => markers.push(m);
const getExpectedContainerType = (schema) => {
if (!schemaTools) return null;
const types = schemaTools.getSchemaTypes(schema);
const wantsObject = types.has('object');
const wantsArray = types.has('array');
if (wantsObject && !wantsArray) return 'object';
if (wantsArray && !wantsObject) return 'array';
return null;
};
const stack = [{
indent: -1,
schema: (schemaTools && schemaTools.schemaRoot) || null,
expected: 'object',
actual: null,
keys: new Map(),
childIndent: null,
origin: null,
reportedTypeMismatch: false,
}];
let hasTopLevelKey = false;
let hasTopLevelList = false;
const lineCount = model.getLineCount();
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
let line = model.getLineContent(lineNumber);
if (!line.trim()) continue;
line = stripInlineComment(line).trimEnd();
if (!line.trim()) continue;
const kv = parseKey(line);
if (kv && kv.indent === 0) hasTopLevelKey = true;
const li = parseListItem(line);
if (li && li.indent === 0) hasTopLevelList = true;
}
const setActualType = (ctx, actual, fallbackLineNumber) => {
if (ctx.actual !== null) return;
ctx.actual = actual;
if (ctx.origin && ctx.expected && ctx.expected !== actual && !ctx.reportedTypeMismatch) {
pushMarker({
severity: monaco.MarkerSeverity.Error,
message: `Type mismatch: expected ${ctx.expected}, got ${actual}`,
startLineNumber: ctx.origin.lineNumber,
startColumn: ctx.origin.startColumn,
endLineNumber: ctx.origin.lineNumber,
endColumn: ctx.origin.endColumn,
});
ctx.reportedTypeMismatch = true;
} else if (!ctx.origin && ctx.expected && ctx.expected !== actual && fallbackLineNumber) {
pushMarker({
severity: monaco.MarkerSeverity.Error,
message: `Type mismatch: expected ${ctx.expected}, got ${actual}`,
startLineNumber: fallbackLineNumber,
startColumn: 1,
endLineNumber: fallbackLineNumber,
endColumn: 2,
});
ctx.reportedTypeMismatch = true;
}
};
const checkValueType = (schema, actualType, lineNumber, startColumn, endColumn, keyName) => {
if (!schemaTools || !schema) return;
if (actualType === 'dynamic') return;
if (schemaTools.schemaAllowsType(schema, actualType)) return;
pushMarker({
severity: monaco.MarkerSeverity.Error,
message: `Type mismatch for ${keyName ? `"${keyName}"` : 'value'}: expected ${schemaTools.schemaTypesLabel(schema)}, got ${actualType}`,
startLineNumber: lineNumber,
startColumn,
endLineNumber: lineNumber,
endColumn: Math.max(startColumn + 1, endColumn),
});
};
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
let line = model.getLineContent(lineNumber);
if (!line.trim()) continue;
line = stripInlineComment(line).trimEnd();
if (!line.trim()) continue;
const indent = countIndent(line);
if (blockScalarParentIndent !== null && indent <= blockScalarParentIndent) {
blockScalarParentIndent = null;
}
if (blockScalarParentIndent !== null && indent > blockScalarParentIndent) {
continue; // treat as block scalar content
}
if (indent === 0 && (hasTopLevelKey || hasTopLevelList)) {
const trimmed = line.trim();
if (trimmed !== '---' && trimmed !== '...' && !trimmed.startsWith('#')) {
const listItem0 = parseListItem(line);
const kv0 = parseKey(line);
const flow0 = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!flow0 && !listItem0 && !kv0) {
markLineError(lineNumber, 'YAML: unexpected content at document root');
continue;
}
}
}
const listItem = parseListItem(line);
if (listItem) {
if (listItem.indent > 0 && (hasTopLevelKey || hasTopLevelList) && stack.length === 1) {
markLineError(lineNumber, 'YAML: unexpected indentation at document root', 1);
}
while (stack.length > 1 && listItem.indent <= stack[stack.length - 1].indent) stack.pop();
const parent = stack[stack.length - 1];
checkChildIndent(parent, listItem.indent, lineNumber);
setActualType(parent, 'array', lineNumber);
const itemSchema = schemaTools ? schemaTools.getArrayItemSchema(parent.schema) : null;
const itemExpected = getExpectedContainerType(itemSchema);
const itemCtx = {
indent: listItem.indent,
schema: itemSchema,
expected: itemExpected,
actual: null,
keys: new Map(),
childIndent: null,
origin: {lineNumber, startColumn: listItem.dashIndex + 1, endColumn: listItem.dashIndex + 2},
reportedTypeMismatch: false,
};
stack.push(itemCtx);
if (!listItem.rest) continue;
if (isBlockScalarHeader(listItem.rest)) {
blockScalarParentIndent = listItem.indent;
checkValueType(itemSchema, 'string', lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
continue;
}
const inline = parseKey(' '.repeat(listItem.afterDashIndex) + listItem.rest);
if (inline) {
// handle inline mapping in the same line: "- key: value"
const kv = inline;
while (stack.length > 1 && kv.indent <= stack[stack.length - 1].indent) stack.pop();
const ctx = stack[stack.length - 1];
checkChildIndent(ctx, kv.indent, lineNumber);
setActualType(ctx, 'object', lineNumber);
const prev = ctx.keys.get(kv.key);
if (prev) {
pushMarker({
severity: monaco.MarkerSeverity.Warning,
message: `Duplicate key "${kv.key}" (previous at line ${prev.lineNumber})`,
startLineNumber: lineNumber,
startColumn: kv.keyStartIndex + 1,
endLineNumber: lineNumber,
endColumn: kv.keyEndIndex + 1,
});
} else {
ctx.keys.set(kv.key, {lineNumber});
}
if (!kv.isQuoted && isNumberLike(kv.rawKey.trim())) {
pushMarker({
severity: monaco.MarkerSeverity.Error,
message: `Map keys must be strings. Quote numeric key as '${kv.rawKey}':`,
startLineNumber: lineNumber,
startColumn: kv.keyStartIndex + 1,
endLineNumber: lineNumber,
endColumn: kv.keyEndIndex + 1,
});
}
const propSchema = schemaTools ? schemaTools.getPropertySchema(ctx.schema, kv.key) : null;
if (isBlockScalarHeader(kv.after)) {
blockScalarParentIndent = kv.indent;
}
if (kv.isContainer) {
stack.push({
indent: kv.indent,
schema: propSchema,
expected: getExpectedContainerType(propSchema),
actual: null,
keys: new Map(),
childIndent: null,
origin: {lineNumber, startColumn: kv.keyStartIndex + 1, endColumn: kv.keyEndIndex + 1},
reportedTypeMismatch: false,
});
} else if (propSchema) {
const valueText = kv.after.trim();
const actual = classifyYamlScalar(valueText).type;
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
}
continue;
}
const scalar = classifyYamlScalar(listItem.rest).type;
checkValueType(itemSchema, scalar, lineNumber, listItem.afterDashIndex + 1, line.length + 1, null);
continue;
}
const kv = parseKey(line);
if (!kv) {
markLineError(lineNumber, 'YAML: expected a map key (key:) or list item (-)', indent + 1);
continue;
}
if (kv.indent > 0 && (hasTopLevelKey || hasTopLevelList) && stack.length === 1) {
markLineError(lineNumber, 'YAML: unexpected indentation at document root', 1);
}
while (stack.length > 1 && kv.indent <= stack[stack.length - 1].indent) stack.pop();
const ctx = stack[stack.length - 1];
checkChildIndent(ctx, kv.indent, lineNumber);
setActualType(ctx, 'object', lineNumber);
const prev = ctx.keys.get(kv.key);
if (prev) {
pushMarker({
severity: monaco.MarkerSeverity.Warning,
message: `Duplicate key "${kv.key}" (previous at line ${prev.lineNumber})`,
startLineNumber: lineNumber,
startColumn: kv.keyStartIndex + 1,
endLineNumber: lineNumber,
endColumn: kv.keyEndIndex + 1,
});
} else {
ctx.keys.set(kv.key, {lineNumber});
}
if (!kv.isQuoted && isNumberLike(kv.rawKey.trim())) {
pushMarker({
severity: monaco.MarkerSeverity.Error,
message: `Map keys must be strings. Quote numeric key as '${kv.rawKey}':`,
startLineNumber: lineNumber,
startColumn: kv.keyStartIndex + 1,
endLineNumber: lineNumber,
endColumn: kv.keyEndIndex + 1,
});
}
const propSchema = schemaTools ? schemaTools.getPropertySchema(ctx.schema, kv.key) : null;
if (kv.isContainer) {
stack.push({
indent: kv.indent,
schema: propSchema,
expected: getExpectedContainerType(propSchema),
actual: null,
keys: new Map(),
childIndent: null,
origin: {lineNumber, startColumn: kv.keyStartIndex + 1, endColumn: kv.keyEndIndex + 1},
reportedTypeMismatch: false,
});
continue;
}
if (isBlockScalarHeader(kv.after)) {
blockScalarParentIndent = kv.indent;
}
if (!propSchema) continue;
const actual = classifyYamlScalar(kv.after).type;
const valueStartColumn = kv.valueStartIndex + 1 + (kv.after.length - kv.after.trimStart().length);
checkValueType(propSchema, actual, lineNumber, valueStartColumn, line.length + 1, kv.key);
}
return markers;
};
let schemaTools = null;
let completionProvider = null;
let hoverProvider = null;
const scheduleLint = (() => {
let handle = null;
return () => {
if (handle) clearTimeout(handle);
handle = setTimeout(() => {
const model = editor.getModel();
if (!model) return;
monaco.editor.setModelMarkers(model, 'yaml-lint', lintYamlModel(model, schemaTools));
}, 250);
};
})();
editor.onDidChangeModelContent(() => scheduleLint());
const setupYamlHints = (schemaRoot) => {
schemaTools = createSchemaTools(schemaRoot);
scheduleLint();
const {
resolveRef,
getObjectProperties,
getPropertySchema,
getValueSuggestions,
} = schemaTools;
const buildContextStack = (model, upToLineNumber) => {
const stack = [{indent: -1, schema: schemaRoot}];
for (let lineNumber = 1; lineNumber <= upToLineNumber; lineNumber++) {
let line = model.getLineContent(lineNumber);
if (!line.trim()) continue;
line = stripInlineComment(line).trimEnd();
if (!line.trim()) continue;
const listItem = parseListItem(line);
if (listItem) {
while (stack.length > 1 && listItem.indent <= stack[stack.length - 1].indent) stack.pop();
const parent = resolveRef(stack[stack.length - 1].schema);
if (parent && parent.type === 'array' && parent.items) {
stack.push({indent: listItem.indent, schema: resolveRef(parent.items)});
} else {
stack.push({indent: listItem.indent, schema: null});
}
const inline = listItem.rest ? parseKey(' '.repeat(listItem.contentIndent) + listItem.rest) : null;
if (inline && inline.isContainer) {
while (stack.length > 1 && inline.indent <= stack[stack.length - 1].indent) stack.pop();
const ctx = resolveRef(stack[stack.length - 1].schema);
const next = ctx ? getPropertySchema(ctx, inline.key) : null;
stack.push({indent: inline.indent, schema: next});
}
continue;
}
const kv = parseKey(line);
if (!kv) continue;
while (stack.length > 1 && kv.indent <= stack[stack.length - 1].indent) stack.pop();
if (!kv.isContainer) continue;
const ctx = resolveRef(stack[stack.length - 1].schema);
const next = ctx ? getPropertySchema(ctx, kv.key) : null;
stack.push({indent: kv.indent, schema: next});
}
return stack;
};
if (completionProvider) completionProvider.dispose();
completionProvider = monaco.languages.registerCompletionItemProvider('yaml', {
triggerCharacters: [':', ' '],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber);
const lineNoComment = stripInlineComment(line);
const lineNoCommentTrimmedEnd = lineNoComment.trimEnd();
const listItem = parseListItem(lineNoCommentTrimmedEnd);
const wordUntil = model.getWordUntilPosition(position);
const range = new monaco.Range(position.lineNumber, wordUntil.startColumn, position.lineNumber, wordUntil.endColumn);
const cursorIndex = position.column - 1;
let contentStartIndex = 0;
if (listItem) {
contentStartIndex = listItem.afterDashIndex;
} else {
contentStartIndex = countIndent(lineNoComment);
}
if (cursorIndex < contentStartIndex) return {suggestions: []};
const text = lineNoCommentTrimmedEnd.slice(contentStartIndex);
const cursorInText = cursorIndex - contentStartIndex;
const colonIndex = text.indexOf(':');
const isValueContext = colonIndex >= 0 && cursorInText > colonIndex;
const stack = buildContextStack(model, position.lineNumber - 1);
const effectiveIndent = listItem ? listItem.contentIndent : countIndent(lineNoComment);
while (stack.length > 1 && effectiveIndent <= stack[stack.length - 1].indent) stack.pop();
let contextSchema = resolveRef(stack[stack.length - 1].schema);
if (listItem && cursorIndex >= listItem.afterDashIndex && contextSchema && contextSchema.type === 'array') {
contextSchema = resolveRef(contextSchema.items);
}
if (!contextSchema) return {suggestions: []};
// Scalar array item (e.g. "- tcp4") - suggest values (enum/examples/default)
if (listItem && colonIndex === -1 && !isValueContext) {
const props = getObjectProperties(contextSchema);
if (!props || Object.keys(props).length === 0) {
const values = getValueSuggestions(contextSchema);
const suggestions = values.map((v) => ({
label: toYamlScalar(v),
kind: monaco.languages.CompletionItemKind.Value,
insertText: toYamlScalar(v),
range,
}));
return {suggestions};
}
}
if (!isValueContext) {
const props = getObjectProperties(contextSchema);
const suggestions = Object.keys(props).map((key) => {
const s = resolveRef(props[key]);
const wantsBlock = s && (s.type === 'object' || s.type === 'array' || s.properties);
const indent = listItem ? ' '.repeat(listItem.contentIndent) : ' '.repeat(countIndent(lineNoComment));
const innerIndent = indent + ' ';
const insertText = wantsBlock ? `${key}:\n${innerIndent}` : `${key}: `;
return {
label: key,
kind: monaco.languages.CompletionItemKind.Property,
insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: s && s.description,
range,
};
});
return {suggestions};
}
const keyName = text.slice(0, colonIndex).trim();
const keySchema = getPropertySchema(contextSchema, keyName);
if (!keySchema) return {suggestions: []};
const values = getValueSuggestions(keySchema);
const suggestions = values.map((v) => ({
label: toYamlScalar(v),
kind: monaco.languages.CompletionItemKind.Value,
insertText: toYamlScalar(v),
range,
}));
return {suggestions};
}
});
if (hoverProvider) hoverProvider.dispose();
hoverProvider = monaco.languages.registerHoverProvider('yaml', {
provideHover: (model, position) => {
if (!schemaTools) return null;
const line = model.getLineContent(position.lineNumber);
const lineNoComment = stripInlineComment(line).trimEnd();
const listItem = parseListItem(lineNoComment);
const cursorIndex = position.column - 1;
if (listItem && cursorIndex < listItem.afterDashIndex) return null;
let kv;
let effectiveIndent;
let keyStartIndex;
let keyEndIndex;
if (listItem) {
if (!listItem.rest) return null;
const synthetic = ' '.repeat(listItem.contentIndent) + listItem.rest;
kv = parseKey(synthetic);
if (!kv) return null;
effectiveIndent = listItem.contentIndent;
keyStartIndex = listItem.afterDashIndex + (kv.keyStartIndex - listItem.contentIndent);
keyEndIndex = listItem.afterDashIndex + (kv.keyEndIndex - listItem.contentIndent);
} else {
kv = parseKey(lineNoComment);
if (!kv) return null;
effectiveIndent = countIndent(lineNoComment);
keyStartIndex = kv.keyStartIndex;
keyEndIndex = kv.keyEndIndex;
}
if (cursorIndex < keyStartIndex || cursorIndex >= keyEndIndex) return null;
const stack = buildContextStack(model, position.lineNumber - 1);
while (stack.length > 1 && effectiveIndent <= stack[stack.length - 1].indent) stack.pop();
let contextSchema = resolveRef(stack[stack.length - 1].schema);
if (listItem && cursorIndex >= listItem.afterDashIndex && contextSchema && contextSchema.type === 'array') {
contextSchema = resolveRef(contextSchema.items);
}
if (!contextSchema) return null;
const propSchema = getPropertySchema(contextSchema, kv.key);
if (!propSchema) return null;
const resolved = resolveRef(propSchema);
const description = resolved && resolved.description;
if (!description) return null;
return {
range: new monaco.Range(
position.lineNumber,
keyStartIndex + 1,
position.lineNumber,
keyEndIndex + 1
),
contents: [{value: description}],
};
}
});
};
let dump;
document.getElementById('save').addEventListener('click', async () => {
let r = await fetch('api/config', {cache: 'no-cache'});
if (r.ok && dump !== await r.text()) {
alert('Config was changed from another place. Refresh the page and make changes again');
return;
}
r = await fetch('api/config', {method: 'POST', body: editor.getValue()});
if (r.ok) {
alert('OK');
dump = editor.getValue();
await fetch('api/restart', {method: 'POST'});
} else {
alert(await r.text());
}
});
(async () => {
try {
const schemaRes = await fetch('schema.json', {cache: 'no-cache'});
if (schemaRes.ok) setupYamlHints(await schemaRes.json());
} catch (e) {
// ignore schema load errors
}
const r = await fetch('api/config', {cache: 'no-cache'});
if (r.status === 410) {
alert('Config file is not set');
} else if (r.status === 404) {
editor.setValue(''); // config file not exist
} else if (r.ok) {
dump = await r.text();
editor.setValue(dump);
} else {
alert(`Unknown error: ${r.statusText} (${r.status})`);
}
})();
});
</script>
</body>
</html>