From 68ba9aa91723d2078886458fd79f631adda9ece2 Mon Sep 17 00:00:00 2001 From: Aliaksandr Samuseu Date: Tue, 15 Sep 2026 14:55:36 +0200 Subject: [PATCH] =?UTF-8?q?EPBDS-16211=20=C2=A79.8:=20unpin=20LESS=20?= =?UTF-8?q?=E2=80=94=203.13=20->=204.x,=20output=20byte-identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin is gone and the published `static/all.css` still has the sha256 it has had all through the exit (dcdb0a40…). Verified before touching the manifest, by compiling the same source through both majors and comparing. NEITHER ANCHOR WAS WHAT §9.8 SAID IT WAS, and the step-4 measurement had already shown that: `javascriptEnabled` is required by our OWN `_variables.less:23` — a `Math.random()` font cache-buster that reaches `static/font.css` and never `all.css` — so it STAYS. `less-plugin-functions` is not vestigial either: it is what makes `size()` and `px()` callable, at 186 sites across 30 files. THE REAL BLOCKER WAS ONE LINE OF THE PLUGIN'S, under its own comment calling it "the most ugly hack ever": DetachedSet.prototype.type = 'NotDetachedRuleset'; LESS 4 defines that property with only a getter, so the assignment throws and every compile dies. `scripts/less-plugin-compat.js` makes the assignment a NO-OP rather than making it work — the hack exists so a mixin can return a detached ruleset, and ours return plain values. Costed against the alternatives before choosing: rewriting 186 call sites changes a computed value at every one and destroys the readability `size()` exists for; a fork or `patch-package` adds something to maintain. TWO FURTHER CHANGES THE BUMP FORCED: - `math: 'always'`. LESS 4 defaults to `parens-division`, under which `1/4` in `_variables.less:236` stops being a quotient and `round` gets a non-number. `always` is LESS 3's semantics and is what keeps the output identical. - The node build must be required EXPLICITLY. LESS 4's manifest carries a `browser` field and jest's jsdom environment resolves it, so four CSS suites failed with a jsdom `AggregateError` from a socket — the browser build fetches `@import`s over XHR. That error looks nothing like a LESS problem and cost a detour; the reason is now a comment where the require is. ALL THREE LIVE IN ONE MODULE. `scripts/less-options.js` is what every LESS compile imports — three webpack configs, `build-css.js`, the fixture and vendor generators, and four CSS suites. Ten sites used to set these options independently, which is the shape that already bit us with the `theme.config` copy: one implementation cannot drift from another. A METHOD NOTE, because it nearly produced a false pass: the first LESS 4 trial succeeded against a copy of the plugin I had patched by hand while diagnosing. Restoring it to pristine upstream and re-running is what actually proved the shim does the work. Gates: jsdom 167 suites / 2540 tests / 38 snapshots on React 16.14, 17 and 18.3, four consecutive full runs with output kept; Chrome 41/41; `lint:js`, `lint:css`, `css:fixture:check`, both docs checks, `build-lib` and `build-css` green; published CSS byte-identical. --- CLAUDE.md | 2 +- docs/UPGRADE-PLAN.md | 16 +- package-lock.json | 236 ++++++++++++------ package.json | 2 +- scripts/build-css.js | 10 +- scripts/generate-semantic-css-fixture.js | 7 +- scripts/less-options.js | 48 ++++ scripts/less-plugin-compat.js | 51 ++++ scripts/vendor-semantic-css.js | 9 +- src/demo/markdowns/changelog.md | 7 + src/style/__tests__/css.compilation.test.js | 7 +- .../__tests__/css.dropdown-contract.test.js | 7 +- .../__tests__/css.pipeline.parity.test.js | 7 +- .../__tests__/css.tooltip-contract.test.js | 7 +- webpack.demo.config.mjs | 13 +- webpack.library.config.mjs | 22 +- webpack.watch.config.mjs | 9 +- 17 files changed, 320 insertions(+), 140 deletions(-) create mode 100644 scripts/less-options.js create mode 100644 scripts/less-plugin-compat.js diff --git a/CLAUDE.md b/CLAUDE.md index 9f627bb0..8a4197d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ Examples live in `src/demo/examples/` (e.g., `example_meta.json` / `example_data - React 16 (peer dependency). **No Semantic UI at all**: the components went in-house at §9.7-F1 steps 1-3 and the CSS at step 4, where the two modules still in use were compiled into `src/style/vendor/` and the package removed. Components still emit Semantic's class tokens (`ui selection dropdown`, `ui table`) because the vendored CSS selects on them. - react-final-form for form state management - moment for dates (peer dependency, externalized); charts are custom SVG (`src/core/components/charts/` — no recharts) -- LESS for styling, compiled via webpack (entry: `src/style/index.less`). Semantic UI theme overrides at `src/style/override/`. PostCSS prefixwrap scopes all CSS under `.ui-render`. Less is pinned to 3.x, and after step 4 both reasons are OURS rather than Semantic's (measured): `javascriptEnabled` is required by `_variables.less:23`, a `` `Math.random()` `` cache-buster, and `less-plugin-functions` by `round()` at `_variables.less:264`. See `docs/UPGRADE-PLAN.md` §9.8 before changing. +- LESS for styling, compiled via webpack (entry: `src/style/index.less`). Semantic UI theme overrides at `src/style/override/`. PostCSS prefixwrap scopes all CSS under `.ui-render`. LESS is on **4.x** — the 3.x pin was removed at §9.8 with byte-identical output. Every compile takes its options from `scripts/less-options.js`; do not set them locally. Three things there are load-bearing and each has its reason in the file: `math: 'always'` (LESS 4 changed division), `javascriptEnabled` (our own `` `Math.random()` `` font cache-buster at `_variables.less:23`, not Semantic's), and requiring the NODE build explicitly, because LESS 4's `browser` field plus jest's jsdom environment otherwise loads a build that fetches imports over XHR. `less-plugin-functions` makes `size()`/`px()` callable at 186 sites and needs `scripts/less-plugin-compat.js` to run on LESS 4. - Node.js v24 (see `.nvmrc`) - ESLint with `react-app` config (configured in package.json). `lint:js` runs with `--max-warnings 0`, so a new warning fails CI — fix it, or suppress it with a comment stating why the rule is wrong. Never blanket-disable: one tolerated warning here turned out to be a real crash (see `docs/UPGRADE-PLAN.md` §11 R18). - Babel config lives only in `babel.config.js` and is shared by the library build, the demo build and jest. Do not add `presets` to a `babel-loader` `options` block: a loader-level entry **replaces** the shared one for the same plugin identifier, silently dropping the shared options. Only demo-specific dev transforms (`react-refresh/babel`) belong inline. diff --git a/docs/UPGRADE-PLAN.md b/docs/UPGRADE-PLAN.md index b8a9d852..02574465 100644 --- a/docs/UPGRADE-PLAN.md +++ b/docs/UPGRADE-PLAN.md @@ -921,7 +921,19 @@ The form stack splits into a React-free core and React bindings: - **Babel targets (verified — real, library-side):** `babel.config.js` sets `targets: { node: 'current' }`, which governs the **library and watch** builds; the demo config carries its own inline presets (browserslist applies there) and Jest is env-split (§2.6-10). Published `dist/` therefore contains syntax as modern as the build machine's Node while `package.json`'s `browserslist` is never consulted for it. Fix: env-split the root config (test → `node: current`; build → browserslist) **and** collapse the demo's duplicated inline presets into it — **scheduled as Phase 0 step 0.5**. (If all hosts are evergreen-only, document that decision instead.) - **Automatic JSX runtime:** peer floor 16.14 makes `@babel/preset-react` `runtime: 'automatic'` safe across the whole support range. **Prerequisite:** add `react/jsx-runtime` (and `react/jsx-dev-runtime`) to the library/watch `externals` first — the current exact-match externals (`react`, `react-dom`) would NOT catch the new subpath imports, and webpack would silently bundle React's JSX runtime from devDependencies into the UMD. These subpath externals have no meaningful script-tag global — one more input to the §10 UMD script-tag gate. Do as one mechanical PR after Phase 2. - **Legacy decorators:** leave as-is; they retire naturally as §9.2/§9.3 convert their host modules. Churn for its own sake is against the ground rules (§9.1). -- **LESS 3.13 pin**: the pin is anchored to the semantic-ui-less toolchain (inline-JS evaluation via `javascriptEnabled` + `theme.config` machinery) and to `less-plugin-functions`. Two findings: (a) after the SUIR CSS exit (§9.7-F1 step 4) the semantic-side constraints disappear; (b) ~~**no custom `.function-…` definitions were found under `src/style`** — `less-plugin-functions` may be vestigial~~ — **REFUTED (2026-09-11, measured).** It is load-bearing: compiling without it in a clean process fails at `src/style/_variables.less:264`, `error evaluating function 'round'`. Searching for `.function-…` definitions was the wrong probe — the plugin also REPLACES built-ins, and `round()` is called on the unit-carrying values `size()`/`px()` produce (nine call sites across `_variables.less` and `_mixins.less`). A caveat for anyone re-checking this: the plugin registers its functions GLOBALLY, so a second `less.render` in the same process still sees them and reports a false pass — test it in a fresh process. Finding (a) stands, so the pin may still relax after step 4, but dropping this plugin is its own piece of work, not a freebie. Re-evaluate right after F1 step 4. +- **LESS 3.13 pin — REMOVED 2026-09-15. The project is on LESS 4.** The bump produces the byte-identical published `static/all.css` (`dcdb0a40…`), verified by compiling the same source through both majors before touching the manifest. + + **Neither anchor was what this section said it was.** `javascriptEnabled` is required by `_variables.less:23` — our own `` `Math.random()` `` font cache-buster, which reaches `static/font.css` and never `all.css` — so it STAYS, and removing it is a separate decision about how fonts are cached. `less-plugin-functions` is not vestigial either: it is what makes `size()` and `px()` callable, at **186 sites across 30 files**, so it stays too. + + **The real blocker was one line of the plugin's, under its own comment calling it "the most ugly hack ever":** `DetachedSet.prototype.type = 'NotDetachedRuleset'`. LESS 4 defines that property with only a getter, so the assignment throws and every compile dies. `scripts/less-plugin-compat.js` makes the assignment a no-op rather than making it work — the hack exists so a mixin can return a detached ruleset, and ours return plain values. Costed against the alternatives: rewriting 186 call sites changes a computed value at every one and destroys the readability `size()` exists for; a fork or `patch-package` adds a thing to maintain. + + **Two further changes the bump forced, both worth knowing:** + - `math: 'always'`. LESS 4 defaults to `parens-division`, under which `1/4` in `_variables.less:236` stops being a quotient and `round` receives a non-number. `always` is LESS 3's semantics and is what keeps the output identical. + - The node build must be required EXPLICITLY. LESS 4's manifest carries a `browser` field, and jest's `jsdom` environment resolves it — four CSS suites then failed with a jsdom `AggregateError` from a socket, because the browser build fetches `@import`s over XHR. That error looks nothing like a LESS problem. + + All three now live in `scripts/less-options.js`, the one module every LESS compile imports. Ten sites used to set these options independently — the same shape that already bit us with the `theme.config` copy. + + ~~The pin is anchored to the semantic-ui-less toolchain (inline-JS evaluation via `javascriptEnabled` + `theme.config` machinery) and to `less-plugin-functions`.~~ Two findings: (a) after the SUIR CSS exit (§9.7-F1 step 4) the semantic-side constraints disappear; (b) ~~**no custom `.function-…` definitions were found under `src/style`** — `less-plugin-functions` may be vestigial~~ — **REFUTED (2026-09-11, measured).** It is load-bearing: compiling without it in a clean process fails at `src/style/_variables.less:264`, `error evaluating function 'round'`. Searching for `.function-…` definitions was the wrong probe — the plugin also REPLACES built-ins, and `round()` is called on the unit-carrying values `size()`/`px()` produce (nine call sites across `_variables.less` and `_mixins.less`). A caveat for anyone re-checking this: the plugin registers its functions GLOBALLY, so a second `less.render` in the same process still sees them and reports a false pass — test it in a fresh process. Finding (a) stands, so the pin may still relax after step 4, but dropping this plugin is its own piece of work, not a freebie. Re-evaluate right after F1 step 4. ### 9.9 Workstream H — Project structure & housekeeping @@ -1059,7 +1071,7 @@ Phases 3 and 4 can partially overlap. Tracks **5a/5b** (SUIR exit) and **6** (en | R5 | Hosts stuck on React 16 get broken by an inadvertent 17+-only API | Low | Medium | Keep 16.14 floor; React 16/17 CI mount smoke (§9.5) | | R6 | `dist/` ships syntax too modern for consumer browser targets (Babel `node: current` for builds) | Low–Medium | Medium | Fixed in Phase 0 step 0.5 (env split); verify the actual host browser matrix | | R7 | Native date adapter (if ever pursued) diverges from moment semantics — parse leniency, local-vs-UTC off-by-one | Medium (only if pursued) | Medium | moment stays by default (F2 decision); golden parity suite vs moment gates any flip; date-only strings parsed from parts in local time; moment demoted to *optional* peer only at a major | -| R8 | LESS 3 pin blocks future style tooling | Low | Low–Med | Partly dissolves after F1 step 4 (§9.8). **Two of the three anchors turn out to be OURS, not semantic's** (measured 2026-09-11): `javascriptEnabled` is required by `_variables.less:23` and `less-plugin-functions` by `round()` at `_variables.less:264`. So step 4 removes the `theme.config` machinery but leaves both flags standing; unpinning LESS is a separate, small piece of work with two named targets rather than a consequence of the CSS exit | +| ~~R8~~ | ~~LESS 3 pin blocks future style tooling~~ — **RETIRED 2026-09-15: the project is on LESS 4**, with byte-identical output. What remains is not a pin: `javascriptEnabled` (our font cache-buster) and `less-plugin-functions` (`size()`/`px()` at 186 sites) are both kept deliberately, and a one-line compatibility shim lets the latter run on LESS 4 | — | — | Partly dissolves after F1 step 4 (§9.8). **Two of the three anchors turn out to be OURS, not semantic's** (measured 2026-09-11): `javascriptEnabled` is required by `_variables.less:23` and `less-plugin-functions` by `round()` at `_variables.less:264`. So step 4 removes the `theme.config` machinery but leaves both flags standing; unpinning LESS is a separate, small piece of work with two named targets rather than a consequence of the CSS exit | | R9 | `Dropdown` replacement misses feature/a11y parity (keyboard matrix, cascading resets, `upward` auto-flip, the `.ui.selection.dropdown` class contract) | Medium | High | **Step 0 shipped the parity checklist** (`docs/SUPPORTED-PROPS.md`, 21 tier-1 props still forwarded after step 1, drift-checked) and re-scoped the risk: search+deburr, multi-select chips and additions are used by *nothing* in either corpus, so the exposure is the keyboard/a11y matrix and the cascading flows, not the feature list. Headless engine (downshift) supplies the a11y core; wrapper-owned logic (sanitization, dedup, cascading) is kept untouched; contract suite + example QA gate the swap | | R10 | Consumer metas rely on undocumented SUIR passthrough props | Medium | Medium | Step-0 audit across example *and consumer* metas; publish the supported-prop list; anything dropped ⇒ major release with migration notes | | R11 | Structure moves (H3/H4) collide with in-flight feature branches | Medium | Low–Med | Land as pure-move commits (no logic changes) in a quiet window; announce to the team; git follows renames, so history and blame survive | diff --git a/package-lock.json b/package-lock.json index 40d967d1..0596ac5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", - "less": "^3.13.1", + "less": "^4.4.2", "less-loader": "^12.2.0", "less-plugin-functions": "^1.0.0", "mini-css-extract-plugin": "^2.9.2", @@ -7319,13 +7319,16 @@ "license": "MIT" }, "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", "dev": true, "license": "MIT", "dependencies": { - "is-what": "^3.14.1" + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" }, "funding": { "url": "https://github.com/sponsors/mesqueeb" @@ -10755,20 +10758,6 @@ "dev": true, "license": "ISC" }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "license": "MIT", - "optional": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11529,11 +11518,17 @@ } }, "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } }, "node_modules/is-wsl": { "version": "2.2.0", @@ -13100,28 +13095,28 @@ } }, "node_modules/less": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/less/-/less-3.13.1.tgz", - "integrity": "sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/less/-/less-4.9.1.tgz", + "integrity": "sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "copy-anything": "^2.0.1", - "tslib": "^1.10.0" + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" }, "bin": { "lessc": "bin/lessc" }, "engines": { - "node": ">=6" + "node": ">=18" }, "optionalDependencies": { "errno": "^0.1.1", "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", + "make-dir": "^5.1.0", "mime": "^1.4.1", - "native-request": "^1.0.5", + "needle": "^3.1.0", + "probe-image-size": "^7.2.3", "source-map": "~0.6.0" } }, @@ -13163,40 +13158,17 @@ } }, "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", + "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", "dev": true, "license": "MIT", "optional": true, "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/less/node_modules/source-map": { @@ -13210,13 +13182,6 @@ "node": ">=0.10.0" } }, - "node_modules/less/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -14686,14 +14651,6 @@ "url": "https://opencollective.com/napi-postinstall" } }, - "node_modules/native-request": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/native-request/-/native-request-1.1.2.tgz", - "integrity": "sha512-/etjwrK0J4Ebbcnt35VMWnfiUX/B04uwGJxyJInagxDqf2z5drSt/lsOvEMWGYunz1kaLZAFrV4NDAbOoDKvAQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -14708,6 +14665,24 @@ "dev": true, "license": "MIT" }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -15279,6 +15254,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -16316,6 +16301,73 @@ "node": ">=6" } }, + "node_modules/probe-image-size": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.4.0.tgz", + "integrity": "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/probe-image-size/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/probe-image-size/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/probe-image-size/node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -18256,6 +18308,36 @@ "node": ">= 0.4" } }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index 1097c708..5c42cc34 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "identity-obj-proxy": "^3.0.0", "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", - "less": "^3.13.1", + "less": "^4.4.2", "less-loader": "^12.2.0", "less-plugin-functions": "^1.0.0", "mini-css-extract-plugin": "^2.9.2", diff --git a/scripts/build-css.js b/scripts/build-css.js index 8be378ef..cb6428fa 100644 --- a/scripts/build-css.js +++ b/scripts/build-css.js @@ -1,9 +1,9 @@ const fs = require('fs'); const path = require('path'); -const less = require('less'); + const postcss = require('postcss'); const prefixwrap = require('postcss-prefixwrap'); -const LessPluginFunctions = require('less-plugin-functions'); +const { lessOptions, less } = require('./less-options.js'); const ROOT = path.resolve(__dirname, '..'); const STYLE_DIR = path.join(ROOT, 'src/style'); @@ -13,12 +13,10 @@ const OUT_DIR = path.join(ROOT, 'public/static'); async function compileLess(entryFile) { const source = fs.readFileSync(entryFile, 'utf8'); - const result = await less.render(source, { + const result = await less.render(source, lessOptions({ filename: entryFile, paths: [path.dirname(entryFile)], - plugins: [new LessPluginFunctions()], - javascriptEnabled: true, - }); + })); return result.css; } diff --git a/scripts/generate-semantic-css-fixture.js b/scripts/generate-semantic-css-fixture.js index 4d5c61d0..3dac37d3 100644 --- a/scripts/generate-semantic-css-fixture.js +++ b/scripts/generate-semantic-css-fixture.js @@ -26,9 +26,9 @@ */ const fs = require('fs'); const path = require('path'); -const less = require('less'); + const postcss = require('postcss'); -const LessPluginFunctions = require('less-plugin-functions'); +const { lessOptions, less } = require('./less-options.js'); const ROOT = path.resolve(__dirname, '..'); const STYLE_DIR = path.join(ROOT, 'src/style'); @@ -61,8 +61,7 @@ async function compile (semanticSource) { const result = await less.render(fs.readFileSync(ENTRY, 'utf8'), { filename: ENTRY, paths: [STYLE_DIR, path.join(ROOT, 'node_modules')], - javascriptEnabled: true, - plugins: [new LessPluginFunctions()], + ...lessOptions(), }); return result.css; } finally { diff --git a/scripts/less-options.js b/scripts/less-options.js new file mode 100644 index 00000000..0392a199 --- /dev/null +++ b/scripts/less-options.js @@ -0,0 +1,48 @@ +/** + * THE ONE PLACE THAT SAYS HOW THIS PROJECT COMPILES LESS. + * + * Ten sites used to set `javascriptEnabled: true` and a plugin list independently — three webpack + * configs, `build-css.js`, and the CSS test suites. That is the shape that already bit us once: + * the `theme.config` copy existed in two implementations and a third was about to be written when + * the difference surfaced on CI. One module, imported everywhere, cannot drift. + * + * WHY `math: 'always'`. LESS 4 changed the default to `parens-division`, where `/` outside + * parentheses is not division. `_variables.less:236` reads `size(round(@size-base-px * 1/4))`, and + * under the new default `1/4` stops being a quotient, so `round` receives something that is not a + * number and the compile dies. `always` is LESS 3's behaviour, and it is what keeps the output + * identical — verified byte for byte across the two majors before the bump. + * + * WHY `javascriptEnabled` IS STILL HERE, since §9.7-F1 step 4 established it is not Semantic's + * requirement: `_variables.less:23` is ours — `@version: \`Math.random()\``, a cache-buster appended + * to the font URLs in `_mixins.less`. It only ever reaches `static/font.css`, never `all.css`. + * Removing it is a separate decision (it makes every build's font URLs differ, which is either the + * point or a nuisance depending on how the fonts are served), so it is left alone here and the flag + * stays. What is no longer true is the claim that the flag came from the Semantic toolchain. + */ +const LessPluginFunctions = require('less-plugin-functions'); +const { less4Compatibility } = require('./less-plugin-compat.js'); + +/** + * THE NODE BUILD, REQUIRED EXPLICITLY — do not change this to `require('less')`. + * + * LESS 4's manifest carries a `browser` field pointing at `dist/less.js`, and jest's `jsdom` + * environment resolves it. The CSS suites then got the BROWSER build, which fetches `@import`s over + * XHR: four suites failed with a jsdom `AggregateError` from a socket, which looks nothing like a + * LESS problem and cost a detour to trace. LESS 3 had no such field, so this is new with the bump. + * + * `dist/less-node.cjs` is a published entry point in the package's `exports` map, not an internal + * path, so this is a supported way to ask for the node build. + */ +const less = require('less/dist/less-node.cjs'); + +/** Plugins, in order. The compatibility shim must come FIRST — see its own file for why. */ +function plugins () { + return [less4Compatibility, new LessPluginFunctions()]; +} + +/** The options every LESS compile in this repository uses. Spread it, then add `filename`/`paths`. */ +function lessOptions (extra = {}) { + return { javascriptEnabled: true, math: 'always', plugins: plugins(), ...extra }; +} + +module.exports = { lessOptions, plugins, less }; diff --git a/scripts/less-plugin-compat.js b/scripts/less-plugin-compat.js new file mode 100644 index 00000000..60a089af --- /dev/null +++ b/scripts/less-plugin-compat.js @@ -0,0 +1,51 @@ +/** + * Lets `less-plugin-functions` 1.0.0 run on LESS 4. + * + * THE PROBLEM, and it is one line of theirs. That plugin is how `size()` and `px()` work: they are + * mixins in `src/style/_mixins.less` (`.function { .size() … }`) called as functions, at 186 sites + * across 30 files. To call a mixin it does this, under a comment of its own that reads "the most + * ugly hack ever": + * + * DetachedSet.prototype.type = 'NotDetachedRuleset'; + * …eval… + * DetachedSet.prototype.type = 'DetachedRuleset'; + * + * In LESS 4 that property is defined with only a getter, so the assignment throws + * `Cannot set property type of # which has only a getter` and every compile fails. + * + * THE FIX IS TO MAKE THE ASSIGNMENT A NO-OP, not to make it work. The hack exists so a mixin can + * return a detached ruleset; ours return a plain `unit(...)` value, so nothing is lost by ignoring + * it. Reads still answer `DetachedRuleset`, which is what the rest of LESS expects. + * + * WHY NOT the alternatives, each measured or costed: + * - rewriting the 186 call sites to inline arithmetic: every one changes a computed value, and + * the result is unreadable where the point of `size()` is readability; + * - forking or patching the plugin: a fork to maintain, or a `patch-package` step in the build; + * - staying on LESS 3: the pin was the thing being removed. + * + * WHAT WOULD MAKE THIS UNNECESSARY: `less-plugin-functions` publishing a LESS 4 release, or LESS + * gaining a first-class way to call a mixin as a function. Either one, delete this file and the + * `plugins()` entry in `less-options.js`. + * + * Installed BEFORE `less-plugin-functions` so the property is already neutralised when it runs. + */ +const less4Compatibility = { + install (less) { + const DetachedRuleset = less.tree && less.tree.DetachedRuleset; + if (!DetachedRuleset) return; + + const descriptor = Object.getOwnPropertyDescriptor(DetachedRuleset.prototype, 'type'); + // LESS 3 has a plain writable property; only LESS 4's getter-only form needs the shim, and + // leaving 3 untouched keeps this a no-op on the version we are migrating FROM. + if (!descriptor || descriptor.writable !== false || descriptor.get) { + if (!descriptor || !descriptor.get) return; + } + Object.defineProperty(DetachedRuleset.prototype, 'type', { + configurable: true, + get: () => 'DetachedRuleset', + set: () => { /* the plugin's hack, deliberately ignored — see above */ }, + }); + }, +}; + +module.exports = { less4Compatibility }; diff --git a/scripts/vendor-semantic-css.js b/scripts/vendor-semantic-css.js index b61c22f4..e75a6b2f 100644 --- a/scripts/vendor-semantic-css.js +++ b/scripts/vendor-semantic-css.js @@ -58,8 +58,8 @@ */ const fs = require('fs'); const path = require('path'); -const less = require('less'); -const LessPluginFunctions = require('less-plugin-functions'); + +const { lessOptions, plugins, less } = require('./less-options.js'); /** * Puts our `theme.config` where `semantic-ui-less` looks for it. Inlined here when the shared * helper was deleted with the rest of the `node_modules` mutation machinery: this script is now the @@ -146,8 +146,9 @@ async function compileModule (spec) { const result = await less.render(source, { filename: path.join(DEFINITIONS, `${spec.from}.less`), paths: [STYLE_DIR, path.join(ROOT, 'node_modules')], - javascriptEnabled: true, - plugins: [new LessPluginFunctions(), excludeOurOverrides], + // The shared options plus this script's own file manager, which is why the plugin list is + // spelled out here rather than taken wholesale. + ...lessOptions({ plugins: [...plugins(), excludeOurOverrides] }), }); return result.css; } diff --git a/src/demo/markdowns/changelog.md b/src/demo/markdowns/changelog.md index 8325226b..bdcbdf52 100644 --- a/src/demo/markdowns/changelog.md +++ b/src/demo/markdowns/changelog.md @@ -120,6 +120,13 @@ rather than only from a URL. The manifest also gained `repository` and `bugs`, so npm links to the source and to somewhere you can tell us the removals hurt. +#### Build + +- **The LESS toolchain is on 4.x.** It had been pinned to 3.x for years because of the Semantic UI + build; with Semantic gone the pin was re-examined and lifted. The compiled stylesheet is + byte-for-byte identical — same SHA-256 before and after — so nothing about how the library looks + changes. This only matters if you build the demo or the stylesheet from source. + #### Dependencies - **Semantic UI is gone entirely — and nothing about how anything looks has changed.** The last diff --git a/src/style/__tests__/css.compilation.test.js b/src/style/__tests__/css.compilation.test.js index d3595b8f..f0d7985c 100644 --- a/src/style/__tests__/css.compilation.test.js +++ b/src/style/__tests__/css.compilation.test.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); -const less = require('less'); -const LessPluginFunctions = require('less-plugin-functions'); + +const { lessOptions, less } = require('../../../scripts/less-options.js'); const STYLE_DIR = path.resolve(__dirname, '..'); const INVENTORY_FILE = path.join(STYLE_DIR, 'class-inventory.txt'); @@ -19,8 +19,7 @@ beforeAll(async () => { const result = await less.render(source, { filename: path.join(STYLE_DIR, 'index.less'), paths: [STYLE_DIR], - plugins: [new LessPluginFunctions()], - javascriptEnabled: true, + ...lessOptions(), }); compiledCss = result.css; // 60s, matching the three sibling CSS suites, and raised from 30s because this one timed out diff --git a/src/style/__tests__/css.dropdown-contract.test.js b/src/style/__tests__/css.dropdown-contract.test.js index 842f7a41..1b8f6def 100644 --- a/src/style/__tests__/css.dropdown-contract.test.js +++ b/src/style/__tests__/css.dropdown-contract.test.js @@ -27,9 +27,9 @@ */ const fs = require('fs'); const path = require('path'); -const less = require('less'); + const postcss = require('postcss'); -const LessPluginFunctions = require('less-plugin-functions'); +const { lessOptions, less } = require('../../../scripts/less-options.js'); const React = require('react'); const { fireEvent, render } = require('@testing-library/react'); const webpackPostcssConfig = require('../../../postcss.config.js'); @@ -102,8 +102,7 @@ beforeAll(async () => { const compiled = await less.render(fs.readFileSync(ENTRY, 'utf8'), { filename: ENTRY, paths: [STYLE_DIR], - plugins: [new LessPluginFunctions()], - javascriptEnabled: true, + ...lessOptions(), }); const processed = await postcss(webpackPostcssConfig.plugins) .process(compiled.css, { from: undefined }); diff --git a/src/style/__tests__/css.pipeline.parity.test.js b/src/style/__tests__/css.pipeline.parity.test.js index b73372d3..935a0207 100644 --- a/src/style/__tests__/css.pipeline.parity.test.js +++ b/src/style/__tests__/css.pipeline.parity.test.js @@ -39,10 +39,10 @@ */ const fs = require('fs'); const path = require('path'); -const less = require('less'); + const postcss = require('postcss'); const prefixwrap = require('postcss-prefixwrap'); -const LessPluginFunctions = require('less-plugin-functions'); +const { lessOptions, less } = require('../../../scripts/less-options.js'); const webpackPostcssConfig = require('../../../postcss.config.js'); const standaloneBuild = require('../../../scripts/build-css.js'); @@ -235,8 +235,7 @@ beforeAll(async () => { const result = await less.render(fs.readFileSync(ENTRY, 'utf8'), { filename: ENTRY, paths: [STYLE_DIR], - plugins: [new LessPluginFunctions()], - javascriptEnabled: true, + ...lessOptions(), }); compiledLess = result.css; diff --git a/src/style/__tests__/css.tooltip-contract.test.js b/src/style/__tests__/css.tooltip-contract.test.js index 4797d814..16d0322a 100644 --- a/src/style/__tests__/css.tooltip-contract.test.js +++ b/src/style/__tests__/css.tooltip-contract.test.js @@ -78,9 +78,9 @@ */ const fs = require('fs'); const path = require('path'); -const less = require('less'); + const postcss = require('postcss'); -const LessPluginFunctions = require('less-plugin-functions'); +const { lessOptions, less } = require('../../../scripts/less-options.js'); const React = require('react'); const { act, fireEvent, render } = require('@testing-library/react'); const webpackPostcssConfig = require('../../../postcss.config.js'); @@ -295,8 +295,7 @@ beforeAll(async () => { const compiled = await less.render(fs.readFileSync(ENTRY, 'utf8'), { filename: ENTRY, paths: [STYLE_DIR], - plugins: [new LessPluginFunctions()], - javascriptEnabled: true, + ...lessOptions(), }); const processed = await postcss(webpackPostcssConfig.plugins) .process(compiled.css, { from: undefined }); diff --git a/webpack.demo.config.mjs b/webpack.demo.config.mjs index e6d45e1b..0d2c7df5 100644 --- a/webpack.demo.config.mjs +++ b/webpack.demo.config.mjs @@ -6,7 +6,8 @@ import CopyPlugin from 'copy-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import Dotenv from 'dotenv-webpack'; -import LessPluginFunctions from 'less-plugin-functions'; +import lessOptionsModule from './scripts/less-options.js'; +const { lessOptions } = lessOptionsModule; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -76,15 +77,7 @@ export default (env, argv) => { { loader: 'less-loader', options: { - lessOptions: { - // `javascriptEnabled` stays: it is required by our OWN - // `_variables.less:23` (a `` `Math.random()` `` cache-buster), - // not by Semantic — measured at §9.7-F1 step 4, where the plan - // claimed the opposite. - javascriptEnabled: true, - relativeUrls: false, - plugins: [new LessPluginFunctions()], - }, + lessOptions: lessOptions({ relativeUrls: false }), }, }, ], diff --git a/webpack.library.config.mjs b/webpack.library.config.mjs index 2e992aa5..27054b8c 100644 --- a/webpack.library.config.mjs +++ b/webpack.library.config.mjs @@ -5,7 +5,8 @@ import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; import { fileURLToPath } from 'url'; import webpack from 'webpack'; -import LessPluginFunctions from 'less-plugin-functions'; +import lessOptionsModule from './scripts/less-options.js'; +const { lessOptions } = lessOptionsModule; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -65,15 +66,7 @@ export default { { loader: 'less-loader', options: { - lessOptions: { - // `javascriptEnabled` stays: it is required by our OWN - // `_variables.less:23` (a `` `Math.random()` `` cache-buster), not - // by Semantic — measured at §9.7-F1 step 4, where the plan claimed - // the opposite. - javascriptEnabled: true, - relativeUrls: false, - plugins: [new LessPluginFunctions()], - }, + lessOptions: lessOptions({ relativeUrls: false }), }, }, ], @@ -124,9 +117,12 @@ export default { const fontLess = fs.readFileSync(path.resolve(__dirname, 'src/style/font.less'), 'utf8'); const result = await less.render(fontLess, { filename: path.resolve(__dirname, 'src/style/font.less'), - paths: [path.resolve(__dirname, 'src/style')], - relativeUrls: false, - javascriptEnabled: true, + ...lessOptions({ + // `paths` stays local: this compile is `font.less` alone, so it needs + // only our own style directory, not the whole resolution chain. + paths: [path.resolve(__dirname, 'src/style')], + relativeUrls: false, + }), }); fs.writeFileSync(path.join(ROOT_STATIC, 'font.css'), result.css); writeReExport('font.css'); diff --git a/webpack.watch.config.mjs b/webpack.watch.config.mjs index b735459f..20017257 100644 --- a/webpack.watch.config.mjs +++ b/webpack.watch.config.mjs @@ -3,7 +3,8 @@ import CopyPlugin from 'copy-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { fileURLToPath } from 'url'; import webpack from 'webpack'; -import LessPluginFunctions from 'less-plugin-functions'; +import lessOptionsModule from './scripts/less-options.js'; +const { lessOptions } = lessOptionsModule; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -47,11 +48,7 @@ export default { { loader: 'less-loader', options: { - lessOptions: { - javascriptEnabled: true, - relativeUrls: false, - plugins: [new LessPluginFunctions()], - }, + lessOptions: lessOptions({ relativeUrls: false }), }, }, ],