scavold 0.2.0-rc.7 → 0.2.0-rc.9
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/CHANGELOG.md +34 -1
- package/README.md +6 -1
- package/SECURITY.md +38 -0
- package/lib/media.js +19 -5
- package/package.json +9 -5
- package/scripts/check-fixture.js +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,37 @@ changes may occur in any release.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.2.0-rc.9] — 2026-10-01
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- An image wider than the largest of `image_widths` named that width twice in its
|
|
17
|
+
`srcset`, and had its widest variant written twice at the same time.
|
|
18
|
+
|
|
19
|
+
## [0.2.0-rc.8] — 2026-10-01
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `SECURITY.md` says how to report a vulnerability and which versions receive fixes,
|
|
24
|
+
beside the [cratly security policy](https://cratly.io/security) it belongs to.
|
|
25
|
+
Every release's tag pipeline keeps a CycloneDX SBOM of what a site installs along
|
|
26
|
+
with scavold, and checks those packages for known vulnerabilities; `bun run sbom`
|
|
27
|
+
and `bun run audit:runtime` do the same locally.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A photo whose camera stored it turned, saying in its EXIF data how to show it, came
|
|
32
|
+
out upside down or lying on its side on the site, and a portrait got the variant
|
|
33
|
+
widths of a landscape. The variants are now turned the way the photo says, and
|
|
34
|
+
measured by the width it is shown at. Every image gets new variant names once, so
|
|
35
|
+
variants kept between builds are written again rather than served as they were.
|
|
36
|
+
|
|
37
|
+
### Security
|
|
38
|
+
|
|
39
|
+
- `sharp` 0.35, whose bundled libvips and libheif fix vulnerabilities reported against
|
|
40
|
+
0.34 (GHSA-f88m-g3jw-g9cj, GHSA-rgj7-g3m4-5g8c). It needs Node 20.9 or newer, and so
|
|
41
|
+
does scavold now.
|
|
42
|
+
|
|
12
43
|
## [0.2.0-rc.7] — 2026-09-29
|
|
13
44
|
|
|
14
45
|
### Fixed
|
|
@@ -258,7 +289,9 @@ First published release. Version `0.1.0` existed in-tree only.
|
|
|
258
289
|
- Only images are processed out of `media_folder`; other file types (video, documents)
|
|
259
290
|
are never copied into the build output and have to live in the static folder.
|
|
260
291
|
|
|
261
|
-
[Unreleased]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.
|
|
292
|
+
[Unreleased]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.9...main
|
|
293
|
+
[0.2.0-rc.9]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.8...v0.2.0-rc.9
|
|
294
|
+
[0.2.0-rc.8]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.7...v0.2.0-rc.8
|
|
262
295
|
[0.2.0-rc.7]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.6...v0.2.0-rc.7
|
|
263
296
|
[0.2.0-rc.6]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.5...v0.2.0-rc.6
|
|
264
297
|
[0.2.0-rc.5]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.4...v0.2.0-rc.5
|
package/README.md
CHANGED
|
@@ -18,9 +18,14 @@ bun add vitepress vue scavold@^0.2.0-rc.3
|
|
|
18
18
|
|
|
19
19
|
Responsive image generation uses [sharp](https://sharp.pixelplumbing.com/), which
|
|
20
20
|
installs a platform-specific native binary — build environments therefore need to
|
|
21
|
-
be able to fetch it (Node 20 or newer).
|
|
21
|
+
be able to fetch it (Node 20.9 or newer).
|
|
22
22
|
|
|
23
23
|
Full documentation, including a quick-start guide, component and front matter
|
|
24
24
|
references, and deployment instructions, is available at
|
|
25
25
|
|
|
26
26
|
**[https://scavold.io](https://scavold.io)**.
|
|
27
|
+
|
|
28
|
+
## Security
|
|
29
|
+
|
|
30
|
+
To report a security vulnerability, contact **security@cepharum.de**. How reports are
|
|
31
|
+
handled, and where to find the SBOM of every release, is in [SECURITY.md](SECURITY.md).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
scavold is part of cratly, for which cepharum GmbH acts as open-source software steward
|
|
4
|
+
under the EU Cyber Resilience Act. The [cratly security policy](https://cratly.io/security)
|
|
5
|
+
covers how vulnerabilities are reported, handled and disclosed; this page adds what is
|
|
6
|
+
specific to scavold.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Please report it privately, not in a public issue:
|
|
11
|
+
|
|
12
|
+
- open a [confidential issue](https://gitlab.com/cratly/scavold/-/issues/new?issue[confidential]=true)
|
|
13
|
+
in the scavold project, or
|
|
14
|
+
- write to [security@cepharum.de](mailto:security@cepharum.de).
|
|
15
|
+
|
|
16
|
+
Useful to include: the affected version, what an attacker can achieve, and the steps
|
|
17
|
+
or a minimal site that shows it. A vulnerability in one of scavold's dependencies is
|
|
18
|
+
welcome too, when scavold is how it reaches a site.
|
|
19
|
+
|
|
20
|
+
## Supported versions
|
|
21
|
+
|
|
22
|
+
Until 1.0.0, only the latest published release receives security fixes; there are no
|
|
23
|
+
backports to earlier pre-releases. Update to the latest release before reporting, if
|
|
24
|
+
you can.
|
|
25
|
+
|
|
26
|
+
## What scavold brings into a site
|
|
27
|
+
|
|
28
|
+
scavold runs while a site is built, not when it is visited. Its runtime dependencies
|
|
29
|
+
are few — `sharp` for image processing among them — and `vitepress` and `vue` are
|
|
30
|
+
peer dependencies the site chooses itself.
|
|
31
|
+
|
|
32
|
+
- **SBOM.** Every release comes with a CycloneDX software bill of materials, kept as
|
|
33
|
+
the `sbom` job's artifact of the release's tag pipeline in GitLab, from 0.2.0-rc.8
|
|
34
|
+
onwards. `bun run sbom` writes the same for the working tree.
|
|
35
|
+
- **Known vulnerabilities.** The runtime dependencies are checked against the npm
|
|
36
|
+
advisory database in every tag pipeline and in a weekly scheduled pipeline;
|
|
37
|
+
`bun run audit:runtime` runs the same check locally. A finding is fixed by a release
|
|
38
|
+
of its own.
|
package/lib/media.js
CHANGED
|
@@ -6,6 +6,13 @@ import sharp from "sharp";
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_WIDTHS = [ 320, 640, 960, 1280, 1920 ];
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Enters every variant's name along with the source content. Raise it whenever the
|
|
11
|
+
* pipeline would write different pixels for the same source, or variants a site keeps
|
|
12
|
+
* between builds are taken for correct and never written again.
|
|
13
|
+
*/
|
|
14
|
+
const PIPELINE_REVISION = "2";
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* File types the image pipeline can scale. Everything else in the media folder is
|
|
11
18
|
* published by copying it verbatim — authors upload PDFs, videos and other downloads
|
|
@@ -41,7 +48,7 @@ function variantsPublicDir( root, staticDir ) {
|
|
|
41
48
|
* @returns {string}
|
|
42
49
|
*/
|
|
43
50
|
function contentHash( filePath ) {
|
|
44
|
-
return createHash( "sha1" ).update( readFileSync( filePath ) ).digest( "hex" ).slice( 0, 8 );
|
|
51
|
+
return createHash( "sha1" ).update( PIPELINE_REVISION ).update( readFileSync( filePath ) ).digest( "hex" ).slice( 0, 8 );
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
/**
|
|
@@ -73,14 +80,21 @@ async function generateVariants( srcPath, outDir, widths ) {
|
|
|
73
80
|
const hash = contentHash( srcPath );
|
|
74
81
|
const ext = extname( srcPath ).toLowerCase().replace( ".", "" ) || "jpg";
|
|
75
82
|
const base = toKebabCase( basename( srcPath, extname( srcPath ) ) );
|
|
76
|
-
|
|
83
|
+
// A camera stores pixels as the sensor saw them and an EXIF tag saying how to turn
|
|
84
|
+
// them. The variants carry no metadata, so the turn has to go into their pixels —
|
|
85
|
+
// and the width that counts is the one the photo is shown at.
|
|
86
|
+
const image = sharp( srcPath ).autoOrient();
|
|
77
87
|
const meta = await image.metadata();
|
|
78
|
-
const sourceWidth = meta.width ?? Infinity;
|
|
88
|
+
const sourceWidth = meta.autoOrient?.width ?? meta.width ?? Infinity;
|
|
79
89
|
|
|
80
90
|
const targets = widths.filter( w => w <= sourceWidth );
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
// A source between two steps of the ladder caps it at its own width; one wider
|
|
93
|
+
// than the ladder already ends on its last step.
|
|
94
|
+
const widest = Math.min( sourceWidth, widths[widths.length - 1] );
|
|
95
|
+
|
|
96
|
+
if ( targets.length === 0 || targets[targets.length - 1] < widest ) {
|
|
97
|
+
targets.push( widest );
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
const srcsetParts = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scavold",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "VitePress theme framework — a scaffold for building custom VitePress themes with Vue at the core",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"homepage": "https://scavold.io",
|
|
24
24
|
"bugs": "https://gitlab.com/cratly/scavold/-/issues",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=20"
|
|
26
|
+
"node": ">=20.9.0"
|
|
27
27
|
},
|
|
28
28
|
"types": "./index.d.ts",
|
|
29
29
|
"exports": {
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"COMPONENTS.md",
|
|
54
54
|
"FRONTMATTER.md",
|
|
55
55
|
"README.md",
|
|
56
|
-
"
|
|
56
|
+
"SECURITY.md",
|
|
57
|
+
"!**/*.test.js",
|
|
58
|
+
"!scripts/supply-chain.js"
|
|
57
59
|
],
|
|
58
60
|
"scripts": {
|
|
59
61
|
"check-csp": "node scripts/check-csp.js",
|
|
@@ -65,7 +67,9 @@
|
|
|
65
67
|
"test:fixture": "bun scripts/check-fixture.js",
|
|
66
68
|
"test:unit:dev": "bun test --watch",
|
|
67
69
|
"lint": "eslint .",
|
|
68
|
-
"lint:fix": "eslint . --fix"
|
|
70
|
+
"lint:fix": "eslint . --fix",
|
|
71
|
+
"sbom": "node scripts/supply-chain.js sbom",
|
|
72
|
+
"audit:runtime": "node scripts/supply-chain.js audit"
|
|
69
73
|
},
|
|
70
74
|
"peerDependencies": {
|
|
71
75
|
"vitepress": ">=1.0.0",
|
|
@@ -74,7 +78,7 @@
|
|
|
74
78
|
"dependencies": {
|
|
75
79
|
"@cepharum/vue3-i18n": "^2.0.0",
|
|
76
80
|
"markdown-it-container": "^4.0.0",
|
|
77
|
-
"sharp": "^0.
|
|
81
|
+
"sharp": "^0.35.5",
|
|
78
82
|
"yaml": "^2.8.3"
|
|
79
83
|
},
|
|
80
84
|
"devDependencies": {
|
package/scripts/check-fixture.js
CHANGED
|
@@ -74,6 +74,22 @@ async function writeMedia() {
|
|
|
74
74
|
// its variants exist only if the front matter pass reached the media pipeline.
|
|
75
75
|
await sharp( gradient( 1200, 800 ) ).jpeg( { quality: 70 } ).toFile( join( media, "teaser.jpg" ) );
|
|
76
76
|
|
|
77
|
+
// Photos the way a phone camera writes them: pixels as the sensor saw them, plus an
|
|
78
|
+
// EXIF tag saying how to turn them. Red marks the edge that has to end up on top.
|
|
79
|
+
const halves = ( w, h, red ) => Buffer.from(
|
|
80
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
|
|
81
|
+
`<rect width="${w}" height="${h}" fill="#0000ff"/>` +
|
|
82
|
+
`<rect ${red === "bottom" ? `y="${h / 2}"` : ""} width="${red === "left" ? w / 2 : w}" ` +
|
|
83
|
+
`height="${red === "left" ? h : h / 2}" fill="#ff0000"/></svg>`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Stored upside down, to be turned by 180°.
|
|
87
|
+
await sharp( halves( 1200, 800, "bottom" ) ).jpeg( { quality: 70 } ).withMetadata( { orientation: 3 } )
|
|
88
|
+
.toFile( join( media, "upside-down.jpg" ) );
|
|
89
|
+
// Stored lying on its side, 1600 × 1200, to be turned by 90° into a 1200px wide portrait.
|
|
90
|
+
await sharp( halves( 1600, 1200, "left" ) ).jpeg( { quality: 70 } ).withMetadata( { orientation: 6 } )
|
|
91
|
+
.toFile( join( media, "portrait.jpg" ) );
|
|
92
|
+
|
|
77
93
|
writeFileSync( join( media, "doc.pdf" ), "%PDF-1.4\n% fixture placeholder\n" );
|
|
78
94
|
writeFileSync( join( media, "clip.mp4" ), "fixture placeholder\n" );
|
|
79
95
|
// An extension VitePress does not know, so its link rules take the file for a page.
|
|
@@ -184,11 +200,59 @@ expect( published.some( f => /^narrow--800w-[0-9a-f]+\.jpg$/.test( f ) ),
|
|
|
184
200
|
"caps the widest variant at the source width" );
|
|
185
201
|
expect( !published.some( f => /^narrow--(960|1280|1920)w-/.test( f ) ),
|
|
186
202
|
"does not upscale a narrow source" );
|
|
203
|
+
|
|
204
|
+
const wideSrcset = ( media.match( /srcset="([^"]*wide--[^"]*\.jpg[^"]*)"/ ) ?? [ "", "" ] )[1];
|
|
205
|
+
const wideWidths = wideSrcset.split( "," ).map( entry => entry.trim().split( " " )[1] );
|
|
206
|
+
|
|
207
|
+
expect( wideWidths.join( " " ) === "320w 640w 960w 1280w 1920w",
|
|
208
|
+
"names each width once in the srcset of a source wider than the ladder",
|
|
209
|
+
`found ${wideWidths.join( " " ) || "nothing"}` );
|
|
187
210
|
expect( media.includes( 'sizes="(min-width: 40rem) 32rem, 100vw"' ),
|
|
188
211
|
"applies the sizes value from the site configuration" );
|
|
189
212
|
expect( media.includes( 'sizes="100vw"' ), "lets a single image override sizes through its title" );
|
|
190
213
|
expect( media.includes( 'src="https://example.com/absent.png"' ), "leaves a remote image untouched" );
|
|
191
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Tells which edge of a published variant is red, the colour marking the top.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} file name of the variant in the published media folder
|
|
219
|
+
* @returns {Promise<{width: number, height: number, top: string}>}
|
|
220
|
+
*/
|
|
221
|
+
async function orientationOf( file ) {
|
|
222
|
+
const { data, info } = await sharp( join( MEDIA_OUT, file ) ).raw().toBuffer( { resolveWithObject: true } );
|
|
223
|
+
const red = offset => data[offset] > data[offset + 2];
|
|
224
|
+
const middle = x => ( Math.floor( info.height / 2 ) * info.width + x ) * info.channels;
|
|
225
|
+
const top = red( Math.floor( info.width / 2 ) * info.channels ) ? "top"
|
|
226
|
+
: red( ( ( info.height - 1 ) * info.width + Math.floor( info.width / 2 ) ) * info.channels ) ? "bottom"
|
|
227
|
+
: red( middle( 0 ) ) ? "left" : red( middle( info.width - 1 ) ) ? "right" : "none";
|
|
228
|
+
|
|
229
|
+
return { width: info.width, height: info.height, top };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Browsers honour the EXIF tag only on the file they are given. The variants carry
|
|
233
|
+
// no metadata, so the turn has to be in their pixels already.
|
|
234
|
+
const turned = await Promise.all( published
|
|
235
|
+
.filter( f => /^(upside-down|portrait)--/.test( f ) )
|
|
236
|
+
.map( async f => ( { f, ...await orientationOf( f ) } ) ) );
|
|
237
|
+
|
|
238
|
+
for ( const name of [ "upside-down", "portrait" ] ) {
|
|
239
|
+
const found = turned.filter( v => v.f.startsWith( `${name}--` ) );
|
|
240
|
+
const wrong = found.filter( v => v.top !== "top" );
|
|
241
|
+
|
|
242
|
+
expect( found.length > 0 && wrong.length === 0, `turns the variants of ${name}.jpg the way its EXIF tag says`,
|
|
243
|
+
wrong.map( v => `${v.f} has red on the ${v.top}` ).join( ", " ) );
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const portraits = turned.filter( v => v.f.startsWith( "portrait--" ) );
|
|
247
|
+
const named = v => Number( v.f.match( /--(\d+)w-/ )[1] );
|
|
248
|
+
const misshapen = portraits.filter( v => v.width !== named( v ) || v.height <= v.width );
|
|
249
|
+
|
|
250
|
+
expect( portraits.some( v => named( v ) === 1200 ) && !portraits.some( v => named( v ) > 1200 ),
|
|
251
|
+
"measures a turned photo by the width it is shown at, not the width it is stored at",
|
|
252
|
+
`found ${[ ...new Set( portraits.map( named ) ) ].sort( ( a, b ) => a - b ).join( ", " )}` );
|
|
253
|
+
expect( portraits.length > 0 && misshapen.length === 0, "writes a turned photo's variants upright, at the width they are named for",
|
|
254
|
+
misshapen.map( v => `${v.f} is ${v.width} × ${v.height}` ).join( ", " ) );
|
|
255
|
+
|
|
192
256
|
// ── published media that is not an image ─────────────────────────────────────
|
|
193
257
|
|
|
194
258
|
console.log( "\nmedia that is not an image" );
|