Просмотр исходного кода

Keep a lookbehind Safari 16 cannot parse out of the bundle (issue #2971)

    An iPhone on iOS 16 loaded nothing at all -- no error, no partial render,
    just white, over LAN IP and over an HTTPS domain alike, while the same
    install was fine on Android, macOS, Windows and Linux. remark-gfm, added
    in v1.2.5 for the folder README panel, reaches
    mdast-util-gfm-autolink-literal, whose module body carries a lookbehind
    assertion. Safari did not support lookbehind until 16.4.

    A regex literal is validated when its module is compiled, not when the
    function holding it runs, so this was never going to fail as a broken
    README panel. FolderReadmePanel -> FileManagerPage -> App is a plain
    static import chain, the regex landed in the entry chunk, and the browser
    refused to compile all 10 MB of it. Nothing executed, so nothing
    rendered. v1.2.4 is the last release that loads on those iOS versions.

    The panel now renders GFM through a locally composed plugin holding four
    of remark-gfm's five sub-extensions -- tables, strikethrough, task lists,
    footnotes -- and omitting autolink literals, the only one carrying the
    lookbehind. Composing rather than configuring is forced by the bug:
    importing remark-gfm at all is what breaks the page, so no runtime option
    could have reached it.

    Parity was measured rather than assumed. Serialized ASTs against real
    remark-gfm over a 34-case corpus, position data included, are identical
    in 29; the five that differ are exactly the autolink cases, where the
    only change is link -> text with table and list structure intact. Across
    26 hostile inputs -- NUL bytes, a BOM, an RTL override, a lone surrogate,
    combining marks, a 200 KB line, 500 stacked tables, 60-deep nesting,
    malformed and ragged tables -- neither implementation throws and none
    diverge, and applying the plugin twice is idempotent for both.

    The visible cost is that a bare https://example.com or foo@example.com
    typed into a folder README no longer links itself; [text](url) and
    <https://example.com> are core markdown and still do. The wiki claimed
    "links all render" and now says which.

    remark-gfm, mdast-util-gfm and micromark-extension-gfm leave the
    dependency tree and their eight surviving sub-extensions are declared
    directly, at ranges equal to or tighter than the ^2.0.0 those two
    packages declared, so the resolution surface did not widen. The bundle is
    23 KB smaller.

    Vite's build.target governs syntax lowering and esbuild does not rewrite
    regular expressions -- measured, a lookbehind builds silently under
    safari15, safari16.0 and es2020 alike, which is how this shipped and then
    sat unnoticed for two months. So the guard is a real check rather than a
    compiler setting: npm run build now ends in check-browser-baseline.mjs,
    which scans the emitted bundles for syntax Safari 16.0 cannot parse and
    fails with the offending snippet. It is scoped to parse-time failures
    only -- a missing runtime API breaks one feature, while one of these
    takes down the whole app and has no graceful degradation to fall back on.
    Verified firing on the stale bundle before the rebuild, and running
    correctly inside the Docker frontend stage where only frontend/ is
    copied.

    Seven renderer tests pin both halves of the trade: each surviving GFM
    feature still renders, and both forms of autolinking stay off on purpose
    so a future dependency bump cannot quietly bring the lookbehind back.
MartinNYHC 1 неделя назад
Родитель
Сommit
bccfae82b9

+ 10 - 138
frontend/package-lock.json

@@ -27,6 +27,14 @@
         "i18next-browser-languagedetector": "^8.2.0",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
+        "mdast-util-gfm-footnote": "^2.1.0",
+        "mdast-util-gfm-strikethrough": "^2.0.0",
+        "mdast-util-gfm-table": "^2.0.0",
+        "mdast-util-gfm-task-list-item": "^2.0.0",
+        "micromark-extension-gfm-footnote": "^2.1.0",
+        "micromark-extension-gfm-strikethrough": "^2.1.0",
+        "micromark-extension-gfm-table": "^2.1.1",
+        "micromark-extension-gfm-task-list-item": "^2.1.0",
         "qrcode.react": "^4.2.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
@@ -35,7 +43,6 @@
         "react-router-dom": "7.18.2",
         "react-simple-keyboard": "^3.8.164",
         "recharts": "^3.5.1",
-        "remark-gfm": "^4.0.1",
         "three": "^0.181.2"
       },
       "devDependencies": {
@@ -58,9 +65,11 @@
         "jsdom": "^25.0.0",
         "msw": "^2.6.0",
         "postcss": "^8.5.6",
+        "remark-parse": "^11.0.0",
         "tailwindcss": "^4.1.17",
         "typescript": "~5.9.3",
         "typescript-eslint": "^8.46.4",
+        "unified": "^11.0.5",
         "vite": "^8.0.16",
         "vitest": "^4.1.8"
       }
@@ -5501,32 +5510,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/mdast-util-find-and-replace": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
-      "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
-      "dependencies": {
-        "@types/mdast": "^4.0.0",
-        "escape-string-regexp": "^5.0.0",
-        "unist-util-is": "^6.0.0",
-        "unist-util-visit-parents": "^6.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
-      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/mdast-util-from-markdown": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -5550,40 +5533,6 @@
         "url": "https://opencollective.com/unified"
       }
     },
-    "node_modules/mdast-util-gfm": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
-      "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
-      "dependencies": {
-        "mdast-util-from-markdown": "^2.0.0",
-        "mdast-util-gfm-autolink-literal": "^2.0.0",
-        "mdast-util-gfm-footnote": "^2.0.0",
-        "mdast-util-gfm-strikethrough": "^2.0.0",
-        "mdast-util-gfm-table": "^2.0.0",
-        "mdast-util-gfm-task-list-item": "^2.0.0",
-        "mdast-util-to-markdown": "^2.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/mdast-util-gfm-autolink-literal": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
-      "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
-      "dependencies": {
-        "@types/mdast": "^4.0.0",
-        "ccount": "^2.0.0",
-        "devlop": "^1.0.0",
-        "mdast-util-find-and-replace": "^3.0.0",
-        "micromark-util-character": "^2.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/mdast-util-gfm-footnote": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
@@ -5846,40 +5795,6 @@
         "micromark-util-types": "^2.0.0"
       }
     },
-    "node_modules/micromark-extension-gfm": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
-      "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
-      "dependencies": {
-        "micromark-extension-gfm-autolink-literal": "^2.0.0",
-        "micromark-extension-gfm-footnote": "^2.0.0",
-        "micromark-extension-gfm-strikethrough": "^2.0.0",
-        "micromark-extension-gfm-table": "^2.0.0",
-        "micromark-extension-gfm-tagfilter": "^2.0.0",
-        "micromark-extension-gfm-task-list-item": "^2.0.0",
-        "micromark-util-combine-extensions": "^2.0.0",
-        "micromark-util-types": "^2.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
-    "node_modules/micromark-extension-gfm-autolink-literal": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
-      "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
-      "dependencies": {
-        "micromark-util-character": "^2.0.0",
-        "micromark-util-sanitize-uri": "^2.0.0",
-        "micromark-util-symbol": "^2.0.0",
-        "micromark-util-types": "^2.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/micromark-extension-gfm-footnote": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
@@ -5932,18 +5847,6 @@
         "url": "https://opencollective.com/unified"
       }
     },
-    "node_modules/micromark-extension-gfm-tagfilter": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
-      "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
-      "dependencies": {
-        "micromark-util-types": "^2.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/micromark-extension-gfm-task-list-item": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
@@ -7229,23 +7132,6 @@
         "redux": "^5.0.0"
       }
     },
-    "node_modules/remark-gfm": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
-      "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
-      "dependencies": {
-        "@types/mdast": "^4.0.0",
-        "mdast-util-gfm": "^3.0.0",
-        "micromark-extension-gfm": "^3.0.0",
-        "remark-parse": "^11.0.0",
-        "remark-stringify": "^11.0.0",
-        "unified": "^11.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/remark-parse": {
       "version": "11.0.0",
       "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -7277,20 +7163,6 @@
         "url": "https://opencollective.com/unified"
       }
     },
-    "node_modules/remark-stringify": {
-      "version": "11.0.0",
-      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
-      "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
-      "dependencies": {
-        "@types/mdast": "^4.0.0",
-        "mdast-util-to-markdown": "^2.0.0",
-        "unified": "^11.0.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/unified"
-      }
-    },
     "node_modules/require-directory": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

+ 13 - 3
frontend/package.json

@@ -5,14 +5,15 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "build": "tsc -b && vite build",
+    "build": "tsc -b && vite build && npm run check:baseline",
     "lint": "eslint .",
     "preview": "vite preview",
     "test": "vitest",
     "test:run": "vitest run && npm run check:i18n",
     "test:coverage": "vitest run --coverage",
     "test:ui": "vitest --ui",
-    "check:i18n": "node scripts/check-i18n-parity.mjs"
+    "check:i18n": "node scripts/check-i18n-parity.mjs",
+    "check:baseline": "node scripts/check-browser-baseline.mjs"
   },
   "dependencies": {
     "@dnd-kit/core": "^6.3.1",
@@ -34,6 +35,14 @@
     "i18next-browser-languagedetector": "^8.2.0",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
+    "mdast-util-gfm-footnote": "^2.1.0",
+    "mdast-util-gfm-strikethrough": "^2.0.0",
+    "mdast-util-gfm-table": "^2.0.0",
+    "mdast-util-gfm-task-list-item": "^2.0.0",
+    "micromark-extension-gfm-footnote": "^2.1.0",
+    "micromark-extension-gfm-strikethrough": "^2.1.0",
+    "micromark-extension-gfm-table": "^2.1.1",
+    "micromark-extension-gfm-task-list-item": "^2.1.0",
     "qrcode.react": "^4.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
@@ -42,7 +51,6 @@
     "react-router-dom": "7.18.2",
     "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
-    "remark-gfm": "^4.0.1",
     "three": "^0.181.2"
   },
   "overrides": {
@@ -72,9 +80,11 @@
     "jsdom": "^25.0.0",
     "msw": "^2.6.0",
     "postcss": "^8.5.6",
+    "remark-parse": "^11.0.0",
     "tailwindcss": "^4.1.17",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
+    "unified": "^11.0.5",
     "vite": "^8.0.16",
     "vitest": "^4.1.8"
   }

+ 101 - 0
frontend/scripts/check-browser-baseline.mjs

@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * Fail the build when the bundle uses a JS feature our oldest supported browser
+ * cannot parse (#2971).
+ *
+ * Why this exists as a grep rather than a build target: Vite's `build.target`
+ * only governs *syntax lowering*. esbuild does not rewrite regular expressions,
+ * so a lookbehind assertion - unsupported before Safari 16.4 - builds silently
+ * under `safari15`, `safari16.0` and `es2020` alike (measured, all three). That
+ * is exactly how #2971 shipped: `remark-gfm` pulled a lookbehind regex literal
+ * into the entry chunk, iOS 16.0-16.3 refused to compile the module, and every
+ * page rendered as a blank white screen from v1.2.5 until it was found in the
+ * field two months later.
+ *
+ * A regex literal is validated when its module is *compiled*, so one of these
+ * anywhere in the entry chunk takes down the entire app, not just the feature
+ * that pulled it in. There is no graceful degradation to fall back on, which is
+ * why this is a hard build failure and not a warning.
+ *
+ * BASELINE: Safari 16.0 / iOS 16.0. Raising it is a product decision - if you
+ * do, drop the entries that the new floor supports rather than deleting the
+ * check.
+ *
+ * Scope: parse-time failures only. Runtime APIs (`Object.groupBy`,
+ * `Promise.withResolvers`, ...) break one feature rather than the whole bundle
+ * and are better caught by real-browser testing, so they are deliberately not
+ * listed here.
+ */
+
+import { readdirSync, readFileSync } from 'node:fs';
+import { join, dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
+
+/**
+ * Each pattern must match only real occurrences of the feature. Anything that
+ * needs context to tell a false positive from a real hit (regex flags, for
+ * instance, are indistinguishable from division by a variable in a minified
+ * bundle without parsing) is left out rather than made noisy.
+ */
+const FORBIDDEN = [
+  {
+    pattern: /\(\?<[=!]/g,
+    feature: 'regex lookbehind assertion',
+    since: 'Safari 16.4',
+    hint: 'A dependency shipped `(?<=` or `(?<!` in a regex literal. Find it with:\n'
+      + '      grep -rl \'(?<[=!]\' --include=*.js node_modules/\n'
+      + '    then avoid importing that module (see src/utils/remarkGfmNoAutolink.ts).',
+  },
+  {
+    // The one pattern here that can in principle fire on a string literal
+    // containing the text `static {`. No bundle has ever hit it, and the
+    // snippet printed above makes such a hit obvious at a glance - if that is
+    // what you are looking at, narrow this pattern rather than deleting it.
+    pattern: /\bstatic\s*\{/g,
+    feature: 'class static initialisation block',
+    since: 'Safari 16.4',
+    hint: 'Set `build.target` low enough that esbuild lowers it, or drop the dependency.',
+  },
+];
+
+let bundles;
+try {
+  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js'));
+} catch {
+  console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
+  process.exit(1);
+}
+
+if (bundles.length === 0) {
+  console.error(`check-browser-baseline: no .js files in ${ASSETS} - did the build succeed?`);
+  process.exit(1);
+}
+
+const failures = [];
+
+for (const name of bundles) {
+  const source = readFileSync(join(ASSETS, name), 'utf8');
+  for (const { pattern, feature, since, hint } of FORBIDDEN) {
+    const hits = source.match(pattern);
+    if (!hits) continue;
+    const index = source.search(pattern);
+    failures.push(
+      `  ${name}: ${hits.length}x ${feature} (requires ${since})\n`
+      + `    ...${source.slice(Math.max(0, index - 70), index + 70).replace(/\n/g, ' ')}...\n`
+      + `    ${hint}`,
+    );
+  }
+}
+
+if (failures.length > 0) {
+  console.error(
+    `\ncheck-browser-baseline: bundle uses syntax that Safari 16.0 / iOS 16.0 cannot parse.\n`
+    + `A parse error takes down the WHOLE app on those browsers - blank white screen (#2971).\n\n`
+    + `${failures.join('\n\n')}\n`,
+  );
+  process.exit(1);
+}
+
+console.log(`✓ ${bundles.length} bundle(s) parse-compatible with the Safari 16.0 baseline.`);

+ 76 - 0
frontend/src/__tests__/components/FolderReadmePanel.test.tsx

@@ -110,4 +110,80 @@ describe('FolderReadmePanel', () => {
     expect((await screen.findAllByRole('button', { name: 'Show README' })).length).toBeGreaterThan(0);
     expect(screen.queryByRole('heading', { name: 'Robot model' })).not.toBeInTheDocument();
   });
+
+  /**
+   * The panel renders GFM through `remarkGfmNoAutolink` rather than
+   * `remark-gfm`, because `remark-gfm` reaches a regex lookbehind that iOS
+   * 16.0-16.3 cannot parse and that blanked the entire app (#2971). These pin
+   * the two halves of that trade: every GFM feature a README uses still works,
+   * and the one feature we gave up stays given up on purpose rather than
+   * creeping back in with a future dependency bump.
+   */
+  describe('GFM support without autolink literals (#2971)', () => {
+    const withReadme = (content: string) =>
+      server.use(
+        http.get('/api/v1/library/folders/:id/readme', () =>
+          HttpResponse.json({ filename: 'README.md', content, truncated: false }),
+        ),
+      );
+
+    it('renders GFM tables', async () => {
+      withReadme('| Part | Filament |\n| --- | --- |\n| Body | PLA |');
+      render(<FolderReadmePanel folderId={11} />);
+      expect(await screen.findByRole('columnheader', { name: 'Part' })).toBeInTheDocument();
+      expect(screen.getByRole('columnheader', { name: 'Filament' })).toBeInTheDocument();
+      expect(screen.getByRole('cell', { name: 'Body' })).toBeInTheDocument();
+    });
+
+    it('renders GFM strikethrough', async () => {
+      withReadme('Print at ~~0.2mm~~ 0.16mm.');
+      const { container } = render(<FolderReadmePanel folderId={12} />);
+      await waitFor(() => {
+        expect(container.querySelector('del')).toHaveTextContent('0.2mm');
+      });
+    });
+
+    it('renders GFM task lists', async () => {
+      withReadme('- [x] Sliced\n- [ ] Printed');
+      render(<FolderReadmePanel folderId={13} />);
+      const boxes = await screen.findAllByRole('checkbox');
+      expect(boxes).toHaveLength(2);
+      expect(boxes[0]).toBeChecked();
+      expect(boxes[1]).not.toBeChecked();
+    });
+
+    it('renders GFM footnotes', async () => {
+      withReadme('Supports supports[^1]\n\n[^1]: Tree, 0.4mm.');
+      const { container } = render(<FolderReadmePanel folderId={14} />);
+      await waitFor(() => {
+        expect(container.querySelector('a[href="#user-content-fn-1"]')).toBeInTheDocument();
+      });
+      expect(screen.getByText(/Tree, 0.4mm./)).toBeInTheDocument();
+    });
+
+    it('keeps core-markdown links working', async () => {
+      withReadme('See [the model](https://example.com/model) and <https://example.com/raw>.');
+      const { container } = render(<FolderReadmePanel folderId={15} />);
+      expect(await screen.findByRole('link', { name: 'the model' })).toHaveAttribute(
+        'href',
+        'https://example.com/model',
+      );
+      expect(container.querySelector('a[href="https://example.com/raw"]')).toBeInTheDocument();
+    });
+
+    it('leaves a bare URL as plain text - the deliberate cost of dropping autolink literals', async () => {
+      withReadme('Grab it from https://example.com/model today.');
+      const { container } = render(<FolderReadmePanel folderId={16} />);
+      // Wait for the body to render before asserting on an absence.
+      expect(await screen.findByText(/Grab it from/)).toBeInTheDocument();
+      expect(container.querySelector('a')).toBeNull();
+    });
+
+    it('leaves a bare email as plain text', async () => {
+      withReadme('Questions to nobody@example.com please.');
+      const { container } = render(<FolderReadmePanel folderId={17} />);
+      expect(await screen.findByText(/Questions to/)).toBeInTheDocument();
+      expect(container.querySelector('a')).toBeNull();
+    });
+  });
 });

+ 2 - 2
frontend/src/components/FolderReadmePanel.tsx

@@ -3,9 +3,9 @@ import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { FileText, PanelRightClose, PanelRightOpen } from 'lucide-react';
 import ReactMarkdown from 'react-markdown';
-import remarkGfm from 'remark-gfm';
 
 import { api } from '../api/client';
+import remarkGfmNoAutolink from '../utils/remarkGfmNoAutolink';
 
 interface FolderReadmePanelProps {
   folderId: number;
@@ -104,7 +104,7 @@ export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
       </div>
       <div className="px-4 py-3 border-t border-bambu-dark-tertiary flex-1 overflow-y-auto max-h-96 lg:max-h-none text-sm text-bambu-gray-light leading-relaxed space-y-2">
         <ReactMarkdown
-          remarkPlugins={[remarkGfm]}
+          remarkPlugins={[remarkGfmNoAutolink]}
           components={{
             h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
             h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,

+ 85 - 0
frontend/src/utils/remarkGfmNoAutolink.ts

@@ -0,0 +1,85 @@
+/**
+ * GFM markdown support without autolink literals (#2971).
+ *
+ * This is `remark-gfm` minus one of its five sub-extensions. We compose the
+ * pieces by hand rather than importing `remark-gfm`, because the problem is an
+ * *import-time* one and no runtime configuration can reach it.
+ *
+ * `remark-gfm` -> `mdast-util-gfm` -> `mdast-util-gfm-autolink-literal`, whose
+ * module body contains this regex literal:
+ *
+ *     /(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu
+ *
+ * `(?<=` is a lookbehind assertion. Safari shipped lookbehind in **16.4**; it
+ * does not exist in iOS 16.0-16.3. A regex literal is validated when its module
+ * is *compiled*, not when the enclosing function runs, so on those browsers the
+ * module fails to parse. `FolderReadmePanel` -> `FileManagerPage` -> `App` is a
+ * plain static import chain, so the regex lands in the entry chunk and the whole
+ * bundle fails to compile: every page renders as a blank white screen, not just
+ * the File Manager. That is #2971, and it has been true since v1.2.5.
+ *
+ * Vite cannot help here. esbuild lowers modern *syntax* down to the build
+ * target, but it does not rewrite regular expressions - measured: a lookbehind
+ * builds silently under `safari15`, `safari16.0` and `es2020` alike. The only
+ * fix is to keep the module out of the bundle, which is what this file does.
+ *
+ * What we keep: tables, strikethrough, task lists, footnotes - the GFM features
+ * a folder README actually uses.
+ *
+ * What we lose: bare `https://example.com` and `foo@example.com` no longer turn
+ * themselves into links. Explicit `[text](url)` and `<https://example.com>` are
+ * core markdown and still work.
+ *
+ * Mirrors `remark-gfm@4`'s `lib/index.js` and `gfmFromMarkdown()` in
+ * `mdast-util-gfm@3`. Unlike `remark-gfm` we push the four micromark extensions
+ * individually instead of pre-combining them, which is equivalent - micromark
+ * combines whatever array it is handed - and saves a dependency on
+ * `micromark-util-combine-extensions`.
+ *
+ * Unlike `remark-gfm` this registers only the *parse* half. `remark-stringify`
+ * is not a dependency of this project - nothing here serialises an mdast tree
+ * back to markdown - and without it TypeScript cannot even see the
+ * `toMarkdownExtensions` field, since that is a module augmentation
+ * `remark-stringify` contributes. If you ever add `remark-stringify`, add the
+ * matching `gfm*ToMarkdown()` extensions here too, or GFM tables and task lists
+ * will serialise back out as plain paragraphs with no error.
+ *
+ * `frontend/scripts/check-browser-baseline.mjs` fails the build if a lookbehind
+ * ever reaches the bundle again.
+ */
+
+import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote';
+import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough';
+import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
+import { gfmTaskListItemFromMarkdown } from 'mdast-util-gfm-task-list-item';
+import { gfmFootnote } from 'micromark-extension-gfm-footnote';
+import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
+import { gfmTable } from 'micromark-extension-gfm-table';
+import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
+import type { Processor } from 'unified';
+
+// Side-effect type import: `remark-parse` is what augments unified's `Data`
+// with `micromarkExtensions` / `fromMarkdownExtensions`. `remark-gfm` does the
+// same thing for the same reason.
+import type {} from 'remark-parse';
+
+/**
+ * Drop-in for `remark-gfm`, minus autolink literals. Takes no options; the one
+ * call site wants GFM defaults, and `remark-gfm`'s options only tune
+ * strikethrough/table/footnote behaviour we do not override.
+ */
+export default function remarkGfmNoAutolink(this: Processor): undefined {
+  const data = this.data();
+
+  const micromarkExtensions = data.micromarkExtensions || (data.micromarkExtensions = []);
+  const fromMarkdownExtensions = data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []);
+
+  micromarkExtensions.push(gfmFootnote(), gfmStrikethrough(), gfmTable(), gfmTaskListItem());
+
+  fromMarkdownExtensions.push(
+    gfmFootnoteFromMarkdown(),
+    gfmStrikethroughFromMarkdown(),
+    gfmTableFromMarkdown(),
+    gfmTaskListItemFromMarkdown(),
+  );
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BzPiBXm_.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DkJ3AJ3x.js"></script>
+    <script type="module" crossorigin src="/assets/index-BzPiBXm_.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов