scavold 0.2.0-rc.6 → 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 CHANGED
@@ -9,6 +9,45 @@ 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
+
43
+ ## [0.2.0-rc.7] — 2026-09-29
44
+
45
+ ### Fixed
46
+
47
+ - A link to a media file whose extension VitePress does not know, such as `.diff`,
48
+ pointed at the file name with `.html` appended and therefore at nothing. It now names
49
+ the file as published.
50
+
12
51
  ## [0.2.0-rc.6] — 2026-09-13
13
52
 
14
53
  ### Added
@@ -250,6 +289,11 @@ First published release. Version `0.1.0` existed in-tree only.
250
289
  - Only images are processed out of `media_folder`; other file types (video, documents)
251
290
  are never copied into the build output and have to live in the static folder.
252
291
 
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
295
+ [0.2.0-rc.7]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.6...v0.2.0-rc.7
296
+ [0.2.0-rc.6]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.5...v0.2.0-rc.6
253
297
  [0.2.0-rc.5]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.4...v0.2.0-rc.5
254
298
  [0.2.0-rc.4]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.3...v0.2.0-rc.4
255
299
  [0.2.0-rc.3]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
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/config.js CHANGED
@@ -653,7 +653,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
653
653
  // during in-app use.
654
654
  //
655
655
  // The redirect value can be either:
656
- // - a site-relative URL ("/de/jobs") — written by the Cratly editor
656
+ // - a site-relative URL ("/de/jobs") — written by the cratly editor
657
657
  // - a relative .md path ("jobs.md") — written by hand in frontmatter
658
658
  // - an absolute external URL — e.g. "https://example.com"
659
659
  const redirect = pageData.frontmatter?.redirect;
@@ -714,14 +714,31 @@ export async function augmentConfig( rawConfig, options = {} ) {
714
714
  // offers, which is relative to that folder. Rewrite it to the URL the
715
715
  // built site serves. Targets that are not media files (ordinary page
716
716
  // links, anchors, remote URLs) resolve to null and stay untouched.
717
- patchRenderer( md, "link_open", ( defaultHandler, tokens, idx, ...args ) => {
718
- const published = media.collectAsset( tokens[idx].attrGet( "href" ) );
717
+ //
718
+ // VitePress then takes any extension missing from its own list of file
719
+ // types (`.diff`, say) for part of a page name and appends `.html`. The
720
+ // published URL is final, so that suffix is taken back — after VitePress
721
+ // ran, which leaves prefixing the site's base to it.
722
+ patchRenderer( md, "link_open", ( defaultHandler, tokens, idx, mdOptions, env, slf ) => {
723
+ const token = tokens[idx];
724
+ const published = media.collectAsset( token.attrGet( "href" ) );
725
+
726
+ if ( !published ) {
727
+ return defaultHandler( tokens, idx, mdOptions, env, slf );
728
+ }
729
+
730
+ token.attrSet( "href", published );
731
+
732
+ const rendered = defaultHandler( tokens, idx, mdOptions, env, slf );
733
+ const href = token.attrGet( "href" );
734
+
735
+ if ( !published.endsWith( ".html" ) && href?.endsWith( published + ".html" ) ) {
736
+ token.attrSet( "href", href.slice( 0, -".html".length ) );
719
737
 
720
- if ( published ) {
721
- tokens[idx].attrSet( "href", published );
738
+ return slf.renderToken( tokens, idx, mdOptions );
722
739
  }
723
740
 
724
- return defaultHandler( tokens, idx, ...args );
741
+ return rendered;
725
742
  } );
726
743
 
727
744
  registerContainers( md, containerMap, {
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
- const image = sharp( srcPath );
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
- if ( targets.length === 0 || targets[targets.length - 1] < sourceWidth ) {
83
- targets.push( Math.min( sourceWidth, widths[widths.length - 1] ) );
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.6",
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
- "!**/*.test.js"
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.34.5",
81
+ "sharp": "^0.35.5",
78
82
  "yaml": "^2.8.3"
79
83
  },
80
84
  "devDependencies": {
@@ -53,7 +53,7 @@ function linkSelf() {
53
53
 
54
54
  /**
55
55
  * Creates the fixture's media files. Images are real (the pipeline reads their
56
- * dimensions); the other two only ever get copied, so a few bytes carrying the right
56
+ * dimensions); the others only ever get copied, so a few bytes carrying the right
57
57
  * extension are enough.
58
58
  */
59
59
  async function writeMedia() {
@@ -74,8 +74,26 @@ 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" );
95
+ // An extension VitePress does not know, so its link rules take the file for a page.
96
+ writeFileSync( join( media, "patch.diff" ), "--- a\n+++ b\n" );
79
97
  }
80
98
 
81
99
  /**
@@ -182,11 +200,59 @@ expect( published.some( f => /^narrow--800w-[0-9a-f]+\.jpg$/.test( f ) ),
182
200
  "caps the widest variant at the source width" );
183
201
  expect( !published.some( f => /^narrow--(960|1280|1920)w-/.test( f ) ),
184
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"}` );
185
210
  expect( media.includes( 'sizes="(min-width: 40rem) 32rem, 100vw"' ),
186
211
  "applies the sizes value from the site configuration" );
187
212
  expect( media.includes( 'sizes="100vw"' ), "lets a single image override sizes through its title" );
188
213
  expect( media.includes( 'src="https://example.com/absent.png"' ), "leaves a remote image untouched" );
189
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
+
190
256
  // ── published media that is not an image ─────────────────────────────────────
191
257
 
192
258
  console.log( "\nmedia that is not an image" );
@@ -194,6 +260,8 @@ console.log( "\nmedia that is not an image" );
194
260
  expect( published.includes( "doc.pdf" ), "copies a document into the site output" );
195
261
  expect( published.includes( "clip.mp4" ), "copies a video into the site output" );
196
262
  expect( media.includes( 'href="/media/doc.pdf"' ), "rewrites a link to a media file" );
263
+ expect( media.includes( 'href="/media/patch.diff"' ),
264
+ "links a media file of an extension VitePress does not know as that file, not as a page" );
197
265
  // On the entry page, where the fixture's page links live. VitePress resolves them to
198
266
  // .html; what matters is that the media pipeline did not touch them.
199
267
  expect( built( "index.html" ).includes( 'href="/containers.html"' ), "leaves a link to a page alone" );