feat(mobile): substitute build-time constants into the emitted document

The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.

The fixture script's TypeScript loader moves beside it rather than being
written twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 09:11:46 -04:00
parent 4aa81071c2
commit 22a8a2e7e9
5 changed files with 90 additions and 17 deletions
@@ -1,6 +1,6 @@
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
/**
* Writes the committed copy of the terminal WebView document that
@@ -36,19 +36,6 @@ export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join(
export const ENGINE_JS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_JS__'
export const ENGINE_CSS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_CSS__'
async function loadModule(entryPoint) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent'
})
const code = result.outputFiles[0].text
return import(`data:text/javascript;base64,${Buffer.from(code, 'utf8').toString('base64')}`)
}
/**
* The document with both generated sections replaced by their placeholders.
*
@@ -76,8 +63,8 @@ export function terminalDocumentFixture(document, engineJs, engineCss) {
async function main() {
const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([
loadModule(entry),
loadModule(enginePath)
importTypeScriptModule(entry),
importTypeScriptModule(enginePath)
])
const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)
await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture)
@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
/**
* Turns one module of the in-WebView terminal document back into the script text the document
@@ -20,6 +22,34 @@ import * as esbuild from 'esbuild'
*/
const INDENT = ' '
const constantsPath = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'document',
'document-constants.ts'
)
let substitutions = null
/**
* `document-constants.ts` as esbuild `define` entries.
*
* Substitution happens after the import lines are dropped, when the names are free again; while the
* import is still there esbuild sees a bound name and leaves it alone, which is the correct thing
* for the page and the wrong thing for the document.
*/
async function documentConstantSubstitutions() {
if (substitutions === null) {
const module = await importTypeScriptModule(constantsPath)
substitutions = Object.fromEntries(
Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)])
)
}
return substitutions
}
/** Whether a line opens an import the document does not need. */
function isImportLine(line) {
return /^import[\s{'"]/.test(line)
@@ -68,7 +98,15 @@ export async function emitTerminalDocumentModule(modulePath) {
}
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
}
const body = kept.join('\n').trim()
const define = await documentConstantSubstitutions()
const substituted = await esbuild.transform(kept.join('\n'), {
loader: 'js',
format: 'esm',
target: 'chrome74',
minify: false,
define
})
const body = substituted.code.trim()
return body
.split('\n')
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from './build-terminal-document-script.mjs'
import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'
/**
* What the generator drops, what it keeps, and how it puts a module back into the document.
@@ -61,4 +62,15 @@ describe('emitting one terminal document module', () => {
)
).toBe(' function f(v) {\n return v.a;\n }')
})
it('substitutes a build-time constant the document carries as a literal', async () => {
const emitted = await emit(
"import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'\n" +
'export function paint() {\n' +
' return terminalBackgroundFallback\n' +
'}\n'
)
expect(emitted).toContain(JSON.stringify(terminalBackgroundFallback))
expect(emitted).not.toContain('terminalBackgroundFallback')
})
})
@@ -0,0 +1,21 @@
import * as esbuild from 'esbuild'
/**
* Imports a TypeScript module from a build script, by bundling it to a data URL.
*
* Node cannot import TypeScript and these scripts run outside the app's bundler, so the values the
* document is built from — the theme, the URL limits, the caret options — would otherwise have to be
* restated here. Restating them is what the generator exists to avoid.
*/
export async function importTypeScriptModule(entryPoint) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent'
})
const code = result.outputFiles[0].text
return import(`data:text/javascript;base64,${Buffer.from(code, 'utf8').toString('base64')}`)
}
@@ -0,0 +1,15 @@
import { colors } from '../../theme/mobile-theme'
/**
* The build-time values the document's script text carries as literals.
*
* The document is a string, so it cannot import: today each of these is interpolated into a
* template literal at the site that needs it. A module cannot do that and still be the same
* program, so the generator substitutes these exports into the text it emits, and the web page
* imports the very same bindings. One source either way.
*
* Every export must be JSON-serialisable, because a substitution is a JSON literal.
*/
/** The page background before a theme arrives, and the fallback when a theme omits one. */
export const terminalBackgroundFallback = colors.terminalBg