@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,226 @@
1
+ /**
2
+ * Re-runs the media pipeline over media that already exists.
3
+ *
4
+ * Strapi only builds derivatives on upload, so adding the pipeline (see
5
+ * ../extensions/upload/strapi-server.ts) improves NOTHING that is already in
6
+ * the library — every existing file keeps the stock 245/500/750/1000 ladder in
7
+ * its original format, and the frontend withholds an uncapped original from the
8
+ * srcset, so those images stay capped at 1000px until this runs.
9
+ *
10
+ * NOT wired into anything. Nothing imports it, `bootstrap()` does not call it,
11
+ * and the container runs `npm run start` — so it never fires on deploy or on
12
+ * boot. It is a ONE-OFF catch-up you invoke by hand, once. New uploads need it
13
+ * no more, because the pipeline already runs on them at upload time.
14
+ *
15
+ * Usage — open a console against the running app, then call it:
16
+ *
17
+ * npm run console
18
+ * > const { reprocessMedia } = require('./dist/src/scripts/reprocess-media');
19
+ * > await reprocessMedia() // dry run, first 25 files
20
+ * > await reprocessMedia({ apply: true }) // writes
21
+ * > await reprocessMedia({ apply: true, offset: 25 }) // next batch
22
+ *
23
+ * In a dev checkout running `npm run develop`, the path is
24
+ * './src/scripts/reprocess-media' instead — production runs the compiled
25
+ * JavaScript under dist/.
26
+ *
27
+ * DRY RUN BY DEFAULT. `apply: true` is what actually writes.
28
+ *
29
+ * Take a database backup first. This rewrites the `formats` column and uploads
30
+ * new files through the configured provider; there is no undo.
31
+ *
32
+ * Safe to re-run: each pass regenerates from the stored original, so a partial
33
+ * run just gets finished by the next one. Work in batches (`limit`/`offset`) so
34
+ * a failure costs one batch rather than the whole library.
35
+ */
36
+
37
+ interface ReprocessOptions {
38
+ /** Write. Without it the script only reports what it would do. */
39
+ apply?: boolean;
40
+ /** How many files to touch per batch. */
41
+ limit?: number;
42
+ /** Where to start. */
43
+ offset?: number;
44
+ /**
45
+ * Keep going until the library is exhausted (the default). Set false to run
46
+ * exactly one batch — useful for a cautious first pass.
47
+ */
48
+ all?: boolean;
49
+ }
50
+
51
+ interface ReprocessResult {
52
+ scanned: number;
53
+ processed: number;
54
+ skipped: number;
55
+ failed: number;
56
+ }
57
+
58
+ /** Files the pipeline cannot or should not touch. */
59
+ const SKIP_MIME = new Set(['image/svg+xml', 'image/gif']);
60
+
61
+ /**
62
+ * Walks the WHOLE library by default, one batch at a time.
63
+ *
64
+ * It batches internally rather than asking the caller to advance `offset`:
65
+ * getting that wrong is silent and looks like success, because a re-run over
66
+ * already-processed files reports them as `skipped` and exits happily. The
67
+ * first run of an offset-driven version left 13 of 30 homepage images — the LCP
68
+ * hero among them — unconverted for exactly that reason.
69
+ */
70
+ export async function reprocessMedia(
71
+ options: ReprocessOptions = {},
72
+ ): Promise<ReprocessResult> {
73
+ const { apply = false, limit = 25, offset = 0, all = true } = options;
74
+
75
+ const total: ReprocessResult = {
76
+ scanned: 0,
77
+ processed: 0,
78
+ skipped: 0,
79
+ failed: 0,
80
+ };
81
+
82
+ for (let cursor = offset; ; cursor += limit) {
83
+ const batch = await reprocessBatch({ apply, limit, offset: cursor });
84
+ total.scanned += batch.scanned;
85
+ total.processed += batch.processed;
86
+ total.skipped += batch.skipped;
87
+ total.failed += batch.failed;
88
+ // A short batch means the query ran out of rows.
89
+ if (!all || batch.scanned < limit) break;
90
+ }
91
+
92
+ strapi.log.info(`[reprocess] TOTAL ${JSON.stringify(total)}`);
93
+ return total;
94
+ }
95
+
96
+ async function reprocessBatch(
97
+ options: Required<Pick<ReprocessOptions, 'apply' | 'limit' | 'offset'>>,
98
+ ): Promise<ReprocessResult> {
99
+ const { apply, limit, offset } = options;
100
+
101
+ const providerService = strapi.plugin('upload').service('provider');
102
+ const imageService = strapi.plugin('upload').service('image-manipulation');
103
+
104
+ const files = await strapi.db.query('plugin::upload.file').findMany({
105
+ where: { mime: { $startsWith: 'image/' } },
106
+ orderBy: { id: 'asc' },
107
+ limit,
108
+ offset,
109
+ });
110
+
111
+ const result: ReprocessResult = {
112
+ scanned: files.length,
113
+ processed: 0,
114
+ skipped: 0,
115
+ failed: 0,
116
+ };
117
+
118
+ strapi.log.info(
119
+ `[reprocess] ${apply ? 'APPLY' : 'DRY RUN'} — ${files.length} file(s), offset ${offset}`,
120
+ );
121
+
122
+ for (const file of files) {
123
+ if (SKIP_MIME.has(file.mime)) {
124
+ strapi.log.info(`[reprocess] skip ${file.name} (${file.mime})`);
125
+ result.skipped += 1;
126
+ continue;
127
+ }
128
+
129
+ // `formats` keyed `<breakpoint>_<ext>` means this file has already been
130
+ // through the pipeline. Re-running would be correct but wasteful.
131
+ const alreadyDone = Object.keys(file.formats ?? {}).some((key) =>
132
+ /_(avif|webp)$/v.test(key),
133
+ );
134
+ if (alreadyDone) {
135
+ strapi.log.info(`[reprocess] skip ${file.name} (already piped)`);
136
+ result.skipped += 1;
137
+ continue;
138
+ }
139
+
140
+ if (!apply) {
141
+ strapi.log.info(
142
+ `[reprocess] would reprocess ${file.name} ` +
143
+ `(${file.width}x${file.height}, ${Math.round(file.size)} kB, ` +
144
+ `formats: ${Object.keys(file.formats ?? {}).join(',') || 'none'})`,
145
+ );
146
+ result.processed += 1;
147
+ continue;
148
+ }
149
+
150
+ try {
151
+ // Mirrors what @strapi/upload's own uploadImage() does for a fresh
152
+ // upload: build the ladder, push each derivative through the
153
+ // provider, then record it under formats[key].
154
+ //
155
+ // Deliberately does NOT call optimize(). That would cap and rewrite
156
+ // the STORED ORIGINAL, replacing a file the provider already serves
157
+ // — much more invasive than this needs to be. An uncapped original
158
+ // is harmless now: lib/media.ts withholds it from every srcset and
159
+ // only keeps it as the <img src> backstop.
160
+ const downloaded = await fetchOriginal(file);
161
+ const formats =
162
+ await imageService.generateResponsiveFormats(downloaded);
163
+
164
+ const nextFormats: Record<string, unknown> = {
165
+ // Keep the stock ladder. It is still the correct fallback for a
166
+ // browser that supports neither AVIF nor WebP.
167
+ ...(file.formats ?? {}),
168
+ };
169
+ for (const entry of formats ?? []) {
170
+ if (!entry?.file) continue;
171
+ await providerService.upload(entry.file);
172
+ nextFormats[entry.key] = stripStream(entry.file);
173
+ }
174
+
175
+ await strapi.db.query('plugin::upload.file').update({
176
+ where: { id: file.id },
177
+ data: { formats: nextFormats },
178
+ });
179
+
180
+ strapi.log.info(
181
+ `[reprocess] ${file.name}: +${(formats ?? []).length} derivative(s)`,
182
+ );
183
+ result.processed += 1;
184
+ } catch (error) {
185
+ strapi.log.error(
186
+ `[reprocess] FAILED ${file.name}: ${(error as Error).message}`,
187
+ );
188
+ result.failed += 1;
189
+ }
190
+ }
191
+
192
+ strapi.log.info(`[reprocess] done ${JSON.stringify(result)}`);
193
+ return result;
194
+ }
195
+
196
+ /** The provider stores files, not streams; rehydrate one for sharp to read. */
197
+ async function fetchOriginal(file: any): Promise<any> {
198
+ const { Readable } = await import('node:stream');
199
+ const url = file.url.startsWith('http')
200
+ ? file.url
201
+ : `${strapi.config.get('server.url', '')}${file.url}`;
202
+
203
+ const response = await fetch(url);
204
+ if (!response.ok) throw new Error(`GET ${url} -> ${response.status}`);
205
+ const buffer = Buffer.from(await response.arrayBuffer());
206
+
207
+ return {
208
+ ...file,
209
+ getStream: () => Readable.from(buffer),
210
+ filepath: undefined,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Format entries are persisted as JSON, so the non-serialisable bits have to go.
216
+ * Deleting from a shallow copy rather than destructuring-and-discarding: the
217
+ * latter binds three variables only to throw them away, which reads as a
218
+ * mistake and trips no-unused-vars.
219
+ */
220
+ function stripStream(file: Record<string, unknown>): Record<string, unknown> {
221
+ const rest = { ...file };
222
+ delete rest.getStream;
223
+ delete rest.filepath;
224
+ delete rest.tmpWorkingDirectory;
225
+ return rest;
226
+ }
@@ -88,14 +88,14 @@ services:
88
88
  - "traefik.http.routers.__APP_NAME__-astro.rule=Host(`${PROJECT_DOMAIN}`) || Host(`www.${PROJECT_DOMAIN}`)"
89
89
  - "traefik.http.routers.__APP_NAME__-astro.tls.certresolver=letsencrypt"
90
90
  # Global middlewares (defined in webhosting-infra/traefik/dynamic)
91
- - "traefik.http.routers.__APP_NAME__-astro.middlewares=secure-headers@file,rate-limit@file"
91
+ - "traefik.http.routers.__APP_NAME__-astro.middlewares=secure-headers@file,rate-limit@file,compress@file"
92
92
  - "traefik.http.routers.__APP_NAME__-astro.service=__APP_NAME__-cache"
93
93
  # Backend host → this same cache (Strapi behind it; /uploads cached). The
94
94
  # 25 MB upload body limit + rate-limit/secure-headers apply on this route.
95
95
  - "traefik.http.routers.__APP_NAME__-strapi.rule=Host(`${BACKEND_DOMAIN}`)"
96
96
  - "traefik.http.routers.__APP_NAME__-strapi.tls.certresolver=letsencrypt"
97
97
  - "traefik.http.middlewares.__APP_NAME__-strapi-limit.buffering.maxRequestBodyBytes=25000000"
98
- - "traefik.http.routers.__APP_NAME__-strapi.middlewares=rate-limit@file,secure-headers@file,__APP_NAME__-strapi-limit"
98
+ - "traefik.http.routers.__APP_NAME__-strapi.middlewares=rate-limit@file,secure-headers@file,compress@file,__APP_NAME__-strapi-limit"
99
99
  - "traefik.http.routers.__APP_NAME__-strapi.service=__APP_NAME__-cache"
100
100
  # Both routers share this container's single service (Port 80), which
101
101
  # proxies to Astro / Strapi by server_name.
@@ -0,0 +1,78 @@
1
+ # DESIGN.md
2
+
3
+ Implementation and UI design rules, imported into [CLAUDE.md](./CLAUDE.md) via `@DESIGN.md`. Split out because this section changes far more often than the architecture/tech-stack content in CLAUDE.md — edit this file directly for design-rule changes.
4
+
5
+ ## Implementation Rules
6
+
7
+ Always use the predefined TextStyles, Paddings and Colors as defined in the project's style guide in the /styles folder.
8
+ If a design spec (Figma file, screenshot, written brief) is given to you then always make sure to use exactly the distances/paddings, colors and textStyles it specifies. This is the most important step of the development.
9
+
10
+ ### Figma-to-Code Fidelity (exact-copy mode)
11
+
12
+ Whenever the designer says "here's the Figma component" (or points to a specific Figma node/frame), this is a **copy task, not an inspiration task**. The designs are built almost exclusively with Auto Layout specifically so they can be translated 1:1 into code — do not "improve," simplify, or reinterpret any part of the structure he has already thought through.
13
+
14
+ - **Fetch first, assume never.** Use the Figma MCP (`get_design_context` / `get_metadata` / `get_variable_defs`) to pull the actual node data before writing any markup. Never eyeball a screenshot and reconstruct structure from memory.
15
+ - **Copy the Auto Layout structure as the div structure.** Every Auto Layout frame becomes a wrapping element with matching flex-direction, gap, alignment, and nesting depth. Do not flatten, merge, or restructure frames "because it's simpler in code" — the frame hierarchy in Figma is the source of truth for the DOM hierarchy.
16
+ - **Copy paddings exactly**, per frame, as given by Figma — do not round to the nearest value from `_variables.scss`'s spacing scale. If an exact Figma padding doesn't map cleanly to an existing token, use the exact value (via a CSS custom value) rather than substituting the closest predefined one, and flag the mismatch.
17
+ - **Text layers NOT inside Auto Layout** (freely positioned/absolute text) must be made **fluid/responsive to viewport width** — e.g. `clamp()` or `vw`-based sizing — instead of a fixed rem size, since Auto Layout isn't there to reflow them at different breakpoints.
18
+ - **Never substitute your own layout idea for the given one.** If something in the Figma structure seems technically awkward to implement as specified (e.g. conflicts with SSR, an existing component, or a real constraint), stop and ask — don't quietly implement a "cleaner" version instead.
19
+
20
+ ### UI Design Reasoning
21
+
22
+ Before implementing any UI change, work through these in order. This isn't decoration —
23
+ skipping steps produces code that compiles and lints clean but looks wrong.
24
+
25
+ #### 1. Typography — which role, why
26
+
27
+ - Never set font-size/line-height/letter-spacing ad-hoc. Always use a `text-*` mixin from `_textStyles.scss`.
28
+ - Role selection logic: `f1/f2` = fact or Number text (rare). `d1/d2/d3` = section
29
+ headlines, descending by section importance. `title` = card/subsection headers. `body` = paragraph text.
30
+ `caption` = supporting/meta text. `label` = UI chrome (buttons, tags, nav) — always `$font-mono`.
31
+ - If a design spec's text doesn't cleanly match an existing role's size, that's a signal to ask
32
+ (new role needed?) — not to hardcode a one-off size.
33
+ - If a designs spec or figma component uses a different font that can be a signal for a responsive design used in a Hero Section, ask here.
34
+ - Weight comes from the shared `$weights` map (thin/regular/bold) — never a raw `font-weight` value.
35
+
36
+ #### 2. Spacing — deliberate, not categorical
37
+
38
+ - Because Capsize trims line-box whitespace, padding/margin values translate directly to visual
39
+ gap — there's no implicit cushion to hide an imprecise value. Treat every spacing value as exact.
40
+ - Spacing must encode the relationship between adjacent elements, not just "which section": a
41
+ heading sits closer to the content it introduces than to the next unrelated block. Two elements
42
+ with a tight relationship (headline + its subline, label + its input) get a smaller value than
43
+ two elements that just happen to be stacked (end of one section, start of the next).
44
+ - Separately, check whether the section itself is static text or animated/graphic-heavy:
45
+ static sections use the φ-rem spacing scale from `_variables.scss`; animated/heavy-graphic
46
+ sections use viewport-relative (vw) or clamped values instead, since a fixed rem value
47
+ breaks once the section starts moving/scaling with scroll. This is independent of the
48
+ relationship-based decision above — apply both.
49
+ - If a spacing value is being picked "because it's what's used elsewhere" rather than because
50
+ of the actual relationship between these two specific elements, stop and reconsider.
51
+
52
+ #### 3.1. Hierarchy — opacity per element type, not a flat 3-tier
53
+
54
+ - Opacity is calibrated per element type, not just "primary/secondary/tertiary": e.g. H1 at 1.0,
55
+ H2 at ~0.9, H3 at ~0.8, The exact values depend on how many heading levels and text roles a given component has. Less Important gets less opacity.
56
+ - Primary content (what the user should read first) stays at or near 1.0. Everything else steps
57
+ down based on its actual role in that specific component, not a fixed lookup table.
58
+ - If a component has more distinct text elements at full opacity than it has genuinely
59
+ equal-importance messages, that's a hierarchy bug — flag it rather than implementing it as-is.
60
+
61
+ #### 3.2. Hierarchy with Spacing
62
+
63
+ - The Golden Spacing System is also there to help with Hierarchy.
64
+ - Less related Objects get larger paddings, than closer related objects. Specifically with Text on Text.
65
+ - Go bigger on Spacing than you think, because of Capsize. Also when using relative Paddings think about setting minimum borders so that text never gets cut off, or interferes with other text
66
+
67
+ #### 4. Composition — preserving intent, not inventing
68
+
69
+ - Identify the section's or website's focal element (its "star") — the one thing meant to hold attention.
70
+ When implementing, make sure any motif tied to it (shape, color, texture) that recurs
71
+ elsewhere in the same design is actually preserved in code — don't silently drop a
72
+ repeated visual detail because it's inconvenient to implement (e.g. a clipped shape reused
73
+ as both a card background and a button accent).
74
+ - Depth effects (noise texture, glass/blur, parallax) must stay subtle — if an effect visually
75
+ competes with the focal element, that's a regression, flag it before shipping.
76
+ - If a provided spec (design file, screenshot, written brief) conflicts with this checklist,
77
+ the spec wins — this section is for filling gaps or catching implementation drift, not
78
+ overriding what's actually provided.