runtime: the site runtime's composition (siteRuntime, pageStrings, panelButton, commandReport)
What every app's page runtime entry did by hand (moved from wayBetter for X's inject/entry.tsx): - siteRuntime.ts: pageInvoke (the IPC bridge read at call time), createFeatureFlags (parts that install on enable and dispose on disable, the flags native code delivers, a deck column's overrides on top of them, recorded at once and applied when <html> exists) and installSiteRuntime (the runtime object with the members native code calls, the plugin API views, developer-mode errors, its window names, the queued native deliveries, the command report). - pageStrings.ts: the page's language (English built in, deliveries checked as data, the bundled-plugin namespace rule). - panelButton.ts: the way into the panel from the page (the site's own navigation entry when the adapter mounts one, else a floating button that hides under the site's overlays and sits above its bottom bar). - commandReport.ts: the palette command report and developer-mode start errors. - tests/siteRuntime.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6a50602d7
commit
d3ca1d7b16
5 changed files with 688 additions and 0 deletions
47
packages/runtime/src/commandReport.ts
Normal file
47
packages/runtime/src/commandReport.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Plugin palette commands: a site tab tells the shell which commands its plugins registered, so the
|
||||||
|
// command palette can list them under each plugin's name. Only `{ id, title, keywords }` strings
|
||||||
|
// travel (capped in the registry and again natively, waybetter-host `plugins::commands`), only
|
||||||
|
// about this tab, only when the list changes (`tab_report.commands`). Running one is the shell
|
||||||
|
// asking native code to call `api.commands.run(id)` in this page; `run` never leaves it.
|
||||||
|
//
|
||||||
|
// Developer mode's start failures are reported here too: a toast in the page with file and line.
|
||||||
|
|
||||||
|
import type { CommandInfo } from './commands.ts';
|
||||||
|
import { devErrorLocation, formatDevError } from './devErrors.ts';
|
||||||
|
|
||||||
|
export type Invoke = (command: string, args: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
type Commands = { list(): CommandInfo[]; onChange(callback: (list: CommandInfo[]) => void): () => void };
|
||||||
|
|
||||||
|
/** Reports the command list after it changes (batched, only real changes). Returns the disposer. */
|
||||||
|
export function installCommandReport(commands: Commands, invoke: Invoke | undefined, delay = 80): () => void {
|
||||||
|
if (!invoke) return () => {};
|
||||||
|
let last = '[]';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const send = () => {
|
||||||
|
timer = undefined;
|
||||||
|
const list = commands.list();
|
||||||
|
const json = JSON.stringify(list);
|
||||||
|
if (json === last) return;
|
||||||
|
last = json;
|
||||||
|
invoke('tab_report', { report: { commands: list } }).catch(() => {});
|
||||||
|
};
|
||||||
|
const off = commands.onChange(() => {
|
||||||
|
if (timer === undefined) timer = setTimeout(send, delay);
|
||||||
|
});
|
||||||
|
return () => { off(); if (timer !== undefined) clearTimeout(timer); };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = (message: string, options: { tone: 'danger'; duration: number }) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Developer mode (live reload): a plugin whose start failed shows `file:line — message` as a toast
|
||||||
|
* in this page. The host evaluates reloaded plugins with a `sourceURL`, so the line is the file's.
|
||||||
|
* `text` wraps the location in the app's words ("Plugin failed: {error}").
|
||||||
|
*/
|
||||||
|
export function devErrorReporter(toast: () => Toast | undefined, text: (error: string) => string) {
|
||||||
|
return (file: unknown, error: unknown) => {
|
||||||
|
if (typeof file !== 'string') return;
|
||||||
|
const location = formatDevError(devErrorLocation(error, file));
|
||||||
|
try { toast()?.(text(location), { tone: 'danger', duration: 12000 }); } catch { /* the page keeps working */ }
|
||||||
|
};
|
||||||
|
}
|
||||||
100
packages/runtime/src/pageStrings.ts
Normal file
100
packages/runtime/src/pageStrings.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
// The page runtime's language. Native code hands site tabs the owner's language and exactly the
|
||||||
|
// strings the page needs (the app's own page strings, the plugin UI kit's `kit.*` and the bundled
|
||||||
|
// plugins' `plugin.*`; waybetter-host `i18n::page_script`) as `<global>.i18n.set({ locale, dir,
|
||||||
|
// messages })`, queued in `__wbI18n` until the runtime exists. The messages arrive already resolved
|
||||||
|
// along the language's fallback chain by the shell; English is built into the runtime (the build
|
||||||
|
// keeps only those prefixes of the app's catalog). A switch applies live: `onChange` listeners
|
||||||
|
// re-render. Everything delivered is data: checked, bounded and inserted as text.
|
||||||
|
|
||||||
|
import { formatMessage, normalizeLocale, type Params } from './i18n.ts';
|
||||||
|
import { pluginNamespace, type I18nSource } from './pluginI18n.ts';
|
||||||
|
|
||||||
|
export type PageStringsOptions = {
|
||||||
|
/** English for every string the page shows (the app's page strings and the framework's kit words). */
|
||||||
|
english: Readonly<Record<string, string>>;
|
||||||
|
/** Key prefixes a delivery may carry (`['x.', 'kit.', 'plugin.']`); other keys are dropped. */
|
||||||
|
prefixes: readonly string[];
|
||||||
|
/** Console label (the app's name). */
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* The file-name prefix the app installs its bundled plugins with (`bt-` for `bt-<name>.plugin.js`)
|
||||||
|
* when their strings are keyed by the source name (`plugin.<name>.*`).
|
||||||
|
*/
|
||||||
|
bundledPrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageStrings = {
|
||||||
|
/** The active language's message for `key`, else English. */
|
||||||
|
message(key: string): string | undefined;
|
||||||
|
/** A runtime string: `t('x.media.download')`. Placeholder values are inserted as text. */
|
||||||
|
t(key: string, params?: Params): string;
|
||||||
|
locale(): string;
|
||||||
|
/** Native code delivers `{ locale, dir, messages }`; anything malformed is ignored (English stays). */
|
||||||
|
set(value: unknown): void;
|
||||||
|
onChange(listener: () => void): () => void;
|
||||||
|
/** The plugin API's view of the language (pluginI18n.ts): `api.i18n` and the UI kit's words. */
|
||||||
|
source: I18nSource;
|
||||||
|
/** A plugin file's catalog namespace (`plugin.<namespace>.*`). */
|
||||||
|
pluginNamespace(id: string): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A delivery holds at most this many messages, each at most this long. */
|
||||||
|
export const PAGE_STRING_LIMITS = Object.freeze({ keys: 4000, value: 2000 });
|
||||||
|
|
||||||
|
export function createPageStrings(options: PageStringsOptions): PageStrings {
|
||||||
|
const { english, prefixes, label, bundledPrefix } = options;
|
||||||
|
let delivered: Record<string, string> = {};
|
||||||
|
let active = 'en';
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const message = (key: string) => delivered[key] ?? english[key];
|
||||||
|
const t = (key: string, params?: Params) => {
|
||||||
|
const text = message(key);
|
||||||
|
return text === undefined ? key : formatMessage(text, params ?? {}, active);
|
||||||
|
};
|
||||||
|
|
||||||
|
function set(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object') return;
|
||||||
|
const input = value as { locale?: unknown; messages?: unknown };
|
||||||
|
const tag = normalizeLocale(input.locale);
|
||||||
|
if (!tag || !input.messages || typeof input.messages !== 'object') return;
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
let count = 0;
|
||||||
|
for (const [key, text] of Object.entries(input.messages as Record<string, unknown>)) {
|
||||||
|
if (count >= PAGE_STRING_LIMITS.keys) break;
|
||||||
|
if (typeof text !== 'string' || text.length > PAGE_STRING_LIMITS.value || !prefixes.some(prefix => key.startsWith(prefix))) continue;
|
||||||
|
next[key] = text;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
const changed = tag !== active || JSON.stringify(next) !== JSON.stringify(delivered);
|
||||||
|
delivered = next;
|
||||||
|
active = tag;
|
||||||
|
if (!changed) return;
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
try { listener(); } catch (error) { console.error(`[${label}] language listener failed`, error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = (listener: () => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
t,
|
||||||
|
locale: () => active,
|
||||||
|
set,
|
||||||
|
onChange,
|
||||||
|
source: { locale: () => active, message, onChange },
|
||||||
|
// Bundled plugins are installed as `<bundledPrefix><name>.plugin.js`, but their strings are keyed
|
||||||
|
// by their source name: without this, every bundled plugin would stay English.
|
||||||
|
pluginNamespace(id) {
|
||||||
|
const namespace = pluginNamespace(id);
|
||||||
|
if (!bundledPrefix || !namespace.startsWith(bundledPrefix)) return namespace;
|
||||||
|
const bare = namespace.slice(bundledPrefix.length);
|
||||||
|
const prefix = `plugin.${bare}.`;
|
||||||
|
return Object.keys(english).some(key => key.startsWith(prefix)) ? bare : namespace;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
97
packages/runtime/src/panelButton.ts
Normal file
97
packages/runtime/src/panelButton.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// The way from a site page into the app's panel. The site adapter puts an entry into the site's own
|
||||||
|
// navigation when it has one (`mountNav`: X clones its "More" item); where the page draws no
|
||||||
|
// navigation (a full-pane viewer opened by URL, signed-out pages, a mobile layout) a small floating
|
||||||
|
// button stands in. The button must never cover the site's own UI: it hides while the site layers
|
||||||
|
// something over the page and sits above the site's bottom bar. Both are re-checked on every DOM
|
||||||
|
// change through the shared DOM hub.
|
||||||
|
|
||||||
|
import { watchDom, type DomWindow } from './domHub.ts';
|
||||||
|
|
||||||
|
/** Gap between the button and the pane edge or the site's bottom bar. */
|
||||||
|
export const BUTTON_GAP = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: the button's `bottom` offset. `barTop` is the top edge of the site's bottom bar, null when
|
||||||
|
* it has none; a zero-height or implausibly tall bar (a desktop layout may keep an empty one at the
|
||||||
|
* bottom edge) adds nothing.
|
||||||
|
*/
|
||||||
|
export function buttonBottom(barTop: number | null, viewportHeight: number, gap = BUTTON_GAP): number {
|
||||||
|
if (barTop === null) return gap;
|
||||||
|
const bar = viewportHeight - barTop;
|
||||||
|
return bar > 0 && bar < viewportHeight / 3 ? Math.round(bar) + gap : gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PanelButtonSite = {
|
||||||
|
/** The floating button's DOM id. */
|
||||||
|
id: string;
|
||||||
|
/** The app's logo (an image URL, usually a data URI: the site's CSP may block others). */
|
||||||
|
logo: string;
|
||||||
|
/** The button's tooltip and accessible name, in the owner's language. */
|
||||||
|
title(): string;
|
||||||
|
/** Puts the entry into the site's own navigation (re-attaching it when the site re-renders); true while it is there. */
|
||||||
|
mountNav?(open: () => void): boolean;
|
||||||
|
/** Pages where the button stays hidden (a full-pane photo or video viewer whose actions sit at the bottom). */
|
||||||
|
hiddenOn?(pathname: string): boolean;
|
||||||
|
/** The site is showing a modal sheet or a dialog layer: the button hides under it. */
|
||||||
|
overlaySelector?: string;
|
||||||
|
/** The site's bottom bar (a mobile layout): the button sits above it. */
|
||||||
|
bottomBarSelector?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure: whether the button stays hidden on this route / with a site overlay open. */
|
||||||
|
export function buttonHidden(site: Pick<PanelButtonSite, 'hiddenOn'>, pathname: string, overlayOpen: boolean): boolean {
|
||||||
|
return overlayOpen || !!site.hiddenOn?.(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the panel entry on the page: the site's navigation item when `mountNav` places one, else
|
||||||
|
* the floating button. `open` opens the panel (the runtime's `open()`).
|
||||||
|
*/
|
||||||
|
export function installPanelButton(win: Window & typeof globalThis, site: PanelButtonSite, open: () => void): { sync(): void } {
|
||||||
|
const doc = win.document;
|
||||||
|
|
||||||
|
const create = (): HTMLButtonElement => {
|
||||||
|
const button = doc.createElement('button');
|
||||||
|
button.id = site.id;
|
||||||
|
button.type = 'button';
|
||||||
|
button.title = site.title();
|
||||||
|
button.setAttribute('aria-label', button.title);
|
||||||
|
const img = doc.createElement('img');
|
||||||
|
img.src = site.logo;
|
||||||
|
img.alt = '';
|
||||||
|
Object.assign(img.style, { width: '100%', height: '100%', borderRadius: '22%', display: 'block' });
|
||||||
|
button.appendChild(img);
|
||||||
|
Object.assign(button.style, {
|
||||||
|
position: 'fixed', left: '16px', bottom: '16px', zIndex: '2147483646', width: '40px', height: '40px',
|
||||||
|
padding: '0', border: '0', background: 'transparent', cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.35)', borderRadius: '22%',
|
||||||
|
});
|
||||||
|
button.addEventListener('click', () => open());
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncButton = (navMounted: boolean) => {
|
||||||
|
const existing = doc.getElementById(site.id);
|
||||||
|
if (navMounted) { existing?.remove(); return; }
|
||||||
|
const button = existing ?? create();
|
||||||
|
const overlay = site.overlaySelector ? !!doc.querySelector(site.overlaySelector) : false;
|
||||||
|
const barTop = site.bottomBarSelector ? doc.querySelector(site.bottomBarSelector)?.getBoundingClientRect().top ?? null : null;
|
||||||
|
const display = buttonHidden(site, win.location.pathname, overlay) ? 'none' : 'block';
|
||||||
|
const bottom = `${buttonBottom(barTop, win.innerHeight)}px`;
|
||||||
|
if (button.style.display !== display) button.style.display = display;
|
||||||
|
if (button.style.bottom !== bottom) button.style.bottom = bottom;
|
||||||
|
if (!existing) doc.body.appendChild(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!doc.body) return;
|
||||||
|
syncButton(site.mountNav?.(open) ?? false);
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
sync();
|
||||||
|
// Sites re-render their navigation on route and layout changes; re-attach when the entry disappears.
|
||||||
|
watchDom(win as unknown as DomWindow, sync);
|
||||||
|
};
|
||||||
|
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', start, { once: true });
|
||||||
|
else start();
|
||||||
|
return { sync };
|
||||||
|
}
|
||||||
259
packages/runtime/src/siteRuntime.ts
Normal file
259
packages/runtime/src/siteRuntime.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
// The page runtime's composition helpers: what every app's runtime entry does the same way. An
|
||||||
|
// app's entry (injected at document start into its site's tabs) installs its features and then:
|
||||||
|
//
|
||||||
|
// const invoke = pageInvoke(window, SITE.product);
|
||||||
|
// const flags = createFeatureFlags({ defaults, label: SITE.product, installers, apply });
|
||||||
|
// const runtime = installSiteRuntime({ win: window, site: SITE, version, invoke, plugins, strings, features: flags, ... });
|
||||||
|
// installPanelButton(window, button, () => runtime.open()); // panelButton.ts
|
||||||
|
//
|
||||||
|
// - `pageInvoke`: the IPC bridge, read at call time.
|
||||||
|
// - `createFeatureFlags`: flag-controlled parts that install on enable and dispose on disable, the
|
||||||
|
// flags native code delivers (`features_set`) and a deck column's overrides on top of them.
|
||||||
|
// - `installSiteRuntime`: the runtime object under its window names (`window.wayBetter` and the
|
||||||
|
// app's own), the members native code calls (features, theme, fonts, pane, links, i18n, column),
|
||||||
|
// the plugin API views, developer-mode errors, the palette command report, and whatever native
|
||||||
|
// code delivered before the runtime existed (the `__bt*` / `__wb*` queues).
|
||||||
|
|
||||||
|
import { installCommandReport, devErrorReporter } from './commandReport.ts';
|
||||||
|
import type { CommandInfo } from './commands.ts';
|
||||||
|
import type { PageStrings } from './pageStrings.ts';
|
||||||
|
import type { PluginContext } from './permissions.ts';
|
||||||
|
import type { Params } from './i18n.ts';
|
||||||
|
import type { SiteAdapter } from './site.ts';
|
||||||
|
|
||||||
|
export type PageInvoke = (command: string, args: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
|
type BridgeWindow = { __TAURI_INTERNALS__?: { invoke(cmd: string, args?: Record<string, unknown>): Promise<unknown> }; navigator: { webdriver?: boolean } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native commands from the page. The IPC bridge is read at call time: a runtime installed at
|
||||||
|
* document start can run before Tauri has put `__TAURI_INTERNALS__` on the page. A runtime only
|
||||||
|
* ships inside the app's site tabs; automated test browsers (`navigator.webdriver`) have no
|
||||||
|
* bridge, and the features that need it stay off there (undefined).
|
||||||
|
*/
|
||||||
|
export function pageInvoke(win: Window, product: string): PageInvoke | undefined {
|
||||||
|
const target = win as unknown as BridgeWindow;
|
||||||
|
const inApp = !!target.__TAURI_INTERNALS__ || !target.navigator.webdriver;
|
||||||
|
if (!inApp) return undefined;
|
||||||
|
return (command, args) => {
|
||||||
|
const ipc = target.__TAURI_INTERNALS__;
|
||||||
|
return ipc ? ipc.invoke(command, args) : Promise.reject(new Error(`${product}: IPC bridge not ready`));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Feature flags
|
||||||
|
|
||||||
|
/** A flag-controlled part of the runtime, while it is installed. */
|
||||||
|
export type Installed = { dispose(): void };
|
||||||
|
export type FlagInstaller<Flags> = readonly [flag: keyof Flags & string, install: () => Installed];
|
||||||
|
|
||||||
|
export type FeatureFlagsOptions<Flags extends Record<string, boolean>> = {
|
||||||
|
/** Every flag with its default; a flag not in here is ignored. */
|
||||||
|
defaults: Flags;
|
||||||
|
/** Console label (the app's name). */
|
||||||
|
label: string;
|
||||||
|
/** Parts that install when their flag turns on and dispose when it turns off, in install order. */
|
||||||
|
installers: ReadonlyArray<FlagInstaller<Flags>>;
|
||||||
|
/** Every applied config, before the installers (flags that are CSS, or that another part reads). */
|
||||||
|
apply?(config: Partial<Flags>): void;
|
||||||
|
/** The page's document: a column's flags delivered at document start wait for `<html>` to exist. */
|
||||||
|
document?: Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureFlags<Flags extends Record<string, boolean>> = {
|
||||||
|
/** The flags native code delivers (the owner's tweaks); a deck column's overrides stay on top. */
|
||||||
|
apply(config: Partial<Flags>): void;
|
||||||
|
/**
|
||||||
|
* A deck column's flag overrides (allow-listed natively; unknown keys and non-booleans are
|
||||||
|
* dropped here too). Flags a column stops overriding go back to the global value, or the default
|
||||||
|
* when the global config never named them. Recorded at once; applied now, or once the document
|
||||||
|
* has its `<html>` (a new column webview gets its config at document start).
|
||||||
|
*/
|
||||||
|
setColumnFlags(flags: unknown): void;
|
||||||
|
/** Turns one installer on or off directly (a kill switch). */
|
||||||
|
set(flag: keyof Flags & string, enabled: boolean): void;
|
||||||
|
/** Whether a flag's installer is installed right now. */
|
||||||
|
installed(flag: keyof Flags & string): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createFeatureFlags<Flags extends Record<string, boolean>>(options: FeatureFlagsOptions<Flags>): FeatureFlags<Flags> {
|
||||||
|
const { defaults, label, installers } = options;
|
||||||
|
const active = new Map<string, Installed>();
|
||||||
|
const installerOf = new Map(installers.map(([flag, install]) => [flag as string, install]));
|
||||||
|
|
||||||
|
const set = (flag: string, enabled: boolean) => {
|
||||||
|
const install = installerOf.get(flag);
|
||||||
|
if (!install) return;
|
||||||
|
const current = active.get(flag);
|
||||||
|
if (enabled && !current) {
|
||||||
|
try { active.set(flag, install()); }
|
||||||
|
catch (error) { console.error(`[${label}] ${flag} failed`, error); }
|
||||||
|
} else if (!enabled && current) {
|
||||||
|
current.dispose();
|
||||||
|
active.delete(flag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Installed at document start, before the site's bundle runs (request gates wrap its fetch).
|
||||||
|
for (const [flag] of installers) set(flag, defaults[flag]);
|
||||||
|
|
||||||
|
let globalFlags: Partial<Flags> = {};
|
||||||
|
let columnFlags: Partial<Flags> = {};
|
||||||
|
const merged = (previous: Partial<Flags> = {}): Partial<Flags> => {
|
||||||
|
const restore = Object.fromEntries(Object.keys(previous).map(key => [key, defaults[key]])) as Partial<Flags>;
|
||||||
|
return { ...restore, ...globalFlags, ...columnFlags };
|
||||||
|
};
|
||||||
|
const applyAll = (config: Partial<Flags>) => {
|
||||||
|
options.apply?.(config);
|
||||||
|
for (const [flag] of installers) {
|
||||||
|
const enabled = config[flag];
|
||||||
|
if (typeof enabled === 'boolean') set(flag, enabled);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply(config) {
|
||||||
|
globalFlags = { ...globalFlags, ...config };
|
||||||
|
applyAll(Object.keys(columnFlags).length ? merged() : config);
|
||||||
|
},
|
||||||
|
setColumnFlags(flags) {
|
||||||
|
const next: Partial<Flags> = {};
|
||||||
|
if (flags && typeof flags === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(flags)) if (key in defaults && typeof value === 'boolean') (next as Record<string, boolean>)[key] = value;
|
||||||
|
}
|
||||||
|
const previous = columnFlags;
|
||||||
|
columnFlags = next;
|
||||||
|
if (!Object.keys(previous).length && !Object.keys(next).length) return;
|
||||||
|
const apply = () => { try { applyAll(merged(previous)); } catch (error) { console.error(`[${label}] column flags failed`, error); } };
|
||||||
|
const doc = options.document;
|
||||||
|
if (!doc || doc.documentElement) apply();
|
||||||
|
else doc.addEventListener('DOMContentLoaded', apply, { once: true });
|
||||||
|
},
|
||||||
|
set,
|
||||||
|
installed: flag => active.has(flag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// The runtime object
|
||||||
|
|
||||||
|
/** What native code and plugins reach through `window.<runtimeGlobal>`. */
|
||||||
|
export type SiteRuntime<Api extends object> = {
|
||||||
|
version: string;
|
||||||
|
/** Running plugins by file (native start/stop scripts manage this). */
|
||||||
|
plugins: Record<string, { start?: () => void; stop?: () => void }>;
|
||||||
|
/** Load errors by plugin file. */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
/** Native code hands over the feature flags (`features_set`). */
|
||||||
|
features: { apply(config: unknown): void };
|
||||||
|
/** Native code hands over the owner's language: `{ locale, dir, messages }` (pageStrings.ts). */
|
||||||
|
i18n: { set(strings: unknown): void; locale(): string; t(key: string, params?: Params): string };
|
||||||
|
/** Native code hands over where clicked site pages open (`{ site: 'same' | 'pane' | 'tab' | 'browser' }`). */
|
||||||
|
links: { configure(value: unknown): void };
|
||||||
|
/** Native code says whether this pane's navigation repeats another pane's (`{ redundantNav }`). */
|
||||||
|
pane: { set(state: unknown): void };
|
||||||
|
/** Native code hands over downloaded Google Fonts (validated woff2 bytes) that this tab's theme uses. */
|
||||||
|
fonts: { provide(fonts: unknown): void };
|
||||||
|
/** Power User Mode: native code hands a deck column its config (null = an ordinary tab again). */
|
||||||
|
column: { set(config: unknown): void; markRead(): void; jumpToUnread(): void; unread(): number };
|
||||||
|
/** Opens the app's panel in the shell (at a section). */
|
||||||
|
open(section?: string): void;
|
||||||
|
/**
|
||||||
|
* Helpers for `*.plugin.js` files. Read while a plugin's code runs (its module body, constructor,
|
||||||
|
* start() and stop()), this is that plugin's view for its declared `@permissions`; elsewhere it
|
||||||
|
* is the full API.
|
||||||
|
*/
|
||||||
|
readonly api: Api;
|
||||||
|
/** Runs `fn` as plugin `id` with `grants` (null = legacy, full API; undefined = as registered). Native start/stop scripts call it. */
|
||||||
|
withPlugin<R>(id: string, grants: readonly string[] | null | undefined, fn: () => R): R;
|
||||||
|
/** Developer mode: a reloaded plugin failed to start (native `dev_start_script`); shown as a toast. */
|
||||||
|
devError(file: unknown, error: unknown): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiWithUi = { ui?: { toast(message: string, options: { tone: 'danger'; duration: number }): unknown }; commands?: { list(): CommandInfo[]; onChange(callback: (list: CommandInfo[]) => void): () => void } };
|
||||||
|
|
||||||
|
export type SiteRuntimeOptions<Api extends object, Extra extends object> = {
|
||||||
|
win: Window & typeof globalThis;
|
||||||
|
site: Pick<SiteAdapter, 'product' | 'runtimeGlobal'>;
|
||||||
|
version: string;
|
||||||
|
/** Other window names for the runtime, besides `wayBetter` and `site.runtimeGlobal` (names older plugins use). */
|
||||||
|
aliases?: readonly string[];
|
||||||
|
invoke?: PageInvoke;
|
||||||
|
/** Per-plugin API views (permissions.ts `createPluginContext`). */
|
||||||
|
plugins: PluginContext<Api>;
|
||||||
|
strings: PageStrings;
|
||||||
|
/** The toast for a plugin that failed to start in developer mode ("Plugin failed: {error}"). */
|
||||||
|
pluginFailed(error: string): string;
|
||||||
|
features?: { apply(config: never): void };
|
||||||
|
/** The theme handle (themeRuntime.ts), exposed as `theme`. */
|
||||||
|
theme?: { apply(choice: unknown): void };
|
||||||
|
fonts?(fonts: unknown): void;
|
||||||
|
pane?: { set(state: unknown): void };
|
||||||
|
links?: { configure(value: unknown): void };
|
||||||
|
column?: SiteRuntime<Api>['column'];
|
||||||
|
/** The app's own members (X: alerts, space, fixes). */
|
||||||
|
extra?: Extra;
|
||||||
|
/** Values native code may have delivered for the app's own members before the runtime existed: queue global and receiver. */
|
||||||
|
queues?: ReadonlyArray<readonly [queue: string, deliver: (value: unknown) => void]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the runtime object, publishes it under its window names (not writable, not configurable),
|
||||||
|
* takes over what native code queued before it existed, and starts the palette command report.
|
||||||
|
*/
|
||||||
|
export function installSiteRuntime<Api extends object, Extra extends object = Record<never, never>>(options: SiteRuntimeOptions<Api, Extra>): SiteRuntime<Api> & Extra {
|
||||||
|
const { win, site, plugins, strings } = options;
|
||||||
|
const label = site.product;
|
||||||
|
// A failure in one member never breaks native code's other deliveries (or the page).
|
||||||
|
const guarded = <T>(what: string, fn: (value: T) => void) => (value: T) => {
|
||||||
|
try { fn(value); } catch (error) { console.error(`[${label}] ${what} failed`, error); }
|
||||||
|
};
|
||||||
|
const runtime = {
|
||||||
|
...(options.extra ?? {}),
|
||||||
|
version: options.version,
|
||||||
|
plugins: {},
|
||||||
|
errors: {},
|
||||||
|
features: { apply: (config: unknown) => (options.features as { apply(config: unknown): void } | undefined)?.apply(config) },
|
||||||
|
...(options.theme ? { theme: options.theme } : {}),
|
||||||
|
fonts: { provide: guarded('fonts', (list: unknown) => options.fonts?.(list)) },
|
||||||
|
pane: { set: guarded('split navigation', (state: unknown) => options.pane?.set(state)) },
|
||||||
|
links: { configure: (value: unknown) => options.links?.configure(value) },
|
||||||
|
i18n: { set: guarded('language', (value: unknown) => strings.set(value)), locale: strings.locale, t: strings.t },
|
||||||
|
column: {
|
||||||
|
set: guarded('deck column', (config: unknown) => options.column?.set(config)),
|
||||||
|
markRead: () => options.column?.markRead(),
|
||||||
|
jumpToUnread: () => options.column?.jumpToUnread(),
|
||||||
|
unread: () => options.column?.unread() ?? 0,
|
||||||
|
},
|
||||||
|
open(section?: string) {
|
||||||
|
(win as unknown as BridgeWindow).__TAURI_INTERNALS__?.invoke('open_panel', { section: section ?? null }).catch(() => {});
|
||||||
|
},
|
||||||
|
withPlugin: <R>(id: string, grants: readonly string[] | null | undefined, fn: () => R) => plugins.run(id, grants, fn),
|
||||||
|
get api() { return plugins.current(); },
|
||||||
|
devError: devErrorReporter(() => {
|
||||||
|
const ui = (plugins.current() as ApiWithUi).ui;
|
||||||
|
return ui ? (message, toastOptions) => { ui.toast(message, toastOptions); } : undefined;
|
||||||
|
}, options.pluginFailed),
|
||||||
|
} as unknown as SiteRuntime<Api> & Extra & { theme?: { apply(choice: unknown): void } };
|
||||||
|
|
||||||
|
// `wayBetter` is the documented name; the app's own names stay for existing plugins.
|
||||||
|
const names = [...new Set(['wayBetter', site.runtimeGlobal, ...(options.aliases ?? [])])];
|
||||||
|
for (const name of names) Object.defineProperty(win, name, { value: runtime, configurable: false, writable: false });
|
||||||
|
|
||||||
|
// Native code may deliver before this runtime exists.
|
||||||
|
const queued = win as unknown as Record<string, unknown>;
|
||||||
|
const take = (queue: string, deliver: (value: unknown) => void) => { if (queued[queue]) deliver(queued[queue]); };
|
||||||
|
take('__wbI18n', runtime.i18n.set);
|
||||||
|
take('__btFeatures', runtime.features.apply);
|
||||||
|
take('__wbFonts', runtime.fonts.provide);
|
||||||
|
take('__btTheme', value => runtime.theme?.apply(value));
|
||||||
|
take('__btPane', runtime.pane.set);
|
||||||
|
take('__btLinks', runtime.links.configure);
|
||||||
|
take('__btColumn', runtime.column.set);
|
||||||
|
for (const [queue, deliver] of options.queues ?? []) take(queue, deliver);
|
||||||
|
|
||||||
|
// Plugin commands for the shell's command palette: ids, titles and keywords about this tab only.
|
||||||
|
const commands = (plugins.current() as ApiWithUi).commands;
|
||||||
|
if (commands) installCommandReport(commands, options.invoke);
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
185
packages/runtime/tests/siteRuntime.test.ts
Normal file
185
packages/runtime/tests/siteRuntime.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// The runtime composition helpers: the IPC bridge read at call time, flag-controlled parts with a
|
||||||
|
// deck column's overrides, the runtime object under its window names with what native code queued,
|
||||||
|
// the page strings native code delivers, the palette command report and the panel button's placement.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { devErrorReporter, installCommandReport } from '../src/commandReport.ts';
|
||||||
|
import { createPageStrings, PAGE_STRING_LIMITS } from '../src/pageStrings.ts';
|
||||||
|
import { BUTTON_GAP, buttonBottom, buttonHidden } from '../src/panelButton.ts';
|
||||||
|
import { createPluginContext } from '../src/permissions.ts';
|
||||||
|
import { createFeatureFlags, installSiteRuntime, pageInvoke } from '../src/siteRuntime.ts';
|
||||||
|
|
||||||
|
const tick = (ms = 15) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
const SITE = { product: 'wayBetter for Test', runtimeGlobal: 'Demo' };
|
||||||
|
|
||||||
|
test('pageInvoke: the bridge is read at call time; a test browser without one has no invoke', async () => {
|
||||||
|
const win = { navigator: { webdriver: false } } as unknown as Window & { __TAURI_INTERNALS__?: unknown };
|
||||||
|
const invoke = pageInvoke(win, 'Demo')!;
|
||||||
|
await assert.rejects(invoke('ping', {}), /Demo: IPC bridge not ready/);
|
||||||
|
win.__TAURI_INTERNALS__ = { invoke: async (command: string, args: unknown) => ({ command, args }) };
|
||||||
|
assert.deepEqual(await invoke('ping', { a: 1 }), { command: 'ping', args: { a: 1 } });
|
||||||
|
assert.equal(pageInvoke({ navigator: { webdriver: true } } as unknown as Window, 'Demo'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
type Flags = { gate: boolean; pill: boolean; css: boolean };
|
||||||
|
|
||||||
|
test('feature flags: parts install on enable and dispose on disable, in order, and a failing part is contained', () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const part = (name: string) => () => { log.push(`+${name}`); return { dispose: () => { log.push(`-${name}`); } }; };
|
||||||
|
const applied: Array<Partial<Flags>> = [];
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const original = console.error;
|
||||||
|
console.error = (...args: unknown[]) => { errors.push(args[0]); };
|
||||||
|
try {
|
||||||
|
const flags = createFeatureFlags<Flags>({
|
||||||
|
defaults: { gate: true, pill: false, css: true },
|
||||||
|
label: 'Demo',
|
||||||
|
installers: [['gate', part('gate')], ['pill', () => { throw new Error('no pill'); }]],
|
||||||
|
apply: config => applied.push(config),
|
||||||
|
});
|
||||||
|
assert.deepEqual(log, ['+gate'], 'default-on parts install at once');
|
||||||
|
assert.ok(flags.installed('gate') && !flags.installed('pill'));
|
||||||
|
flags.apply({ gate: false, css: false });
|
||||||
|
assert.deepEqual(log, ['+gate', '-gate']);
|
||||||
|
assert.deepEqual(applied, [{ gate: false, css: false }]);
|
||||||
|
flags.apply({ gate: true, pill: true });
|
||||||
|
assert.deepEqual(log, ['+gate', '-gate', '+gate']);
|
||||||
|
assert.equal(flags.installed('pill'), false);
|
||||||
|
assert.match(String(errors[0]), /\[Demo\] pill failed/);
|
||||||
|
flags.set('gate', false);
|
||||||
|
assert.equal(flags.installed('gate'), false, 'a kill switch');
|
||||||
|
flags.set('css', true);
|
||||||
|
} finally { console.error = original; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feature flags: a deck column overrides global flags; dropped overrides go back to the global value or the default', () => {
|
||||||
|
const applied: Array<Partial<Flags>> = [];
|
||||||
|
const flags = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => applied.push(config) });
|
||||||
|
flags.apply({ pill: true });
|
||||||
|
flags.setColumnFlags({ pill: false, css: false, unknown: true, gate: 'yes' });
|
||||||
|
assert.deepEqual(applied.at(-1), { pill: false, css: false }, 'only known booleans; the column wins');
|
||||||
|
flags.apply({ css: true });
|
||||||
|
assert.deepEqual(applied.at(-1), { pill: false, css: false }, 'a global change does not undo the column');
|
||||||
|
flags.setColumnFlags({ pill: false });
|
||||||
|
assert.deepEqual(applied.at(-1), { pill: false, css: true }, 'css is the global value again');
|
||||||
|
flags.setColumnFlags(null);
|
||||||
|
assert.deepEqual(applied.at(-1), { pill: true, css: true }, 'an ordinary tab again: the global flags');
|
||||||
|
const before = applied.length;
|
||||||
|
flags.setColumnFlags(undefined);
|
||||||
|
assert.equal(applied.length, before, 'nothing to change');
|
||||||
|
// Delivered at document start, a column's flags wait for <html>.
|
||||||
|
const listeners: Array<() => void> = [];
|
||||||
|
const doc = { documentElement: null as unknown, addEventListener: (_: string, listener: () => void) => { listeners.push(listener); } };
|
||||||
|
const late: Array<Partial<Flags>> = [];
|
||||||
|
const waiting = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => late.push(config), document: doc as unknown as Document });
|
||||||
|
waiting.setColumnFlags({ css: false });
|
||||||
|
assert.deepEqual(late, []);
|
||||||
|
waiting.apply({ pill: true });
|
||||||
|
assert.deepEqual(late.at(-1), { pill: true, css: false }, 'recorded at once: a global delivery already respects it');
|
||||||
|
listeners[0]!();
|
||||||
|
assert.deepEqual(late.at(-1), { pill: true, css: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeWindow(queued: Record<string, unknown> = {}) {
|
||||||
|
return { navigator: { webdriver: false }, ...queued } as unknown as Window & typeof globalThis & Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the runtime is published under its names, takes what native code queued and reports commands', async () => {
|
||||||
|
const seen: Record<string, unknown> = {};
|
||||||
|
const sent: unknown[] = [];
|
||||||
|
const commandListeners: Array<(list: unknown[]) => void> = [];
|
||||||
|
let list = [{ id: 'a.plugin.js:go', title: 'Go', keywords: '' }];
|
||||||
|
const toasts: string[] = [];
|
||||||
|
const api = { ui: { toast: (message: string) => { toasts.push(message); } }, commands: { list: () => list, onChange: (callback: (next: unknown[]) => void) => { commandListeners.push(callback); return () => {}; } }, i18n: {} };
|
||||||
|
const strings = createPageStrings({ english: { 'demo.hello': 'Hello {name}' }, prefixes: ['demo.'], label: 'Demo' });
|
||||||
|
const win = runtimeWindow({
|
||||||
|
__wbI18n: { locale: 'tr', messages: { 'demo.hello': 'Merhaba {name}', 'other.key': 'dropped' } },
|
||||||
|
__btFeatures: { gate: false },
|
||||||
|
__btLinks: { site: 'tab' },
|
||||||
|
__btColumn: { source: 'home' },
|
||||||
|
__demoExtra: ['x'],
|
||||||
|
});
|
||||||
|
const runtime = installSiteRuntime({
|
||||||
|
win, site: SITE, version: '1.2.3', aliases: ['OldName'],
|
||||||
|
invoke: async (command, args) => { sent.push([command, args]); },
|
||||||
|
plugins: createPluginContext(api),
|
||||||
|
strings,
|
||||||
|
pluginFailed: error => `Plugin failed: ${error}`,
|
||||||
|
features: { apply: (config: unknown) => { seen.features = config; } },
|
||||||
|
links: { configure: value => { seen.links = value; } },
|
||||||
|
column: { set: config => { seen.column = config; }, markRead() {}, jumpToUnread() {}, unread: () => 3 },
|
||||||
|
extra: { demo: { set: (value: unknown) => { seen.extra = value; } } },
|
||||||
|
queues: [['__demoExtra', value => { seen.extra = value; }]],
|
||||||
|
});
|
||||||
|
for (const name of ['wayBetter', 'Demo', 'OldName']) {
|
||||||
|
assert.equal(win[name], runtime, name);
|
||||||
|
assert.deepEqual([Object.getOwnPropertyDescriptor(win, name)!.writable, Object.getOwnPropertyDescriptor(win, name)!.configurable], [false, false]);
|
||||||
|
}
|
||||||
|
assert.equal(runtime.version, '1.2.3');
|
||||||
|
assert.deepEqual(seen, { features: { gate: false }, links: { site: 'tab' }, column: { source: 'home' }, extra: ['x'] });
|
||||||
|
assert.equal(runtime.i18n.locale(), 'tr');
|
||||||
|
assert.equal(runtime.i18n.t('demo.hello', { name: 'Ada' }), 'Merhaba Ada');
|
||||||
|
assert.equal(strings.message('other.key'), undefined, 'only the runtime\'s prefixes are taken');
|
||||||
|
assert.equal(runtime.column.unread(), 3);
|
||||||
|
assert.equal(runtime.api, api, 'outside a plugin: the full API');
|
||||||
|
assert.equal(runtime.withPlugin('a.plugin.js', null, () => runtime.api), api, 'a legacy plugin: the full API');
|
||||||
|
// A member that throws never breaks the others.
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const original = console.error;
|
||||||
|
console.error = (...args: unknown[]) => { errors.push(args[0]); };
|
||||||
|
try {
|
||||||
|
const broken = installSiteRuntime({ win: runtimeWindow(), site: { product: 'Demo', runtimeGlobal: 'Broken' }, version: '1', plugins: createPluginContext(api), strings, pluginFailed: String, fonts: () => { throw new Error('bad fonts'); } });
|
||||||
|
broken.fonts.provide([]);
|
||||||
|
assert.match(String(errors[0]), /\[Demo\] fonts failed/);
|
||||||
|
} finally { console.error = original; }
|
||||||
|
// Developer mode: a plugin that failed to start shows file:line in a toast.
|
||||||
|
runtime.devError('a.plugin.js', Object.assign(new Error('boom'), { stack: 'Error: boom\n at start (wb-dev/a.plugin.js:12:5)' }));
|
||||||
|
assert.match(toasts[0] ?? '', /^Plugin failed: a\.plugin\.js:\d+/);
|
||||||
|
// The palette hears about commands only when they change.
|
||||||
|
list = [...list, { id: 'a.plugin.js:stop', title: 'Stop', keywords: 'halt' }];
|
||||||
|
commandListeners.forEach(listener => listener(list));
|
||||||
|
await tick(120);
|
||||||
|
assert.deepEqual(sent, [['tab_report', { report: { commands: list } }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page strings: deliveries are data (prefix, type and length checked), English is the fallback', () => {
|
||||||
|
const strings = createPageStrings({ english: { 'x.a': 'A', 'kit.ok': 'OK', 'plugin.clean-links.name': 'Clean links' }, prefixes: ['x.', 'kit.', 'plugin.'], label: 'Demo', bundledPrefix: 'bt-' });
|
||||||
|
let changes = 0;
|
||||||
|
const off = strings.onChange(() => { changes += 1; });
|
||||||
|
strings.set(null);
|
||||||
|
strings.set({ locale: 'not a tag!', messages: {} });
|
||||||
|
strings.set({ locale: 'de' });
|
||||||
|
assert.equal(strings.locale(), 'en');
|
||||||
|
strings.set({ locale: 'de', messages: { 'x.a': 'Ä', 'x.long': 'l'.repeat(PAGE_STRING_LIMITS.value + 1), 'x.n': 5, 'native.secret': 'no' } });
|
||||||
|
assert.equal(changes, 1);
|
||||||
|
assert.deepEqual([strings.locale(), strings.t('x.a'), strings.t('kit.ok'), strings.message('x.long'), strings.message('native.secret'), strings.t('x.unknown')], ['de', 'Ä', 'OK', undefined, undefined, 'x.unknown']);
|
||||||
|
strings.set({ locale: 'de', messages: { 'x.a': 'Ä' } });
|
||||||
|
assert.equal(changes, 1, 'the same delivery changes nothing');
|
||||||
|
assert.equal(strings.source.message('x.a'), 'Ä');
|
||||||
|
// Bundled plugins are installed with a prefix; their strings are keyed by the source name.
|
||||||
|
assert.equal(strings.pluginNamespace('bt-clean-links.plugin.js'), 'clean-links');
|
||||||
|
assert.equal(strings.pluginNamespace('bt-mine.plugin.js'), 'bt-mine', 'an owner\'s file that only looks bundled keeps its name');
|
||||||
|
assert.equal(strings.pluginNamespace('other.plugin.js'), 'other');
|
||||||
|
off();
|
||||||
|
strings.set({ locale: 'fr', messages: {} });
|
||||||
|
assert.equal(changes, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command report and developer errors without a bridge or a toast do nothing', () => {
|
||||||
|
assert.doesNotThrow(() => installCommandReport({ list: () => [], onChange: () => () => {} }, undefined)());
|
||||||
|
assert.doesNotThrow(() => devErrorReporter(() => undefined, String)('a.plugin.js', new Error('x')));
|
||||||
|
assert.doesNotThrow(() => devErrorReporter(() => () => { throw new Error('toast broke'); }, String)('a.plugin.js', new Error('x')));
|
||||||
|
assert.doesNotThrow(() => devErrorReporter(() => () => {}, String)(42, new Error('x')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the panel button sits above a bottom bar, never over a viewer or under an overlay', () => {
|
||||||
|
assert.equal(buttonBottom(null, 800), BUTTON_GAP);
|
||||||
|
assert.equal(buttonBottom(747, 800), 53 + BUTTON_GAP, 'above a 53px bottom bar');
|
||||||
|
assert.equal(buttonBottom(800, 800), BUTTON_GAP, 'an empty bar at the bottom edge adds nothing');
|
||||||
|
assert.equal(buttonBottom(100, 800), BUTTON_GAP, 'an implausibly tall bar adds nothing');
|
||||||
|
const site = { hiddenOn: (pathname: string) => /\/photo\/\d+$/.test(pathname) };
|
||||||
|
assert.equal(buttonHidden(site, '/a/status/1/photo/1', false), true);
|
||||||
|
assert.equal(buttonHidden(site, '/home', true), true);
|
||||||
|
assert.equal(buttonHidden(site, '/home', false), false);
|
||||||
|
assert.equal(buttonHidden({}, '/a/status/1/photo/1', false), false);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue