@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 +11 -1
- package/package.json +9 -1
- package/templates/base/gitignore.template +4 -1
- package/templates/integrations/strapi-astro/AGENTS.md.template +73 -10
- package/templates/integrations/strapi-astro/Dockerfile.backend.template +20 -0
- package/templates/integrations/strapi-astro/backend/config/plugins.ts.template +44 -0
- package/templates/integrations/strapi-astro/backend/src/extensions/upload/strapi-server.ts.template +253 -0
- package/templates/integrations/strapi-astro/backend/src/scripts/reprocess-media.ts.template +226 -0
- package/templates/integrations/strapi-astro/docker-compose.yml.template +2 -2
- package/templates/integrations/strapi-astro/docs/design.md.template +78 -0
- package/templates/integrations/strapi-astro/docs/performance-why.md.template +780 -0
- package/templates/integrations/strapi-astro/docs/performance.md.template +191 -0
- package/templates/integrations/strapi-astro/integration.config.json +20 -0
- package/templates/integrations/strapi-astro/nginx-cache.conf.template +13 -3
|
@@ -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.
|