mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
b7e7058a5c
commit
9d24945143
32 files changed
+243
-63
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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\//];
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user