Files
orca/mobile/scripts/build-terminal-document-script.mjs
T
Jinwoo-H eff4d33d88 Merge C7.1 at 0ce0fc99a2 into ota-c7-5-page-terminal
One conflict, resolved in favour of this branch: `terminal-document-flip.test.ts` is
modified there and deleted here. C7.1's commit gave its docstring ruling 18's wording;
this lane's first commit retired the test and its `terminal-document-pre-flip-script.txt`
fixture under that same ruling, because C7.5 is the lane that changes a module and the
flip pin holds exactly while none does. The deletion stands, and the standing pin is
the whole-document byte golden.

Everything else merged on its own. Kept from C7.1: the order-list guard, the tokens
test-support module the equivalence comparator was split into, the tightened
comparator, `substituteDocumentConstants`, the named theme type, and the deletions of
`URL_TAP_WEBVIEW_JS`, `document/url-tap.test.ts` and
`terminal-webview-html-source.test-support.ts`. Kept from this branch: the host-seams
module ahead of the scope in the generator, and the two source greps in the reflow and
scroll-routing tests that C7.1 also touched — both files auto-merged with C7.1's
reads of the generated document and this lane's reads of the controller and the ready
promises side by side.

The document did not move: rebuilt from the merged modules it is 724,002 bytes and the
golden is byte for byte what it was, 105,968.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 12:16:57 -04:00

196 lines
7.2 KiB
JavaScript

import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from './terminal-document-module-order.mjs'
/**
* Turns one module of the in-WebView terminal document back into the script text the document
* carries.
*
* The document is a string the native WebView loads, so its parts cannot be imported by anything;
* the web page needs exactly those parts and must not re-implement them. So the parts are modules,
* and this is the other direction: the modules' declarations, with their imports removed and their
* exports unmarked, spliced into the one function scope the document has always been.
*
* Imports are dropped rather than resolved because inside the document every name is already in
* scope — that is what the single IIFE means. `document-externals.ts` declares the names that have
* not moved into modules yet, and it emits nothing at all.
*
* `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a
* transform and not a bundle: a bundler would order the output by its dependency graph, and the
* document's order is part of what the equivalence test holds fixed.
*/
const INDENT = ' '
const constantsPath = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'document',
'document-constants.ts'
)
let substitutions = null
/**
* `document-constants.ts` as the literal text each name stands for.
*
* Substitution happens after the import lines are dropped, when the names are free again, and it is
* textual rather than an esbuild `define` because a `define` whose value is an object or an array
* is injected as a helper binding instead of being inlined, which is not what the document carries.
* The names are exported for this purpose only and none of them appears inside a string.
*/
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
}
/**
* Replaces each constant's name with its literal.
*
* The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and
* `$n` are replacement patterns, so a constant whose value contains one would be spliced with the
* match rather than written out. A function replacer has no such reading.
*/
export function substituteDocumentConstants(text, substitutions) {
let substituted = text
for (const [name, literal] of Object.entries(substitutions)) {
substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal)
}
return substituted
}
/**
* Whether a line is a lint directive.
*
* These are removed before the transform, not after it: a directive inside an expression makes
* esbuild wrap that expression in parentheses to keep the comment where it was, and those
* parentheses are tokens the document does not have. They are tooling metadata about the source,
* not part of the program the WebView runs.
*/
function isLintDirectiveLine(line) {
return /^\s*\/\/\s*oxlint-disable/.test(line)
}
/** Whether a line opens an import the document does not need. */
function isImportLine(line) {
return /^import[\s{'"]/.test(line)
}
/** Whether a statement that started on this line also ended on it. */
function closesOnSameLine(line, closer) {
return line.includes(closer)
}
/**
* The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE.
*
* Multi-line imports are handled by dropping through to the line that closes them, which esbuild's
* output makes safe: it prints one import per line.
*/
export async function emitTerminalDocumentModule(modulePath) {
const source = await readFile(modulePath, 'utf8')
const program = source
.split('\n')
.filter((line) => !isLintDirectiveLine(line))
.join('\n')
const { code } = await esbuild.transform(program, {
loader: 'ts',
format: 'esm',
target: 'chrome74',
// The document is read by people as well as by a WebView, and the equivalence test compares
// tokens, so keeping the printer's own layout costs nothing and keeps the diff legible.
minify: false
})
const kept = []
// esbuild wraps a long import or export list across lines, so both are skipped to their closer
// rather than by their first line. An export list dropped by its keyword alone would leave a
// bare block statement in the document, and an import list would leave its names loose.
let skipUntil = null
for (const line of code.split('\n')) {
if (skipUntil !== null) {
if (closesOnSameLine(line, skipUntil)) {
skipUntil = null
}
continue
}
if (isImportLine(line)) {
skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from '
continue
}
if (line.startsWith('export {')) {
skipUntil = closesOnSameLine(line, '}') ? null : '}'
continue
}
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
}
const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions())
const substituted = await esbuild.transform(text, {
loader: 'js',
format: 'esm',
target: 'chrome74',
minify: false
})
const body = substituted.code.trim()
return body
.split('\n')
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
.join('\n')
}
const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document')
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'terminal-webview-document-script.generated.ts'
)
/**
* The document's whole script: every module in the order the document had, inside the one function
* scope it has always been.
*/
export async function buildTerminalDocumentScript() {
const emitted = []
// The scope object goes first: every module below reads it, and the document is one function
// scope, so it has to exist before any of them run. It is the only part of the emitted script
// the hand-written document did not have, and the host seams come ahead of it because its
// defaults are those four functions.
for (const name of [
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
]) {
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
}
return `(function() {\n${emitted.join('\n')}\n})();`
}
async function main() {
const script = await buildTerminalDocumentScript()
await writeFile(
TERMINAL_DOCUMENT_SCRIPT_PATH,
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
`// The source is mobile/src/terminal/document/, in the order\n` +
`// scripts/terminal-document-module-order.mjs pins.\n` +
`export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
)
}
if (process.argv[1] === import.meta.filename) {
await main()
}