Add the web interface

Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
// DOM and stylesheet checks without a browser.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { beforeEach, test } from 'node:test';
class Element {
constructor(tag) {
this.tagName = tag;
this.attributes = {};
this.children = [];
this.listeners = {};
this.className = '';
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, listener) { this.listeners[name] = listener; }
}
const toasts = new Element('div');
globalThis.document = {
createElement: (tag) => new Element(tag),
addEventListener() {},
getElementById: () => toasts,
querySelectorAll: () => [],
};
const { button } = await import('../../bindery/static/js/lib.js');
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
const { jobPanel, stopPolling } = await import('../../bindery/static/js/jobs.js');
const obsolete = new Set([
'btn', 'btn-primary', 'btn-danger', 'btn-small', 'btn-link', 'input', 'input-number',
'row', 'spread', 'stack', 'push', 'grow', 'wide', 'block', 'plain', 'muted', 'small',
'mono', 'nowrap', 'wrap', 'faded',
]);
const elements = (node) => node instanceof Element ? [node, ...node.children.flatMap(elements)] : [];
const text = (node) => node instanceof Element ? node.children.map(text).join('') : String(node);
const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label);
const settle = () => new Promise((resolve) => setImmediate(resolve));
const config = { metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, update_interval: 0 };
const story = {
id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title' },
};
const chapter = {
id: 2, title: 'Chapter', url: 'https://example.com/chapter', included: false,
on_site: true, current_rev: 2, revisions: 2,
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: 'A note', enabled: true };
beforeEach(() => {
toasts.replaceChildren();
globalThis.fetch = async (url) => {
const replies = {
'api/auth/check': { authenticated: true, mode: 'cwa', user: 'Reader' },
'api/config': config,
'api/jobs': [],
'api/settings': { recent_chapters: 3, personal_ini: '[defaults]' },
'api/rules': { rules: [rule], paragraphs: false, global_paragraphs: false },
'api/stories/1/rules': { rules: [rule], paragraphs: null, global_paragraphs: false },
'api/stories': [story],
'api/stories/1': { story, chapters: [chapter] },
'api/chapters/2/diff?': {
chapter, a: 1, b: 2, identical: true,
revisions: [{ rev: 1, source: 'download', size: 100 }, { rev: 2, source: 'recheck', size: 100 }],
},
};
assert.ok(url in replies, `Unexpected request: ${url}`);
return { ok: true, json: async () => replies[url] };
};
});
test('plain buttons need no class and stay disabled until the action finishes', async () => {
let finish;
const pending = new Promise((resolve) => { finish = resolve; });
const control = button('Save', () => pending);
assert.equal(control.tagName, 'button');
assert.equal(control.attributes.type, 'button');
assert.equal(control.className, '');
let prevented = false;
const clicked = control.listeners.click({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(control.disabled, true);
finish();
await clicked;
assert.equal(control.disabled, false);
});
test('button variants and attributes do not need a base class', () => {
const control = button('Delete', () => {}, { class: 'danger', disabled: true, 'aria-label': 'Delete revision' });
assert.equal(control.className, 'danger');
assert.equal(control.disabled, true);
assert.equal(control.attributes['aria-label'], 'Delete revision');
});
for (const [name, render] of [
['settings', (main) => settingsPage(main)],
['stories', (main) => storiesPage(main, {}, config)],
['story', (main) => storyPage(main, { storyId: '1' }, config)],
['chapter', (main) => chapterPage(main, { storyId: '1', chapterId: '2' })],
]) {
test(`${name} renders native controls without utility classes`, async () => {
const main = new Element('main');
const cleanup = render(main);
try {
await settle();
assert.equal(toasts.children.length, 0);
const nodes = elements(main);
assert.ok(nodes.some((el) => el.tagName === 'button'));
assert.ok(nodes.some((el) => el.tagName === 'input' || el.tagName === 'select'));
for (const el of nodes) {
for (const cls of el.className.split(/\s+/)) assert.ok(!obsolete.has(cls), `${el.tagName}.${cls}`);
}
if (name === 'settings') {
assert.equal(nodes.find((el) => el.attributes.id === 'recent-chapters').value, 3);
assert.equal(nodes.find((el) => el.tagName === 'textarea').className, 'ini');
const pattern = nodes.find((el) => el.attributes['aria-label'] === 'Pattern');
assert.equal(pattern.value, 'before');
assert.equal(pattern.className, '');
assert.equal(nodes.find((el) => el.attributes['aria-label'] === 'Note').className, 'rule-note');
}
if (name === 'story') {
const download = find(main, 'a', 'Download ePub');
assert.equal(download.attributes.href, 'api/stories/1/epub');
assert.equal(download.attributes.download, '');
assert.equal(download.className, '');
assert.equal(find(main, 'button', 'Use the site value').className, '');
assert.ok(nodes.some((el) => el.className === 'chapter-excluded'));
}
if (name === 'chapter') {
assert.equal(find(main, 'button', 'Delete').className, 'danger');
assert.equal(find(main, 'button', 'Use this revision').className, '');
}
} finally {
cleanup?.();
}
});
}
test('jobs use native secondary text and timestamps', () => {
const updated = '2026-01-02T03:04:05Z';
const { el: list, render } = jobPanel();
render([{ id: 'job', status: 'queued', title: 'Story', mode_label: 'Update', updated, message: 'Waiting' }]);
const nodes = elements(list);
assert.ok(find(list, 'small', 'Update'));
assert.equal(nodes.find((el) => el.tagName === 'time').attributes.datetime, updated);
assert.equal(find(list, 'button', 'Cancel').className, '');
assert.ok(find(list, 'p', 'Waiting'));
});
test('a job update keeps the scroll box of the job list', () => {
const { el, render } = jobPanel();
const job = { id: 'job', status: 'running', title: 'Story', mode_label: 'Update', progress: 0.1 };
render([job]);
const [box] = el.children;
assert.equal(box.className, 'jobs');
render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]);
assert.deepEqual(el.children, [box]);
assert.equal(box.children.length, 2);
const [first] = box.children;
render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]);
assert.equal(box.children[0], first, 'A poll without changes leaves the list alone');
render([]);
assert.deepEqual(el.children[0].children, ['No jobs.']);
render([job]);
assert.deepEqual(el.children, [box]);
});
test('header navigation follows account controls and marks the current page', async (t) => {
const html = readFileSync(new URL('../../bindery/static/index.html', import.meta.url), 'utf8');
assert.match(html, /<div id="bindery-app"><\/div>/);
assert.doesNotMatch(html, /id="app"/);
const app = new Element('div');
const previousWindow = globalThis.window;
const listeners = {};
globalThis.window = {
location: { hash: '#/' },
addEventListener: (name, listener) => { listeners[name] = listener; },
scrollTo() {},
};
t.mock.method(document, 'getElementById', (id) => {
if (id === 'bindery-app') return app;
if (id === 'main') return elements(app).find((el) => el.attributes.id === 'main');
return toasts;
});
t.mock.method(document, 'querySelectorAll', (selector) => {
assert.equal(selector, 'header nav a');
const header = app.children[0];
const nav = header.children.find((el) => el.tagName === 'nav');
return nav.children;
});
try {
await import('../../bindery/static/js/main.js');
await settle();
assert.equal(toasts.children.length, 0);
const header = app.children[0];
assert.deepEqual(header.children.map((el) => el.tagName), ['a', 'div', 'nav']);
assert.ok(find(header.children[1], 'button', 'Log out'));
const nav = header.children[2];
assert.equal(nav.attributes['aria-label'], 'Main navigation');
assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], 'page');
assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], undefined);
window.location.hash = '#/settings';
listeners.hashchange();
await settle();
assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], undefined);
assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], 'page');
assert.equal(toasts.children.length, 0);
} finally {
stopPolling();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test('CSS and HTML generators contain no obsolete utility classes', () => {
const root = new URL('../../bindery/static/', import.meta.url);
const css = readFileSync(new URL('style.css', root), 'utf8');
for (const [, cls] of css.matchAll(/\.([a-z][\w-]*)/gi)) assert.ok(!obsolete.has(cls), `CSS: .${cls}`);
const files = ['index.html', ...readdirSync(new URL('js/', root)).filter((file) => file.endsWith('.js')).map((file) => `js/${file}`)];
for (const file of files) {
const source = readFileSync(new URL(file, root), 'utf8');
for (const [, , classes] of source.matchAll(/\bclass\s*[:=]\s*(['"`])([^'"`]+)\1/g)) {
for (const cls of classes.split(/\s+/)) assert.ok(!obsolete.has(cls), `${file}: ${cls}`);
}
}
});