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(/^([^<]+)), 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('