From 92fcb73f8a245f47aa46b62c8399aafaae8fd110 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:19:15 -0700 Subject: [PATCH] fix(mobile): close exact-head review findings --- .../mobile-embedded-document-candidates.mjs | 108 +++++++++++++++++- ...bile-localization-candidate-rules.test.mjs | 5 +- .../mobile-rich-markdown-editor-html.ts | 2 +- mobile/src/i18n/embedded-webview-copy.test.ts | 9 +- mobile/src/i18n/locales/es.json | 4 +- mobile/src/i18n/locales/ja.json | 18 +-- mobile/src/i18n/locales/ko.json | 4 +- mobile/src/i18n/locales/zh.json | 4 +- mobile/src/i18n/mobile-i18n.test.ts | 29 +++++ 9 files changed, 158 insertions(+), 25 deletions(-) diff --git a/config/scripts/mobile-embedded-document-candidates.mjs b/config/scripts/mobile-embedded-document-candidates.mjs index 06e23ae0852..d992fb54a83 100644 --- a/config/scripts/mobile-embedded-document-candidates.mjs +++ b/config/scripts/mobile-embedded-document-candidates.mjs @@ -5,8 +5,7 @@ const EMBEDDED_DOCUMENT_FILE_RE = /(?:-html|webview-html)\.[cm]?[jt]sx?$/ const USER_VISIBLE_ATTRIBUTE_RE = /\b(aria-description|aria-label|alt|data-placeholder|placeholder|title)\s*=\s*(["'])(.*?)\2/gi const USER_VISIBLE_PROMPT_RE = /\b(?:window\.)?(alert|confirm|prompt)\(\s*(["'])(.*?)\2/g -const INSERTED_HTML_RE = - /\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,\s*(["'])([\s\S]*?)\2\s*\)/g +const INSERTED_HTML_CALL_RE = /\bexecCommand\(\s*(["'])insertHTML\1\s*,\s*false\s*,/g function maskNonMarkupContent(documentText) { return documentText.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, (value) => @@ -48,6 +47,99 @@ function collectMarkupCandidates(markup, baseOffset, candidates) { } } +function collectMarkupFragmentCandidates(markup, baseOffset, candidates) { + collectMarkupCandidates(markup, baseOffset, candidates) + + for (const match of [markup.match(/^([^<]+)([^<]+)$/)]) { + if (!match) { + continue + } + const value = match[1] + const start = baseOffset + capturedOffset(match, value) + candidates.push({ + start, + end: start + value.length, + kind: 'embedded-html-text', + text: value, + dynamic: false + }) + } +} + +function skipQuotedString(source, start) { + const quote = source[start] + let cursor = start + 1 + while (cursor < source.length) { + if (source[cursor] === '\\') { + cursor += 2 + } else if (source[cursor] === quote) { + return cursor + 1 + } else { + cursor += 1 + } + } + return source.length +} + +function skipTemplateInterpolation(source, start) { + let depth = 1 + let cursor = start + 2 + while (cursor < source.length && depth > 0) { + if (source[cursor] === '"' || source[cursor] === "'") { + cursor = skipQuotedString(source, cursor) + } else { + if (source[cursor] === '{') { + depth += 1 + } + if (source[cursor] === '}') { + depth -= 1 + } + cursor += 1 + } + } + return cursor +} + +function insertedHtmlArgumentEnd(source, start) { + let depth = 1 + let cursor = start + while (cursor < source.length) { + if (source[cursor] === '"' || source[cursor] === "'") { + cursor = skipQuotedString(source, cursor) + continue + } + if (source[cursor] === '(') { + depth += 1 + } + if (source[cursor] === ')') { + depth -= 1 + if (depth === 0) { + return cursor + } + } + cursor += 1 + } + return source.length +} + +function collectInsertedHtmlCandidates(expression, baseOffset, candidates) { + let cursor = 0 + while (cursor < expression.length) { + if (expression.startsWith('${', cursor)) { + cursor = skipTemplateInterpolation(expression, cursor) + continue + } + if (expression[cursor] === '"' || expression[cursor] === "'") { + const end = skipQuotedString(expression, cursor) + const markup = expression.slice(cursor + 1, end - 1) + collectMarkupFragmentCandidates(markup, baseOffset + cursor + 1, candidates) + cursor = end + continue + } + cursor += 1 + } +} + export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) { if (!EMBEDDED_DOCUMENT_FILE_RE.test(filePath)) { return [] @@ -85,10 +177,14 @@ export function collectMobileEmbeddedDocumentCandidates(filePath, sourceText) { }) } - for (const match of documentText.matchAll(INSERTED_HTML_RE)) { - const markup = match[3] - const markupOffset = baseOffset + capturedOffset(match, markup) - collectMarkupCandidates(markup, markupOffset, candidates) + for (const match of documentText.matchAll(INSERTED_HTML_CALL_RE)) { + const argumentStart = (match.index ?? 0) + match[0].length + const argumentEnd = insertedHtmlArgumentEnd(documentText, argumentStart) + collectInsertedHtmlCandidates( + documentText.slice(argumentStart, argumentEnd), + baseOffset + argumentStart, + candidates + ) } return } diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index 9bc5621b3c2..ff513124414 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -21,7 +21,7 @@ export const HTML = \` \` ` @@ -36,7 +36,8 @@ export const HTML = \` 'Copy', 'Select All', 'Link URL', - 'Task' + 'Task', + 'Follow up' ]) }) }) diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index 707410ba428..fd0b7d4ab2d 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -4,7 +4,7 @@ import { t } from '../i18n/mobile-i18n' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' export function escapeInjectedJavaScriptString(value: string): string { - return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') + return JSON.stringify(value).replaceAll('<', '\\u003c') } export function buildMobileRichMarkdownEditorHtml(): string { diff --git a/mobile/src/i18n/embedded-webview-copy.test.ts b/mobile/src/i18n/embedded-webview-copy.test.ts index 560f134b02a..1c72caad405 100644 --- a/mobile/src/i18n/embedded-webview-copy.test.ts +++ b/mobile/src/i18n/embedded-webview-copy.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' -import { buildMobileRichMarkdownEditorHtml } from '../components/mobile-rich-markdown-editor-html' +import { + buildMobileRichMarkdownEditorHtml, + escapeInjectedJavaScriptString +} from '../components/mobile-rich-markdown-editor-html' import { buildTerminalWebViewHtml } from '../terminal/terminal-webview-html' import { escapeEmbeddedHtmlCopy } from './embedded-webview-copy' import { mobileI18n } from './mobile-i18n' @@ -32,6 +35,10 @@ describe('embedded WebView copy', () => { expect(terminalHtml).toContain('id="sel-menu-all">Seleccionar todo') }) + it('escapes script-state delimiters in translated JavaScript strings', () => { + expect(escapeInjectedJavaScriptString('