// 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..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; 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.` 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 = {}) => ({ 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'); });