diff --git a/web/package.json b/web/package.json index 13b20b77f..f502d7cba 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "i18n:audit": "node scripts/i18n-audit.mjs", "i18n:catalog": "node scripts/generate-source-catalog.mjs", "sync:provider-logos": "node scripts/sync-provider-logos.mjs", - "build": "vite build", + "build": "vite build && node scripts/check-regex-compat.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/web/patches/mdast-util-gfm-autolink-literal@2.0.1.patch b/web/patches/mdast-util-gfm-autolink-literal@2.0.1.patch new file mode 100644 index 000000000..b91eb030a --- /dev/null +++ b/web/patches/mdast-util-gfm-autolink-literal@2.0.1.patch @@ -0,0 +1,17 @@ +--- a/lib/index.js ++++ b/lib/index.js +@@ -132,7 +132,13 @@ + tree, + [ + [/(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/gi, findUrl], +- [/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] ++ // NOTE(acecode): lookbehind removed for legacy WebKit compatibility. ++ // Safari < 16.4 engines (e.g. the WKWebView on macOS 12) cannot compile ++ // lookbehind, and one failing regex literal makes the whole module fail ++ // to parse. `findEmail` re-validates the preceding character at runtime ++ // via `previous(match, true)`, so dropping the assertion is ++ // behavior-neutral. ++ [/([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] + ], + {ignore: ['link', 'linkReference']} + ) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index c95a2d328..456103b19 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + mdast-util-gfm-autolink-literal@2.0.1: a4be58f34b7da3fdec4394752c5654c599ce2acd0797b32b986c8eb6fcac30c7 + importers: .: @@ -3598,7 +3601,7 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@2.0.1: + mdast-util-gfm-autolink-literal@2.0.1(patch_hash=a4be58f34b7da3fdec4394752c5654c599ce2acd0797b32b986c8eb6fcac30c7): dependencies: '@types/mdast': 4.0.4 ccount: 2.0.1 @@ -3646,7 +3649,7 @@ snapshots: mdast-util-gfm@3.1.0: dependencies: mdast-util-from-markdown: 2.0.3 - mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-autolink-literal: 2.0.1(patch_hash=a4be58f34b7da3fdec4394752c5654c599ce2acd0797b32b986c8eb6fcac30c7) mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 41be57701..81fd640f7 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -5,3 +5,10 @@ allowBuilds: core-js: true esbuild: true x-data-spreadsheet: true + +patchedDependencies: + # Remove the lookbehind in the email autolink regex: legacy WebKit + # (Safari < 16.4, e.g. macOS 12 WKWebView) cannot compile it, which made + # the whole frontend bundle fail to parse (blank screen). See the patch + # file for details. + mdast-util-gfm-autolink-literal@2.0.1: patches/mdast-util-gfm-autolink-literal@2.0.1.patch diff --git a/web/scripts/check-regex-compat.mjs b/web/scripts/check-regex-compat.mjs new file mode 100644 index 000000000..4b04f09c2 --- /dev/null +++ b/web/scripts/check-regex-compat.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Scans the built frontend bundle (web/dist/index.html) for regex literals +// that use lookbehind assertions `(?<=...)` / `(?~;{}`'.split('')); +const KEYWORDS = new Set([ + 'return', 'case', 'typeof', 'in', 'of', 'new', 'delete', 'void', + 'instanceof', 'do', 'else', 'yield', 'await', 'throw', +]); + +function extractRegexLiterals(src) { + const found = []; + const n = src.length; + let i = 0; + + const prevSignificant = (idx) => { + let j = idx - 1; + while (j >= 0 && (src[j] === ' ' || src[j] === '\t' || src[j] === '\r' || src[j] === '\n')) j--; + return j >= 0 ? src[j] : ''; + }; + const prevIsKeyword = (idx) => { + let j = idx - 1; + while (j >= 0 && /[A-Za-z0-9_$]/.test(src[j])) j--; + return KEYWORDS.has(src.slice(j + 1, idx)); + }; + + while (i < n) { + const c = src[i]; + if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i) + 1 || n; continue; } + if (c === '/' && src[i + 1] === '*') { i = (src.indexOf('*/', i + 2) + 2) || n; continue; } + if (c === '"' || c === "'") { + i++; + while (i < n && src[i] !== c && src[i] !== '\n') i += src[i] === '\\' ? 2 : 1; + i++; + continue; + } + if (c === '`') { + i++; + while (i < n && src[i] !== '`') { + if (src[i] === '\\') { i += 2; continue; } + if (src[i] === '$' && src[i + 1] === '{') { + let depth = 1; + i += 2; + while (i < n && depth > 0) { + if (src[i] === '\\') { i += 2; continue; } + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + continue; + } + i++; + } + i++; + continue; + } + if (c === '/' && (prevSignificant(i) === '' || REGEX_PRECEDING.has(prevSignificant(i)) || prevIsKeyword(i))) { + let j = i + 1; + let inClass = false; + let closed = false; + while (j < n) { + const ch = src[j]; + if (ch === '\\') { j += 2; continue; } + if (ch === '\n') break; + if (ch === '[') inClass = true; + else if (ch === ']') inClass = false; + else if (ch === '/' && !inClass) { closed = true; break; } + j++; + } + if (closed) { + let k = j + 1; + while (k < n && /[a-z]/i.test(src[k])) k++; + found.push({ index: i, pattern: src.slice(i + 1, j), flags: src.slice(j + 1, k) }); + i = k; + continue; + } + } + i++; + } + return found; +} + +const literals = extractRegexLiterals(html); +const offenders = literals.filter(({ pattern }) => /\(\?<[=!]/.test(pattern)); + +console.log(`[check-regex-compat] scanned ${literals.length} regex literals in dist/index.html`); +if (offenders.length > 0) { + console.error(`[check-regex-compat] FAIL: ${offenders.length} regex literal(s) use lookbehind, which breaks legacy WebKit (Safari < 16.4):`); + for (const { pattern, flags } of offenders.slice(0, 10)) { + console.error(` /${pattern.slice(0, 120)}/${flags}`); + } + process.exit(1); +} +console.log('[check-regex-compat] OK: no lookbehind regex literals found'); diff --git a/web/src/lib/gfmAutolinkCompat.test.js b/web/src/lib/gfmAutolinkCompat.test.js new file mode 100644 index 000000000..69a19d097 --- /dev/null +++ b/web/src/lib/gfmAutolinkCompat.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { fromMarkdown } from 'mdast-util-from-markdown'; +import { gfmFromMarkdown } from 'mdast-util-gfm'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +// Guards the patched mdast-util-gfm-autolink-literal dependency +// (see patches/mdast-util-gfm-autolink-literal@2.0.1.patch): the email +// autolink regex must keep working without a lookbehind so legacy WebKit +// engines (Safari < 16.4) can parse the module. +function extractLinks(markdown) { + const tree = fromMarkdown(markdown, { mdastExtensions: [gfmFromMarkdown()] }); + const links = []; + (function walk(nodes) { + for (const node of nodes) { + if (node.type === 'link') { + links.push({ url: node.url, text: node.children.map((c) => c.value).join('') }); + } + if (Array.isArray(node.children)) walk(node.children); + } + })(tree.children); + return links; +} + +run('GFM email autolink links after whitespace', () => { + const links = extractLinks('联系 foo@bar.com 了解更多'); + assert.equal(links.length, 1); + assert.equal(links[0].url, 'mailto:foo@bar.com'); + assert.equal(links[0].text, 'foo@bar.com'); +}); + +run('GFM email autolink links at start of text', () => { + const links = extractLinks('foo@bar.com'); + assert.equal(links.length, 1); + assert.equal(links[0].url, 'mailto:foo@bar.com'); +}); + +run('GFM email autolink links after punctuation', () => { + for (const source of ['(foo@bar.com)', '(foo@bar.com)', '"foo@bar.com"']) { + const links = extractLinks(source); + assert.equal(links.length, 1, `expected link in ${source}`); + assert.equal(links[0].url, 'mailto:foo@bar.com'); + } +}); + +run('GFM email autolink refuses slash-prefixed emails', () => { + const links = extractLinks('see path/user@example.com for details'); + assert.deepEqual(links, []); +}); + +run('GFM email autolink refuses emails attached to CJK characters', () => { + const links = extractLinks('中文foo@bar.com'); + assert.deepEqual(links, []); +}); + +run('GFM email autolink absorbs preceding latin letters into the address', () => { + // Matches upstream behavior: the leftmost atext run is absorbed, and the + // `^` branch of the original lookbehind allowed this case as well. + const links = extractLinks('abcfoo@bar.com tail'); + assert.equal(links.length, 1); + assert.equal(links[0].url, 'mailto:abcfoo@bar.com'); +}); + +run('GFM URL autolink still works alongside email autolink', () => { + const links = extractLinks('visit https://example.com or mail foo@bar.com'); + assert.equal(links.length, 2); + assert.deepEqual( + links.map((l) => l.url), + ['https://example.com', 'mailto:foo@bar.com'] + ); +}); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index aa3ed9913..03ece232e 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -18,6 +18,7 @@ import './createdFileSource.test.js'; import './sourceCodeHighlight.test.js'; import './markdownBlocks.test.js'; import './markdownWysiwyg.test.js'; +import './gfmAutolinkCompat.test.js'; import './editablePreviewSelection.test.js'; import './previewWorkbenchArchitecture.test.js'; import './editableFileDraft.test.js';