mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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.
This commit is contained in:
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
|
||||
|
||||
### Fixed
|
||||
- **Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by @zevulos)** — 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. The cause was one regular expression. `remark-gfm`, added in v1.2.5 for the folder README panel, reaches `mdast-util-gfm-autolink-literal`, whose module body contains a lookbehind assertion — `(?<=` — that Safari did not support 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. Every install from v1.2.5 onward has been unusable on those iOS versions, and v1.2.4 is the last release that loads on them. **The fix.** The panel now renders GFM through a locally composed plugin that registers four of `remark-gfm`'s five sub-extensions — tables, strikethrough, task lists and footnotes — and omits autolink literals, which is the only one that carries the lookbehind. Composing rather than configuring is forced by the nature of the bug: importing `remark-gfm` at all is what breaks the page, so no runtime option could have reached it. The visible cost is that a bare `https://example.com` or `foo@example.com` typed into a folder README no longer turns itself into a link; `[text](url)` and `<https://example.com>` are core markdown and still do. `remark-gfm`, `mdast-util-gfm` and `micromark-extension-gfm` leave the dependency tree, and the bundle is 23 KB smaller. **Why the build never said anything.** Vite's `build.target` governs syntax lowering, and esbuild does not rewrite regular expressions — measured here, a lookbehind builds silently under `safari15`, `safari16.0` and `es2020` alike, which is exactly 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 that Safari 16.0 cannot parse and fails the build with the offending snippet and the dependency-hunting command. It is deliberately scoped to *parse-time* failures only — a missing runtime API breaks one feature, while one of these takes down the whole app, and there is no graceful degradation to fall back on. Covered by seven renderer tests that pin both halves of the trade: each surviving GFM feature still renders, and the two forms of autolinking stay off on purpose so a future dependency bump cannot quietly bring the lookbehind back.
|
||||
- **The print dialog named an AMS slot after the wrong spool** — The slot dropdown described every slot from the printer's own telemetry, and the printer cannot describe a spool it did not sell: a tray record has no brand field, `tray_sub_brands` is left empty for anything that is not a Bambu spool, and the colour is a bare hex the client resolves against Bambu's colour catalogue. A Devil Design PLA Basic Orange assigned in Bambuddy therefore read as "PLA (Sunflower Yellow)" — Bambu sell a Sunflower Yellow at the same `FEC600` — while the printer card, which reads the assignment, named it correctly. The two views now agree: `GET /printers/{id}/inventory-remain` carries each bound slot's brand, material, subtype, colour name and hex alongside the pooling key it already sent, and the dialog prefers that over telemetry, falling back field by field so a spool with no stored colour name still gets the catalogue lookup it had before, and re-reading it on every open so a spool assigned moments earlier is named correctly straight away. Resolved server-side, so internal inventory and Spoolman mode answer identically rather than the client re-deriving a rule that differs per mode. Matching is deliberately untouched and still runs on the printer's telemetry, so the auto-assignment and the colour-mismatch warning cannot start disagreeing with what the dispatcher does.
|
||||
- **A K profile could be saved against the wrong hotend, and applied to the wrong one** — Which nozzle an AMS slot feeds, and how wide it is, was worked out independently in seven places, each reading the printer's first nozzle entry for every slot on the machine. That is correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, and wrong the moment two sizes are fitted: the K profile for the other hotend was looked up, and with the per-model presets above the wrong preset would have been too. The resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred — measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, the first entry reads the **right** hotend, so the array is indexed by extruder id. Separately, the spool form identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by printer, extruder and diameter throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. SpoolBuddy's write-tag page carried a verbatim copy of the same lookup and gets the same fix.
|
||||
- **RFID auto-assign picked a K profile without checking which hotend it was calibrated on** — The first stored profile matching the printer and nozzle size won outright, with no extruder test at all. On a dual-nozzle printer a spool calibrated on both hotends therefore had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded.
|
||||
|
||||
Generated
+10
-138
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user