@smi-digital/create-smi-app 2.14.2 → 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.
package/dist/index.js CHANGED
@@ -542,6 +542,14 @@ async function createApps(projectRoot, targets) {
542
542
  join4(projectRoot, target.directory)
543
543
  );
544
544
  });
545
+ await runStep(
546
+ "Adding the shared web-kit components",
547
+ async () => runCommandQuiet(
548
+ "npm",
549
+ ["install", "@smi-digital/web-kit"],
550
+ join4(projectRoot, target.directory)
551
+ )
552
+ );
545
553
  }
546
554
  await runSequentially(index + 1);
547
555
  };
@@ -659,9 +667,11 @@ async function createIntegrations(options, projectRoot, templatesDir) {
659
667
  await runCommandQuiet("npx", ["astro", "add", "node", "-y"], frontendDir);
660
668
  const astroConfigPath = join5(frontendDir, "astro.config.mjs");
661
669
  let astroConfig = await readFile3(astroConfigPath, "utf8");
670
+ const viteBlock = /(^|\n)\s*vite\s*:/v.test(astroConfig) ? "" : "\n vite: { ssr: { noExternal: ['@smi-digital/web-kit'] } },";
662
671
  astroConfig = astroConfig.replace(
663
672
  /export default defineConfig\s*\(\s*\{/v,
664
- "export default defineConfig({\n output: 'server',"
673
+ `export default defineConfig({
674
+ output: 'server',${viteBlock}`
665
675
  );
666
676
  await writeFile3(astroConfigPath, astroConfig);
667
677
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@smi-digital/create-smi-app",
3
- "version": "2.14.2",
3
+ "version": "2.15.0",
4
4
  "description": "",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/SMI-Digital/create-smi-app.git"
8
+ },
5
9
  "main": "dist/index.js",
6
10
  "bin": {
7
11
  "create-smi-app": "./bin/cli.js"
@@ -42,8 +46,11 @@
42
46
  "@commitlint/config-conventional": "^20.4.4",
43
47
  "@eslint/js": "^10.0.1",
44
48
  "@eslint/json": "^1.1.0",
49
+ "@semantic-release/changelog": "^6.0.3",
50
+ "@semantic-release/git": "^10.0.1",
45
51
  "@types/chalk-animation": "^1.6.3",
46
52
  "@types/node": "^25.5.0",
53
+ "conventional-changelog-conventionalcommits": "^9.3.1",
47
54
  "dependency-cruiser": "^17.3.9",
48
55
  "eslint": "^10.0.3",
49
56
  "eslint-config-prettier": "^10.1.8",
@@ -53,6 +60,7 @@
53
60
  "knip": "^5.86.0",
54
61
  "lint-staged": "^16.3.3",
55
62
  "prettier": "3.8.1",
63
+ "semantic-release": "^25.0.9",
56
64
  "tsup": "^8.5.1",
57
65
  "tsx": "^4.21.0",
58
66
  "typescript": "^5.9.3",
@@ -1,5 +1,74 @@
1
1
  # Working in this project
2
2
 
3
+ ## What this is
4
+
5
+ Astro SSR (`output: "server"`, Node adapter) consuming Strapi 5 over REST at
6
+ request time. Two independent workspaces: `frontend/` and `backend/`. There is no
7
+ static prerendering — every page renders on demand from live CMS data, behind an
8
+ nginx stale-while-revalidate cache.
9
+
10
+ **This architecture is fixed.** If something cannot be built within it, stop and
11
+ ask rather than changing it.
12
+
13
+ ## Layout
14
+
15
+ ```
16
+ frontend/src/
17
+ components/ sections/ (static .astro) islands/ (interactive) ui/ (primitives)
18
+ layouts/ lib/ styles/ pages/ types/ assets/
19
+ backend/src/
20
+ api/ extensions/ scripts/ config/
21
+ ```
22
+
23
+ Each component lives in its own folder with a co-located `.module.scss`.
24
+ Anything not listed here, read from the directory — do not assume it exists.
25
+
26
+ ## Running things
27
+
28
+ ```bash
29
+ cd frontend npm test | run test:watch | run lint | run build · npx astro check
30
+ cd backend npm run develop | run build · npx tsc --noEmit
31
+ root npx prettier --check . · npx eslint .
32
+ ```
33
+
34
+ ## Non-negotiables
35
+
36
+ - **CMS images go through `<CmsImage>`, local images through `<Image>`** from
37
+ `astro:assets`. A raw `<img>` needs a comment saying why nothing else worked.
38
+ → `docs/performance.md` §1
39
+ - **Never copy code out of `@smi-digital/web-kit` into this repo.** If the shared
40
+ version is wrong, change the shared version. → `docs/performance.md` §7
41
+ - **Verify against the built output, never `astro dev`.** Code splitting, CSS
42
+ inlining and asset optimisation only exist at build.
43
+ - **Never commit or push.** Leave changes in the working tree.
44
+
45
+ ## Where to look
46
+
47
+ Read the relevant file **before** starting, not after.
48
+
49
+ | When you are about to… | Read |
50
+ |---|---|
51
+ | touch an image, font, animation, or anything that loads | `docs/performance.md` |
52
+ | build UI — spacing, type, colour, hierarchy | `docs/design.md` |
53
+ | disagree with a performance rule, or wonder why a budget is that number | `docs/performance-why.md` |
54
+ | need a fact about *this* site that is not in the code | `MEMORY.md` |
55
+
56
+ ## Coding style
57
+
58
+ - TypeScript strict everywhere; always type function return values explicitly
59
+ - `const` over `let`, never `var`; early returns over nested conditionals
60
+ - camelCase for variables and functions, PascalCase for components
61
+ - SCSS Modules for component styles; never ad-hoc font sizes or colours — use the
62
+ scales in `frontend/src/styles/`
63
+ - Formatting is Prettier's job, per `.prettierrc`. Do not hand-format.
64
+ - `@/` maps to `frontend/src/`
65
+
66
+ ## Before assuming a library exists
67
+
68
+ Read `package.json`. This is a scaffolded fleet and sites diverge — a library
69
+ used on one site is not necessarily present on another. The same applies to
70
+ files: check the directory rather than trusting a path you have seen elsewhere.
71
+
3
72
  ## The two checks that gate a pull request
4
73
 
5
74
  **The A/B visual check** builds the frontend twice — once from the merge-base,
@@ -91,6 +160,10 @@ it, and they are not interchangeable:
91
160
 
92
161
  The element is still captured and compared; freezing only pins where it sits.
93
162
 
163
+ Separately: an animation that runs **on load** is a performance problem as well as
164
+ an A/B one — it competes with the browser's first paint. See `docs/performance.md`
165
+ §5.6, which is the rule that nothing may animate *into* position.
166
+
94
167
  ## Adding a hydration probe
95
168
 
96
169
  A screenshot cannot tell you an island stopped mounting — the server-rendered
@@ -101,13 +174,3 @@ The shared ones are `faq-accordion`, `mobile-nav` and `consent-banner`. Remove
101
174
  any this project does not have: a probe that never matches anything is reported
102
175
  as a configuration error rather than passing quietly. A project-specific probe
103
176
  means extending the shared action in `ci-library`.
104
-
105
- ## Running things
106
-
107
- ```bash
108
- npm test # unit tests, once
109
- npm run test:watch
110
- npm run lint
111
- npx astro check # types, including .astro files
112
- npm run build
113
- ```
@@ -1,4 +1,48 @@
1
1
  export default ({ env }) => ({
2
+ // Media pipeline. Editors upload whatever their camera or designer produced;
3
+ // everything that makes it web-ready happens here, once, on upload.
4
+ //
5
+ // `breakpoints` is read by Strapi itself. The stock defaults stop at 1000px,
6
+ // so any viewport wider than that is handed the untouched original — which is
7
+ // how camera JPEGs end up in hero slots.
8
+ //
9
+ // NOTE: this applies only to NEWLY uploaded files. Existing media keeps the
10
+ // derivatives it was created with and must be re-processed before anything
11
+ // improves — see src/scripts/reprocess-media.ts.
12
+ upload: {
13
+ config: {
14
+ breakpoints: {
15
+ xsmall: 320,
16
+ small: 640,
17
+ medium: 1024,
18
+ large: 1600,
19
+ xlarge: 2400,
20
+ },
21
+ // Read by src/extensions/upload/strapi-server.ts.
22
+ smiImagePipeline: {
23
+ // Strapi never resizes the original, so a 6000x4000 upload stays
24
+ // 6000x4000 on disk — and is a candidate in the srcset. Cap it
25
+ // just above the largest breakpoint.
26
+ maxOriginalWidth: 2560,
27
+ // Most modern first. Each gets a COMPLETE ladder: a browser
28
+ // commits to the first <picture> source type it supports and then
29
+ // selects within it, so a partial AVIF ladder would hand a phone a
30
+ // desktop-sized file rather than falling through to a small WebP.
31
+ formats: [
32
+ {
33
+ ext: '.avif',
34
+ mime: 'image/avif',
35
+ options: { quality: 60, effort: 4 },
36
+ },
37
+ {
38
+ ext: '.webp',
39
+ mime: 'image/webp',
40
+ options: { quality: 80 },
41
+ },
42
+ ],
43
+ },
44
+ },
45
+ },
2
46
  email: {
3
47
  config: {
4
48
  provider: 'nodemailer',
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Media pipeline: cap the original, and emit a complete AVIF + WebP ladder.
3
+ *
4
+ * Strapi resizes but never converts — JPEG in, JPEG out — so the format a
5
+ * visitor downloads used to be decided by whatever an editor happened to drag
6
+ * into the CMS. It also never touches the original, so an 11424x7616 camera
7
+ * file stayed 11424x7616 on disk and remained a candidate in the srcset.
8
+ *
9
+ * Two overrides on the upload plugin's image-manipulation service:
10
+ *
11
+ * optimize() caps the original at maxOriginalWidth, keeping
12
+ * its source format so it stays the compatibility
13
+ * fallback on the <img>.
14
+ * generateResponsiveFormats() replaces the same-format ladder with one
15
+ * COMPLETE ladder per configured format.
16
+ *
17
+ * `generateThumbnail` is a separate method and is left alone — the admin media
18
+ * library reads `formats.thumbnail`, so renaming ladder keys here is safe.
19
+ *
20
+ * Format keys are `<breakpoint>_<ext>` (e.g. `large_avif`). Nothing downstream
21
+ * parses those names: CmsImage groups derivatives by the `mime` Strapi stores
22
+ * on each entry, so this naming is free to change.
23
+ */
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import sharp, { type OutputInfo } from 'sharp';
27
+
28
+ // For animated images sharp reports the true frame height as `pageHeight`, and
29
+ // `height` is every frame stacked; without this `height` would be wildly wrong
30
+ // on a GIF.
31
+ //
32
+ // Unlike the other sites in the fleet, this one does NOT need the declaration:
33
+ // sharp 0.35 declares `pageHeight` on its own exported `OutputInfo`, and those
34
+ // typings are in the program because this file imports the module. It is kept
35
+ // anyway, as a no-op that costs nothing and survives a sharp downgrade — 0.33
36
+ // and earlier have no such member, and Strapi's pinned sharp version moves.
37
+ //
38
+ // Note 0.35 also moved the typings entry to dist/index.d.mts, which is an ES
39
+ // module with named exports and no `declare namespace sharp`. That is why
40
+ // `OutputInfo` is imported as a type here rather than written `sharp.OutputInfo`
41
+ // as the other sites do — on this version that spelling does not resolve at all.
42
+ declare module 'sharp' {
43
+ interface OutputInfo {
44
+ pageHeight?: number;
45
+ }
46
+ }
47
+
48
+ interface PipelineFormat {
49
+ ext: string;
50
+ mime: string;
51
+ options?: Record<string, unknown>;
52
+ }
53
+
54
+ interface PipelineConfig {
55
+ maxOriginalWidth: number;
56
+ formats: PipelineFormat[];
57
+ }
58
+
59
+ const DEFAULTS: PipelineConfig = {
60
+ maxOriginalWidth: 2560,
61
+ formats: [{ ext: '.webp', mime: 'image/webp', options: { quality: 80 } }],
62
+ };
63
+
64
+ const bytesToKbytes = (bytes: number): number =>
65
+ Math.round((bytes / 1000) * 100) / 100;
66
+
67
+ /** Sharp's method name for an extension, e.g. ".avif" -> "avif". */
68
+ const encoderFor = (ext: string): 'avif' | 'webp' | 'jpeg' | 'png' => {
69
+ const name = ext.replace('.', '').toLowerCase();
70
+ if (name === 'jpg') return 'jpeg';
71
+ return name as 'avif' | 'webp' | 'jpeg' | 'png';
72
+ };
73
+
74
+ const getConfig = (): PipelineConfig => ({
75
+ ...DEFAULTS,
76
+ ...(strapi.config.get(
77
+ 'plugin::upload.smiImagePipeline',
78
+ {},
79
+ ) as Partial<PipelineConfig>),
80
+ });
81
+
82
+ /**
83
+ * Resize and re-encode a file, mirroring the shape the upload plugin's own
84
+ * `resizeFileTo` returns. `ext`/`mime` are overridden rather than inherited,
85
+ * which is the whole point — the plugin's version keeps the source format.
86
+ */
87
+ async function transformFile(
88
+ file: any,
89
+ {
90
+ resize,
91
+ format,
92
+ name,
93
+ hash,
94
+ }: {
95
+ resize?: { width: number; height: number };
96
+ format?: PipelineFormat;
97
+ name: string;
98
+ hash: string;
99
+ },
100
+ ): Promise<any> {
101
+ const filePath = file.tmpWorkingDirectory
102
+ ? path.join(file.tmpWorkingDirectory, hash)
103
+ : hash;
104
+
105
+ const build = (input?: string) => {
106
+ let pipeline = input
107
+ ? sharp(input, { animated: true })
108
+ : sharp({ animated: true });
109
+ if (resize)
110
+ pipeline = pipeline.resize({
111
+ ...resize,
112
+ fit: 'inside',
113
+ withoutEnlargement: true,
114
+ });
115
+ if (format)
116
+ pipeline = pipeline[encoderFor(format.ext)](format.options ?? {});
117
+ return pipeline;
118
+ };
119
+
120
+ let info: OutputInfo | undefined;
121
+
122
+ if (!file.filepath) {
123
+ const transform = build().on('info', (i) => {
124
+ info = i as OutputInfo;
125
+ });
126
+ await new Promise<void>((resolve, reject) => {
127
+ const writeStream = fs.createWriteStream(filePath);
128
+ file.getStream()
129
+ .pipe(transform)
130
+ .pipe(writeStream)
131
+ .on('close', resolve)
132
+ .on('error', reject);
133
+ });
134
+ // The `info` event does not report final byte size for a streamed encode.
135
+ if (info && !info.size) info.size = fs.statSync(filePath).size;
136
+ } else {
137
+ info = await build(file.filepath).toFile(filePath);
138
+ }
139
+
140
+ const { width, height, size, pageHeight } = (info ?? {}) as OutputInfo;
141
+
142
+ return {
143
+ name,
144
+ hash,
145
+ ext: format?.ext ?? file.ext,
146
+ mime: format?.mime ?? file.mime,
147
+ filepath: filePath,
148
+ path: file.path || null,
149
+ getStream: () => fs.createReadStream(filePath),
150
+ width,
151
+ height: pageHeight ?? height,
152
+ size: size ? bytesToKbytes(size) : 0,
153
+ sizeInBytes: size,
154
+ };
155
+ }
156
+
157
+ /** Strips the extension so a new one can be appended. */
158
+ const baseName = (name: string, ext: string): string =>
159
+ name.endsWith(ext) ? name.slice(0, -ext.length) : name;
160
+
161
+ export default (plugin: any) => {
162
+ const service = plugin.services['image-manipulation'];
163
+ const originalOptimize = service.optimize;
164
+
165
+ /**
166
+ * Cap the original. Format is deliberately preserved: with AVIF and WebP
167
+ * ladders in front of it, the original is what a browser supporting neither
168
+ * falls back to on the <img>.
169
+ */
170
+ service.optimize = async (file: any) => {
171
+ const optimized = await originalOptimize.call(service, file);
172
+ const { maxOriginalWidth } = getConfig();
173
+
174
+ if (!optimized?.width || optimized.width <= maxOriginalWidth)
175
+ return optimized;
176
+
177
+ const capped = await transformFile(optimized, {
178
+ resize: { width: maxOriginalWidth, height: maxOriginalWidth },
179
+ name: optimized.name,
180
+ hash: optimized.hash,
181
+ });
182
+
183
+ strapi.log.info(
184
+ `[media] capped ${optimized.name}: ${optimized.width}px -> ${capped.width}px`,
185
+ );
186
+ return { ...optimized, ...capped };
187
+ };
188
+
189
+ /**
190
+ * One complete ladder per configured format, replacing Strapi's
191
+ * same-format ladder.
192
+ */
193
+ service.generateResponsiveFormats = async (file: any) => {
194
+ const { responsiveDimensions = false } =
195
+ (await strapi.plugin('upload').service('upload').getSettings()) ??
196
+ {};
197
+ if (!responsiveDimensions) return [];
198
+
199
+ const breakpoints = strapi.config.get(
200
+ 'plugin::upload.breakpoints',
201
+ {},
202
+ ) as Record<string, number>;
203
+ const { formats } = getConfig();
204
+ const dimensions = await service.getDimensions(file);
205
+ const longestEdge = Math.max(
206
+ dimensions.width ?? 0,
207
+ dimensions.height ?? 0,
208
+ );
209
+
210
+ const results: { key: string; file: any }[] = [];
211
+
212
+ // Every rung the original can fill, PLUS one at the original's own size.
213
+ //
214
+ // `full` is not optional. The original is stored only in its SOURCE format,
215
+ // so without it each converted ladder stops at the largest breakpoint below
216
+ // the original — and a browser that commits to the AVIF <source> can then
217
+ // never reach the source resolution, because <picture> never falls through
218
+ // to a later source. Measured on team-aha before this existed: the AVIF
219
+ // ladder topped out 5-29% short of the fallback on every image, worst on a
220
+ // 1446px-wide hero that stopped at 1024 and looked soft on a 4K display.
221
+ //
222
+ // `file` here is already the output of optimize(), so its longest edge is
223
+ // at most maxOriginalWidth — `full` inherits the cap rather than undoing it.
224
+ const rungs: { key: string; edge: number }[] = [
225
+ ...Object.entries(breakpoints)
226
+ // Skip rungs the original cannot fill — upscaling costs bytes and loses
227
+ // quality. Every format still gets the same set of widths, which is what
228
+ // <picture> selection requires.
229
+ .filter(([, bp]) => bp < longestEdge)
230
+ .map(([key, bp]) => ({ key, edge: bp })),
231
+ { key: 'full', edge: longestEdge },
232
+ ];
233
+
234
+ for (const { key, edge } of rungs) {
235
+ for (const format of formats) {
236
+ const ext = format.ext.replace('.', '');
237
+ results.push({
238
+ key: `${key}_${ext}`,
239
+ file: await transformFile(file, {
240
+ resize: { width: edge, height: edge },
241
+ format,
242
+ name: `${key}_${baseName(file.name, file.ext)}${format.ext}`,
243
+ hash: `${key}_${file.hash}_${ext}`,
244
+ }),
245
+ });
246
+ }
247
+ }
248
+
249
+ return results;
250
+ };
251
+
252
+ return plugin;
253
+ };
@@ -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
+ }