refactor(build): place the views in the page instead of expanding templates

index.html now lists each view's host and the loading screen itself, so
the gev:template expansion plugin, its view list, the loading-screen
template and the ./build/html export are gone. createApplication takes
omitViews for views an application leaves out; their hosts are removed,
and a view an earlier call rendered is unmounted.
This commit is contained in:
Sameh Khamis committed 2026-10-31 17:52:47 -07:00
1 parent f279200af5
commit 5bf797ef71
18 files changed
+176 -174

No files matched your search

+6
View File
@@ -14,6 +14,12 @@
picker, and Node tests that import application modules register the picker, and Node tests that import application modules register the
component loader with `--import=<core>/build/register-vue` (samehkhamis). component loader with `--import=<core>/build/register-vue` (samehkhamis).
- Pages place each view with a `<div class="gev-view" data-gev-view="NAME">`
host and write their own loading screen; the `build/html` template
expansion and its `gev:template` markers are gone. `createApplication`
takes `omitViews` (for example `['provider-settings']`) for views an
application leaves out (samehkhamis).
- `./ui/display`'s `bindDisplayControls` takes the keyhole fade and outside - `./ui/display`'s `bindDisplayControls` takes the keyhole fade and outside
opacity sliders as `fadeSlider` and `outsideOpacitySlider`, and their opacity sliders as `fadeSlider` and `outsideOpacitySlider`, and their
actions receive whole percentages; `fadeSliders` still works. actions receive whole percentages; `fadeSliders` still works.
-52
View File
@@ -1,52 +0,0 @@
import { readFileSync } from 'node:fs';
import { APPLICATION_VIEW_NAMES } from './applicationViews.js';
export const APPLICATION_TEMPLATES = Object.freeze([
'scene-chrome',
'cockpit',
'display-controls',
'command-dock',
'layer-panels',
'context',
'welcome',
'provider-settings',
'hud-loading',
]);
const allowed = new Set(APPLICATION_TEMPLATES);
const views = new Set(APPLICATION_VIEW_NAMES);
/**
* The element a Vue view renders into. `display: contents` keeps the view's
* own elements laid out as children of the page.
* @param {string} name Application template name.
*/
export function applicationViewHost(name) {
return `<div class="gev-view" data-gev-view="${name}"></div>\n`;
}
/** Expand only known component templates; markers cannot name filesystem paths. */
export function expandApplicationHtml(html) {
return html.replace(
/^[ \t]*<!-- gev:template ([^\s]+) -->\r?\n?/gm,
(_, name) => {
if (!allowed.has(name))
throw new Error(`Unknown application template: ${name}`);
if (views.has(name)) return applicationViewHost(name);
return readFileSync(
new URL(`../src/ui/templates/${name}.html`, import.meta.url),
'utf8',
);
},
);
}
/** Assemble static application markup before Vite processes scripts and assets. */
export function applicationHtmlPlugin() {
return {
name: 'application-component-templates',
transformIndexHtml: {
order: 'pre',
handler: expandApplicationHtml,
},
};
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Application templates that are now Vue views (src/ui/views). Their markers
* expand to a mount host instead of static markup, and
* `mountApplicationViews()` renders them before the page is wired. The
* loading screen stays static so it paints before any script runs.
*/
export const APPLICATION_VIEW_NAMES = Object.freeze([
'provider-settings',
'scene-chrome',
'cockpit',
'display-controls',
'command-dock',
'layer-panels',
'context',
'welcome',
]);
-2
View File
@@ -1,5 +1,4 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium'; import cesium from 'vite-plugin-cesium';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from './vueOptions.js'; import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
@@ -157,7 +156,6 @@ export function createBrowserViteConfig({
// Next, so no provider route answers a Host the server does not allow. // Next, so no provider route answers a Host the server does not allow.
hostCheckPlugin(), hostCheckPlugin(),
cesium(), cesium(),
applicationHtmlPlugin(),
// Application views are Vue single-file components. // Application views are Vue single-file components.
vue(VUE_PLUGIN_OPTIONS), vue(VUE_PLUGIN_OPTIONS),
...plugins, ...plugins,
+7 -5
View File
@@ -200,11 +200,13 @@ its pending results. Source disposal does not silently reinstate a default.
`ui/composition` supplies the default engines to the shell. Applications can `ui/composition` supplies the default engines to the shell. Applications can
supply a HUD implementation or request policy through control services. The supply a HUD implementation or request policy through control services. The
shared chrome owns the welcome/loading transition; standalone composition adds shared chrome owns the welcome/loading transition; standalone composition adds
Provider Settings. `build/html` expands an allowlist of component markers: Provider Settings. A page places each Vue view in `src/ui/views` with a host,
most become hosts for the Vue views in `src/ui/views`, which `<div class="gev-view" data-gev-view="NAME"></div>`, and writes its own loading
`createApplication()` renders before anything reads the page, and the loading screen (`#loading-screen` with a `.loader-status` line) as static markup so it
screen is inlined from `src/ui/templates`. Unknown names cannot read arbitrary paints before any script runs. `createApplication()` renders the views before
filesystem paths. A host that does not initialize Core's locale passes anything reads the page; `omitViews` names views the application leaves out,
such as `provider-settings`, and their hosts are removed. An unknown view name
is an error. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the `localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`. loader with `node --import=<core package>/build/register-vue`.
+8 -7
View File
@@ -63,13 +63,13 @@ a feature supplying only its accent.
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store 2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
mirrors each side's panels and the rail renders from it. Done. mirrors each side's panels and the rail renders from it. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids, 3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. A converted template is listed in classes, `data-i18n` text and CSS. The page places each view with a
`build/applicationViews.js`; its `gev:template` marker then expands to a
`.gev-view` host (`display: contents`), and `mountApplicationViews()` `.gev-view` host (`display: contents`), and `mountApplicationViews()`
(`src/ui/views/index.js`) renders the view into it before the page is (`src/ui/views/index.js`) renders the view into it before the page is
translated and wired. `createApplication()` calls it too, so host pages translated and wired. `createApplication()` calls it too, removing the
need no change. Views belong to the page and are never unmounted. The hosts of any views its `omitViews` names. Views belong to the page and are
loading screen stays static so it paints before any script runs. Markup unmounted only when a later call omits them. The loading screen is static markup in the page so it
paints before any script runs. Markup
tests read `renderApplicationHtml()` from `src/testSupport`, which renders tests read `renderApplicationHtml()` from `src/testSupport`, which renders
the views on the server. Templates compile with Vue's `preserve` the views on the server. Templates compile with Vue's `preserve`
whitespace, which keeps the whitespace between elements as a single whitespace, which keeps the whitespace between elements as a single
@@ -168,8 +168,9 @@ a feature supplying only its accent.
Display popover's slots stay static for the Cockpit display portal). Display popover's slots stay static for the Cockpit display portal).
Layers that write into the right rail directly gain a slot instead. Layers that write into the right rail directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute 4. **Cleanup.** The build-time template expansion is retired: `index.html`
translation pass are retired once nothing uses them. Hosts register panels lists the view hosts and the loading screen itself. The attribute
translation pass goes with the move to vue-i18n. Hosts register panels
and rail items through an explicit API instead of editing markup. and rail items through an explicit API instead of editing markup.
Markup-string tests are replaced by component tests. Markup-string tests are replaced by component tests.
+18 -16
View File
@@ -29,23 +29,25 @@
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers,layers_clear,light_mode,local_fire_department,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head> </head>
<body> <body>
<!-- gev:template scene-chrome --> <div class="gev-view" data-gev-view="scene-chrome"></div>
<div class="gev-view" data-gev-view="cockpit"></div>
<div class="gev-view" data-gev-view="display-controls"></div>
<div class="gev-view" data-gev-view="command-dock"></div>
<div class="gev-view" data-gev-view="layer-panels"></div>
<div class="gev-view" data-gev-view="context"></div>
<div class="gev-view" data-gev-view="welcome"></div>
<div class="gev-view" data-gev-view="provider-settings"></div>
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- gev:template cockpit --> <!-- Loading screen -->
<div id="loading-screen">
<!-- gev:template display-controls --> <div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<!-- gev:template command-dock --> <h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
<!-- gev:template layer-panels --> </div>
</div>
<!-- gev:template context -->
<!-- gev:template welcome -->
<!-- gev:template provider-settings -->
<!-- gev:template hud-loading -->
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
-3
View File
@@ -219,9 +219,6 @@
"./voice/actions": "./src/voice/gevActions.js", "./voice/actions": "./src/voice/gevActions.js",
"./annotations": "./src/annotations/index.js", "./annotations": "./src/annotations/index.js",
"./context": "./src/data/contextStore.js", "./context": "./src/data/contextStore.js",
"./build/html": {
"node": "./build/application-html.js"
},
"./build/register-vue": { "./build/register-vue": {
"node": "./build/registerVue.js" "node": "./build/registerVue.js"
}, },
-1
View File
@@ -650,7 +650,6 @@
"src/voice/realtimeBackend.test.mjs", "src/voice/realtimeBackend.test.mjs",
"src/agent/worldPacket.test.mjs", "src/agent/worldPacket.test.mjs",
"src/voice/layerSummary.js", "src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sources.js", "src/app/sources.js",
"src/ui/composition.js", "src/ui/composition.js",
"src/services/application.js", "src/services/application.js",
+3 -8
View File
@@ -8,8 +8,7 @@
// lists it. // lists it.
// //
// Messages are the English literals passed to t(), tn() and msg() in src/, // Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in index.html, src/ui/templates and // plus the markup opted in with data-i18n* in index.html and the Vue views.
// the Vue views.
// It also lists UI text that is not marked at all (see findUnmarked). // It also lists UI text that is not marked at all (see findUnmarked).
import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
@@ -422,13 +421,9 @@ export function findUnmarked() {
}); });
} }
/** Static markup opted in with data-i18n*: the page, its templates and views. */ /** Static markup opted in with data-i18n*: the page and its views. */
function markupFiles() { function markupFiles() {
return trackedFiles( return trackedFiles('index.html', 'src/ui/views/*.vue');
'index.html',
'src/ui/templates/*.html',
'src/ui/views/*.vue',
);
} }
/** /**
+1 -3
View File
@@ -98,14 +98,12 @@
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": ["./build/vite", "./build/html", "./build/register-vue"], "exports": ["./build/vite", "./build/register-vue"],
"modules": [ "modules": [
"build/vite.js", "build/vite.js",
"build/application-html.js",
"build/allowedHosts.js", "build/allowedHosts.js",
"build/embed-framing.js", "build/embed-framing.js",
"build/panel.js", "build/panel.js",
"build/applicationViews.js",
"build/vueOptions.js", "build/vueOptions.js",
"build/registerVue.js", "build/registerVue.js",
"build/vueLoader.js" "build/vueLoader.js"
+5 -2
View File
@@ -7,7 +7,9 @@ const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
* Own application startup and teardown without importing an application entry. * Own application startup and teardown without importing an application entry.
* Constructors receive earlier components, an AbortSignal and defer(cleanup). * Constructors receive earlier components, an AbortSignal and defer(cleanup).
* Register cleanup immediately after acquiring each resource, before any await. * Register cleanup immediately after acquiring each resource, before any await.
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function}} constructors * The page's views render first; `omitViews` names the ones this application
* leaves out (see mountApplicationViews).
* @param {{createScene:Function,createControls:Function,createData:Function,createTools:Function,omitViews?:string[]}} constructors
* @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}} * @returns {{start:Function,destroy:Function,subscribe:Function,getState:Function,getComponents:Function}}
*/ */
export function createApplication({ export function createApplication({
@@ -15,9 +17,10 @@ export function createApplication({
createControls, createControls,
createData, createData,
createTools, createTools,
omitViews = [],
}) { }) {
// A host's page needs its views before any phase reads the markup. // A host's page needs its views before any phase reads the markup.
mountApplicationViews(); mountApplicationViews(globalThis.document, { omit: omitViews });
const factories = { const factories = {
scene: createScene, scene: createScene,
controls: createControls, controls: createControls,
+5 -6
View File
@@ -2,20 +2,19 @@ import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { createSSRApp } from 'vue'; import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
import { expandApplicationHtml } from '../../build/application-html.js';
import { APPLICATION_VIEWS } from '../ui/views/index.js'; import { APPLICATION_VIEWS } from '../ui/views/index.js';
const HOST = /<div class="gev-view" data-gev-view="([^"]+)"><\/div>/g; const HOST = /<div class="gev-view" data-gev-view="([^"]+)"><\/div>/g;
/** /**
* Expand application markup as the browser shows it once views are mounted: * Application markup as the browser shows it once views are mounted: each
* static templates inline, and each view rendered into its host. Markup tests * view rendered into its host. Markup tests read this instead of the page
* read this instead of the raw template expansion. * source.
* @param {string} html Document with `gev:template` markers. * @param {string} html Document with `.gev-view` hosts.
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
export async function renderApplicationHtml(html) { export async function renderApplicationHtml(html) {
let output = expandApplicationHtml(html); let output = html;
for (const [host, name] of [...output.matchAll(HOST)]) { for (const [host, name] of [...output.matchAll(HOST)]) {
const view = APPLICATION_VIEWS[name]; const view = APPLICATION_VIEWS[name];
if (!view) throw new Error(`Unknown application view: ${name}`); if (!view) throw new Error(`Unknown application view: ${name}`);
+14 -28
View File
@@ -1,41 +1,27 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import {
expandApplicationHtml,
APPLICATION_TEMPLATES,
} from '../../build/application-html.js';
import { APPLICATION_VIEW_NAMES } from '../../build/applicationViews.js';
import { APPLICATION_VIEWS } from '../ui/views/index.js'; import { APPLICATION_VIEWS } from '../ui/views/index.js';
import { readApplicationHtml } from '../testSupport/applicationHtml.mjs'; import { readApplicationHtml } from '../testSupport/applicationHtml.mjs';
test('the standalone document expands every component once and preserves unique element ids', async () => { const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
test('the standalone document hosts every view once and keeps element ids unique', async () => {
const hosts = [...source.matchAll(/data-gev-view="([^"]+)"/g)].map((match) => match[1]);
// Order is stacking order for fixed elements that share a z-index.
assert.deepEqual(hosts, ['scene-chrome', 'cockpit', 'display-controls', 'command-dock', 'layer-panels', 'context', 'welcome', 'provider-settings']);
assert.deepEqual([...hosts].sort(), Object.keys(APPLICATION_VIEWS).sort());
const html = await readApplicationHtml(); const html = await readApplicationHtml();
assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
assert.doesNotMatch(html, /gev:template/);
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length); assert.equal(new Set(ids).size, ids.length);
assert.match(html, /id="cesiumContainer"/); assert.match(html, /id="cesiumContainer"/);
assert.match(html, /type="module" src="\/src\/main.js"/); assert.match(html, /type="module" src="\/src\/main.js"/);
}); });
test('component selection includes only requested markup and refuses filesystem traversal', () => { test('the loading screen is static markup, so it paints before any script runs', () => {
const html = expandApplicationHtml('<!-- gev:template hud-loading -->\n'); const loading = source.indexOf('id="loading-screen"');
assert.match(html, /id="loading-screen"/); assert.ok(loading > 0);
assert.doesNotMatch(html, /id="cesiumContainer"/); assert.ok(loading < source.indexOf('type="module" src="/src/main.js"'));
assert.throws(() => expandApplicationHtml('<!-- gev:template ../../.env -->'), /Unknown application template/); assert.match(source, /class="loader-status" data-i18n>Initializing photorealistic world\.\.\.</);
}); assert.doesNotMatch(source, /gev:template/);
test('views expand to mount hosts, the loading screen stays static, and every view is registered', () => {
assert.deepEqual(Object.keys(APPLICATION_VIEWS).sort(), [...APPLICATION_VIEW_NAMES].sort());
for (const name of APPLICATION_VIEW_NAMES) {
assert.ok(APPLICATION_TEMPLATES.includes(name), name);
assert.equal(
expandApplicationHtml(`<!-- gev:template ${name} -->\n`),
`<div class="gev-view" data-gev-view="${name}"></div>\n`,
);
}
// It paints before any script runs.
assert.ok(!APPLICATION_VIEW_NAMES.includes('hud-loading'));
}); });
+4 -4
View File
@@ -29,8 +29,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
assert.equal(config.plugins[0].enforce, 'pre'); assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[1].name, 'host-check'); assert.equal(config.plugins[1].name, 'host-check');
assert.equal(config.plugins[1].enforce, 'pre'); assert.equal(config.plugins[1].enforce, 'pre');
assert.equal(config.plugins[4].name, 'vite:vue'); assert.equal(config.plugins[3].name, 'vite:vue');
assert.equal(config.plugins[5], plugin); assert.equal(config.plugins[4], plugin);
assert.equal(config.server.host, 'localhost'); assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173); assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
@@ -104,7 +104,7 @@ test('build helper does not discover environment values or construct local provi
undefined, undefined,
); );
assert.deepEqual( assert.deepEqual(
config.plugins.slice(5).map((plugin) => plugin.name), config.plugins.slice(4).map((plugin) => plugin.name),
['embed-framing', 'panel-build'], ['embed-framing', 'panel-build'],
); );
} finally { } finally {
@@ -118,7 +118,7 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
const standaloneProviders = config.plugins const standaloneProviders = config.plugins
.slice(5, -6) .slice(4, -6)
.map((plugin) => plugin.name); .map((plugin) => plugin.name);
assert.equal( assert.equal(
standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, standaloneProviders.filter((name) => name === 'gemini-live-proxy').length,
-11
View File
@@ -1,11 +0,0 @@
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2 translate="no">GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status" data-i18n>Initializing photorealistic world...</p>
</div>
</div>
+28 -10
View File
@@ -10,8 +10,9 @@ import SceneChromeView from './SceneChromeView.vue';
import ProviderSettingsView from './ProviderSettingsView.vue'; import ProviderSettingsView from './ProviderSettingsView.vue';
/** /**
* Application views by template name (build/applicationViews.js lists the * Application views by name. A page places each one with a host element,
* same names). Each renders the markup its static template held. * `<div class="gev-view" data-gev-view="NAME"></div>` (`display: contents`),
* where the view's markup belongs.
*/ */
export const APPLICATION_VIEWS = Object.freeze({ export const APPLICATION_VIEWS = Object.freeze({
'provider-settings': ProviderSettingsView, 'provider-settings': ProviderSettingsView,
@@ -24,21 +25,38 @@ export const APPLICATION_VIEWS = Object.freeze({
welcome: WelcomeView, welcome: WelcomeView,
}); });
const mounted = new WeakSet(); const mounted = new WeakMap();
/** /**
* Render every application view into its host. Views belong to the page, as * Render every application view into its host. Views belong to the page, as
* their static markup did, so they are never unmounted. Call before anything * their static markup did, so they are unmounted only when a later call omits
* them; omit views before any control code is wired to them. Call before anything
* reads or translates the page; repeated calls are harmless. * reads or translates the page; repeated calls are harmless.
* @param {Document} [doc] Page document. * @param {Document} [doc] Page document.
* @param {{omit?: string[]}} [options] Views this page leaves out; their
* hosts are removed, unmounting a view an earlier call rendered.
*/ */
export function mountApplicationViews(doc = globalThis.document) { export function mountApplicationViews(
doc = globalThis.document,
{ omit = [] } = {},
) {
for (const name of omit) {
if (!Object.hasOwn(APPLICATION_VIEWS, name))
throw new Error(`Unknown application view: ${name}`);
}
for (const host of doc?.querySelectorAll?.('[data-gev-view]') ?? []) { for (const host of doc?.querySelectorAll?.('[data-gev-view]') ?? []) {
if (mounted.has(host)) continue;
const name = host.getAttribute('data-gev-view'); const name = host.getAttribute('data-gev-view');
const view = APPLICATION_VIEWS[name]; if (omit.includes(name)) {
if (!view) throw new Error(`Unknown application view: ${name}`); mounted.get(host)?.unmount();
createApp(view).use(applicationPinia()).mount(host); mounted.delete(host);
mounted.add(host); host.remove();
continue;
}
if (mounted.has(host)) continue;
if (!Object.hasOwn(APPLICATION_VIEWS, name))
throw new Error(`Unknown application view: ${name}`);
const app = createApp(APPLICATION_VIEWS[name]).use(applicationPinia());
app.mount(host);
mounted.set(host, app);
} }
} }
@@ -0,0 +1,77 @@
// src/ui/views/mountApplicationViews.test.mjs — a page's view hosts render
// their views, and an application can leave views out, over happy-dom.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplication } from '../../app/application.js';
import { mountApplicationViews } from './index.js';
function page(names) {
document.body.replaceChildren();
for (const name of names) {
const host = document.createElement('div');
host.className = 'gev-view';
host.setAttribute('data-gev-view', name);
document.body.append(host);
}
}
const hostNames = () =>
[...document.querySelectorAll('[data-gev-view]')].map((host) =>
host.getAttribute('data-gev-view'),
);
test('omitted views lose their hosts and the rest render', () => {
page(['welcome', 'provider-settings']);
mountApplicationViews(document, { omit: ['provider-settings'] });
assert.deepEqual(hostNames(), ['welcome']);
assert.equal(document.getElementById('key-setup-chip'), null);
assert.ok(
document.querySelector('[data-gev-view="welcome"]').children.length,
);
});
test('a view an earlier call rendered is unmounted when a later call omits it', () => {
page(['welcome', 'provider-settings']);
mountApplicationViews(document);
assert.ok(document.getElementById('key-setup-chip'));
mountApplicationViews(document, { omit: ['provider-settings'] });
assert.deepEqual(hostNames(), ['welcome']);
assert.equal(document.getElementById('key-setup-chip'), null);
});
test('unknown view names are refused, in the page or in the omit list', () => {
page([]);
for (const name of ['toString', 'constructor'])
assert.throws(
() => mountApplicationViews(document, { omit: [name] }),
/Unknown application view/,
);
page(['toString']);
assert.throws(
() => mountApplicationViews(document),
/Unknown application view/,
);
assert.throws(
() => mountApplicationViews(document, { omit: ['nope'] }),
/Unknown application view: nope/,
);
page(['nope']);
assert.throws(
() => mountApplicationViews(document),
/Unknown application view: nope/,
);
});
test('createApplication leaves out the views its omitViews names', () => {
page(['welcome', 'provider-settings']);
const noop = () => ({});
createApplication({
createScene: noop,
createControls: noop,
createData: noop,
createTools: noop,
omitViews: ['provider-settings'],
});
assert.deepEqual(hostNames(), ['welcome']);
});