da0b19026c
Adds detection for inconsistent indentation and unexpected indentation at the document root. Enhances block scalar handling by tracking parent indentation and skipping content lines accordingly. Also improves error reporting for missing map keys or list items.
981 lines
41 KiB
HTML
981 lines
41 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;
|
|
min-height: 0;
|
|
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 hasYaml = (monaco.languages.getLanguages?.() || []).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,
|
|
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;
|
|
|
|
let 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?.[name];
|
|
if (!def) return schema;
|
|
const resolved = resolveRef(def, seen);
|
|
const {$ref, ...rest} = schema;
|
|
return {...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 t = (text ?? '').trimStart();
|
|
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?.load) {
|
|
try {
|
|
window.jsyaml.load(model.getValue());
|
|
} catch (e) {
|
|
const mark = e?.mark || {};
|
|
const line = typeof mark.line === 'number' ? mark.line + 1 : 1;
|
|
const column = typeof mark.column === 'number' ? mark.column + 1 : 1;
|
|
markLineError(line, e?.reason ? `YAML: ${e.reason}` : `YAML: ${e?.message || '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?.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;
|
|
|
|
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 {word, startColumn, endColumn} = model.getWordUntilPosition(position);
|
|
const range = new monaco.Range(position.lineNumber, startColumn, position.lineNumber, 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?.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};
|
|
}
|
|
});
|
|
};
|
|
|
|
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>
|