diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 45bce378d43..c64aecce9ba 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -50,8 +50,10 @@ import { * the first time, when main had drifted the haptics module after recording its own number, and a * sum would have read 4283 and been wrong about a module neither side of that merge touched. * - * Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and the four postinstall - * generators run first, the before side in a scratch worktree detached at the same sha, and the + * Both sides read with `mobileWebAppRouteClosure(SESSION_ROUTE)` and every postinstall generator + * `mobile/package.json` names run first — five at this reading, since C7.10 C1 added the rich + * Markdown editor's document, and the list is read there rather than counted from here because it + * grows. The before side is a scratch worktree detached at the same sha, and the * three modules above read out of the after side's list by name rather than inferred from the * total. Measured rather than taken from main's pin because the pin covers only the module count, * so the local count beside it would otherwise be a number nobody had read. diff --git a/mobile/.gitignore b/mobile/.gitignore index 474807b74b5..85bf6e054a2 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -2,6 +2,7 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts src/terminal/terminal-webview-engine-css.generated.ts src/terminal/terminal-webview-document-script.generated.ts +src/components/rich-markdown-editor-document-script.generated.ts src/components/pr-sidebar/mermaid-webview-engine.generated.ts src/components/pr-sidebar/mermaid-page-engine.generated.ts .expo/ diff --git a/mobile/package.json b/mobile/package.json index f1359d96a7b..5c2ac13abb9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-mermaid-page-engine.mjs && node scripts/build-terminal-document-script.mjs && node scripts/build-rich-markdown-editor-script.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", diff --git a/mobile/scripts/build-rich-markdown-editor-script.mjs b/mobile/scripts/build-rich-markdown-editor-script.mjs new file mode 100644 index 00000000000..c173f96f791 --- /dev/null +++ b/mobile/scripts/build-rich-markdown-editor-script.mjs @@ -0,0 +1,95 @@ +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() +} diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs index 0fa4e1a5569..67b3b77ac49 100644 --- a/mobile/scripts/build-terminal-document-script.mjs +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -38,9 +38,15 @@ const GENERATED_HEADER = * * `minify: false` is load-bearing beyond readability: the engine-error overlay reports the line and * column `window.onerror` hands it, and a minified document makes both useless. + * + * So is `absWorkingDir`: 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 — and from outside the repo the comments carry an absolute path with the builder's home + * directory in it. */ export function terminalDocumentBuildOptions(extra = {}) { return { + absWorkingDir: mobileRoot, entryPoints: [ENTRY], bundle: true, format: 'iife', diff --git a/mobile/src/components/mobile-rich-markdown-editor-document-body.ts b/mobile/src/components/mobile-rich-markdown-editor-document-body.ts deleted file mode 100644 index a381b7d38e4..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-document-body.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Head of the editor document through the editable surface; the script follows it. -export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_BODY = [ - ';', - ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;', - ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', - ' }', - ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }', - ' html, body {', - ' width: 100%;', - ' min-height: 100%;', - ' margin: 0;', - ' background: var(--editor-surface);', - ' color: var(--foreground);', - ' font-family: var(--font-sans);', - ' overscroll-behavior: contain;', - ' }', - ' body { overflow: auto; }', - ' #editor {', - ' min-height: 100vh;', - ' padding: 18px 16px 112px;', - ' outline: none;', - ' font-size: 14px;', - ' line-height: 1.7;', - ' word-wrap: break-word;', - ' overflow-wrap: anywhere;', - ' caret-color: var(--foreground);', - ' }', - ' #editor[contenteditable="false"] {', - ' opacity: 0.78;', - ' }', - ' #editor:empty::before,', - ' #editor p.is-empty:first-child::before {', - ' content: attr(data-placeholder);', - ' color: var(--muted-foreground);', - ' pointer-events: none;', - ' }', - ' #editor > :first-child { margin-top: 0; }', - ' h1, h2, h3, h4, h5, h6 {', - ' margin: 1.5em 0 0.5em;', - ' font-weight: 600;', - ' line-height: 1.3;', - ' letter-spacing: 0;', - ' }', - ' h1 { font-size: 1.85em; font-weight: 700; }', - ' h2 { font-size: 1.4em; }', - ' h3 { font-size: 1.15em; }', - ' p, ul, ol, blockquote { margin: 0.75em 0; }', - ' ul, ol { padding-left: 1.5em; }', - ' ul { list-style: disc; }', - ' ol { list-style: decimal; }', - ' li { margin: 0.15em 0; }', - ' li > p { margin: 0; }', - ' ul[data-type="taskList"] {', - ' padding-left: 0;', - ' list-style: none;', - ' }', - ' ul[data-type="taskList"] > li {', - ' display: flex;', - ' align-items: flex-start;', - ' gap: 6px;', - ' }', - ' ul[data-type="taskList"] > li > label {', - ' flex-shrink: 0;', - ' display: flex;', - ' align-items: center;', - ' height: 1.55em;', - ' user-select: none;', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"] {', - ' appearance: none;', - ' width: 16px;', - ' height: 16px;', - ' margin: 0;', - ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);', - ' border-radius: 4px;', - ' background: transparent;', - ' position: relative;', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:checked {', - ' background: var(--primary);', - ' border-color: var(--primary);', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {', - ' content: "";', - ' position: absolute;', - ' left: 4px;', - ' top: 1px;', - ' width: 5px;', - ' height: 9px;', - ' border: solid var(--primary-foreground);', - ' border-width: 0 2px 2px 0;', - ' transform: rotate(45deg);', - ' }', - ' ul[data-type="taskList"] input[type="checkbox"]:disabled {', - ' opacity: 0.65;', - ' }', - ' ul[data-type="taskList"] > li > div {', - ' flex: 1;', - ' min-width: 0;', - ' }', - ' ul[data-type="taskList"] > li[data-checked="true"] > div {', - ' text-decoration: line-through;', - ' color: var(--muted-foreground);', - ' }', - ' blockquote {', - ' padding: 0.5em 1em;', - ' border-left: 3px solid var(--border);', - ' border-radius: 0 6px 6px 0;', - ' color: var(--muted-foreground);', - ' background: color-mix(in srgb, var(--foreground) 2%, transparent);', - ' }', - ' table {', - ' width: 100%;', - ' margin: 1em 0;', - ' border-collapse: collapse;', - ' font-size: 0.95em;', - ' }', - ' th, td {', - ' padding: 8px 14px;', - ' border: 1px solid var(--border);', - ' text-align: left;', - ' vertical-align: top;', - ' }', - ' th {', - ' font-weight: 600;', - ' background: color-mix(in srgb, var(--foreground) 4%, transparent);', - ' }', - ' tr:nth-child(odd) td {', - ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);', - ' }', - ' code {', - ' padding: 0.2em 0.4em;', - ' border-radius: 5px;', - ' background: color-mix(in srgb, var(--foreground) 8%, transparent);', - ' font-size: 0.88em;', - ' font-family: var(--font-mono);', - ' }', - ' pre {', - ' margin: 0.75em 0;', - ' padding: 14px 18px;', - ' border-radius: 8px;', - ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);', - ' overflow-x: auto;', - ' line-height: 1.55;', - ' background: color-mix(in srgb, var(--foreground) 6%, transparent);', - ' font-family: var(--font-mono);', - ' white-space: pre-wrap;', - ' }', - ' pre::before {', - ' content: attr(data-language);', - ' display: block;', - ' min-height: 13px;', - ' margin-bottom: 4px;', - ' color: var(--muted-foreground);', - ' font-size: 11px;', - ' text-transform: uppercase;', - ' }', - ' pre code {', - ' padding: 0;', - ' border-radius: 0;', - ' background: transparent;', - ' font-size: 0.92em;', - ' white-space: pre-wrap;', - ' }', - ' hr {', - ' margin: 1.5em 0;', - ' border: none;', - ' border-top: 1px solid var(--border);', - ' }', - ' a {', - ' color: var(--accent-link);', - ' text-decoration: underline;', - ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);', - ' text-underline-offset: 2px;', - ' }', - ' img {', - ' display: block;', - ' max-width: 100%;', - ' margin: 0.75em 0;', - ' border-radius: 8px;', - ' }', - ' ', - '', - '
', - ' ' -].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts b/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts deleted file mode 100644 index fb9bd80b3c6..00000000000 --- a/mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts +++ /dev/null @@ -1,90 +0,0 @@ -export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [ - '', - ' function runCommand(command) {', - " if (!editable || editor.getAttribute('contenteditable') !== 'true') return;", - ' restoreSelectionOrEnd();', - " if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');", - " else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');", - " else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');", - " else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');", - " else if (command === 'bold') document.execCommand('bold');", - " else if (command === 'italic') document.execCommand('italic');", - " else if (command === 'strike') document.execCommand('strikeThrough');", - " else if (command === 'bulletList') document.execCommand('insertUnorderedList');", - " else if (command === 'orderedList') document.execCommand('insertOrderedList');", - " else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');", - " else if (command === 'inlineCode') wrapSelection('code');", - " else if (command === 'codeBlock') document.execCommand('insertHTML', false, 'codeTask