Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
17 changes: 17 additions & 0 deletions web/patches/mdast-util-gfm-autolink-literal@2.0.1.patch
Original file line number Diff line number Diff line change
@@ -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']}
)
7 changes: 5 additions & 2 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions web/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions web/scripts/check-regex-compat.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
// Scans the built frontend bundle (web/dist/index.html) for regex literals
// that use lookbehind assertions `(?<=...)` / `(?<!...)`.
//
// Legacy WebKit engines (Safari < 16.4, e.g. the WKWebView on macOS 12)
// cannot compile lookbehind. Regex literals are compiled when the enclosing
// module script is parsed, so a single incompatible literal makes the whole
// bundle fail and the app renders a blank screen.
//
// Run automatically after `pnpm build`. Exits non-zero when a lookbehind
// regex literal is found.

import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distPath = resolve(root, 'dist/index.html');

let html;
try {
html = readFileSync(distPath, 'utf8');
} catch {
console.error(`[check-regex-compat] cannot read ${distPath}; run \`pnpm build\` first`);
process.exit(1);
}

// Tokenizer that walks the bundle and extracts regex literals while skipping
// strings, template literals and comments. Regex-vs-division is decided with
// the usual preceding-token heuristic.
const REGEX_PRECEDING = new Set('(,=:[!&|?+*-%^<>~;{}`'.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');
79 changes: 79 additions & 0 deletions web/src/lib/gfmAutolinkCompat.test.js
Original file line number Diff line number Diff line change
@@ -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']
);
});
1 change: 1 addition & 0 deletions web/src/lib/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading