The entry opens the notes in the page; its keywords carry the paused flags of that area. English for the three new words; the other catalogs follow with the words step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
18 KiB
TypeScript
241 lines
18 KiB
TypeScript
// The panel's pure parts: its words (every key it uses has English and every English key is used),
|
|
// which sections an app gets and in what order, the Theme Builder's model, the Google Fonts
|
|
// catalog, Make's plugin header rules and UI kit demos, and the Markdown the bundled docs use.
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { themeFromColors, themeVars } from '../../runtime/src/themeApi.ts';
|
|
import { BASE_EFFECTS, BASE_FONT, BASE_LAYOUT, BASE_SHAPE, sanitizeSpec, type ThemeSpec } from '../../runtime/src/themeSpec.ts';
|
|
import { configureSite, type ShellSite } from '../src/frame/site.ts';
|
|
import { autoFix, contrastReport, draftFrom, paletteFrom, pinnedRoles, record, redo, startHistory, themeJson, undo, uniqueId } from '../src/panel/builderModel.ts';
|
|
import { compareLines } from '../src/panel/compare.ts';
|
|
import { defaultWeights, parseCatalog, searchFonts, stackFor } from '../src/panel/fonts/catalog.ts';
|
|
import { CREATE_COMMANDS, KIT_DEMOS, cssVarOf, parsePluginHeader, pluginFileFor, pluginWarnings, validPluginFileName } from '../src/panel/makeModel.ts';
|
|
import { anchorOf, inlineTokens, parseMarkdown, resolveLink } from '../src/panel/markdown.ts';
|
|
import { sparseFlags, tableDefaults, type FeatureTable } from '../../runtime/src/featureTable.ts';
|
|
import { areaCards, featureFiles, fixRows, modePlan, rowKeywords } from '../src/panel/featureModel.ts';
|
|
import { navEntries } from '../src/panel/nav.ts';
|
|
import { PANEL_MESSAGES } from '../src/panel/panelMessages.ts';
|
|
import type { PanelSite } from '../src/panel/site.ts';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const PANEL = join(here, '../src/panel');
|
|
const files = (dir: string): string[] => readdirSync(dir).flatMap(name => {
|
|
const path = join(dir, name);
|
|
return statSync(path).isDirectory() ? files(path) : [path];
|
|
});
|
|
|
|
test('every key the panel uses has English, and every English key is used', () => {
|
|
const sources = files(PANEL).filter(file => /\.tsx?$/.test(file) && !file.endsWith('panelMessages.ts')).map(file => readFileSync(file, 'utf8')).join('\n');
|
|
const used = new Set([...sources.matchAll(/'(wb\.[A-Za-z0-9_.-]+)'/g)].map(match => match[1]!));
|
|
// Keys built at runtime (`wb.fonts.role.<role>.label`, ...): every key under the prefix counts.
|
|
const prefixes = [...sources.matchAll(/`(wb\.[A-Za-z0-9_.]+\.)\$\{/g)].map(match => match[1]!);
|
|
const en = JSON.parse(readFileSync(join(here, '../../waybetter/locales/en.json'), 'utf8')).messages as Record<string, string>;
|
|
for (const key of used) assert.ok(key in en, `${key} is in the framework catalog`);
|
|
for (const prefix of prefixes) assert.ok(Object.keys(en).some(key => key.startsWith(prefix)), `${prefix}* is in the framework catalog`);
|
|
// `wb.fonts.choice.<id>` is named by the runtime's FONT_CHOICES (`key`).
|
|
for (const key of Object.keys(PANEL_MESSAGES)) {
|
|
assert.ok(used.has(key) || prefixes.some(prefix => key.startsWith(prefix)) || key.startsWith('wb.fonts.choice.'), `${key} is used`);
|
|
assert.equal(en[key], PANEL_MESSAGES[key as keyof typeof PANEL_MESSAGES], `${key} is the catalog's English`);
|
|
}
|
|
});
|
|
|
|
const shellSite = (extra: Partial<ShellSite> = {}) => ({
|
|
name: 'Example', product: 'wayBetter for Example', logo: '', homeUrl: 'https://example.com/home', loginUrl: 'https://example.com/login', logoutUrl: 'https://example.com/logout',
|
|
isSiteUrl: (url: string) => url.startsWith('https://example.com/'),
|
|
accounts: { handle: /^\w{1,15}$/, avatarPrefixes: [], handlePrefix: '@' },
|
|
searchUrl: (text: string) => `https://example.com/search?q=${encodeURIComponent(text)}`,
|
|
address: () => null,
|
|
panel: { id: 'app', title: 'App', component: () => null },
|
|
newTab: { search: () => 'Search', links: [] },
|
|
palette: { placeholder: () => 'Search', sections: [] },
|
|
...extra,
|
|
}) satisfies ShellSite;
|
|
|
|
const home: PanelSite['home'] = { kicker: { name: 'wayBetter', rest: ' for Example' }, title: () => ['Example, ', 'better', '.'], lede: () => 'An example.' };
|
|
|
|
test('an app gets the sections it serves, in its order', () => {
|
|
// A small app: no themes, store, decks, backup, developer mode or updater.
|
|
configureSite(shellSite({ features: { workspaces: false, sync: false, developer: false, pluginCommands: false } }));
|
|
const small: PanelSite = { brand: { name: 'Example', logo: '' }, home };
|
|
assert.deepEqual(navEntries(small).map(entry => entry.id), ['home', 'plugins', 'accounts', 'settings']);
|
|
// Its own sections come after the built-in ones unless it orders them.
|
|
const Own = () => null;
|
|
const own: PanelSite = { ...small, sections: [{ id: 'tweaks', label: () => 'Tweaks', icon: 'M0 0', component: Own, searchable: true }] };
|
|
assert.deepEqual(navEntries(own).map(entry => entry.id), ['home', 'plugins', 'accounts', 'settings', 'tweaks']);
|
|
const ordered = navEntries({ ...own, order: ['home', 'tweaks', 'plugins', 'nope', 'store'], backup: true, updates: true });
|
|
assert.deepEqual(ordered.map(entry => entry.id), ['home', 'tweaks', 'plugins', 'accounts', 'settings', 'backup', 'updates']);
|
|
assert.equal(ordered[1]!.component, Own);
|
|
assert.equal(ordered[1]!.label(key => key), 'Tweaks');
|
|
assert.equal(ordered[0]!.label(key => `[${key}]`), '[wb.panel.section.home]');
|
|
// Developer mode is a frame feature; Power User needs a deck.
|
|
configureSite(shellSite({
|
|
deck: { kinds: [], urlFor: () => null, title: () => ({ title: '' }), parseValue: () => '', invalid: () => '', flags: [], defaultFlags: {} },
|
|
}));
|
|
assert.deepEqual(navEntries(small).map(entry => entry.id), ['home', 'plugins', 'accounts', 'power', 'settings', 'developer']);
|
|
});
|
|
|
|
const SITE_THEME: ThemeSpec = {
|
|
id: 'site', name: 'Site default', description: '', author: 'Example', version: 1, mode: 'auto', colors: null,
|
|
font: BASE_FONT, shape: BASE_SHAPE, layout: BASE_LAYOUT, background: { kind: 'none' }, effects: BASE_EFFECTS, css: '',
|
|
};
|
|
const DRAFT_SITE = { colors: { bg: '#000000', text: '#e7e9ea', accent: '#1d9bf0' }, siteTheme: 'site' };
|
|
|
|
test('builder: history, palette from three colors, contrast report and auto-fix, export', () => {
|
|
const draft = draftFrom(SITE_THEME, DRAFT_SITE, 'Mine');
|
|
assert.ok(draft.colors, "the site's own look gets a real palette to edit");
|
|
assert.equal(draft.colors!.bg, '#000000');
|
|
assert.equal(draft.description, '', "the site's own theme is not \"based on\" anything");
|
|
assert.equal(draftFrom({ ...SITE_THEME, id: 'paper', name: 'Paper' }, DRAFT_SITE).description, 'Based on Paper.');
|
|
let history = startHistory(draft);
|
|
const light = { ...draft, colors: paletteFrom({ bg: '#fbf8f1', text: '#8a8a8a', accent: '#ffd400' }) };
|
|
history = record(history, light);
|
|
// A slider drag: many in-place updates, one undo step from where it started.
|
|
const dragged = record(record(history, { ...light, name: 'a' }, { commit: false }), { ...light, name: 'ab' }, { commit: false });
|
|
const committed = record(dragged, { ...light, name: 'abc' }, { from: light });
|
|
assert.equal(committed.past.length, 2);
|
|
assert.equal(undo(committed).present.name, light.name);
|
|
assert.equal(redo(undo(committed)).present.name, 'abc');
|
|
assert.equal(undo(undo(undo(committed))).present, draft);
|
|
const report = contrastReport(light.colors!);
|
|
assert.ok(report.some(check => !check.pass));
|
|
assert.ok(contrastReport(autoFix(light.colors!)).every(check => check.pass), 'auto-fix passes every check');
|
|
const colors = paletteFrom({ bg: '#000000', text: '#ffffff', accent: '#1d9bf0' }, { border: '#ff0000' });
|
|
assert.deepEqual(pinnedRoles(colors), { border: '#ff0000' });
|
|
assert.equal(uniqueId('Paper', ['site', 'paper']), 'paper-2', 'built-in ids are taken');
|
|
assert.equal(uniqueId('Mine', ['mine']), 'mine-2');
|
|
assert.equal(uniqueId('Mine', []), 'mine');
|
|
const schema = JSON.parse(readFileSync(join(here, '../../waybetter/theme.schema.json'), 'utf8'));
|
|
const json = JSON.parse(themeJson(light));
|
|
assert.equal(json.$schema, schema.$id);
|
|
assert.deepEqual(sanitizeSpec(json), sanitizeSpec(light));
|
|
});
|
|
|
|
test('Google Fonts catalog: parse, search, default weights, fallback stacks', () => {
|
|
const catalog = parseCatalog({ families: [
|
|
['Inter', 'sans', [100, 900], [100, 400, 900], 3], ['Lato', 'sans', null, [100, 300, 400, 700, 900], 2],
|
|
['Fraunces', 'serif', [100, 900], [400], 9], ['Bad/Name', 'sans', null, [400], 1], ['Odd', 'weird', null, [400], 1],
|
|
] });
|
|
assert.deepEqual(catalog.map(font => font.family), ['Inter', 'Lato', 'Fraunces']);
|
|
assert.deepEqual(searchFonts(catalog, {}).map(font => font.family), ['Lato', 'Inter', 'Fraunces'], 'popularity');
|
|
assert.deepEqual(searchFonts(catalog, { query: 'in' }).map(font => font.family), ['Inter'], 'prefix');
|
|
assert.deepEqual(searchFonts(catalog, { category: 'serif' }).map(font => font.family), ['Fraunces']);
|
|
assert.deepEqual(searchFonts(catalog, { only: new Set(['Lato']) }).map(font => font.family), ['Lato']);
|
|
assert.deepEqual(defaultWeights(catalog[0]!), { kind: 'range', min: 100, max: 900 });
|
|
assert.deepEqual(defaultWeights(catalog[1]!), { kind: 'list', values: [400, 700] });
|
|
assert.deepEqual(stackFor(catalog[2]!), ['Fraunces', 'Georgia', 'serif']);
|
|
// The bundled snapshot parses and only holds names native code accepts.
|
|
const bundled = parseCatalog(JSON.parse(readFileSync(join(PANEL, 'fonts/googleFontsCatalog.json'), 'utf8')));
|
|
assert.ok(bundled.length > 1500);
|
|
assert.ok(bundled.every(font => /^[A-Za-z0-9]+( [A-Za-z0-9]+)*$/.test(font.family) && font.family.length <= 48));
|
|
});
|
|
|
|
test('Make: header parsing follows the plugin store (waybetter-host parse_meta)', () => {
|
|
const source = '/**\n * @name Hide Trends\n * @description Hides trends\n * @version 1.2.0\n * @author Someone\n */\nmodule.exports = class {};';
|
|
assert.deepEqual(parsePluginHeader('hide.plugin.js', source), { file: 'hide.plugin.js', name: 'Hide Trends', description: 'Hides trends', version: '1.2.0', author: 'Someone', header: true });
|
|
assert.equal(parsePluginHeader('plain.plugin.js', 'module.exports = {};').name, 'plain', 'no header: the file name');
|
|
assert.equal(parsePluginHeader('one.plugin.js', ' /** @name One */ module.exports = {};').name, 'One');
|
|
assert.equal(parsePluginHeader('x.plugin.js', '/**\n * @name \n */').name, 'x', 'an empty @name keeps the file name');
|
|
assert.equal(parsePluginHeader('x.plugin.js', `/**\n * @description ${'d'.repeat(400)}\n */`).description.length, 300);
|
|
assert.equal(validPluginFileName('../evil.plugin.js'), false);
|
|
assert.equal(validPluginFileName('.hidden.plugin.js'), false);
|
|
assert.equal(validPluginFileName('ok name.plugin.js'), true);
|
|
assert.equal(pluginFileFor('Hide Trends'), 'hide-trends.plugin.js');
|
|
assert.deepEqual(pluginWarnings(parsePluginHeader('a.plugin.js', 'console.log(1)'), 'console.log(1)').length, 3);
|
|
});
|
|
|
|
test('Make: UI kit demos name real kit calls; color roles map to the --wb-* variables', () => {
|
|
for (const demo of KIT_DEMOS) assert.ok(demo.code.includes(`ui.${demo.kind}(`), demo.id);
|
|
const theme = themeFromColors('#101010', '#f0f0f0', '#3ddc97');
|
|
const vars = themeVars(theme);
|
|
for (const role of Object.keys(theme.colors)) assert.ok(cssVarOf(role) in vars, role);
|
|
assert.ok(CREATE_COMMANDS.every(item => item.command.startsWith('npm create waybetter ')));
|
|
});
|
|
|
|
test('Markdown: headings, tables, code and links as data; anchors and doc links resolve', () => {
|
|
const blocks = parseMarkdown('# Title\n\nSee [the API](./OTHER.md#the-api) and `code`.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nlet x = 1;\n```\n\n- one\n- **two**\n');
|
|
assert.deepEqual(blocks.map(block => block.kind), ['heading', 'paragraph', 'table', 'code', 'list']);
|
|
const table = blocks.find(block => block.kind === 'table')!;
|
|
assert.ok(table.kind === 'table' && table.rows.every(row => row.length === table.head.length));
|
|
const links = inlineTokens('See [the API](./OTHER.md#the-api) and `code`.').filter(token => token.kind === 'link');
|
|
assert.equal(links.length, 1);
|
|
assert.deepEqual(resolveLink('./OTHER.md#the-api', ['OTHER.md']), { doc: 'OTHER.md', anchor: 'the-api' });
|
|
assert.equal(anchorOf('Matching the theme: `api.theme`'), 'matching-the-theme-apitheme');
|
|
});
|
|
|
|
test('compare: changed lines are marked on both sides', () => {
|
|
const diff = compareLines('one\ntwo\nthree', 'one\n2\nthree');
|
|
assert.deepEqual(diff.previous.map(line => line.changed), [false, true, false]);
|
|
assert.deepEqual(diff.current.map(line => line.changed), [false, true, false]);
|
|
assert.equal(diff.current[1]!.text, '2');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Area plugins: what the cards show of a feature table, and what choosing a mode changes.
|
|
|
|
const FEATURE_TABLE: FeatureTable = {
|
|
areas: [
|
|
{ id: 'navigation', name: 'Navigation+', description: 'plugin.navigation-plus.description', owner: 'wb-navigation-plus.plugin.js', namespace: 'navigation-plus' },
|
|
{ id: 'reader', name: 'Reader+', description: 'plugin.reader-plus.description', owner: 'wb-reader-plus.plugin.js', namespace: 'reader-plus' },
|
|
],
|
|
features: [
|
|
{ id: 'logo', area: null, group: 'branding', default: true, backing: 'app' },
|
|
{ id: 'fixScroll', area: null, group: 'fixes', default: true, backing: 'app' },
|
|
{ id: 'hideTrends', area: 'navigation', group: 'sidebar', default: false, backing: 'module' },
|
|
{ id: 'navigation.focusMode', area: 'navigation', group: 'tools', default: false, backing: 'module', settings: true, supersedes: 'wb-focus-mode.plugin.js', keywords: 'Focus Mode' },
|
|
{ id: 'hideAds', area: 'reader', group: 'timeline', default: true, backing: 'app' },
|
|
{ id: 'reader.cleanLinks', area: 'reader', group: 'posts', default: false, backing: 'plugin', file: 'wb-clean-links.plugin.js', label: 'plugin.clean-links.name' },
|
|
{ id: 'reader.readingTime', area: 'reader', group: 'posts', default: false, backing: 'plugin', file: 'wb-reading-time.plugin.js' },
|
|
],
|
|
modes: [
|
|
{ id: 'basic', name: 'mode.basic', description: 'mode.basic.text', flags: { 'area.navigation': false, 'area.reader': false, logo: false } },
|
|
{ id: 'advanced', name: 'mode.advanced', description: 'mode.advanced.text', recommended: true, flags: {} },
|
|
],
|
|
};
|
|
|
|
test('area cards: a feature is its flag or, until it is ported, its plugin file; the Fixes list is what no area owns', () => {
|
|
const flags = { ...tableDefaults(FEATURE_TABLE), 'navigation.focusMode': true };
|
|
const plugins = [{ file: 'wb-clean-links.plugin.js', enabled: true, name: 'Clean Links' }, { file: 'mine.plugin.js', enabled: true }];
|
|
const [navigation, reader] = areaCards(FEATURE_TABLE, flags, plugins);
|
|
assert.deepEqual([navigation!.on, navigation!.total, navigation!.enabled, navigation!.settings], [1, 2, true, true]);
|
|
assert.deepEqual(navigation!.groups.map(group => [group.id, group.rows.map(row => row.spec.id)]), [['sidebar', ['hideTrends']], ['tools', ['navigation.focusMode']]]);
|
|
assert.deepEqual(reader!.rows.map(row => [row.spec.id, row.kind, row.on, row.missing]), [
|
|
['hideAds', 'flag', true, false],
|
|
['reader.cleanLinks', 'plugin', true, false],
|
|
['reader.readingTime', 'plugin', false, true],
|
|
], 'an old plugin file is on when it is enabled; a deleted one cannot be switched');
|
|
assert.equal(reader!.rows[1]!.label, 'plugin.clean-links.name');
|
|
assert.equal(reader!.rows[0]!.label, 'features.hideAds.label');
|
|
assert.deepEqual([...featureFiles(FEATURE_TABLE)].sort(), ['wb-clean-links.plugin.js', 'wb-reading-time.plugin.js'], 'listed in a card, so not again under JavaScript plugins');
|
|
// The area's switch off: the card says so and offers no Settings; the feature's own choice is kept.
|
|
const off = areaCards(FEATURE_TABLE, { ...flags, 'area.navigation': false }, plugins)[0]!;
|
|
assert.deepEqual([off.enabled, off.settings, off.on], [false, false, 1]);
|
|
// The owner kept an edited copy of the plugin a feature replaced.
|
|
const own = areaCards(FEATURE_TABLE, tableDefaults(FEATURE_TABLE), [{ file: 'wb-focus-mode.plugin.js', enabled: true, name: 'Focus Mode' }])[0]!;
|
|
assert.equal(own.rows[1]!.ownCopy?.enabled, true);
|
|
assert.equal(own.rows[1]!.on, false, 'never both: the built-in feature stays off while their copy is on');
|
|
assert.match(rowKeywords(own.rows[1]!, own.area), /Focus Mode.*wb-focus-mode\.plugin\.js.*Navigation\+/, 'the search finds a feature by its old plugin name');
|
|
assert.deepEqual(fixRows(FEATURE_TABLE, flags, ['logo']).map(row => row.spec.id), ['fixScroll'], 'core flags, minus what has a control elsewhere');
|
|
// A feature that paused itself in the focused tab: its row and its card say so, only while it would run.
|
|
const paused = new Set(['navigation.focusMode', 'hideTrends']);
|
|
const noted = areaCards(FEATURE_TABLE, flags, plugins, paused)[0]!;
|
|
assert.deepEqual([noted.paused, noted.rows.map(row => row.paused)], [1, [false, true]], 'the one that is off is not "paused"');
|
|
assert.equal(navigation!.paused, 0);
|
|
assert.equal(areaCards(FEATURE_TABLE, { ...flags, 'area.navigation': false }, plugins, paused)[0]!.paused, 0, 'an area that is off runs nothing');
|
|
});
|
|
|
|
test('modes: a mode replaces the owner\'s changes or keeps them, old plugin files included', () => {
|
|
const defaults = tableDefaults(FEATURE_TABLE);
|
|
const mine = { ...defaults, hideTrends: true };
|
|
const plugins = [{ file: 'wb-clean-links.plugin.js', enabled: true }, { file: 'mine.plugin.js', enabled: true }];
|
|
const fresh = modePlan(FEATURE_TABLE, mine, plugins, null, 'basic', false);
|
|
assert.equal(fresh.changes, 2, 'one flag and one old plugin file');
|
|
assert.deepEqual(fresh.disable, ['wb-clean-links.plugin.js'], 'a plugin of the owner\'s own is never touched');
|
|
assert.deepEqual(sparseFlags(FEATURE_TABLE, fresh.flags), { 'area.navigation': false, 'area.reader': false, logo: false });
|
|
const kept = modePlan(FEATURE_TABLE, mine, plugins, null, 'basic', true);
|
|
assert.deepEqual(kept.disable, []);
|
|
assert.deepEqual(sparseFlags(FEATURE_TABLE, kept.flags), { 'area.navigation': false, 'area.reader': false, logo: false, hideTrends: true });
|
|
assert.equal(modePlan(FEATURE_TABLE, defaults, [], 'advanced', 'basic', false).changes, 0, 'nothing of the owner\'s to ask about');
|
|
});
|