@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.
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.1",
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",
@@ -4,4 +4,7 @@ node_modules
4
4
  !production.*.env
5
5
 
6
6
  # Ansible Vault Password (NEVER COMMIT THIS)
7
- .vault-password
7
+ .vault-password
8
+
9
+ # Jetbrains IDE
10
+ .idea
@@ -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
- ```
@@ -43,5 +43,25 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
43
43
  RUN chmod +x /usr/local/bin/docker-entrypoint.sh
44
44
 
45
45
  EXPOSE 1337
46
+
47
+ # KNOWN GAP — this image runs as root, unlike frontend/Dockerfile.
48
+ #
49
+ # Strapi writes into /opt/app/public, which is a mounted volume at runtime, and
50
+ # the entrypoint reseeds it on every start. The deploy provisions that volume
51
+ # owned by root, so adding `USER node` here leaves the container unable to write
52
+ # to it and the entrypoint fails on boot. Making this non-root is a change to how
53
+ # the volume is provisioned — cd-library and the Ansible role — not a Dockerfile
54
+ # edit, so it cannot be fixed in this template alone.
55
+ #
56
+ # Every backend in the fleet carries this same suppression. It is here so a new
57
+ # project starts consistent with them rather than failing its first Semgrep run,
58
+ # not because running as root is intended. If the provisioning side is ever
59
+ # changed to create the volume as uid 1000, delete these two lines and add
60
+ # `USER node` — this template is the right place to start that.
61
+ #
62
+ # Suppressed narrowly, by rule and on these two lines only, so that a NEW
63
+ # root-running image elsewhere in the project still fails the scan.
64
+ # nosemgrep: dockerfile.security.missing-user-entrypoint.missing-user-entrypoint
46
65
  ENTRYPOINT ["docker-entrypoint.sh"]
66
+ # nosemgrep: dockerfile.security.missing-user.missing-user
47
67
  CMD ["npm", "run", "start"]
@@ -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
+ };