Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Importing from another dashboard reads a list written level with its key, a
value folded across lines, and an empty `[]` or `{}`, each of which used to
refuse the whole file.

- Confirming an import and naming a new folder use in-page dialogs instead of
the browser's own.

Expand Down
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=23b16aa6"></script>
<script type="module" src="/js/admin.js?v=88a8068e"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion ui/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { canJoinFolder, applyDrop } from '/js/admin-drag-logic.js?v=53aeaa55';
/* openModal is the item editor in this file, so the shared dialog comes in
under a different name. */
import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=10d34989';
import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=d4e5311d';
import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=a3f8f22e';
import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=e0db2fb3';

/* Admin UI: Stackyard Dashboard */
Expand Down
122 changes: 103 additions & 19 deletions ui/js/yaml-lite.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
The subset is deliberate. Every construct this cannot represent faithfully is
refused with the line number rather than approximated, because the failure
mode of a permissive hand-written parser is not an exception, it is an item
that imports with the wrong URL and nobody notices. Anchors, flow
collections, block scalars, tabs and multi-document files all throw.
that imports with the wrong URL and nobody notices. Anchors and aliases,
merge keys, flow collections with contents, tabs and multi-document files all
throw.

What it does read is set by what real files contain, not by what is tidy to
implement. A sequence written level with its key, a long value folded across
lines, and an empty pair of brackets are all ordinary in configs people
actually keep, and each one used to refuse the whole file.

Pure: no DOM, no imports, no browser globals. */

Expand Down Expand Up @@ -42,9 +48,12 @@ function scalar(raw, line) {
if (!m) throw new YamlLiteError('unterminated single-quoted value', line);
return m[1].replace(/''/g, "'");
}
if (s === '|' || s === '>' || /^[|>][-+\d]*$/.test(s))
throw new YamlLiteError('block scalars are not supported', line);
if (s[0] === '&' || s[0] === '*') throw new YamlLiteError('anchors and aliases are not supported', line);
/* An empty one is unambiguous and common: real configs disable a whole
section by writing `sections: []`. Anything with contents inside the
brackets still refuses, since that is where the parsing gets involved. */
if (s === '[]') return [];
if (s === '{}') return Object.create(null);
if (s[0] === '{' || s[0] === '[') throw new YamlLiteError('flow collections are not supported', line);
if (s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null;
if (s === 'true' || s === 'True' || s === 'TRUE' || s === 'yes' || s === 'Yes') return true;
Expand All @@ -57,7 +66,36 @@ function scalar(raw, line) {
return cut === -1 ? s : s.slice(0, cut).trim();
}

/** @typedef {{ indent: number, text: string, line: number }} Line */
/** @typedef {{ indent: number, text: string, line: number, block?: string }} Line */

/* A key whose value is a block scalar: "key: |", "key: >-", "key: |2". */
const BLOCK_RE = /^(.*?):\s*([|>])([-+]?)(\d*)\s*$/;

/** Fold the lines of a block scalar into its value.

Only the rules a real config exercises. A folded block joins its lines with
spaces, which is how a long API key or icon URL gets wrapped across lines; a
literal block keeps them. A blank line is a paragraph break either way, and
a line indented past the block keeps its own newlines.

@param {string[]} raw the block's lines, already stripped of its indentation
@param {string} style either "|" or ">" @param {string} chomp */
function foldBlock(raw, style, chomp) {
let body;
if (style === '|') body = raw.join('\n');
else {
body = '';
for (let i = 0; i < raw.length; i++) {
const cur = raw[i];
if (i === 0) body = cur;
else if (cur === '' || raw[i - 1] === '' || /^\s/.test(cur) || /^\s/.test(raw[i - 1])) body += '\n' + cur;
else body += ' ' + cur;
}
}
if (chomp === '-') return body.replace(/\n+$/, '');
if (chomp === '+') return body + '\n';
return body.replace(/\n+$/, '') + (body.length ? '\n' : '');
}

/** @param {string} text @returns {Line[]} */
function scan(text) {
Expand All @@ -67,25 +105,56 @@ function scan(text) {
/* A file saved on Windows starts with a byte order mark. Left in place it
becomes part of the first key and the document reads as malformed. */
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
text.split(/\r\n|\r|\n/).forEach((raw, i) => {
const rows = text.split(/\r\n|\r|\n/);

for (let i = 0; i < rows.length; i++) {
const raw = rows[i];
const line = i + 1;
if (/^\s*$/.test(raw)) return;
if (/^\s*#/.test(raw)) return;
if (/^\s*$/.test(raw)) continue;
if (/^\s*#/.test(raw)) continue;
if (/^\t/.test(raw) || /^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line);
const trimmed = raw.trim();
if (trimmed === '---') {
/* One leading marker is ordinary. A second means a multi-document file,
where taking only the first document would silently drop the rest. */
if (++docs > 1 || out.length) throw new YamlLiteError('multi-document files are not supported', line);
return;
continue;
}
if (trimmed === '...') throw new YamlLiteError('multi-document files are not supported', line);
/* Before the anchor check: a merge key is written with an alias, and naming
the alias would send someone looking for the wrong thing. */
if (/^<<\s*:/.test(trimmed)) throw new YamlLiteError('merge keys are not supported', line);
if (/(^|\s)[&*][^\s]/.test(trimmed)) throw new YamlLiteError('anchors and aliases are not supported', line);
out.push({ indent: raw.length - raw.trimStart().length, text: trimmed, line });
});

const indent = raw.length - raw.trimStart().length;
const bm = BLOCK_RE.exec(trimmed);
/* Consumed here rather than in the value parser, because the block's own
lines are text, not structure: they may be indented like anything, hold a
tab, or begin with a character that means something at the start of a
line. Nothing below this point should look at them. */
if (bm && bm[1] !== '' && !bm[1].includes('#')) {
const body = [];
let base = bm[4] ? indent + Number(bm[4]) : -1;
let j = i + 1;
for (; j < rows.length; j++) {
const r = rows[j];
if (/^\s*$/.test(r)) {
body.push('');
continue;
}
const ri = r.length - r.trimStart().length;
if (ri <= indent) break;
if (base === -1) base = ri;
if (ri < base) break;
body.push(r.slice(base));
}
while (body.length && body[body.length - 1] === '') body.pop();
out.push({ indent, text: bm[1].trim() + ':', line, block: foldBlock(body, bm[2], bm[3]) });
i = j - 1;
continue;
}
out.push({ indent, text: trimmed, line });
}
return out;
}

Expand Down Expand Up @@ -119,14 +188,14 @@ function sequence(lines, cur, indent) {
key text starts at, not the dash. */
const inner = indent + 2;
const map = Object.create(null);
assign(map, m, l.line, lines, cur, inner);
assign(map, m, l.line, lines, cur, inner, l);
while (cur.pos < lines.length && lines[cur.pos].indent === inner) {
const nl = lines[cur.pos];
if (nl.text.startsWith('- ') || nl.text === '-') break;
const nm = KEY_RE.exec(nl.text);
if (!nm) throw new YamlLiteError('expected "key: value"', nl.line);
cur.pos++;
assign(map, nm, nl.line, lines, cur, inner);
assign(map, nm, nl.line, lines, cur, inner, nl);
}
out.push(map);
continue;
Expand All @@ -139,21 +208,36 @@ function sequence(lines, cur, indent) {
/** Set one key on `map` from a matched key line, reading its nested block when
the value is empty.
@param {any} map @param {RegExpExecArray} m @param {number} line
@param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */
function assign(map, m, line, lines, cur, indent) {
@param {Line[]} lines @param {{ pos: number }} cur @param {number} indent
@param {Line} [own] the key's own line, when it carried a block scalar */
function assign(map, m, line, lines, cur, indent, own) {
const key = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim();
if (own && own.block !== undefined) {
map[key] = own.block;
return;
}
const inline = m[4];
if (inline !== undefined && inline.trim() !== '' && !/^#/.test(inline.trim())) {
map[key] = scalar(inline, line);
return;
}
const next = lines[cur.pos];
if (!next || next.indent <= indent) {
if (!next) {
map[key] = null;
return;
}
/* A sequence belonging to a key is written either indented under it or level
with it, and level is the more common of the two: Dashy's own default
config writes navLinks that way. Only a sequence may do this. A mapping at
the same column is the next key of the same parent, not this key's value. */
if (next.indent === indent && (next.text.startsWith('- ') || next.text === '-')) {
map[key] = sequence(lines, cur, indent);
return;
}
if (next.indent <= indent) {
map[key] = null;
return;
}
/* A sequence may sit at the parent's own indentation, which is legal YAML and
common in both source formats. */
map[key] = block(lines, cur, next.indent);
}

Expand All @@ -172,7 +256,7 @@ function mapping(lines, cur, indent) {
const m = KEY_RE.exec(l.text);
if (!m) throw new YamlLiteError('expected "key: value"', l.line);
cur.pos++;
assign(map, m, l.line, lines, cur, indent);
assign(map, m, l.line, lines, cur, indent, l);
}
return map;
}
Expand Down
29 changes: 29 additions & 0 deletions ui/test/fixtures/dashy-flush-layout.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# The layout Dashy's own default config ships with: sequences written level
# with the key they belong to, rather than indented under it.
---
pageInfo:
title: Flush Layout
navLinks:
- title: Docs
path: https://dashy.to/docs

appConfig:
theme: colorful
searchBangs: {}

sections:
- name: Getting Started
icon: fas fa-rocket
items:
- title: Source
description: Where the code lives
icon: fab fa-github
url: https://github.com/example/repo
target: newtab
- title: Long Icon
icon: >-
https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg
url: https://plex.example.com
statusCheck: true
- name: Empty On Purpose
items: []
26 changes: 26 additions & 0 deletions ui/test/import-fixtures.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,29 @@ test('nothing in the real files produces an item the save route would refuse', (
assert.equal(out.skipped.filter(s => s.reason === SKIP.UNREADABLE).length, 0, `${f}: nothing unreadable`);
}
});

/* Dashy's own default config writes its sequences level with the key rather
than indented under it, and that layout used to refuse the whole file. */
test('a config in the flush layout converts like any other', () => {
const out = load('dashy-flush-layout.yml');
assert.equal(out.kind, 'dashy');
assert.deepEqual(
folders(out).map(f => f.label),
['Getting Started'],
);
assert.deepEqual(
apps(out).map(a => a.label),
['Source', 'Long Icon'],
);
/* A section with an empty list of items contributes no folder. */
assert.equal(
folders(out).some(f => f.label === 'Empty On Purpose'),
false,
);
/* The icon was folded across two lines in the source. */
assert.equal(
apps(out).find(a => a.label === 'Long Icon').iconUrl,
'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg',
);
assert.equal(apps(out).find(a => a.label === 'Long Icon').monitoring.healthcheck.pingUrl, 'https://plex.example.com');
});
70 changes: 66 additions & 4 deletions ui/test/yaml-lite.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,8 @@ for (const [name, text, reason] of [
['anchors', 'base: &defaults\n icon: x\napp:\n href: http://a\n', 'anchors'],
['aliases', 'a: *defaults\n', 'anchors'],
['merge keys', 'a:\n <<: *defaults\n', 'merge keys'],
['flow mappings', 'a: { b: 1 }\n', 'flow collections'],
['flow sequences', 'a: [1, 2]\n', 'flow collections'],
['block scalars', 'a: |\n line one\n', 'block scalars'],
['folded scalars', 'a: >\n line one\n', 'block scalars'],
['flow mappings with contents', 'a: { b: 1 }\n', 'flow collections'],
['flow sequences with contents', 'a: [1, 2]\n', 'flow collections'],
['tab indentation', 'a:\n\tb: 1\n', 'tab indentation'],
['a second document', 'a: 1\n---\nb: 2\n', 'multi-document'],
]) {
Expand Down Expand Up @@ -137,3 +135,67 @@ test('an empty or comment-only file parses to nothing', () => {
assert.equal(parseYaml(''), null);
assert.equal(parseYaml('# just a note\n\n'), null);
});

/* ── layouts real files use ───────────────────────────────────────────────── */

/* Every one of these came from a config someone actually keeps. Each used to
refuse the whole file, including Dashy's own default config, which writes its
navLinks level with the key rather than indented under it. */

test('a sequence written level with its key belongs to that key', () => {
assert.deepEqual(plain(parseYaml('pageInfo:\n navLinks:\n - title: A\n path: http://a\n')), {
pageInfo: { navLinks: [{ title: 'A', path: 'http://a' }] },
});
});

test('a sequence indented under its key means the same thing', () => {
const flush = parseYaml('a:\n b:\n - x: 1\n');
const nested = parseYaml('a:\n b:\n - x: 1\n');
assert.deepEqual(plain(flush), plain(nested));
});

test("a mapping level with a key is the next key, not that key's value", () => {
assert.deepEqual(plain(parseYaml('a:\n b:\n c: 2\n')), { a: { b: null, c: 2 } });
});

test('a Homepage group nested by two spaces reads like one nested by four', () => {
const two = parseYaml('- Group:\n - Service:\n href: http://a\n');
const four = parseYaml('- Group:\n - Service:\n href: http://a\n');
assert.deepEqual(plain(two), plain(four));
});

test('a folded value is joined into one line, which is how a long key is wrapped', () => {
assert.equal(parseYaml('key: >-\n abc\n def\n').key, 'abc def');
assert.equal(parseYaml('icon: >-\n https://example.com/a.svg\n').icon, 'https://example.com/a.svg');
});

test('a literal value keeps its line breaks', () => {
assert.equal(parseYaml('note: |\n one\n two\n').note, 'one\ntwo\n');
assert.equal(parseYaml('note: |-\n one\n two\n').note, 'one\ntwo');
});

test('a folded value on the same line as a list dash is still read', () => {
assert.deepEqual(plain(parseYaml('- items:\n - key: >-\n abc123\n')), [{ items: [{ key: 'abc123' }] }]);
});

/* The body of a block is text. A parser that kept reading it as structure would
see a list item, a comment or a tab in there and either refuse a valid file or
mis-read it. */
test('the body of a block is never read as structure', () => {
assert.equal(
parseYaml('note: |\n - not a list item\n # not a comment\n').note,
'- not a list item\n# not a comment\n',
);
assert.equal(parseYaml('note: |\n has\ta tab\n').note, 'has\ta tab\n');
assert.deepEqual(plain(parseYaml('a: |\n text\nb: 2\n')), { a: 'text\n', b: 2 });
});

test('an empty pair of brackets is a value, not a refusal', () => {
assert.deepEqual(plain(parseYaml('sections: []\n')), { sections: [] });
assert.deepEqual(plain(parseYaml('appConfig: {}\n')), { appConfig: {} });
});

test('brackets with contents still refuse, since that is where guessing starts', () => {
assert.throws(() => parseYaml('tags: [a, b]\n'), /flow collections/);
assert.throws(() => parseYaml('opts: { a: 1 }\n'), /flow collections/);
});
Loading