feat(ui): render application templates as Vue views mounted before wiring

Templates listed in build/applicationViews.js expand to mount hosts, and
mountApplicationViews() renders them before translation and wiring. Markup
tests read the page through a server-side render helper, the i18n scanner
reads view templates, and Vue preserves whitespace so views keep the exact
DOM. No template is converted yet.
This commit is contained in:
Sameh Khamis committed 2026-10-30 17:23:45 -07:00
1 parent b7e7058a5c
commit 9d24945143
32 files changed
+243 -63

No files matched your search

+12
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs';
import { APPLICATION_VIEW_NAMES } from './applicationViews.js';
export const APPLICATION_TEMPLATES = Object.freeze([
'scene-chrome',
@@ -12,6 +13,16 @@ export const APPLICATION_TEMPLATES = Object.freeze([
'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) {
@@ -20,6 +31,7 @@ export function expandApplicationHtml(html) {
(_, 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',
+7
View File
@@ -0,0 +1,7 @@
/**
* 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([]);
+2 -1
View File
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url';
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
import { embedFramingPlugin } from './embed-framing.js';
import { panelBuildPlugin } from './panel.js';
@@ -158,7 +159,7 @@ export function createBrowserViteConfig({
cesium(),
applicationHtmlPlugin(),
// Application views are Vue single-file components.
vue(),
vue(VUE_PLUGIN_OPTIONS),
...plugins,
embedFramingPlugin(),
panelBuildPlugin(),
+11
View File
@@ -0,0 +1,11 @@
/**
* Template compiler options shared by the browser build, the boundary check
* and the unit-test loader. Whitespace is preserved so a view renders exactly
* the DOM its static markup did: inline layouts depend on text between nodes.
*/
export const VUE_COMPILER_OPTIONS = Object.freeze({ whitespace: 'preserve' });
/** `@vitejs/plugin-vue` options for application builds. */
export const VUE_PLUGIN_OPTIONS = Object.freeze({
template: { compilerOptions: VUE_COMPILER_OPTIONS },
});
+11 -1
View File
@@ -53,7 +53,17 @@ They contain no application state.
2. **Rail.** `PanelChrome` enforces one panel per side; the panel store
mirrors each side's panels and the rail renders from it. Done.
3. **Panels.** Each template and its wiring become a component, keeping ids,
classes, `data-i18n` text and CSS. Order: Scenes, Weather, Recent imagery,
classes, `data-i18n` text and CSS. A converted template is listed in
`build/applicationViews.js`; its `gev:template` marker then expands to a
`.gev-view` host (`display: contents`), and `mountApplicationViews()`
(`src/ui/views/index.js`) renders the view into it before the page is
translated and wired. `createApplication()` calls it too, so host pages
need no change. Views belong to the page and are never unmounted. The
loading screen stays static so it paints before any script runs. Markup
tests read `renderApplicationHtml()` from `src/testSupport`, which renders
the views on the server. Vue preserves template whitespace so a view's DOM
matches its template exactly. First the markup moves, unchanged;
then each view takes over its wiring. Order: Scenes, Weather, Recent imagery,
Street level, CCTV, Layers, Display, Context and Radio, the command dock,
the phone shell, then Cockpit. Layers that write into the right rail
directly gain a slot instead.
+2 -1
View File
@@ -2,6 +2,7 @@ import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { VUE_PLUGIN_OPTIONS } from '../build/vueOptions.js';
import { build, normalizePath } from 'vite';
/** Build each browser or Node export group and reject imports outside its declared ownership. */
@@ -100,7 +101,7 @@ export async function checkPackageBoundaries(root) {
ssr: node ? { noExternal: true } : undefined,
logLevel: 'silent',
plugins: [
vue(),
vue(VUE_PLUGIN_OPTIONS),
{
name: 'check-package-ownership',
moduleParsed(info) {
+22 -6
View File
@@ -119,10 +119,10 @@ export function extractMessages() {
}
}
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
const markup = markupFiles();
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
for (const file of markup) {
const html = readFileSync(path.join(ROOT, file), 'utf8');
const html = readMarkup(file);
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
const where = `${file} <${tag}>`;
if (/\sdata-i18n(\s|=|$)/.test(attrs)) {
@@ -404,10 +404,8 @@ export function unmarkedScript(file, original) {
*/
export function findUnmarked() {
const found = [];
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
found.push(
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
);
for (const file of markupFiles())
found.push(...unmarkedMarkup(file, readMarkup(file)));
for (const file of sourceFiles().filter(
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
))
@@ -423,6 +421,24 @@ export function findUnmarked() {
});
}
/** Static markup opted in with data-i18n*: the page, its templates and views. */
function markupFiles() {
return trackedFiles(
'index.html',
'src/ui/templates/*.html',
'src/ui/views/*.vue',
);
}
/** A file's markup; for a single-file component, its template block. */
function readMarkup(file) {
const source = readFileSync(path.join(ROOT, file), 'utf8');
if (!file.endsWith('.vue')) return source;
const start = source.indexOf('<template>');
const end = source.lastIndexOf('</template>');
return start === -1 ? '' : source.slice(start + '<template>'.length, end);
}
// Text that never reaches the page as UI: tool results and descriptions for
// AI clients (MCP and the voice model).
const NOT_UI = [/^src\/tools\//];
+9 -3
View File
@@ -17,8 +17,12 @@
},
"application": {
"exports": ["./application"],
"modules": ["src/app/application.js"],
"external": []
"modules": [
"src/app/application.js",
"src/ui/vueRuntime.js",
"src/ui/views/index.js"
],
"external": ["vue", "pinia"]
},
"viewer": {
"exports": ["./application/viewer"],
@@ -40,7 +44,9 @@
"build/application-html.js",
"build/allowedHosts.js",
"build/embed-framing.js",
"build/panel.js"
"build/panel.js",
"build/applicationViews.js",
"build/vueOptions.js"
],
"external": ["vite-plugin-cesium", "@vitejs/plugin-vue"]
},
+3
View File
@@ -1,3 +1,4 @@
import { mountApplicationViews } from '../ui/views/index.js';
const START_ORDER = ['scene', 'controls', 'data', 'tools'];
// Controls cancel restoration while the data manager and viewer still exist.
const STOP_ORDER = ['tools', 'controls', 'data', 'scene'];
@@ -15,6 +16,8 @@ export function createApplication({
createData,
createTools,
}) {
// A host's page needs its views before any phase reads the markup.
mountApplicationViews();
const factories = {
scene: createScene,
controls: createControls,
+2 -2
View File
@@ -2,7 +2,7 @@ import {
readShellSource,
shellMethod,
} from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js';
@@ -46,7 +46,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'),
);
const ui = readShellSource();
+2 -2
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js';
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
+2 -2
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -19,7 +19,7 @@ import {
} from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0);
+2 -2
View File
@@ -1,6 +1,6 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -734,7 +734,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
/this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity',
);
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
await readFile(new URL('../../index.html', import.meta.url), 'utf8'),
);
assert.match(
+5 -5
View File
@@ -1,4 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import test from 'node:test';
@@ -797,8 +797,8 @@ test('the decision table is written down where the next editor will read it', ()
// ── Markup, startup ordering, accessibility ─────────────────────────────────
test('markup, startup ordering and accessibility remain pinned', () => {
const html = expandApplicationHtml(
test('markup, startup ordering and accessibility remain pinned', async () => {
const html = await renderApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const startup = fs.readFileSync(
@@ -915,8 +915,8 @@ test('markup, startup ordering and accessibility remain pinned', () => {
assert.match(choicesBlock, /overflow-y: auto;/);
});
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', () => {
const html = expandApplicationHtml(
test('the card closes without a keyboard: the ESC hint is a button, kept on phones', async () => {
const html = await renderApplicationHtml(
fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const module = fs.readFileSync(
+2 -2
View File
@@ -1,9 +1,9 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium.
+3 -3
View File
@@ -1,6 +1,6 @@
import { ShareRestoration } from './ui/shareRestoration.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -202,9 +202,9 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed
'elapsed wall time while hidden expires the notice instead of replaying it');
});
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
test('universal notice lifecycle clears on dispose and uses the one top-center live region', async () => {
const ui = readShellSource();
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const disposeStart = ui.indexOf(' async dispose() {');
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
const dispose = ui.slice(disposeStart, disposeEnd);
+2 -2
View File
@@ -1,5 +1,5 @@
import { shellMethod } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs';
import { msg, t } from './i18n/index.js';
// Exercise the installed event routes and central close method, without WebGL.
const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const markup = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
const locationToggleAttributes = Object.fromEntries(
+3 -3
View File
@@ -1,5 +1,5 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
// MAP STACK chip row — the dropdown's replacement control surface.
//
@@ -482,8 +482,8 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
);
});
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = expandApplicationHtml(
test('the Visual Presets tray owns Map Source and the retired left panel is absent', async () => {
const html = await renderApplicationHtml(
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const ui = readShellSource();
+3 -3
View File
@@ -1,5 +1,5 @@
import { createNamedMarkers } from '../layers/installations/namedMarkers.js';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -447,7 +447,7 @@ test('destroying before the first init leaves pre-init buffering intact', () =>
env.cleanup();
});
test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', () => {
test('lifecycle is idempotent and teardown removes listeners, observers, and DOM', async () => {
const env = installMockEnvironment();
assert.equal(env.document.getElementById('world-overlay-detection-surface'), null);
initWorldOverlay(env.viewer);
@@ -465,7 +465,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
assert.deepEqual(root.children, [canvas],
'the overlay root carries only the shared card canvas');
assert.doesNotMatch(
expandApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')),
await renderApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')),
/world-overlay-detection-surface/,
'the surface is runtime host-owned, not static markup',
);
+5 -5
View File
@@ -1,5 +1,5 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -264,8 +264,8 @@ test('expanded left panels integrate their headers with the container shell', ()
);
});
test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => {
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
test('Map Source uses five compact tiles in the bottom Visual Presets tray', async () => {
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(html, /id="stack-panel"/);
@@ -277,8 +277,8 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', ()
);
});
test('expanded right panels highlight the title divider without changing collapsed launchers', () => {
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
test('expanded right panels highlight the title divider without changing collapsed launchers', async () => {
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(
+2 -2
View File
@@ -2,7 +2,7 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import { realtimeInstructions } from '../server/providers/openai/instructions.js';
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { readFileSync as readRadioSource } from 'node:fs';
@@ -14,7 +14,7 @@ import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readShellSource();
const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
+2 -2
View File
@@ -1,5 +1,5 @@
import { readShellSource } from './testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
// src/reasonableDefaults.test.mjs
//
// What the console looks like the FIRST time it opens — before any share link,
@@ -46,7 +46,7 @@ import { ShareLinkManager } from './sharelink.js';
// Follow the UI wiring and its extracted preset definitions.
const uiSource = readShellSource()
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const indexHtml = await renderApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
/** Slice ui.js between two literal anchors, so a pin reads one method, not the file. */
+3
View File
@@ -8,6 +8,7 @@ import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js';
import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js';
import { applyDocumentTranslations, setLocale } from '../i18n/index.js';
import { mountApplicationViews } from '../ui/views/index.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
// The existing controls and layer catalog contain page-scoped state.
@@ -33,6 +34,8 @@ export function createStandaloneApplication({
const activeLocale = resolveLocale();
setLocale(activeLocale);
applyDocumentLanguage(document, activeLocale);
// Views render the page's markup; translation and wiring read it.
mountApplicationViews(document);
applyDocumentTranslations(document);
const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status');
+2 -2
View File
@@ -3,13 +3,13 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { expandApplicationHtml } from '../build/application-html.js';
import { renderApplicationHtml } from './testSupport/applicationHtml.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
const html = expandApplicationHtml(read('index.html'));
const html = await renderApplicationHtml(read('index.html'));
const css = readStylesheet(path.join(ROOT, 'style.css'));
const panelCss = read('src/ui/styles/street-level.css');
const panelChrome = read('src/ui/panelChrome.js');
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs';
import { createPinia } from 'pinia';
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { expandApplicationHtml } from '../../build/application-html.js';
import { APPLICATION_VIEWS } from '../ui/views/index.js';
const HOST = /<div class="gev-view" data-gev-view="([^"]+)"><\/div>/g;
/**
* Expand application markup as the browser shows it once views are mounted:
* static templates inline, and each view rendered into its host. Markup tests
* read this instead of the raw template expansion.
* @param {string} html Document with `gev:template` markers.
* @returns {Promise<string>}
*/
export async function renderApplicationHtml(html) {
let output = expandApplicationHtml(html);
for (const [host, name] of [...output.matchAll(HOST)]) {
const view = APPLICATION_VIEWS[name];
if (!view) throw new Error(`Unknown application view: ${name}`);
const markup = (await renderToString(createSSRApp(view).use(createPinia())))
// Hydration markers and empty-attribute values are SSR detail; the
// DOM is the same as the template's.
.replace(/<!--\[-->|<!--\]-->|<!---->/g, '')
.replace(/ ([\w:.-]+)=""/g, ' $1');
output = output.replace(
host,
`<div class="gev-view" data-gev-view="${name}">${markup}</div>`,
);
}
return output;
}
/** The standalone page's markup with every view rendered. */
export function readApplicationHtml() {
return renderApplicationHtml(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'),
);
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc';
import { VUE_COMPILER_OPTIONS } from '../../build/vueOptions.js';
/**
* Compile a single-file component to an ES module for Node's test runner.
@@ -11,7 +12,11 @@ export function compileVueModule(source, filename) {
if (errors.length) throw errors[0];
const id = filename;
if (descriptor.scriptSetup) {
return compileScript(descriptor, { id, inlineTemplate: true }).content;
return compileScript(descriptor, {
id,
inlineTemplate: true,
templateOptions: { compilerOptions: VUE_COMPILER_OPTIONS },
}).content;
}
const script = descriptor.script
? compileScript(descriptor, { id, genDefaultAs: '__sfc__' }).content
@@ -22,6 +27,7 @@ export function compileVueModule(source, filename) {
source: descriptor.template.content,
filename,
id,
compilerOptions: VUE_COMPILER_OPTIONS,
});
if (compiled.errors.length) throw compiled.errors[0];
template = compiled.code;
+24 -5
View File
@@ -1,11 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { expandApplicationHtml, APPLICATION_TEMPLATES } from '../../build/application-html.js';
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 { readApplicationHtml } from '../testSupport/applicationHtml.mjs';
test('the standalone document expands every component once and preserves unique element ids', () => {
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 html = expandApplicationHtml(source);
const html = await readApplicationHtml();
assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length);
assert.doesNotMatch(html, /gev:template/);
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]);
@@ -15,8 +21,21 @@ test('the standalone document expands every component once and preserves unique
});
test('component selection includes only requested markup and refuses filesystem traversal', () => {
const html = expandApplicationHtml('<!-- gev:template welcome -->\n');
assert.match(html, /id="first-run-launcher"/);
const html = expandApplicationHtml('<!-- gev:template hud-loading -->\n');
assert.match(html, /id="loading-screen"/);
assert.doesNotMatch(html, /id="cesiumContainer"/);
assert.throws(() => expandApplicationHtml('<!-- gev:template ../../.env -->'), /Unknown application 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'));
});
+2 -2
View File
@@ -52,7 +52,7 @@ import {
} from '../i18n/index.js';
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js';
import { createUiRuntime } from './vueRuntime.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -136,7 +136,7 @@ export class StyleManager extends ShellFacade {
this._mobileShell?.sharedCollapsed(panelId),
});
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime();
this._ui = createUiRuntime({ pinia: applicationPinia() });
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
});
+2 -2
View File
@@ -2,11 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { renderApplicationHtml } from '../testSupport/applicationHtml.mjs';
import { createBrowserViteConfig } from '../../build/vite.js';
import { R820T_GAIN_STEPS_DB } from '../sdr/gain.js';
const html = expandApplicationHtml(
const html = await renderApplicationHtml(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'),
);
const css = readFileSync(
+6
View File
@@ -98,3 +98,9 @@
font: 600 9px/14px var(--font-mono);
text-align: center;
}
/* Application view hosts (src/ui/views): the view's own elements lay out as
children of the page, as the static markup they replace did. */
.gev-view {
display: contents;
}
+27
View File
@@ -0,0 +1,27 @@
import { createApp } from 'vue';
import { applicationPinia } from '../vueRuntime.js';
/**
* Application views by template name (build/applicationViews.js lists the
* same names). Each renders the markup its static template held.
*/
export const APPLICATION_VIEWS = Object.freeze({});
const mounted = new WeakSet();
/**
* 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
* reads or translates the page; repeated calls are harmless.
* @param {Document} [doc] Page document.
*/
export function mountApplicationViews(doc = globalThis.document) {
for (const host of doc?.querySelectorAll?.('[data-gev-view]') ?? []) {
if (mounted.has(host)) continue;
const name = host.getAttribute('data-gev-view');
const view = APPLICATION_VIEWS[name];
if (!view) throw new Error(`Unknown application view: ${name}`);
createApp(view).use(applicationPinia()).mount(host);
mounted.add(host);
}
}
+16 -4
View File
@@ -1,13 +1,25 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
let pagePinia = null;
/**
* Create the application's Vue runtime: one Pinia instance shared by every
* view the shell mounts, and the mounts themselves for teardown.
* The page's Pinia instance, shared by the application views and the shell,
* so a view and a shell control read the same stores.
* @returns {import('pinia').Pinia}
*/
export function applicationPinia() {
pagePinia ??= createPinia();
return pagePinia;
}
/**
* Create a Vue runtime: one Pinia instance shared by every view it mounts,
* and the mounts themselves for teardown.
* @param {{pinia?: import('pinia').Pinia}} [options]
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
*/
export function createUiRuntime() {
const pinia = createPinia();
export function createUiRuntime({ pinia = createPinia() } = {}) {
const apps = new Set();
return {
pinia,