gatsby-matrix-theme 53.20.0 → 54.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.ci.yml CHANGED
@@ -44,6 +44,9 @@ Theme Publish:
44
44
  - yarn
45
45
  - cd matrix-theme/
46
46
  - yarn
47
+ # husky install (run by yarn prepare) sets a local core.hooksPath; override it
48
+ # locally AFTER install so semantic-release's release commit skips commitlint
49
+ - git config core.hooksPath /dev/null
47
50
  - rm -rf /root/.npmrc /root/.npm
48
51
  - npm cache clean --force
49
52
  - npx semantic-release@22.0.0 -b $CI_COMMIT_REF_NAME
@@ -63,11 +66,21 @@ Theme Beta Publish:
63
66
  - git config --global http.sslverify false
64
67
  - git config --global user.email "floyd@gig.com"
65
68
  - git config --global user.name "floyd"
69
+ - echo "@gigmedia:registry=https://npm.gigmedia.tech/" >> ~/.npmrc
70
+ - echo "//npm.gigmedia.tech/:_authToken=${NPM_PRIVATE_TOKEN}" >> ~/.npmrc
71
+ - echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" >> ~/.npmrc
72
+
66
73
  script:
67
74
  - export HUSKY=0
68
75
  - yarn config set cache-folder .yarn
69
76
  - yarn
70
77
  - cd matrix-theme/
78
+ - yarn
79
+ # husky install (run by yarn prepare) sets a local core.hooksPath; override it
80
+ # locally AFTER install so semantic-release's release commit skips commitlint
81
+ - git config core.hooksPath /dev/null
82
+ - rm -rf /root/.npmrc /root/.npm
83
+ - npm cache clean --force
71
84
  - npx semantic-release@22.0.0
72
85
  when: manual
73
86
  only:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,51 @@
1
+ # [54.0.0-beta.1](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/compare/v53.20.0...v54.0.0-beta.1) (2026-06-17)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **ci:** clean stale npmrc before beta semantic-release to fix EINVALIDNPMTOKEN ([e360eea](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/e360eea13e02f76b641f55acf2fb618e967c4bcb))
7
+ * **ci:** disable git hooks in publish jobs so semantic-release commit passes ([05ea13e](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/05ea13e11c16048731fee84af01fed5f777ba1b2))
8
+ * **ci:** set local core.hooksPath after yarn install to bypass husky in release ([9437ab7](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/9437ab740479532d417d99417c23448eaa3f5c69))
9
+ * **critical-css:** per-page render-blocking chunk linking to eliminate CLS ([bb3578f](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/bb3578f4f781f82decfdaa30535a802ff798695d))
10
+ * testing gtm removal ([c717e12](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/c717e12b6d935645a8cdce9da76292ce32ef0165))
11
+ * tm-5551 - remove conflicting env var from ci ([f113ae2](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/f113ae26095f4e9907d40eb5040f47a82547adca))
12
+ * tm-5551 - revert yarn.lock ([98f717e](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/98f717ef9602ecd4b7f8f2af6b7908c32346186f))
13
+ * update env ([d2e6558](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/d2e65585707890da96b2ea9bd1db0146ee5a7238))
14
+ * update theme ([5f6d82f](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/5f6d82fed691c71c2bb64805c1b4c9f841ae634f))
15
+
16
+
17
+ ### chore
18
+
19
+ * **release:** 53.10.0-beta.1 [skip ci] ([b5c909b](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/b5c909bff492c9de6594653f2cea2ec57895bea8))
20
+
21
+
22
+ ### Config
23
+
24
+ * cicd token ([e44e3c5](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/e44e3c5c376d48bca55843fb498ca342d730879c))
25
+ * update theme ([c87e63f](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/c87e63ff3a5c0c2ab877b8b6f9598188165bedf1))
26
+
27
+
28
+ * Merge branch 'en-391-unused-css' into 'beta' ([a37b54f](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/a37b54f7eb9cf75078fbed90c48f058a69cedc66))
29
+ * Merge branch 'master' into beta ([18b071d](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/18b071da6c64fb5bb4ce75b5a6112ab40afe665e))
30
+
31
+
32
+ ### Features
33
+
34
+ * **critical-css:** flag-gated critical-CSS deferral (CSS split + manifest + above-fold re-inline) ([ae6bc2b](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/ae6bc2bd3bf73f2386961d12992b679ff9665297))
35
+ * remove unused scripts ([5ba0309](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/5ba0309d196acf6023a267dbb0f730deaab77cbe))
36
+ * tm-5551 - add enigma-middleware configurations to env file ([53a0453](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/53a04532d968addfdec96bca38e08a107ec01086))
37
+ * tm-5551 - add enigma-middleware to matrix theme ([f1a4b9b](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/f1a4b9b9716052d83e31412d5663a7a48257724b))
38
+
39
+
40
+ ### norelease
41
+
42
+ * reconcile yarn.lock after merging master into beta ([7b4021a](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/7b4021a4c47b8851377e83568063e0b0e54f6680))
43
+
44
+
45
+ ### Tests
46
+
47
+ * **critical-css:** integration tests for injectCriticalCss to restore coverage ([87a6d19](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/commit/87a6d19fe6872e8dcaa972377bf41255883c60c7))
48
+
1
49
  # [53.20.0](https://gitlab.com/g2m-gentoo/team-floyd/themes/matrix-theme/compare/v53.19.1...v53.20.0) (2026-06-16)
2
50
 
3
51
 
@@ -0,0 +1,106 @@
1
+ # Critical-CSS split (per-page render-blocking)
2
+
3
+ Splits the single merged stylesheet (~400–520 KB) that the core theme inlines render-blocking on
4
+ **every** page into separate cacheable chunks, then, for each page, references **only the chunks that
5
+ page actually renders** as render-blocking `<link rel="stylesheet">` in `<head>`. Each page paints
6
+ fully styled on first paint (**no FOUC, no CLS**) while carrying only its own CSS — well under the
7
+ stock merged stylesheet — and the chunks are external cacheable files instead of bytes re-inlined into
8
+ every HTML document.
9
+
10
+ **Why this shape (history):**
11
+
12
+ - The merged baseline inlines all CSS render-blocking into every page → no CLS, but ~520 KB re-shipped
13
+ per page, uncacheable.
14
+ - A first attempt *deferred* below-the-fold lazy-module CSS to **async** chunks and re-inlined only an
15
+ above-the-fold subset. That caused **CLS** — async CSS paints unstyled then reflows. With browser
16
+ **scroll restoration** (reload returns the user to wherever they scrolled) the shifting content lands
17
+ in the viewport, so no fixed above-the-fold safelist can prevent it.
18
+ - Making **all** chunks render-blocking on every page killed the CLS but doubled render-blocking CSS
19
+ (~1 MB), because the split duplicates shared rules across chunks → worse cold FCP than baseline.
20
+ - **This version** render-blocks only each page's *own* chunks → no CLS and below-baseline CSS per page.
21
+
22
+ **Trade-off:** the win is on every axis vs the merged baseline *except* request count — each chunk is a
23
+ separate `<link>` (HTTP/2 multiplexed). Per-page CSS is smaller than baseline, and chunks are cached
24
+ across navigation/reload. **LCP is unaffected** (hydration/JS-bound here — a separate track).
25
+
26
+ ## How it works
27
+
28
+ Two Gatsby hooks in `gatsby-node.js`, both guarded (a failure degrades gracefully, never breaks the build):
29
+
30
+ 1. **`onCreateWebpackConfig`** — sets the `styles` cacheGroup to `chunks: 'initial'`, so CSS splits
31
+ into per-route + lazy-module chunks instead of one merged file.
32
+ 2. **`onPostBuild`** (`injectCriticalCss.js`) — builds a `className → chunk` index from the emitted
33
+ `.css` files, then for each page adds a render-blocking
34
+ `<link rel="stylesheet" href="/…" data-crit="…">` for every chunk whose CSS-module class names
35
+ appear in that page's server-rendered HTML (see `selectChunks.js`). A used module's wrapper class is
36
+ always in the SSR HTML, so its chunk is linked; chunks for modules the page doesn't render are
37
+ skipped. This needs **no module→component registry** and auto-covers header variants. Idempotent and
38
+ deduped against CSS already inlined by the core theme (the base chunk) or already linked.
39
+
40
+ ## Enabling it (per site)
41
+
42
+ Off by default. Enable via theme plugin options in the consumer's `gatsby-config.js`:
43
+
44
+ ```js
45
+ plugins: [
46
+ {
47
+ resolve: 'gatsby-matrix-theme',
48
+ options: {
49
+ criticalCss: {
50
+ enabled: true,
51
+ // optional:
52
+ skipRoutes: ['preview'], // route first-segments left with default (full) inlining
53
+ minClassLen: 5, // advanced: min classname length used for chunk matching
54
+ },
55
+ },
56
+ },
57
+ ],
58
+ ```
59
+
60
+ The feature only runs in production `gatsby build` (the split is `build-javascript`-stage;
61
+ `onPostBuild` is build-only).
62
+
63
+ ### Options
64
+
65
+ | option | default | meaning |
66
+ |---|---|---|
67
+ | `enabled` | `false` | hard gate |
68
+ | `skipRoutes` | `['preview']` | route first-segments left untouched; preview/tracker pages are also skipped via `isPreview`/`isTracker` |
69
+ | `minClassLen` | `5` | minimum classname length considered when matching chunks to a page's HTML. Lower → looser matching (more chunks); higher → tighter (risk of missing a chunk). |
70
+
71
+ ## Caveats / QA
72
+
73
+ - **No CLS by design** — a page render-blocks exactly the chunks whose classes it renders, so all its CSS
74
+ is present on first paint at any scroll position. Still spot-check CLS after enabling.
75
+ - **Residual miss risk** — a chunk that styles rendered content using *only* global/element selectors
76
+ (no CSS-module class) would not be matched and would stay async. Module SCSS almost always has a hashed
77
+ wrapper class, so this is rare; watch for it in QA, and lower `minClassLen` if a chunk is missed.
78
+ - **Request count** — each chunk is a separate render-blocking `<link>` (HTTP/2 multiplexes). Per-page
79
+ CSS is smaller than baseline; the payoff over baseline is caching on navigation/reload.
80
+ - **LCP** is unaffected (hydration-bound here).
81
+
82
+ ## Per-site rollout checklist
83
+
84
+ Adopting a site is QA, not code:
85
+
86
+ 1. **Bump** `gatsby-matrix-theme` to a version that includes this feature.
87
+ 2. **Enable** `options.criticalCss.enabled: true`.
88
+ 3. **Build** (`gatsby build`). Confirm the log shows `CSS split enabled` and
89
+ `render-blocking-linked page chunks on X/Y pages`.
90
+ 4. **Measure** with the harness on the homepage + the top template(s):
91
+ ```bash
92
+ CHROME_PATH=/path/to/chrome \
93
+ node scripts/lh-critical-compare.mjs public / /<operator-route>/ --runs 3
94
+ ```
95
+ Run it once before enabling and once after for a before/after.
96
+ 5. **Verify**: **no FOUC**, **CLS = 0 / not regressed** at top *and* after a mid-page reload (scroll
97
+ restoration), per-page CSS smaller than baseline, and reload serves chunks from cache.
98
+ 6. **Ship behind the flag**, then ramp.
99
+
100
+ > Chrome for the harness: snap chromium does not work. Download a standalone build, e.g.
101
+ > `npx @puppeteer/browsers install chrome@stable`, and point `CHROME_PATH` at it.
102
+
103
+ ## Disabling
104
+
105
+ Set `criticalCss.enabled: false` (or remove the option). The build reverts to the stock single
106
+ merged inlined stylesheet.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Critical-CSS feature config — resolved from theme plugin options.
3
+ *
4
+ * A consumer site enables/configures the feature via its gatsby-config:
5
+ * {
6
+ * resolve: 'gatsby-matrix-theme',
7
+ * options: {
8
+ * criticalCss: {
9
+ * enabled: true, // hard gate, default OFF
10
+ * skipRoutes: ['preview'], // route prefixes to leave with default (full) inlining
11
+ * minClassLen: 5, // advanced: min classname length for chunk matching
12
+ * },
13
+ * },
14
+ * }
15
+ *
16
+ * Everything here is build-time Node code (read in gatsby-node), NOT bundled app code —
17
+ * which is why config goes through plugin options rather than component shadowing.
18
+ */
19
+
20
+ function resolveConfig(pluginOptions = {}) {
21
+ const cc = (pluginOptions && pluginOptions.criticalCss) || {};
22
+ return {
23
+ enabled: cc.enabled === true,
24
+ skipRoutes:
25
+ Array.isArray(cc.skipRoutes) && cc.skipRoutes.length
26
+ ? cc.skipRoutes.filter((s) => typeof s === 'string')
27
+ : ['preview'],
28
+ minClassLen: Number.isInteger(cc.minClassLen) && cc.minClassLen >= 1 ? cc.minClassLen : 5,
29
+ };
30
+ }
31
+
32
+ module.exports = { resolveConfig };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * injectCriticalCss — post-build: make each page's own split CSS chunks render-blocking.
3
+ *
4
+ * The webpack split (styles cacheGroup -> chunks:'initial') moves lazy-module CSS into async
5
+ * chunks that the runtime injects at hydration. Async CSS paints unstyled then reflows => FOUC +
6
+ * CLS, and with browser scroll restoration (reload returns the user to wherever they scrolled) the
7
+ * shifting content is often in the viewport, so a fixed above-the-fold safelist can't prevent it.
8
+ *
9
+ * Fix: for each page, add a render-blocking <link rel="stylesheet"> for exactly the chunks that
10
+ * page renders — detected by classname presence (a chunk is needed iff one of its CSS-module class
11
+ * names appears in the page's server-rendered HTML; see ./selectChunks). All CSS the page uses is
12
+ * then present on first paint => no FOUC/CLS at any scroll position, while pages only carry their
13
+ * own chunks (well under the stock single merged inlined stylesheet) and the chunks stay separate
14
+ * cacheable files (cross-page / reload caching, smaller HTML).
15
+ *
16
+ * Idempotent (skips chunks already inlined by the core theme or already linked) and fully guarded —
17
+ * never throws out of the function.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { buildClassIndex, selectChunksForHtml } = require('./selectChunks');
23
+
24
+ // Lazy-module chunk .css files live at the public root (Gatsby / mini-css-extract). Exclude the
25
+ // initial/route stylesheets: the base (`styles.*`) and route-component shells (`component---*`) are
26
+ // already inlined render-blocking by the core theme (or are duplicate SSR/preview entry CSS that a
27
+ // normal page never loads). Only the lazy *module* chunks otherwise load async and need re-linking.
28
+ function isModuleChunk(name) {
29
+ return name.endsWith('.css') && !/^styles\./.test(name) && !/^component---/.test(name);
30
+ }
31
+
32
+ function listCssChunks(publicDir) {
33
+ let entries;
34
+ try {
35
+ entries = fs.readdirSync(publicDir, { withFileTypes: true });
36
+ } catch {
37
+ return [];
38
+ }
39
+ return entries.filter((e) => e.isFile() && isModuleChunk(e.name)).map((e) => e.name);
40
+ }
41
+
42
+ function injectCriticalCss({ publicDir, config, reporter }) {
43
+ const log = reporter || { info() {}, warn() {} };
44
+ const minLen = config && Number.isInteger(config.minClassLen) ? config.minClassLen : 5;
45
+
46
+ const chunkFiles = listCssChunks(publicDir);
47
+ if (!chunkFiles.length) {
48
+ log.warn('[critical-css] no css chunks found in public/; nothing to link');
49
+ return;
50
+ }
51
+
52
+ // Build the classname -> chunk index once for the whole build.
53
+ const chunks = [];
54
+ for (const f of chunkFiles) {
55
+ try {
56
+ chunks.push({ file: f, css: fs.readFileSync(path.join(publicDir, f), 'utf8') });
57
+ } catch {
58
+ /* skip unreadable chunk */
59
+ }
60
+ }
61
+ const classIndex = buildClassIndex(chunks, minLen);
62
+
63
+ const skipRoutes = Array.isArray(config && config.skipRoutes) ? config.skipRoutes : ['preview'];
64
+ const pageDataRoot = path.join(publicDir, 'page-data');
65
+ let scanned = 0;
66
+ let patched = 0;
67
+ let skipped = 0;
68
+ let missedHead = 0;
69
+ let totalLinked = 0;
70
+
71
+ const walk = (dir) => {
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(dir, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const ent of entries) {
79
+ const full = path.join(dir, ent.name);
80
+ if (ent.isDirectory()) {
81
+ walk(full);
82
+ // eslint-disable-next-line no-continue
83
+ continue;
84
+ }
85
+ // eslint-disable-next-line no-continue
86
+ if (ent.name !== 'page-data.json') continue;
87
+ scanned += 1;
88
+
89
+ // page-data/<route>/page-data.json -> public/<route>/index.html ("index" => public/index.html)
90
+ const route = path.relative(pageDataRoot, dir);
91
+
92
+ // never touch preview/tracker routes — they keep default (full) inlining
93
+ const firstSeg = route.split(path.sep)[0];
94
+ if (skipRoutes.indexOf(firstSeg) !== -1) {
95
+ skipped += 1;
96
+ // eslint-disable-next-line no-continue
97
+ continue;
98
+ }
99
+
100
+ let pageCtx;
101
+ try {
102
+ const pd = JSON.parse(fs.readFileSync(full, 'utf8'));
103
+ pageCtx = pd && pd.result && pd.result.pageContext ? pd.result.pageContext : undefined;
104
+ } catch {
105
+ // eslint-disable-next-line no-continue
106
+ continue;
107
+ }
108
+
109
+ // skip preview/tracker pages regardless of route
110
+ if (pageCtx && (pageCtx.isPreview === true || pageCtx.isTracker === true)) {
111
+ skipped += 1;
112
+ // eslint-disable-next-line no-continue
113
+ continue;
114
+ }
115
+
116
+ const htmlPath =
117
+ route === 'index'
118
+ ? path.join(publicDir, 'index.html')
119
+ : path.join(publicDir, route, 'index.html');
120
+ // eslint-disable-next-line no-continue
121
+ if (!fs.existsSync(htmlPath)) continue;
122
+
123
+ let html;
124
+ try {
125
+ html = fs.readFileSync(htmlPath, 'utf8');
126
+ } catch {
127
+ // eslint-disable-next-line no-continue
128
+ continue;
129
+ }
130
+
131
+ const needed = selectChunksForHtml(html, classIndex);
132
+ let add = '';
133
+ for (const f of needed) {
134
+ // skip chunks already inlined by the core theme (data-href, e.g. the base chunk) or
135
+ // already linked (href) — idempotent, avoids double-loading.
136
+ // eslint-disable-next-line no-continue
137
+ if (html.includes(`data-href="/${f}"`) || html.includes(`href="/${f}"`)) continue;
138
+ add += `<link rel="stylesheet" href="/${f}" data-crit="${f}">`;
139
+ totalLinked += 1;
140
+ }
141
+ // eslint-disable-next-line no-continue
142
+ if (!add) continue;
143
+
144
+ // if there's no </head> (error/custom pages), leave it split-only but record the miss
145
+ if (html.indexOf('</head>') === -1) {
146
+ missedHead += 1;
147
+ // eslint-disable-next-line no-continue
148
+ continue;
149
+ }
150
+
151
+ try {
152
+ fs.writeFileSync(htmlPath, html.replace('</head>', `${add}</head>`));
153
+ patched += 1;
154
+ } catch {
155
+ /* leave the page as split-only on write failure */
156
+ }
157
+ }
158
+ };
159
+
160
+ walk(pageDataRoot);
161
+
162
+ const avg = patched ? (totalLinked / patched).toFixed(1) : '0';
163
+ const parts = [
164
+ `[critical-css] render-blocking-linked page chunks on ${patched}/${scanned} pages (avg ${avg}/page)`,
165
+ ];
166
+ if (skipped) parts.push(`(${skipped} preview/tracker skipped)`);
167
+ if (missedHead) parts.push(`(${missedHead} pages had no </head>, left split-only)`);
168
+ log.info(parts.join(' '));
169
+ }
170
+
171
+ module.exports = { injectCriticalCss, listCssChunks, isModuleChunk };
@@ -0,0 +1,119 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { injectCriticalCss, isModuleChunk } from './injectCriticalCss';
5
+
6
+ describe('isModuleChunk', () => {
7
+ test('accepts lazy module chunks (numeric / hashed)', () => {
8
+ expect(isModuleChunk('7058.85be7b8d3f97fe1255e9.css')).toBe(true);
9
+ expect(isModuleChunk('922.3161d0beba28e0a349fc.css')).toBe(true);
10
+ });
11
+
12
+ test('rejects the inlined base and route-component shells', () => {
13
+ expect(isModuleChunk('styles.5f846a3bcbc9df9c2721.css')).toBe(false);
14
+ expect(isModuleChunk('component---node-modules-gatsby-core-theme-src-components-app-ssr-js.832a.css')).toBe(false);
15
+ expect(isModuleChunk('component---node-modules-gatsby-core-theme-src-pages-preview-index-js.832a.css')).toBe(false);
16
+ });
17
+
18
+ test('rejects non-css', () => {
19
+ expect(isModuleChunk('7058.85be7b8d3f97fe1255e9.js')).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe('injectCriticalCss (integration over a temp public/)', () => {
24
+ let dir;
25
+ const silent = { info() {}, warn() {} };
26
+ const html = (body) => `<html><head><title>t</title></head><body>${body}</body></html>`;
27
+ const pageData = (extra = {}) =>
28
+ JSON.stringify({ result: { pageContext: { page: { relation_type: 'page' }, ...extra } } });
29
+
30
+ const write = (rel, content) => {
31
+ const full = path.join(dir, rel);
32
+ fs.mkdirSync(path.dirname(full), { recursive: true });
33
+ fs.writeFileSync(full, content);
34
+ };
35
+ const read = (rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
36
+
37
+ beforeEach(() => {
38
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crit-'));
39
+ // module chunks
40
+ write('100.aaa.css', '.Bonus_wrap__x1{color:red}');
41
+ write('200.bbb.css', '.Toplist_row__y2{margin:0}');
42
+ write('300.ccc.css', '.Unused_z__z3{display:none}'); // no page renders this
43
+ // base + route shell — must be ignored as candidates
44
+ write('styles.deadbeef.css', '.Base_thing__b1{}');
45
+ write('component---node-modules-gatsby-core-theme-src-components-app-js.832a.css', '.App_shell__s1{}');
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ });
51
+
52
+ const run = () =>
53
+ injectCriticalCss({
54
+ publicDir: dir,
55
+ config: { enabled: true, skipRoutes: ['preview'], minClassLen: 5 },
56
+ reporter: silent,
57
+ });
58
+
59
+ test('links only the module chunks whose classes the page renders', () => {
60
+ write('page-data/index/page-data.json', pageData());
61
+ write('index.html', html('<div class="Bonus_wrap__x1 bonus">x</div>'));
62
+
63
+ run();
64
+ const out = read('index.html');
65
+ expect(out).toContain('<link rel="stylesheet" href="/100.aaa.css" data-crit="100.aaa.css">');
66
+ expect(out).not.toContain('200.bbb.css'); // toplist not rendered
67
+ expect(out).not.toContain('300.ccc.css'); // unused
68
+ expect(out).not.toContain('styles.deadbeef.css'); // base, excluded candidate
69
+ expect(out).not.toContain('app-js.832a.css'); // route shell, excluded candidate
70
+ });
71
+
72
+ test('does not double-link a chunk already inlined by the core theme (data-href)', () => {
73
+ write('page-data/index/page-data.json', pageData());
74
+ write(
75
+ 'index.html',
76
+ html('<div class="Bonus_wrap__x1">x</div>').replace(
77
+ '<title>t</title>',
78
+ '<title>t</title><style data-href="/100.aaa.css">.Bonus_wrap__x1{}</style>',
79
+ ),
80
+ );
81
+ run();
82
+ const out = read('index.html');
83
+ expect(out).not.toContain('data-crit="100.aaa.css"'); // already inlined -> skipped
84
+ });
85
+
86
+ test('is idempotent — running twice does not add duplicate links', () => {
87
+ write('page-data/index/page-data.json', pageData());
88
+ write('index.html', html('<div class="Bonus_wrap__x1 Toplist_row__y2">x</div>'));
89
+ run();
90
+ run();
91
+ const out = read('index.html');
92
+ expect(out.match(/data-crit="100\.aaa\.css"/g)).toHaveLength(1);
93
+ expect(out.match(/data-crit="200\.bbb\.css"/g)).toHaveLength(1);
94
+ });
95
+
96
+ test('skips preview/tracker pages (route prefix and pageContext flags)', () => {
97
+ write('page-data/preview/page-data.json', pageData());
98
+ write('preview/index.html', html('<div class="Bonus_wrap__x1">x</div>'));
99
+ write('page-data/tracked/page-data.json', pageData({ isTracker: true }));
100
+ write('tracked/index.html', html('<div class="Bonus_wrap__x1">x</div>'));
101
+ run();
102
+ expect(read('preview/index.html')).not.toContain('data-crit');
103
+ expect(read('tracked/index.html')).not.toContain('data-crit');
104
+ });
105
+
106
+ test('leaves a page untouched when it renders no module chunks', () => {
107
+ write('page-data/index/page-data.json', pageData());
108
+ const original = html('<div class="onlyGlobalClass">x</div>');
109
+ write('index.html', original);
110
+ run();
111
+ expect(read('index.html')).toBe(original);
112
+ });
113
+
114
+ test('guards: missing public dir does not throw', () => {
115
+ expect(() =>
116
+ injectCriticalCss({ publicDir: path.join(dir, 'nope'), config: { enabled: true }, reporter: silent }),
117
+ ).not.toThrow();
118
+ });
119
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * selectChunks — pure helpers for per-page CSS-chunk selection by classname presence.
3
+ *
4
+ * The split emits one .css file per (lazy) module chunk. To know which chunks a page actually
5
+ * needs — without a brittle module→component registry — we look at what's really in the page's
6
+ * server-rendered HTML: a chunk is needed iff at least one of its (CSS-module, hashed) class
7
+ * names appears in the page's `class="…"` attributes. SSR renders every module the page uses, so
8
+ * each used module's wrapper class is present; chunks for modules the page doesn't render are
9
+ * skipped. This auto-covers header variants and needs no per-site config.
10
+ *
11
+ * All functions are pure & total (no I/O, never throw on string input).
12
+ */
13
+
14
+ // class selector in CSS: a dot followed by an identifier (letters/_/-, then word chars). Excludes
15
+ // fractional values (.5em) and file extensions short enough to fall under minLen (.png/.svg/…).
16
+ const CSS_CLASS_RE = /\.(-?[_a-zA-Z][\w-]*)/g;
17
+ const CLASS_ATTR_RE = /class="([^"]*)"/g;
18
+
19
+ function extractCssClasses(css, minLen = 5) {
20
+ const out = new Set();
21
+ if (typeof css !== 'string') return out;
22
+ let m;
23
+ // eslint-disable-next-line no-cond-assign
24
+ while ((m = CSS_CLASS_RE.exec(css))) {
25
+ if (m[1].length >= minLen) out.add(m[1]);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function extractHtmlClasses(html) {
31
+ const out = new Set();
32
+ if (typeof html !== 'string') return out;
33
+ let m;
34
+ // eslint-disable-next-line no-cond-assign
35
+ while ((m = CLASS_ATTR_RE.exec(html))) {
36
+ for (const tok of m[1].split(/\s+/)) if (tok) out.add(tok);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * chunks: [{ file, css }] -> Map<className, Set<file>>
43
+ */
44
+ function buildClassIndex(chunks, minLen = 5) {
45
+ const index = new Map();
46
+ if (!Array.isArray(chunks)) return index;
47
+ for (const c of chunks) {
48
+ if (c && typeof c.file === 'string') {
49
+ for (const cls of extractCssClasses(c.css, minLen)) {
50
+ let set = index.get(cls);
51
+ if (!set) {
52
+ set = new Set();
53
+ index.set(cls, set);
54
+ }
55
+ set.add(c.file);
56
+ }
57
+ }
58
+ }
59
+ return index;
60
+ }
61
+
62
+ /**
63
+ * Given a page's HTML and the class index, return the Set of chunk files the page needs.
64
+ */
65
+ function selectChunksForHtml(html, classIndex) {
66
+ const files = new Set();
67
+ if (!(classIndex instanceof Map)) return files;
68
+ for (const tok of extractHtmlClasses(html)) {
69
+ const set = classIndex.get(tok);
70
+ if (set) for (const f of set) files.add(f);
71
+ }
72
+ return files;
73
+ }
74
+
75
+ module.exports = { extractCssClasses, extractHtmlClasses, buildClassIndex, selectChunksForHtml };
@@ -0,0 +1,73 @@
1
+ import {
2
+ extractCssClasses,
3
+ extractHtmlClasses,
4
+ buildClassIndex,
5
+ selectChunksForHtml,
6
+ } from './selectChunks';
7
+
8
+ describe('extractCssClasses', () => {
9
+ test('captures class selectors, skips fractional values and short tokens', () => {
10
+ const css = '.Bonus_wrap__a1b2c{margin:.5em;width:50px}.x{}.short{}a.Toplist_row__9zz{}';
11
+ const out = extractCssClasses(css, 5);
12
+ expect(out.has('Bonus_wrap__a1b2c')).toBe(true);
13
+ expect(out.has('Toplist_row__9zz')).toBe(true);
14
+ expect(out.has('short')).toBe(true); // exactly 5 chars
15
+ expect(out.has('x')).toBe(false); // below minLen
16
+ expect([...out]).not.toContain('5em'); // fractional value, not a class
17
+ });
18
+
19
+ test('respects minLen and tolerates non-string', () => {
20
+ expect([...extractCssClasses('.abcdefg{}', 8)]).toEqual([]);
21
+ expect([...extractCssClasses(null)]).toEqual([]);
22
+ });
23
+ });
24
+
25
+ describe('extractHtmlClasses', () => {
26
+ test('collects whitespace-separated tokens from every class attribute', () => {
27
+ const html = '<div class="Bonus_wrap__a1b2c bonus module"></div><span class="Toplist_row__9zz"></span>';
28
+ const out = extractHtmlClasses(html);
29
+ expect(out.has('Bonus_wrap__a1b2c')).toBe(true);
30
+ expect(out.has('bonus')).toBe(true);
31
+ expect(out.has('Toplist_row__9zz')).toBe(true);
32
+ });
33
+
34
+ test('tolerates non-string', () => {
35
+ expect([...extractHtmlClasses(undefined)]).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe('buildClassIndex + selectChunksForHtml', () => {
40
+ const chunks = [
41
+ { file: '922.aaa.css', css: '.Bonus_wrap__a1b2c{}.Bonus_logo__c3d{}' },
42
+ { file: '700.bbb.css', css: '.Toplist_row__9zz{}' },
43
+ { file: '404.ccc.css', css: '.NotFound_box__qq1{}' },
44
+ ];
45
+ const index = buildClassIndex(chunks, 5);
46
+
47
+ test('selects only chunks whose classes appear in the page HTML', () => {
48
+ const html = '<div class="Bonus_wrap__a1b2c bonus"></div><div class="Toplist_row__9zz"></div>';
49
+ const sel = selectChunksForHtml(html, index);
50
+ expect(sel).toEqual(new Set(['922.aaa.css', '700.bbb.css']));
51
+ expect(sel.has('404.ccc.css')).toBe(false); // not rendered on this page
52
+ });
53
+
54
+ test('a chunk with no matching class is not selected (empty page)', () => {
55
+ expect(selectChunksForHtml('<div class="someGlobal"></div>', index)).toEqual(new Set());
56
+ });
57
+
58
+ test('a class shared by two chunks selects both', () => {
59
+ const idx = buildClassIndex(
60
+ [
61
+ { file: 'a.css', css: '.Shared_x__h1{}' },
62
+ { file: 'b.css', css: '.Shared_x__h1{}' },
63
+ ],
64
+ 5,
65
+ );
66
+ expect(selectChunksForHtml('<i class="Shared_x__h1"></i>', idx)).toEqual(new Set(['a.css', 'b.css']));
67
+ });
68
+
69
+ test('guards: bad inputs never throw', () => {
70
+ expect(buildClassIndex(null)).toEqual(new Map());
71
+ expect(selectChunksForHtml('<div class="x"></div>', null)).toEqual(new Set());
72
+ });
73
+ });
package/gatsby-node.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * gatsby-matrix-theme — build-time hooks.
3
+ *
4
+ * Critical-CSS split (default OFF; enable per-site via options.criticalCss.enabled):
5
+ * 1. onCreateWebpackConfig — split CSS so only initial-chunk CSS stays inlined; lazy-module CSS
6
+ * goes to separate cacheable chunks.
7
+ * 2. onPostBuild — make each page's own chunks render-blocking (no async => no FOUC/CLS).
8
+ *
9
+ * All hooks are guarded so a failure degrades gracefully and never breaks a consumer build.
10
+ */
11
+
12
+ const path = require('path');
13
+ const { resolveConfig } = require('./critical-css/config');
14
+ const { injectCriticalCss } = require('./critical-css/injectCriticalCss');
15
+
16
+ exports.onCreateWebpackConfig = ({ stage, getConfig, actions, reporter }, pluginOptions) => {
17
+ // The split only matters for the production JS/CSS build.
18
+ if (stage !== 'build-javascript') return;
19
+
20
+ const cfg = resolveConfig(pluginOptions);
21
+ if (!cfg.enabled) return;
22
+
23
+ try {
24
+ const config = getConfig();
25
+ const cacheGroups =
26
+ config.optimization &&
27
+ config.optimization.splitChunks &&
28
+ config.optimization.splitChunks.cacheGroups;
29
+
30
+ if (cacheGroups && cacheGroups.styles) {
31
+ // Only CSS reachable from initial (sync) chunks gets merged into the per-route
32
+ // stylesheet that Gatsby inlines; lazy-module CSS goes to separate chunks (which
33
+ // onPostBuild then re-links render-blocking, per page).
34
+ cacheGroups.styles.chunks = 'initial';
35
+ reporter.info('[critical-css] CSS split enabled (styles cacheGroup -> chunks:"initial")');
36
+ } else {
37
+ reporter.warn('[critical-css] no styles cacheGroup found; CSS split NOT applied');
38
+ }
39
+
40
+ actions.replaceWebpackConfig(config);
41
+ } catch (e) {
42
+ reporter.warn(`[critical-css] onCreateWebpackConfig skipped: ${e && e.message}`);
43
+ }
44
+ };
45
+
46
+ exports.onPostBuild = async ({ reporter, store }, pluginOptions) => {
47
+ const cfg = resolveConfig(pluginOptions);
48
+ if (!cfg.enabled) return;
49
+ try {
50
+ const dir =
51
+ (store && store.getState && store.getState().program && store.getState().program.directory) ||
52
+ process.cwd();
53
+ injectCriticalCss({ publicDir: path.join(dir, 'public'), config: cfg, reporter });
54
+ } catch (e) {
55
+ reporter.warn(`[critical-css] onPostBuild skipped: ${e && e.message}`);
56
+ }
57
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gatsby-matrix-theme",
3
- "version": "53.20.0",
3
+ "version": "54.0.0-beta.1",
4
4
  "main": "index.js",
5
5
  "description": "Matrix Theme NPM Package",
6
6
  "author": "",
@@ -1 +1,2 @@
1
- {"generatedAt":1781601464197,"builder":{"name":"webpack5"},"hasCustomBabel":false,"hasCustomWebpack":true,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"metaFramework":{"name":"Gatsby","packageName":"gatsby","version":"5.13.6"},"monorepo":"Workspaces","packageManager":{"type":"yarn","version":"1.22.19"},"storybookVersion":"6.5.16","language":"javascript","storybookPackages":{"@storybook/addon-actions":{"version":"6.5.16"},"@storybook/builder-webpack5":{"version":"6.5.16"},"@storybook/manager-webpack5":{"version":"6.5.16"},"@storybook/react":{"version":"6.5.16"}},"framework":{"name":"react"},"addons":{"@storybook/addon-links":{"version":"6.5.16"},"@storybook/addon-essentials":{"version":"6.5.16"}}}
1
+ {"generatedAt":1781707357621,"builder":{"name":"webpack5"},"hasCustomBabel":false,"hasCustomWebpack":true,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"metaFramework":{"name":"Gatsby","packageName":"gatsby","version":"5.13.6"},"monorepo":"Workspaces","packageManager":{"type":"yarn","version":"1.22.19"},"storybookVersion":"6.5.16","language":"javascript","storybookPackages":{"@storybook/addon-actions":{"version":"6.5.16"},"@storybook/builder-webpack5":{"version":"6.5.16"},"@storybook/manager-webpack5":{"version":"6.5.16"},"@storybook/react":{"version":"6.5.16"}},"framework":{"name":"react"},"addons":{"@storybook/addon-links":{"version":"6.5.16"},"@storybook/addon-essentials":{"version":"6.5.16"}}}
2
+ don-measure":{"version":"6.5.16-alpha.0"},"@storybook/addon-outline":{"version":"6.5.16-alpha.0"},"@storybook/addon-storyshots":{"version":"6.5.16-alpha.0"},"@storybook/addon-storyshots-puppeteer":{"version":"6.5.16-alpha.0"},"@storybook/addon-storysource":{"version":"6.5.16-alpha.0"},"@storybook/addon-toolbars":{"version":"6.5.16-alpha.0"},"@storybook/addon-viewport":{"version":"6.5.16-alpha.0"},"@storybook/addons":{"version":"6.5.16-alpha.0"},"@storybook/angular":{"version":"6.5.16-alpha.0"},"@storybook/api":{"version":"6.5.16-alpha.0"},"@storybook/babel-plugin-require-context-hook":{"version":"1.0.1"},"@storybook/channel-postmessage":{"version":"6.5.16-alpha.0"},"@storybook/channel-websocket":{"version":"6.5.16-alpha.0"},"@storybook/channels":{"version":"6.5.16-alpha.0"},"@storybook/cli":{"version":"6.5.16-alpha.0"},"@storybook/client-api":{"version":"6.5.16-alpha.0"},"@storybook/client-logger":{"version":"6.5.16-alpha.0"},"@storybook/codemod":{"version":"6.5.16-alpha.0"},"@storybook/components":{"version":"6.5.16-alpha.0"},"@storybook/core":{"version":"6.5.16-alpha.0"},"@storybook/core-events":{"version":"6.5.16-alpha.0"},"@storybook/csf-tools":{"version":"6.5.16-alpha.0"},"@storybook/docs-tools":{"version":"6.5.16-alpha.0"},"@storybook/ember":{"version":"6.5.16-alpha.0"},"@storybook/eslint-config-storybook":{"version":"2.4.0"},"@storybook/html":{"version":"6.5.16-alpha.0"},"@storybook/instrumenter":{"version":"6.5.16-alpha.0"},"@storybook/jest":{"version":"0.0.5"},"@storybook/linter-config":{"version":"2.5.0"},"@storybook/node-logger":{"version":"6.5.16-alpha.0"},"@storybook/postinstall":{"version":"6.5.16-alpha.0"},"@storybook/preact":{"version":"6.5.16-alpha.0"},"@storybook/preview-web":{"version":"6.5.16-alpha.0"},"@storybook/react":{"version":"6.5.16-alpha.0"},"@storybook/router":{"version":"6.5.16-alpha.0"},"@storybook/semver":{"version":"7.3.2"},"@storybook/server":{"version":"6.5.16-alpha.0"},"@storybook/source-loader":{"version":"6.5.16-alpha.0"},"@storybook/store":{"version":"6.5.16-alpha.0"},"@storybook/svelte":{"version":"6.5.16-alpha.0"},"@storybook/telemetry":{"version":"6.5.16-alpha.0"},"@storybook/testing-library":{"version":"0.0.7"},"@storybook/theming":{"version":"6.5.16-alpha.0"},"@storybook/ui":{"version":"6.5.16-alpha.0"},"@storybook/vue":{"version":"6.5.16-alpha.0"},"@storybook/web-components":{"version":"6.5.16-alpha.0"},"eslint-plugin-storybook":{"version":"0.3.5"}},"framework":{"name":"angular"},"addons":{"@storybook/addon-links":{"version":"6.5.16-alpha.0"},"@storybook/addon-essentials":{"version":"6.5.16-alpha.0"}}}
package/package.old.json DELETED
@@ -1,87 +0,0 @@
1
- {
2
- "name": "gatsby-matrix-theme",
3
- "version": "5.0.11",
4
- "main": "index.js",
5
- "description": "Matrix Theme NPM Package",
6
- "author": "",
7
- "license": "MIT",
8
- "GATSBY_RECAPTCHA_SITEKEY": "6LfoyvMUAAAAAO4nl_MQnqHb4XdHxEiu5cXgIqeB",
9
- "peerDependencies": {
10
- "gatsby": "^3.3.1"
11
- },
12
- "directories": {
13
- "test": "tests"
14
- },
15
- "repository": {
16
- "type": "git",
17
- "url": "https://git.ilcd.rocks/team-floyd/themes/matrix-theme"
18
- },
19
- "scripts": {
20
- "test": "jest --coverage",
21
- "update-packages": "npm version patch && npm publish",
22
- "storybook": "cross-env STORYBOOK_IMAGE_CDN_URL=https://cdn.irishluck.ie STORYBOOK_GATSBY_SITE_NAME=irishluck.ie STORYBOOK_GATSBY_TRACKER_LINK_PREFIX=visit start-storybook -s ./storybook-images -p 7007",
23
- "build-storybook": "cross-env STORYBOOK_IMAGE_CDN_URL=https://cdn.irishluck.ie STORYBOOK_GATSBY_SITE_NAME=irishluck.ie STORYBOOK_GATSBY_TRACKER_LINK_PREFIX=visit build-storybook"
24
- },
25
- "storybook": {
26
- "url": "https://storybook-matrix.gigmedia.com/"
27
- },
28
- "dependencies": {
29
- "babel-preset-gatsby": "^1.3.0",
30
- "chalk": "^4.1.0",
31
- "cross-env": "^7.0.2",
32
- "dotenv": "8.2.0",
33
- "gatsby": "^3.3.1",
34
- "gatsby-core-theme": "^5.1.4",
35
- "gatsby-image": "^3.3.0",
36
- "gatsby-plugin-image": "^1.3.1",
37
- "gatsby-plugin-postcss": "^4.3.0",
38
- "postcss": "^8.2.10",
39
- "prop-types": "15.7.2",
40
- "react": "^17.0.2",
41
- "react-dom": "^17.0.2",
42
- "react-icons": "^4.2.0",
43
- "react-masonry-css": "^1.0.16"
44
- },
45
- "devDependencies": {
46
- "@babel/core": "^7.13.1",
47
- "@babel/plugin-syntax-class-properties": "^7.12.1",
48
- "@babel/plugin-syntax-jsx": "^7.12.1",
49
- "@babel/preset-env": "^7.13.5",
50
- "@babel/preset-react": "^7.12.1",
51
- "@semantic-release/changelog": "^5.0.1",
52
- "@semantic-release/commit-analyzer": "^8.0.1",
53
- "@semantic-release/git": "^9.0.0",
54
- "@semantic-release/gitlab": "^6.2.1",
55
- "@semantic-release/gitlab-config": "^8.0.0",
56
- "@semantic-release/npm": "^7.1.3",
57
- "@semantic-release/release-notes-generator": "^9.0.3",
58
- "@storybook/addon-actions": "^6.0.21",
59
- "@storybook/addon-essentials": "^6.0.21",
60
- "@storybook/addon-links": "^6.0.21",
61
- "@storybook/react": "^6.0.21",
62
- "@testing-library/jest-dom": "^5.14.1",
63
- "@testing-library/react": "^11.2.6",
64
- "babel-jest": "^26.6.3",
65
- "babel-loader": "^8.2.2",
66
- "babel-preset-gatsby": "^1.3.0",
67
- "gatsby-plugin-compile-es6-packages": "^2.1.0",
68
- "identity-obj-proxy": "^3.0.0",
69
- "jest": "^26.6.3",
70
- "sass-resources-loader": "^2.1.0"
71
- },
72
- "jest": {
73
- "verbose": true,
74
- "moduleFileExtensions": [
75
- "js",
76
- "jsx"
77
- ],
78
- "moduleDirectories": [
79
- "node_modules"
80
- ],
81
- "transform": {
82
- "\\.js$": "<rootDir>/node_modules/babel-jest"
83
- }
84
- },
85
- "browserslist": "> 0.25%, not dead"
86
- }
87
-