From 40ddf4e6c733512a44b5ae0fc6065d0c4216e507 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:25:00 -0400 Subject: [PATCH] Fix: read the YAML layouts real dashboard configs use Three constructs refused a whole file, and between them they blocked most real configs, including the default one Dashy ships. A sequence written level with the key it belongs to is now read as that key's value. Only a sequence may do this; a mapping at the same column is the next key of the same parent. This is the common way of writing YAML and it also covers a Homepage group nested by two spaces. A block scalar is folded or kept literally, with chomping. Its body is consumed while scanning, so a list dash, a comment marker or a tab inside it is text rather than structure. Long API keys and icon URLs are wrapped this way. An empty flow collection reads as an empty list or mapping. One with contents still refuses, which is where parsing it would start to guess. Checked against js-yaml on fourteen real files: every one that both accept parses identically. --- CHANGELOG.md | 4 + ui/admin/index.html | 2 +- ui/js/admin.js | 2 +- ui/js/yaml-lite.js | 122 ++++++++++++++++++++---- ui/test/fixtures/dashy-flush-layout.yml | 29 ++++++ ui/test/import-fixtures.test.mjs | 26 +++++ ui/test/yaml-lite.test.mjs | 70 +++++++++++++- 7 files changed, 230 insertions(+), 25 deletions(-) create mode 100644 ui/test/fixtures/dashy-flush-layout.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4428c521..95e16517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/admin/index.html b/ui/admin/index.html index 7b9b0f15..052c532d 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -285,6 +285,6 @@

General

- + diff --git a/ui/js/admin.js b/ui/js/admin.js index 53d9ffc9..115d2a38 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -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 */ diff --git a/ui/js/yaml-lite.js b/ui/js/yaml-lite.js index 5e842a9b..f2891956 100644 --- a/ui/js/yaml-lite.js +++ b/ui/js/yaml-lite.js @@ -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. */ @@ -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; @@ -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) { @@ -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; } @@ -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; @@ -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); } @@ -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; } diff --git a/ui/test/fixtures/dashy-flush-layout.yml b/ui/test/fixtures/dashy-flush-layout.yml new file mode 100644 index 00000000..da8ceac5 --- /dev/null +++ b/ui/test/fixtures/dashy-flush-layout.yml @@ -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: [] diff --git a/ui/test/import-fixtures.test.mjs b/ui/test/import-fixtures.test.mjs index 3e553ce0..cdc158ed 100644 --- a/ui/test/import-fixtures.test.mjs +++ b/ui/test/import-fixtures.test.mjs @@ -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'); +}); diff --git a/ui/test/yaml-lite.test.mjs b/ui/test/yaml-lite.test.mjs index 817e8674..af31ed4c 100644 --- a/ui/test/yaml-lite.test.mjs +++ b/ui/test/yaml-lite.test.mjs @@ -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'], ]) { @@ -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/); +});