@smi-digital/create-smi-app 2.14.1 → 2.15.0

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.
@@ -0,0 +1,191 @@
1
+ # Performance rules — the card
2
+
3
+ Every rule for an SMI website project (Astro + Strapi), one line each. **This is the
4
+ file to read at the start of a session and keep in context.** The reasoning, the
5
+ failure modes and the worked examples live in
6
+ **[PERFORMANCE-WHY.md](./PERFORMANCE-WHY.md)** — go there when a rule bites, when you
7
+ want to argue with one, or when a case is not literally covered. Numbers match in both
8
+ directions: rule 5.6 here is rule 5.6 there.
9
+
10
+ Ordered by **decision point** — the question you are actually facing.
11
+
12
+ Tags mark where a rule is meant to be caught: `[scaffold]` `[web-kit]` `[eslint]`
13
+ `[ci]` `[review]`. `[review]` means nothing enforces it but a human noticing.
14
+
15
+ **If you remember nothing else:** weight is 95% of it, and it is fonts and images.
16
+
17
+ ---
18
+
19
+ ## 0. Budgets — mobile Lighthouse, per page
20
+
21
+ | Budget | Limit |
22
+ |---|---|
23
+ | Total page weight | ≤ 1 MB |
24
+ | All fonts on one page | ≤ 400 KB |
25
+ | Any single font file | ≤ 100 KB |
26
+ | Any single image | ≤ 200 KB |
27
+ | JS on a marketing page | 0 framework bytes |
28
+ | CSS per page | ≤ 15 KB |
29
+ | `priority` / eager images per page | exactly 1 |
30
+ | LCP · TBT · CLS | < 2.5 s · < 200 ms · < 0.1 |
31
+
32
+ ---
33
+
34
+ ## 1. Images
35
+
36
+ **Where it comes from decides what you use.** Strapi → `<CmsImage media sizes />` ·
37
+ Strapi + local fallback → `<CmsImage media fallback sizes />` · a local `import` →
38
+ `<Image />` from `astro:assets` · an icon or shape → inline SVG · rich text → the
39
+ renderer maps `img` → `<CmsImage>` · none of the above → raw `<img>` **+ a written
40
+ reason**.
41
+
42
+ - **1.1** Every CMS image goes through `<CmsImage>`. Every local image goes through `<Image>`. `[web-kit]` `[eslint]`
43
+ - **1.2** A raw `<img>` requires a comment explaining why nothing else was possible. `[eslint]` `[review]`
44
+ - **1.3** `<Image src={x} />`, never `<img src={x.src} />`. `[eslint]`
45
+ - **1.4** `sizes` is required, and it describes the CSS box — not the image. `[web-kit]`
46
+ - **1.5** Always emit `width` and `height` from the media metadata. `[web-kit]`
47
+ - **1.6** Lazy by default. Exactly one `priority` image per page, and it must be the LCP. `[web-kit]` `[review]`
48
+ - **1.7** `fetchpriority="high"` is for the LCP element only. Never a logo. `[review]`
49
+ - **1.8** There is a third state: eager but not urgent — `loading="eager"` + `fetchpriority="low"`. `[review]`
50
+ - **1.9** `loading="lazy"` is about layout position, not visibility. Any layout showing one of N things in one box owns its own loading schedule. `[review]`
51
+ - **1.10** Never upscale. `[review]`
52
+ - **1.11** Optimise before committing. Source files are not build artifacts. `[ci]`
53
+ - **1.12** Rich-text renderers must map `img` → `<CmsImage>`. `[review]`
54
+ - **1.13** A browser commits to the first `<source>` type it supports and never falls through: every format needs a complete width ladder, a rung at source resolution, and `sizes` on every `<source>`. `[web-kit]`
55
+ - **1.14** Group derivatives by their `mime`, not by key name. `[web-kit]`
56
+ - **1.15** `picture { display: contents }` in the global reset. `[scaffold]`
57
+ - **1.16** The derivative ladder must reach your largest display size — stock Strapi stops at 1000px. `[scaffold]`
58
+ - **1.17** Convert format at upload, not at request. `[scaffold]`
59
+ - **1.18** Ship the upload pipeline with a re-processing script that walks the whole library by default, dry-run first, after a backup. `[scaffold]` `[review]`
60
+ - **1.19** Do not compensate in the frontend for media the pipeline has not processed. `[review]`
61
+
62
+ **Format:** AVIF photos and gradients · WebP the safer fallback · JPEG photos without
63
+ transparency · PNG flat-colour graphics only · SVG shapes, logos, icons.
64
+
65
+ ---
66
+
67
+ ## 2. Fonts
68
+
69
+ - **2.1** No icon fonts. Ever. Inline SVG. `[scaffold]` `[review]`
70
+ - **2.2** Never download a font file by hand when Fontsource has it. `[scaffold]`
71
+ - **2.3** Import font CSS in `Layout.astro` frontmatter, not in SCSS. `[review]`
72
+ - **2.4** Subsetting: prefer `unicode-range` slices. Check the licence first — subsetting *is* modification. `[review]`
73
+ - **2.5** Declare only the weights you use. `[review]`
74
+ - **2.6** Five or more weights → variable font. Two or three → statics. One strategy per family. `[review]`
75
+ - **2.7** Preload only the weights that render above the fold — verified by looking. `[review]`
76
+ - **2.8** woff2 only. `font-display: swap` always.
77
+ - **2.9** `font-display: swap` protects rendering, not bandwidth. `[review]`
78
+ - **2.10** A custom or licensed face has failure modes a Fontsource package does not — read **Appendix B** of the why-doc before subsetting one, trimming baselines against its metrics, or rasterising its text into a canvas. `[review]`
79
+
80
+ ---
81
+
82
+ ## 3. JavaScript & hydration
83
+
84
+ - **3.1** Prefer the platform: `<details>`, `<dialog>`, CSS `:hover` / `:focus-within`, CSS transitions. `[review]`
85
+ - **3.2** Never re-implement in JavaScript what CSS does natively — especially breakpoints. `[review]`
86
+ - **3.3** The three-question test: state surviving interactions? re-render from changing data? genuinely worse without a framework? All three "no" means it is not an island. `[review]`
87
+ - **3.4** Marketing pages target zero islands — the first island pays a fixed framework runtime the second one does not. `[review]`
88
+ - **3.5** Tool and app pages may use a framework freely.
89
+ - **3.6** Adding an island to a page that had none is a bigger change than it looks. Say so in the PR. `[review]`
90
+ - **3.7** `client:visible` is the default. `client:load` needs a written justification. `[review]`
91
+ - **3.8** Use `client:media` for anything that exists at only one breakpoint or capability. `[review]`
92
+ - **3.9** `client:only` is a last resort, and never above the fold. `[review]`
93
+ - **3.10** An island can only be as lazy as its most urgent job. `[review]`
94
+ - **3.11** Islands are leaves, not trunks. `[review]`
95
+ - **3.12** One island, one job.
96
+ - **3.13** Never gate visible content behind an async operation. `[review]`
97
+ - **3.14** Never duplicate a decision already made server-side or in an inline script. `[review]`
98
+ - **3.15** No unused or duplicate dependencies. `[ci]`
99
+ - **3.16** Delete dead islands. `[ci]`
100
+
101
+ ---
102
+
103
+ ## 4. CSS
104
+
105
+ - **4.1** A dynamic block renderer must import blocks dynamically — CSS follows the import graph, not the render output. `[review]`
106
+ - **4.2** Adding a section type must not make every existing page heavier. `[review]`
107
+ - **4.3** Co-located CSS modules only pay off if the imports are per-page. `[review]`
108
+ - **4.4** Below-the-fold CSS must never block the first paint. `[review]`
109
+ - **4.5** Choose the inlining threshold deliberately. `"auto"` is a default, not a decision. `[scaffold]`
110
+ - **4.6** Shrink before you inline. `[review]`
111
+ - **4.7** Match the strategy to the visit pattern, measure through brotli, and record the numbers in the commit message. `[review]`
112
+ - **4.8** If CSS is too big to inline, make it smaller — do not load it more cleverly.
113
+ - **4.9** PurgeCSS is a safety net, not a fix. `[review]`
114
+ - **4.10** If you run PurgeCSS, the safelist must cover every class name the scanner cannot see as a literal string. `[review]`
115
+ - **4.11** Verify against the built output, never the dev server. `[review]`
116
+ - **4.12** Every site runs the same CSS pipeline. `[scaffold]`
117
+
118
+ ---
119
+
120
+ ## 5. Animation & the main thread
121
+
122
+ Each frame is 16 ms of `Style → Layout → Paint → Composite`. `transform` and `opacity`
123
+ reach only Composite; `filter` / `box-shadow` / `background` / `border-radius` repaint;
124
+ `width` / `height` / `top` / `left` / `margin` re-run layout.
125
+
126
+ - **5.1** Animate `transform` and `opacity`. Nothing else. `[review]`
127
+ - **5.2** For a shadow, glow or colour transition, move it to a pseudo-element and transition that element's `opacity`. `[review]`
128
+ - **5.3** Never transition a layout property. Use `transform: scale()` / `translate()`.
129
+ - **5.4** In any scroll or resize handler: all reads first, then all writes. `[review]`
130
+ - **5.5** Nothing above the fold may depend on JavaScript to become visible. `[review]`
131
+ - **5.6** Nothing may animate *into position* on load. Snap on the first reading via a `primed` flag, animate on every one after. `[review]`
132
+ - **5.7** `will-change` is temporary. Add before, remove after. `[review]`
133
+ - **5.8** One animation system per site. `[review]`
134
+ - **5.9** Every animation has a `prefers-reduced-motion` path.
135
+
136
+ ---
137
+
138
+ ## 6. Delivery
139
+
140
+ - **6.1** Every site's router must include `compress@file`. `[scaffold]`
141
+ - **6.2** The site's own nginx must not compress — `gzip off`. 6.1 and 6.2 do not work alone. `[scaffold]`
142
+ - **6.3** `add_header` appends, it does not replace: uploads need `proxy_hide_header Cache-Control` plus a one-year `immutable` header. `[scaffold]`
143
+ - **6.4** Preconnect to the CMS origin in the base layout, in `<head>` before the stylesheets. Maximum four. `[scaffold]`
144
+ - **6.5** Preload the LCP image — once per page, and only if it is late-discovered. Preload fixes discovery, never weight. `[review]`
145
+
146
+ ---
147
+
148
+ ## 7. Shared code — `@smi-digital/web-kit`
149
+
150
+ - **7.1** Never copy code out of web-kit into a site. `[review]`
151
+ - **7.2** Extend upstream with an optional parameter that defaults to current behaviour. `[review]`
152
+ - **7.3** A site may use the helper instead of the component — but never its own copy of the logic. `[review]`
153
+ - **7.4** web-kit ships source, not a build: `vite: { ssr: { noExternal: ['@smi-digital/web-kit'] } }`. `[scaffold]`
154
+
155
+ ---
156
+
157
+ ## 8. Verification
158
+
159
+ | Check | How | Expect |
160
+ |---|---|---|
161
+ | Fonts | DevTools → Network, filter `font` | **1** file on a German page |
162
+ | Font metrics | read `hhea`, `sTypo`, `fsSelection` bit 7 from the binary | table matches the browser |
163
+ | Brotli | `curl -sI -H "Accept-Encoding: br" <url>` | `content-encoding: br` |
164
+ | Cache headers | `curl -sI <cms>/uploads/<file>` | **one** `Cache-Control`, `immutable` |
165
+ | Preconnect | Lighthouse → "Preconnected origins" | your CMS origin listed |
166
+ | Modern formats | page HTML | `avif` before `webp` before `<img>` |
167
+ | Image sizing | Lighthouse → "Improve image delivery" | no entries |
168
+ | Eager images | grep `priority` / `fetchpriority` per page | **1** high, and it is the LCP |
169
+ | Render-blocking | Lighthouse | 0 stylesheets |
170
+ | Forced reflow | DevTools → Performance, 4× CPU throttle | no purple layout bars in scroll |
171
+ | Layout-defeated lazy | Network tab while **not** scrolling | only above-fold images |
172
+ | Load-time animation | trace → count `DroppedFrame` before first paint | **0** |
173
+
174
+ - **8.1** Verify on the built output, not `astro dev`.
175
+ - **8.2** Verify on a throttled device profile.
176
+ - **8.3** Speed Index is bimodal. Take the median of five runs. `[review]`
177
+ - **8.4** Measure the rendered output, not the intended arithmetic. `[review]`
178
+ - **8.5** When first paint is late, check whether anything was still loading — if load finished and paint did not follow, it is the main thread, not the network. `[review]`
179
+
180
+ ---
181
+
182
+ ## The sentences worth remembering
183
+
184
+ 1. **Weight is 95% of the problem.** Fonts and images.
185
+ 2. **Icons are graphics, not typography.**
186
+ 3. **A raw `<img>` is banned because it is incomplete by default**, not because it is wrong.
187
+ 4. **The first island costs a whole framework runtime** (3.4 — measure it, do not quote it). Astro will happily sell you zero.
188
+ 5. **`transform` and `opacity` are the only two free properties** — and nothing should be using them during the first paint.
189
+ 6. **`<picture>` never falls through.** Every format needs a complete ladder.
190
+ 7. **A shared component copied into a site is a fork**, however correct the copy is.
191
+ 8. **Measure the pixels, not the formula**, and take the median of five.
@@ -86,6 +86,26 @@
86
86
  {
87
87
  "template": "CLAUDE.md.template",
88
88
  "target": "CLAUDE.md"
89
+ },
90
+ {
91
+ "template": "docs/performance.md.template",
92
+ "target": "docs/performance.md"
93
+ },
94
+ {
95
+ "template": "docs/performance-why.md.template",
96
+ "target": "docs/performance-why.md"
97
+ },
98
+ {
99
+ "template": "docs/design.md.template",
100
+ "target": "docs/design.md"
101
+ },
102
+ {
103
+ "template": "backend/src/extensions/upload/strapi-server.ts.template",
104
+ "target": "backend/src/extensions/upload/strapi-server.ts"
105
+ },
106
+ {
107
+ "template": "backend/src/scripts/reprocess-media.ts.template",
108
+ "target": "backend/src/scripts/reprocess-media.ts"
89
109
  }
90
110
  ],
91
111
  "cdFiles": [
@@ -23,8 +23,11 @@ map $http_upgrade $connection_upgrade {
23
23
  '' close;
24
24
  }
25
25
 
26
- gzip on;
27
- gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
26
+ # Compression is Traefik's job, not ours. The `compress@file` middleware
27
+ # negotiates br > zstd > gzip, but it SKIPS any response that already carries a
28
+ # Content-Encoding — so gzipping here would silently disable Brotli at the edge
29
+ # for every asset and every document, with no error anywhere.
30
+ gzip off;
28
31
 
29
32
  # ── Frontend: Astro SSR ───────────────────────────────────────────────────
30
33
  # default_server so the container healthcheck (Host: 127.0.0.1) and any
@@ -83,8 +86,15 @@ server {
83
86
 
84
87
  # Media is static: ignore any cookie/cache headers Strapi attaches so
85
88
  # the response is always cacheable, and never relay a Set-Cookie.
89
+ # proxy_ignore_headers governs only what NGINX caches for itself; the
90
+ # upstream header still reaches the client, and add_header APPENDS rather
91
+ # than replaces. Without proxy_hide_header the response carries two
92
+ # contradictory Cache-Control values (Strapi's own, and ours below),
93
+ # browsers resolve conservatively, and every upload is re-fetched on
94
+ # every visit — invisible in Lighthouse, which only measures a cold load.
86
95
  proxy_ignore_headers Set-Cookie Cache-Control Expires;
87
96
  proxy_hide_header Set-Cookie;
97
+ proxy_hide_header Cache-Control;
88
98
 
89
99
  proxy_pass http://__APP_NAME__-strapi:1337;
90
100
 
@@ -93,7 +103,7 @@ server {
93
103
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
94
104
  proxy_set_header X-Forwarded-Proto $scheme;
95
105
 
96
- add_header Cache-Control "public, max-age=2592000";
106
+ add_header Cache-Control "public, max-age=31536000, immutable";
97
107
  add_header X-Cache-Status $upstream_cache_status;
98
108
  }
99
109