@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
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
|
-
|
|
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.
|
|
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
|
-
```
|
|
@@ -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',
|
package/templates/integrations/strapi-astro/backend/src/extensions/upload/strapi-server.ts.template
ADDED
|
@@ -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
|
+
};
|