diff --git a/build/application-html.js b/build/application-html.js index b14210a56..27cec5fba 100644 --- a/build/application-html.js +++ b/build/application-html.js @@ -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 `
\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', diff --git a/build/applicationViews.js b/build/applicationViews.js new file mode 100644 index 000000000..c277d8b34 --- /dev/null +++ b/build/applicationViews.js @@ -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([]); diff --git a/build/vite.js b/build/vite.js index a1c9c403b..3dacdfdaa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -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(), diff --git a/build/vueOptions.js b/build/vueOptions.js new file mode 100644 index 000000000..c1c5c1b72 --- /dev/null +++ b/build/vueOptions.js @@ -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 }, +}); diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e86562699..84892e3e9 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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. diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index e019efe06..700dc9a1a 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -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) { diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index d19dc6067..f88b6549d 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -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(''); + return start === -1 ? '' : source.slice(start + '