New and improved YAML editor, with schema validation

This commit is contained in:
Alicia Sykes
2026-04-24 09:46:29 +01:00
parent 93a453a40e
commit 6017eb6a2f
6 changed files with 731 additions and 150 deletions

View File

@@ -86,7 +86,7 @@ import { localStorageKeys, modalNames } from '@/utils/config/defaults';
import { getUsersLanguage } from '@/utils/config/ConfigHelpers';
import ErrorHandler from '@/utils/logging/ErrorHandler';
import StoreKeys from '@/utils/StoreMutations';
import JsonEditor from '@/components/Configuration/JsonEditor';
import { defineAsyncComponent, h } from 'vue';
import CustomCssEditor from '@/components/Configuration/CustomCss';
import CloudBackupRestore from '@/components/Configuration/CloudBackupRestore';
import RebuildApp from '@/components/Configuration/RebuildApp';
@@ -105,6 +105,14 @@ import RebuildIcon from '@/assets/interface-icons/application-rebuild.svg';
import LanguageIcon from '@/assets/interface-icons/config-language.svg';
import IconAbout from '@/assets/interface-icons/application-about.svg';
const EditorLoading = {
render: () => h('p', { class: 'editor-loading-placeholder' }, 'Loading editor…'),
};
const JsonEditor = defineAsyncComponent({
loader: () => import('@/components/Configuration/JsonEditor.vue'),
loadingComponent: EditorLoading,
});
export default {
name: 'ConfigContainer',
data() {
@@ -306,12 +314,11 @@ div.code-container {
}
.tab-item {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
@extend .scroll-bar;
background: var(--config-settings-background);
&.main-tab {
min-height: 500px;
}
}
.main-options-container {
@@ -388,4 +395,15 @@ p.small-screen-note {
background: var(--config-settings-background) !important;
}
.editor-loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 55vh;
margin: 0;
font-size: 1rem;
color: var(--config-settings-color);
opacity: var(--dimming-factor);
}
</style>

View File

@@ -1,22 +1,30 @@
<template>
<div class="json-editor-outer" v-if="allowViewConfig">
<!-- Main JSON editor -->
<div class="jsoneditor-container min-box">
<textarea
class="json-textarea"
:value="jsonString"
@input="onJsonInput($event.target.value)"
/>
<!-- Toolbar -->
<div class="editor-toolbar">
<div class="editor-options">
<label><input type="checkbox" v-model="wordWrap" /> Wrap</label>
<button type="button" class="format-btn" @click="formatDocument">Format</button>
</div>
<div class="editor-status" :class="isValid ? 'ok' : 'err'">
{{ isValid ? $t('config-editor.valid-label') : errorMessages.length + ' issue(s)' }}
</div>
</div>
<!-- Options raido, and save button -->
<Radio class="save-options"
<!-- Editor mount point -->
<div ref="editorEl" class="cm-container min-box"></div>
<!-- Save location -->
<Radio
class="save-options"
v-model="saveMode"
:label="$t('config-editor.save-location-label')"
:options="saveOptions"
:initialOption="initialSaveMode"
:disabled="!allowWriteToDisk || !allowSaveLocally"
/>
<!-- Save Buttons -->
/>
<!-- Save / Preview -->
<div :class="`btn-container ${!isValid ? 'err' : ''}`">
<Button :click="save" :disallow="!allowWriteToDisk && !allowSaveLocally">
{{ $t('config-editor.save-button') }}
@@ -25,22 +33,31 @@
{{ $t('config-editor.preview-button') }}
</Button>
</div>
<!-- List validation warnings -->
<!-- Diagnostics -->
<div class="errors">
<ul>
<li v-for="(error, index) in errorMessages" :key="index" :class="`type-${error.type}`">
{{error.msg}}
<li
v-for="(e, i) in errorMessages"
:key="i"
:class="`type-${e.type}`"
@click="jumpTo(e)"
>
<span class="err-loc">L{{ e.line }}</span>
{{ e.message }}
</li>
<li v-if="errorMessages.length < 1" class="type-valid">
<li v-if="!errorMessages.length" class="type-valid">
{{ $t('config-editor.valid-label') }}
</li>
</ul>
</div>
<!-- Information notes -->
<p v-if="saveSuccess !== undefined"
:class="`response-output status-${saveSuccess ? 'success' : 'fail'}`">
{{saveSuccess
? $t('config-editor.status-success-msg') : $t('config-editor.status-fail-msg') }}
<!-- Status / notes -->
<p
v-if="saveSuccess !== undefined"
:class="`response-output status-${saveSuccess ? 'success' : 'fail'}`"
>
{{ saveSuccess ? $t('config-editor.status-success-msg') : $t('config-editor.status-fail-msg') }}
</p>
<p v-if="!allowWriteToDisk" class="no-permission-note">
{{ $t('config-editor.not-admin-note') }}
@@ -56,7 +73,23 @@
</template>
<script>
import { shallowRef, markRaw } from 'vue';
import jsYaml from 'js-yaml';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { yaml } from '@codemirror/lang-yaml';
import { linter, lintGutter, forEachDiagnostic } from '@codemirror/lint';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import {
bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, HighlightStyle,
} from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { tags as t } from '@lezer/highlight';
import { schemaLinter } from '@/utils/config/schemaLinter';
import { schemaHover } from '@/utils/config/schemaHover';
import ConfigSavingMixin from '@/mixins/ConfigSaving';
import { InfoHandler, InfoKeys } from '@/utils/logging/ErrorHandler';
import StoreKeys from '@/utils/StoreMutations';
@@ -65,17 +98,129 @@ import Button from '@/components/FormElements/Button';
import Radio from '@/components/FormElements/Radio';
import AccessError from '@/components/Configuration/AccessError';
const DUMP_OPTS = { noRefs: true, lineWidth: 120 };
// CodeMirror theme — uses --background / --background-darker / --primary / --config-settings-color
// so the editor tracks the user's active theme (the dedicated --code-editor-* vars are hardcoded light).
const MONO_FONT = 'ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Liberation Mono", Menlo, Monaco, Consolas, monospace';
const dashyTheme = EditorView.theme({
'&': {
color: 'var(--config-settings-color)',
backgroundColor: 'var(--background)',
height: '100%',
},
'.cm-scroller': {
fontFamily: MONO_FONT,
},
'.cm-content': {
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--primary-transparent-60)',
},
'.cm-gutters': {
backgroundColor: 'var(--background-darker)',
color: 'var(--medium-grey)',
borderRight: '1px solid var(--transparent-white-10)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: 'var(--transparent-white-10)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--primary-transparent-60)',
color: 'var(--config-settings-color)',
border: 'none',
padding: '0 0.4rem',
},
'.cm-tooltip, .cm-tooltip-autocomplete, .cm-panels': {
backgroundColor: 'var(--background-darker)',
color: 'var(--config-settings-color)',
border: '1px solid var(--primary)',
},
'.cm-searchMatch': {
backgroundColor: 'var(--primary-transparent-60)',
},
'.cm-searchMatch-selected': {
backgroundColor: 'var(--primary)',
},
'.cm-lint-marker-warning': {
color: 'var(--warning)',
},
'.cm-lint-marker-error': {
color: 'var(--danger)',
},
'.cm-diagnostic': {
borderLeft: '3px solid var(--primary)',
backgroundColor: 'var(--background-darker)',
color: 'var(--config-settings-color)',
padding: '0.3rem 0.5rem',
},
'.cm-diagnostic-warning': {
borderLeftColor: 'var(--warning)',
},
'.cm-diagnostic-error': {
borderLeftColor: 'var(--danger)',
},
'.cm-schema-hover': {
maxWidth: '22rem',
padding: '0.5rem 0.7rem',
fontSize: '0.85rem',
lineHeight: '1.45',
},
'.cm-schema-hover .title': {
fontWeight: 'bold',
color: 'var(--primary)',
marginBottom: '0.25rem',
},
'.cm-schema-hover .desc': {
color: 'var(--config-settings-color)',
marginBottom: '0.4rem',
},
'.cm-schema-hover .meta': {
fontSize: '0.8rem',
color: 'var(--medium-grey)',
marginTop: '0.15rem',
},
'.cm-schema-hover .meta .label': {
color: 'var(--primary)',
fontWeight: 'bold',
marginRight: '0.3rem',
},
'.cm-schema-hover code': {
fontFamily: MONO_FONT,
fontSize: '0.8rem',
padding: '0.05rem 0.25rem',
borderRadius: 'var(--curve-factor)',
background: 'var(--transparent-white-10)',
color: 'var(--config-settings-color)',
},
}, { dark: true });
const dashyHighlight = HighlightStyle.define([
{ tag: [t.propertyName, t.attributeName, t.definition(t.propertyName)], color: 'var(--info)' },
{ tag: t.string, color: 'var(--success)' },
{ tag: t.number, color: 'var(--warning)' },
{ tag: t.bool, color: 'var(--info)', fontWeight: 'bold' },
{ tag: t.null, color: 'var(--medium-grey)', fontStyle: 'italic' },
{ tag: t.keyword, color: 'var(--primary)', fontWeight: 'bold' },
{ tag: t.comment, color: 'var(--medium-grey)', fontStyle: 'italic' },
{ tag: t.operator, color: 'var(--primary)' },
{ tag: t.punctuation, color: 'var(--medium-grey)' },
{ tag: t.meta, color: 'var(--info)' },
{ tag: t.invalid, color: 'var(--danger)' },
]);
export default {
name: 'JsonEditor',
mixins: [ConfigSavingMixin],
components: {
Button,
Radio,
AccessError,
},
components: { Button, Radio, AccessError },
data() {
return {
jsonData: {},
wordWrap: true,
errorMessages: [],
saveMode: '',
saveOptions: [
@@ -84,65 +229,149 @@ export default {
],
};
},
setup() {
return {
view: shallowRef(null),
wrapCompartment: markRaw(new Compartment()),
};
},
computed: {
config() {
return this.$store.state.config;
},
jsonString() {
return JSON.stringify(this.jsonData, null, 2);
},
isValid() {
return this.errorMessages.length < 1;
},
permissions() {
// Returns: { allowWriteToDisk, allowSaveLocally, allowViewConfig }
return this.$store.getters.permissions;
},
allowWriteToDisk() {
return this.permissions.allowWriteToDisk;
},
allowSaveLocally() {
return this.permissions.allowSaveLocally;
},
allowViewConfig() {
return this.permissions.allowViewConfig;
},
config() { return this.$store.state.config; },
isValid() { return !this.errorMessages.some((e) => e.type === 'error'); },
permissions() { return this.$store.getters.permissions; },
allowWriteToDisk() { return this.permissions.allowWriteToDisk; },
allowSaveLocally() { return this.permissions.allowSaveLocally; },
allowViewConfig() { return this.permissions.allowViewConfig; },
initialSaveMode() {
if (this.allowWriteToDisk) return 'file';
if (this.allowSaveLocally) return 'local';
return '';
},
},
watch: {
wordWrap(v) {
if (!this.view) return;
this.view.dispatch({
effects: this.wrapCompartment.reconfigure(v ? EditorView.lineWrapping : []),
});
},
},
mounted() {
const jsonData = { ...this.config };
jsonData.sections = (jsonData.sections || []).map(({ filteredItems, ...section }) => section);
if (!jsonData.pageInfo) jsonData.pageInfo = { title: 'Dashy' };
this.jsonData = jsonData;
if (!this.allowWriteToDisk) this.saveMode = 'local';
this.createEditor();
},
beforeUnmount() {
if (this.view) {
this.view.destroy();
this.view = null;
}
},
methods: {
onJsonInput(value) {
initialText() {
const data = { ...this.config };
data.sections = (data.sections || []).map(({ filteredItems, ...s }) => s);
if (!data.pageInfo) data.pageInfo = { title: 'Dashy' };
return jsYaml.dump(data, DUMP_OPTS);
},
createEditor() {
const updateListener = EditorView.updateListener.of((u) => {
if (u.docChanged || u.transactions.some((tr) => tr.effects.length)) {
this.syncDiagnostics();
}
});
const state = EditorState.create({
doc: this.initialText(),
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
foldGutter(),
history(),
bracketMatching(),
closeBrackets(),
indentOnInput(),
syntaxHighlighting(dashyHighlight, { fallback: true }),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
indentWithTab,
]),
yaml(),
lintGutter(),
linter(schemaLinter, { delay: 300 }),
schemaHover,
this.wrapCompartment.of(this.wordWrap ? EditorView.lineWrapping : []),
dashyTheme,
updateListener,
],
});
this.view = markRaw(new EditorView({ state, parent: this.$refs.editorEl }));
this.syncDiagnostics();
},
syncDiagnostics() {
if (!this.view) return;
const next = [];
forEachDiagnostic(this.view.state, (d, from) => {
next.push({
type: d.severity === 'error' ? 'error' : 'warning',
message: d.message,
line: this.view.state.doc.lineAt(from).number,
from,
});
});
// Skip reassignment when nothing changed, to avoid Vue re-renders on
// every keystroke while the lint result is identical.
const same = next.length === this.errorMessages.length
&& next.every((e, i) => e.from === this.errorMessages[i].from
&& e.message === this.errorMessages[i].message);
if (!same) this.errorMessages = next;
},
formatDocument() {
if (!this.view) return;
try {
this.jsonData = JSON.parse(value);
this.errorMessages = [];
const data = jsYaml.load(this.view.state.doc.toString());
const formatted = jsYaml.dump(data ?? {}, DUMP_OPTS);
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: formatted },
});
} catch (e) {
this.errorMessages = [{ type: 'error', msg: e.message }];
this.$toast.error(`Cannot format: ${e.message}`);
}
},
jumpTo(err) {
if (!this.view) return;
this.view.focus();
this.view.dispatch({
selection: { anchor: err.from, head: err.from },
effects: EditorView.scrollIntoView(err.from, { y: 'center' }),
});
},
parseCurrent() {
const text = this.view?.state.doc.toString() ?? '';
try {
return jsYaml.load(text);
} catch (e) {
this.$toast.error(e.message);
return null;
}
},
/* Calls appropriate save method, based on save-type radio selected */
save() {
if (this.saveMode === 'local' || !this.allowWriteToDisk) {
this.saveLocally();
} else if (this.saveMode === 'file') {
this.writeToDisk();
} else {
this.$toast.error(this.$t('config-editor.error-msg-save-mode'));
}
const data = this.parseCurrent();
if (data == null) return;
if (this.saveMode === 'local' || !this.allowWriteToDisk) this.saveLocally(data);
else if (this.saveMode === 'file') this.writeToDisk(data);
else this.$toast.error(this.$t('config-editor.error-msg-save-mode'));
},
/* Applies changes to the local state, begins edit mode and closes modal */
startPreview() {
const data = this.parseCurrent();
if (data == null) return;
InfoHandler('Applying changes to local state...', InfoKeys.RAW_EDITOR);
const data = this.jsonData;
this.$store.commit(StoreKeys.SET_APP_CONFIG, data.appConfig);
this.$store.commit(StoreKeys.SET_PAGE_INFO, data.pageInfo);
this.$store.commit(StoreKeys.SET_SECTIONS, data.sections);
@@ -150,49 +379,16 @@ export default {
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
this.$modal.hide(modalNames.CONF_EDITOR);
},
writeToDisk() {
const newData = this.jsonData;
this.writeConfigToDisk(newData);
// this.$store.commit(StoreKeys.SET_APP_CONFIG, newData.appConfig);
this.$store.commit(StoreKeys.SET_PAGE_INFO, newData.pageInfo);
this.$store.commit(StoreKeys.SET_SECTIONS, newData.sections);
writeToDisk(data) {
this.writeConfigToDisk(data);
this.$store.commit(StoreKeys.SET_PAGE_INFO, data.pageInfo);
this.$store.commit(StoreKeys.SET_SECTIONS, data.sections);
},
saveLocally() {
saveLocally(data) {
const msg = this.$t('interactive-editor.menu.save-locally-warning');
const youSure = confirm(msg); // eslint-disable-line no-alert, no-restricted-globals
if (youSure) {
this.saveConfigLocally(this.jsonData);
}
// eslint-disable-next-line no-alert, no-restricted-globals
if (confirm(msg)) this.saveConfigLocally(data);
},
/* Convert error messages into readable format for UI */
validationErrors(errors) {
const errorMessages = [];
errors.forEach((error) => {
switch (error.type) {
case 'validation':
errorMessages.push({
type: 'validation',
msg: `${this.$t('config-editor.warning-msg-validation')}: `
+ `${(error.error || error).dataPath} ${(error.error || error).message}`,
});
break;
case 'error':
errorMessages.push({
type: 'parse',
msg: error.message,
});
break;
default:
errorMessages.push({
type: 'editor',
msg: this.$t('config-editor.error-msg-bad-json'),
});
break;
}
});
this.errorMessages = errorMessages;
},
/* Shows toast message */
showToast(message, success) {
this.$toast[success ? 'success' : 'error'](message);
},
@@ -206,12 +402,81 @@ export default {
.json-editor-outer {
text-align: center;
}
p.note {
font-size: 0.8rem;
color: var(--medium-grey);
margin: 0.2rem;
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.4rem 0.6rem;
background: var(--background-darker);
border-bottom: 1px solid var(--config-settings-background);
.editor-options {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--config-settings-color);
font-size: 0.85rem;
label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
button.format-btn {
background: transparent;
color: var(--config-settings-color);
border: 1px solid var(--config-settings-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.6rem;
cursor: pointer;
&:hover {
background: var(--config-settings-color);
color: var(--background-darker);
}
}
}
.editor-status {
font-size: 0.85rem;
padding: 0.15rem 0.5rem;
border-radius: var(--curve-factor);
&.ok {
color: var(--success);
}
&.err {
color: var(--danger);
font-weight: bold;
}
}
}
p.errors {
// Structural only — colors and font live in the CodeMirror theme.
.cm-container.min-box {
height: 58vh;
border: 1px solid var(--primary);
border-radius: var(--curve-factor);
overflow: hidden;
text-align: left;
direction: ltr;
.cm-editor {
height: 100%;
font-size: 0.9rem;
}
.cm-focused {
outline: none;
}
}
.errors {
text-align: left;
margin: 0.5rem auto;
width: 95%;
@@ -219,38 +484,75 @@ p.errors {
list-style: none;
padding: 0;
margin: 0;
li {
&.type-validation {
color: var(--warning);
&::before { content: "⚠️"; }
}
li {
font-size: 0.85rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: var(--curve-factor);
&:hover:not(.type-valid) {
background: var(--transparent-white-10);
}
.err-loc {
display: inline-block;
min-width: 2.5rem;
margin-right: 0.4rem;
color: var(--medium-grey);
}
&.type-warning {
color: var(--warning);
&::before {
content: "⚠️ ";
}
&.type-parse {
color: var(--danger);
&::before { content: "❌"; }
}
&.type-error {
color: var(--danger);
&::before {
content: "❌ ";
}
&.type-valid {
color: var(--success);
&::before { content: "✅"; }
}
&.type-valid {
color: var(--success);
cursor: default;
&::before {
content: "✅ ";
}
}
}
}
p.response-output {
font-size: 0.8rem;
text-align: left;
margin: 0.5rem auto;
width: 95%;
color: var(--config-settings-color);
&.status-success {
font-weight: bold;
color: var(--success);
}
&.status-fail {
font-weight: bold;
color: var(--danger);
}
}
p.note {
font-size: 0.8rem;
color: var(--medium-grey);
margin: 0.2rem;
}
p.no-permission-note {
color: var(--warning);
}
@@ -260,7 +562,7 @@ p.no-permission-note {
align-items: center;
justify-content: center;
button {
padding: 0.5rem 1rem;
padding: 0.5rem 1rem;
margin: 0.25rem;
font-size: 1.2rem;
background: var(--config-settings-background);
@@ -291,41 +593,23 @@ div.save-options.radio-container {
margin: 0;
padding: 0;
border-top: 2px solid var(--config-settings-background);
background: var(--code-editor-background);
background: var(--background-darker);
label.radio-label {
font-size: 1rem;
flex-grow: revert;
flex-basis: revert;
color: var(--code-editor-color);
color: var(--config-settings-color);
padding-left: 1rem;
}
.radio-wrapper {
margin: 0;
font-size: 1rem;
justify-content: space-around;
background: var(--code-editor-background);
color: var(--code-editor-color);
background: var(--background-darker);
color: var(--config-settings-color);
.radio-option:hover:not(.wrap-disabled) {
border: 1px solid var(--code-editor-color);
border: 1px solid var(--config-settings-color);
}
}
}
.jsoneditor-container.min-box {
height: 58vh;
}
textarea.json-textarea {
width: 100%;
height: 100%;
border: 1px solid var(--primary);
border-radius: var(--curve-factor);
background: var(--code-editor-background);
color: var(--code-editor-color);
font-family: var(--font-monospace);
font-size: 0.85rem;
padding: 0.5rem;
resize: vertical;
tab-size: 2;
}
</style>

View File

@@ -84,7 +84,14 @@ export default {
</script>
<style scoped lang="scss">
.tabs-component {
display: flex;
flex-direction: column;
height: 100%;
}
.tab__pagination {
flex: 0 0 auto;
display: flex;
overflow-x: auto;
border-bottom: 1px solid var(--config-settings-color);
@@ -123,6 +130,9 @@ export default {
}
.tabs__content {
height: 100%;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,82 @@
/**
* An extension for CodeMirror 6,
* Shows schema info as tooltips on attribute names
* Like title, description, type, allowed enum values, etc.
*/
import { hoverTooltip } from '@codemirror/view';
import { parseDocument } from 'yaml';
import schema from './ConfigSchema.json';
import { yamlPathAtOffset, schemaAt } from './schemaPath';
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[c]));
const describeType = (s) => {
if (Array.isArray(s.type)) return s.type.join(' | ');
if (s.type) return s.type;
if (s.properties) return 'object';
if (s.items) return 'array';
return null;
};
// Returns HTML for a tooltip bod, or null if the schema has nothing worth saying
const renderBody = (s) => {
if (!s) return null;
const rows = [];
if (s.title) {
rows.push(`<div class="title">${escapeHtml(s.title)}</div>`);
}
if (s.description) {
rows.push(`<div class="desc">${escapeHtml(s.description)}</div>`);
}
const type = describeType(s);
if (type) {
rows.push(`<div class="meta"><span class="label">Type</span> <code>${escapeHtml(type)}</code></div>`);
}
if (Array.isArray(s.enum)) {
const opts = s.enum.map((v) => `<code>${escapeHtml(v)}</code>`).join(', ');
rows.push(`<div class="meta"><span class="label">Allowed</span> ${opts}</div>`);
}
if (s.default !== undefined) {
rows.push(`<div class="meta"><span class="label">Default</span> <code>${escapeHtml(JSON.stringify(s.default))}</code></div>`);
}
return rows.length ? rows.join('') : null;
};
export const schemaHover = hoverTooltip((view, pos) => {
const text = view.state.doc.toString();
if (!text.trim()) return null;
let doc;
try {
doc = parseDocument(text);
} catch {
return null;
}
const path = yamlPathAtOffset(doc, pos);
if (!path.length) return null;
const body = renderBody(schemaAt(schema, path));
if (!body) return null;
return {
pos,
above: true,
create() {
const dom = document.createElement('div');
dom.className = 'cm-schema-hover';
dom.innerHTML = body;
return { dom };
},
};
}, { hideOnChange: true, hoverTime: 300 });

View File

@@ -0,0 +1,93 @@
/**
* This is a custom linter plugin for CodeMirror (6),
* used when user editing config as code via the config modal
* Does:
* 1. YAML syntax checker, vie js-yaml
* 2. Ajv schema validator using our ConfigSchema.json
*/
import jsYaml from 'js-yaml';
import { parseDocument } from 'yaml';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import schema from './ConfigSchema.json';
import { pointerToPath, yamlNodeAt } from './schemaPath';
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const validate = ajv.compile(schema);
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
// Turn Ajv's raw errors into user readable strings
const formatError = (err) => {
const { keyword, params, message } = err;
switch (keyword) {
case 'enum':
return `must be one of: ${(params.allowedValues || []).join(', ')}`;
case 'const':
return `must be: ${JSON.stringify(params.allowedValue)}`;
case 'required':
return `missing required field: ${params.missingProperty}`;
case 'additionalProperties':
return `unknown property: ${params.additionalProperty}`;
case 'type':
return `must be ${params.type}`;
case 'minLength':
return `must be at least ${params.limit} characters`;
case 'maxLength':
return `must be at most ${params.limit} characters`;
case 'minimum':
case 'maximum':
return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`;
case 'pattern':
return `must match pattern ${params.pattern}`;
case 'format':
return `must be a valid ${params.format}`;
default:
return message;
}
};
const prefix = (instancePath) => (instancePath ? `${instancePath} ` : '');
export function schemaLinter(view) {
const text = view.state.doc.toString();
if (!text.trim()) return [];
const docLen = view.state.doc.length;
let data;
try {
data = jsYaml.load(text);
} catch (e) {
const line = e.mark?.line ?? 0;
const col = e.mark?.column ?? 0;
const lineObj = view.state.doc.line(clamp(line + 1, 1, view.state.doc.lines));
const from = clamp(lineObj.from + col, lineObj.from, lineObj.to);
return [{
from,
to: clamp(from + 1, from, docLen),
severity: 'error',
source: 'yaml',
message: e.reason || e.message,
}];
}
if (validate(data)) return [];
const doc = parseDocument(text);
return (validate.errors || []).map((err) => {
const path = pointerToPath(err.instancePath);
const node = yamlNodeAt(doc.contents, path);
const [start, end] = node?.range ?? [0, 0];
const from = clamp(start, 0, docLen);
const to = clamp(end || start + 1, from + 1, docLen);
return {
from,
to,
severity: 'warning',
source: 'schema',
message: `${prefix(err.instancePath)}${formatError(err)}`,
};
});
}

View File

@@ -0,0 +1,94 @@
/**
* Shared YAML + JSON-Schema walkers for the linter and the config tooltip extension
*/
import { isMap, isSeq } from 'yaml';
const decodeToken = (s) => s.replace(/~1/g, '/').replace(/~0/g, '~');
// JSON Pointer (Ajv's instancePath, e.g. "/sections/0/items/2") -> path parts.
export const pointerToPath = (pointer) => (pointer
? pointer.split('/').slice(1).map(decodeToken)
: []);
// Walk a parsed YAML Document by path parts, returning the target Node or null.
export const yamlNodeAt = (root, parts) => {
let node = root;
for (const key of parts) {
if (!node) return null;
if (isMap(node)) {
const pair = node.items.find((p) => {
const k = p.key && 'value' in p.key ? p.key.value : p.key;
return String(k) === String(key);
});
node = pair ? pair.value : null;
} else if (isSeq(node)) {
node = node.items[Number(key)] ?? null;
} else {
return null;
}
}
return node;
};
// yaml Nodes expose range as [start, valueEnd, nodeEnd]; fall back if shorter.
const nodeEnd = (r) => (r ? (r[2] ?? r[1] ?? r[0]) : -1);
const inside = (r, offset) => !!r && offset >= r[0] && offset <= nodeEnd(r);
// Given a parsed YAML Document and a cursor offset, return the path from
// document root to the smallest containing node (e.g. ['sections', 0, 'url']).
// Returns [] when the cursor isn't inside any addressable node.
export const yamlPathAtOffset = (doc, offset) => {
const path = [];
let node = doc?.contents ?? null;
while (node) {
if (isMap(node)) {
const pair = node.items.find((p) => inside(p.key?.range, offset) || inside(p.value?.range, offset));
if (!pair) break;
const keyName = pair.key && 'value' in pair.key ? pair.key.value : pair.key;
path.push(String(keyName));
// Cursor on the key but not the value — stop here rather than descending.
if (inside(pair.key?.range, offset) && !inside(pair.value?.range, offset)) break;
node = pair.value;
} else if (isSeq(node)) {
const idx = node.items.findIndex((it) => inside(it?.range, offset));
if (idx < 0) break;
path.push(idx);
node = node.items[idx];
} else {
break;
}
}
return path;
};
// Walk a JSON Schema by path parts, resolving `properties`, `items`,
// `patternProperties` and simple `oneOf`/`anyOf`. Returns the sub-schema at
// the path, or null if the schema doesn't describe that location.
export const schemaAt = (schema, parts) => {
const pickBranch = (s) => {
if (!s) return s;
if (Array.isArray(s.oneOf) && s.oneOf.length) return s.oneOf[0];
if (Array.isArray(s.anyOf) && s.anyOf.length) return s.anyOf[0];
return s;
};
let cur = pickBranch(schema);
for (const key of parts) {
if (!cur) return null;
if (cur.items) {
cur = pickBranch(cur.items);
} else if (cur.properties && Object.prototype.hasOwnProperty.call(cur.properties, key)) {
cur = pickBranch(cur.properties[key]);
} else if (cur.patternProperties) {
const entry = Object.entries(cur.patternProperties)
.find(([pat]) => new RegExp(pat).test(String(key)));
cur = entry ? pickBranch(entry[1]) : null;
} else if (cur.additionalProperties && typeof cur.additionalProperties === 'object') {
cur = pickBranch(cur.additionalProperties);
} else {
return null;
}
}
return cur;
};