Files
orca/mobile/scripts/build-rich-markdown-editor-script.mjs
Jinwoo Hong d9954000b3 refactor(mobile): the rich editor's document becomes scope-threaded modules and a bundled factory (OTA phase C, C7.10 C1) (#21969)
* refactor(mobile): split the rich editor document's stylesheet and markup apart

The body constant carried the tail of a `:root` block, every CSS rule and the
editable surface's markup in one string, which only the HTML builder could
splice. A page mounting the document needs the stylesheet and the markup
separately, so they become a function over the theme and a constant.

Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of
the shipped document is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the keyboard-inset normaliser its own module

It is the host's half of the inset, read by the controller, and it sat in the
module holding the document's in-page script. The script is about to become
ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does
not belong.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): the rich editor's document becomes scope-threaded modules and a factory

The editor's ~600-line program lived in seven string constants a concatenator
glued into one `<script>`: unreadable, untypeable, and unreachable from a page,
which is where the OTA shell has to run it (ruling 26).

It is now ordinary TypeScript under `src/components/rich-markdown/`. Every
function that touches editor state takes `scope: RichMarkdownEditorScope` first,
`createRichMarkdownEditorDocument(host)` builds the scope, runs the start
sequence and returns `{ send, stop }`, and the six window reads the script did
are host seams with those reads as their defaults: `postToHost`, `promptForUrl`,
`keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`.
`runCommand` is async because a host that answers the URL prompt with a modal
cannot answer synchronously; the thirteen commands that never wait stay one
synchronous act.

No module holds a `let` and none does work at parse time (rulings 20, 21), so a
second mount starts from its own state and `stop` takes back both the surface's
four listeners and the viewport's two.

The native document is an esbuild IIFE bundle of `native-document-entry.ts`,
written beside the terminal document's artifact by a fifth postinstall
generator. Nothing ships it yet: the HTML builder still splices the old strings,
which the next commit changes.

Red-first: `rich-markdown-document-parse-time.test.ts` and
`rich-markdown-host-seams.test.ts`. Their readers are the terminal census's,
extracted to `src/test-support/webview-document-census.ts` and pointed at both
documents rather than copied.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): ship the bundled document and retire the editor's script strings

`buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of
`src/components/rich-markdown/`, and the seven string constants and their
concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for
`injectJavaScript`, which is still how the native host reaches the document.

Equivalence, since a byte golden over the script cannot survive a bundler:

- `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact
  exactly as the WebView does — its markup, its bridge, its `execCommand`, its
  `prompt`, its `visualViewport` — and drives it through the injected handle:
  `keyboardInset` then `ready`, all five members, a markdown round trip through
  the real escape, an edit under the host's generation, every toolbar command's
  engine verb, the `javascript:` refusal, a tapped link, and the module list.
- `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the
  page around the document. Measured on main's own document with its script
  region removed and on this one: 5,621 bytes, both
  `5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The
  whole-document digest it replaces was `1ef29c88…`, 29,852 bytes.

Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting
functions out of the emitted text is kept, aimed at the modules:

- nested/ordered/task list rendering and serialization, entities, explicit
  numbering, the parent-start fallback, read-only checkboxes →
  `markdown-round-trip.test.ts`, over real elements rather than shaped objects.
- the emitChange/setEditable guards and the generation carried through a
  replacement → `editor-content.test.ts`, behaviourally.
- dismissKeyboard, the tapped caret, the label tap, the restored caret, the
  end-of-document fallback, the detached caret → `editor-selection.test.ts`,
  with a blur that drops the ranges the way WebKit does.
- parseable script and the injection escape stay in the HTML test.

New with the factory: `document-lifecycle.test.ts` — stop takes the four surface
listeners and the viewport observer off, a second mount is its own document, two
documents do not share `editable`, and a start that throws unwinds.

`use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the
web fallback tests are untouched and green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the document's mutable bindings from the tree, not the line start

The census matched `/^(let|var) /gm`, so `export let`, a declaration indented
inside a top-level block and a `for (let …)` head were all invisible — three
shapes of the one binding two documents would share — and its single
precondition proved only the shape it could already see.

`moduleLevelMutableBindings` walks the program instead and stops at every
function body, because a binding one call owns is not module state. Its
preconditions are one per shape, with the kind each reports, and a negative case
over a `const` and a function-local `let`/`var` so the empty list is a
measurement rather than a reader that refuses everything.

Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it
with `keyboard-inset: let pendingReport`, which the old matcher passed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): pin both WebView document bundles to the mobile root

esbuild writes each module's path into a bundle as a comment relative to the
working directory, and neither generator set `absWorkingDir`. So the artifact's
bytes followed the cwd of whatever postinstall run wrote it: measured from the
repo root, `mobile/`, and `mobile/src`, three digests — and from outside the
repo the comments carried `/Users/<name>/…`, a machine path in the one file
every bundle test compares against a build it makes itself.

Both generators now pin the mobile root, so the four cwds measured agree, and
both bundle tests carry the pin: a digest built in a child process from the OS
temp directory equals the committed artifact's, and no comment in either
artifact is an absolute path or climbs out with `../`.

`build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied
its shape, so both are fixed here rather than leaving the original to be found
again. Neither artifact's bytes move: both were generated from `mobile/`, which
is what `absWorkingDir` now names.

Red-first: deleting the `absWorkingDir` line from either generator reds that
generator's case.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): cover the getSelection seam's override, not just its default

Five of the six seams had both halves and this one had only its window default,
which is the half that cannot fail on the page: there the caret has to come from
the object the host hands over, because a document mounted inside a screen
shares `window` with every other field on it.

The case gives the document a selection of its own, blurs the surface the way
WebKit does — dropping the ranges, which is the whole reason a caret is saved —
and reads the restored caret back out of the host's object. The window's own
selection stays empty throughout, which is what says the default was never
consulted.

Red-first: `rememberSelection` reading `window.getSelection()` instead of the
field reds it; every other case in the file stays green.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the editor document's stop cancel its pending timer

`stop` took the surface's four listeners and the viewport's observer off and
left the input timer, while the scope kept the handle and the `clearTimer` seam
kept the means to cancel it. A listener comes off with the element it was on; a
scheduled callback holds the scope and fires into a document the host has
already unmounted, posting a change under the generation of content it has
replaced.

`stopEditorContent` cancels it through the seam and clears the field, and the
sequence runs it last — after the listeners that could have scheduled another
one are gone.

Nothing schedules the handle today. The cancel is here because the seam and the
field exist for the day something does, and that is not the moment to discover
`stop` never reached it. The case plants the pending change rather than waiting
for a debounce, and carries its own control: the same timer posts while the
document is running, and posts nothing once it is stopped.

Red-first: dropping `stopEditorContent` from the sequence reds both that case
and the parse-time census's start/stop set comparison.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct the postinstall generator count in both censuses

Two comments said four generators and six generated files. There are five
generators writing six files, and the six are not the six either comment
described: `census-source-files.ts` still named the page's copy of the terminal
document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1
added the rich Markdown editor's.

Both now name the lists of record — `mobile/package.json`'s postinstall for the
generators, `mobile/.gitignore` for the files — and say the count is a reading
that grows rather than a fence, which is what made the old numbers wrong twice
over.

Verified against both lists: 5 and 6.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which digest is the document and which is the page around it

The docstring put main's whole-document digest and byte count in the sentence
introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were
what the constant below asserts. They are not: that digest is of main's whole
document, script included, and nothing in the file reproduces it. The constant
is of the document with its `<script>` region emptied, taken on main's document
and on this one.

Both are now named and separated, with what each covers and why the shell one
was read twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): name the parse-time fixture by its role

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): drop an editor command whose dialog answered after the host moved on

C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a
modal. Inside the WebView that changes nothing — `window.prompt` resolves within
a microtask, and the host reaches the document through `injectJavaScript`, which
is a later task — but on the page the modal is a real task boundary, and while
it is open the host can replace the content, make the editor read-only or
unmount it entirely. The continuation ran anyway: `createLink` against markdown
nobody chose, and a change posted under the new generation carrying an edit made
against the old one.

`acceptsCommands` is the question both halves ask: not stopped, still editable,
still the same generation, still contenteditable. `insertUrl` asks it before
`execCommand` and `runCommand` asks it again before emitting, each against the
generation read before its own wait.

The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets.

Inert on native, where no state can change across a microtask, so the answer to
both questions is the one the old code assumed.

Red-first: with either check removed, the new case reports
`[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its
own control — an answer that arrives while nothing has moved is still applied
and still reported.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): make the editor's block reader always consume a line

`markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a
marker followed by a space, and the list test admits the same, but the heading
reader requires text after the hashes and `parseListLine` requires text after
the marker — so on those lines the list branch consumed nothing and returned the
index it was given, and the paragraph loop gathered nothing and pushed an empty
paragraph without advancing. A one-line file the host handed to `setMarkdown`
froze the WebView.

Two guards, both by the same rule: a branch may only commit if it moved the
index. The list branch falls through when its run is empty, and the paragraph
falls back to the line itself when it gathered none.

Present on main verbatim, so this is inherited rather than introduced — but the
fix is observationally inert, because the only inputs it changes are the ones
that previously never returned. Every input that produced output produces the
same output.

Evidence, from a probe that bounds the loop from the inside rather than waiting
on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence
shapes all return. The pinned cases carry their own control, `# ok` and `- ok`,
so the fallback is not swallowing the readers it falls back from.

A red-first case is not possible here: without the fix the case does not fail,
it hangs the worker. The probe above is the measurement.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): see every declaration that runs as a document module is evaluated

The parse-time reader inspected only variable declarations, while
`DECLARATION_KINDS` admits classes and default exports. So
`class A { static value = install() }`, a static block, and
`export default install()` all passed a census whose whole job is to refuse
exactly that — and a static field reading `document` passed too, which is the
remount defect the rule exists for, wearing a different shape.

Three shapes now, each reported by what it does rather than what it looks like:
a variable initialiser, a class's static members, and a default export that is
an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into
the body of `export default function () {}`, whose calls run when something
calls it.

The preconditions are one per shape, with a negative case beside them: an
instance field runs per `new` and nothing in a document is ever constructed, and
a default-exported function declares a body rather than running one.

Inherited from the terminal's census, which had the same reader; both use this
one, and both are green.

Red-first: removing the class branch reds the new precondition case.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read lifecycle exports from the tree, not from one exact spelling

The reader was a regular expression needing `export function`, one line, the
scope parameter and no return type. `export async function startX(`, a return
type, or a parameter list the formatter wrapped made a real lifecycle export
vanish — and the comparison it feeds is a set against the names the sequence
calls, so a function missing from *both* lists makes them agree. A start nobody
runs would have read as a start nobody needs.

It now qualifies a function by what it is: exported, named for its lifecycle,
and taking the document's scope as its only parameter. That last clause is
ruling 20's own wording — a start takes nothing the scope does not already carry
— and the regex was enforcing it by accident, through the single parameter its
pattern happened to allow.

Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the
regex never matched and the sequence never calls. It takes a direction, so it is
the overlay's act for a drag rather than a module's lifecycle, and the one-
parameter rule refuses it for the stated reason instead of by accident. Both
censuses are green.

Red-first: restoring the regex reds the new precondition case, which covers
`async`, a return type and wrapped parameters, with refusals beside them for a
two-parameter start, another document's scope type, and an unexported function.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 07:16:23 -04:00

96 lines
3.3 KiB
JavaScript

import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import esbuild from 'esbuild'
/**
* The in-WebView rich Markdown editor document, bundled from its modules.
*
* The document is a string the native WebView loads inside its HTML, so it cannot be an ES module
* there — and it is ordinary TypeScript everywhere else, which a page imports directly. So this is
* one esbuild bundle of the entry that calls `createRichMarkdownEditorDocument()` with no host,
* written out as a string, exactly as the terminal's document beside it is.
*
* `iife`, so the bundle's module scope is its own and nothing it declares reaches the page it is
* pasted into. Not minified: the document is read in the WebView's own console, and it is a few
* tens of kilobytes rather than an engine.
*/
const mobileRoot = path.join(import.meta.dirname, '..')
/** The oldest WebView Orca supports, matching the terminal document's own floor (#7030). */
const TARGET = 'chrome74'
const ENTRY = path.join(
mobileRoot,
'src',
'components',
'rich-markdown',
'native-document-entry.ts'
)
export const RICH_MARKDOWN_EDITOR_SCRIPT_PATH = path.join(
mobileRoot,
'src',
'components',
'rich-markdown-editor-document-script.generated.ts'
)
const GENERATED_HEADER =
`// Generated by scripts/build-rich-markdown-editor-script.mjs. Do not edit.\n` +
`// The source is mobile/src/components/rich-markdown/, bundled from native-document-entry.ts.\n` +
`// Target: ${TARGET}. Regenerate via pnpm postinstall.`
/**
* One options object, so a census of what the bundle contains measures the bundle that ships.
*
* `absWorkingDir` is load-bearing: esbuild writes each module's path into the bundle as a comment,
* relative to the working directory, so without it the artifact's bytes depend on where the
* generator was run from — three cwds gave three digests, and from outside the repo the comments
* carry an absolute path with the builder's home directory in it.
*/
export function richMarkdownEditorBuildOptions(extra = {}) {
return {
absWorkingDir: mobileRoot,
entryPoints: [ENTRY],
bundle: true,
format: 'iife',
minify: false,
platform: 'browser',
target: TARGET,
legalComments: 'none',
write: false,
logLevel: 'silent',
...extra
}
}
/**
* One bundle, and everything a reader asks about it.
*
* The text and the module list come from the same build because they are two readings of one
* thing: a census that built its own would answer about a bundle nobody ships.
*/
export async function richMarkdownEditorBundle() {
const result = await esbuild.build(richMarkdownEditorBuildOptions({ metafile: true }))
const [output] = result.outputFiles
if (!output) {
throw new Error('[build-rich-markdown-editor-script] esbuild emitted no document bundle')
}
return { script: output.text.trimEnd(), inputs: Object.keys(result.metafile.inputs) }
}
export async function buildRichMarkdownEditorScript() {
return (await richMarkdownEditorBundle()).script
}
async function main() {
const script = await buildRichMarkdownEditorScript()
await writeFile(
RICH_MARKDOWN_EDITOR_SCRIPT_PATH,
`${GENERATED_HEADER}\nexport const RICH_MARKDOWN_EDITOR_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
)
}
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
await main()
}