scavold 0.2.0-rc.7 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.0-rc.7",
3
+ "version": "0.2.0",
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": {
@@ -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.
@@ -138,7 +154,7 @@ expect( manifest.adapter?.version === version, "reports this package's version a
138
154
  expect( manifest.sections?.video?.props?.src?.type === "media-file", "declares video.src as a media file" );
139
155
  expect( manifest.sections?.pagelist?.props?.sort?.values?.join() === "order,date,date-asc,title",
140
156
  "declares the page list's ordering options" );
141
- expect( manifest.sections?.video?.props?.overlay?.type === "boolean", "declares video.overlay as a boolean" );
157
+ expect( manifest.sections?.video?.props?.overlay === undefined, "offers no background mode for a video; that is a cover's" );
142
158
  expect( manifest.sections?.teaser?.props?.background?.type === "media-file",
143
159
  "carries the site's own container with its typed props" );
144
160
  expect( manifest.sections?.teaser?.props?.variant?.values?.join() === "plain,boxed",
@@ -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" );
@@ -207,18 +271,67 @@ expect( built( "index.html" ).includes( 'href="/containers.html"' ), "leaves a l
207
271
  console.log( "\nvideo" );
208
272
 
209
273
  const videos = tags( media, "video" );
210
- const overlays = media.match( /<div class="scavold-video scavold-video--overlay"/g ) ?? [];
211
274
 
212
275
  expect( videos.every( t => /src="\/media\/clip\.mp4"/.test( t ) ), "rewrites the video source to its published URL" );
213
276
  expect( videos.some( t => /poster="\/media\/wide--\d+w-[0-9a-f]+\.jpg"/.test( t ) ),
214
277
  "resolves a poster to a single generated variant rather than a srcset" );
215
- expect( overlays.length === 2, "renders background mode for both spellings of its arguments",
216
- `found ${overlays.length} overlay blocks, expected 2` );
217
-
218
- const backgrounds = videos.filter( t => !t.includes( "controls" ) );
219
278
 
220
- expect( backgrounds.length === 2 && backgrounds.every( t => /autoplay/.test( t ) && /loop/.test( t ) && /muted/.test( t ) ),
221
- "gives both spellings the same autoplay, loop and muted attributes" );
279
+ // Autoplay implies muted, which is how it shows in the markup: the playing itself is
280
+ // started by script, once reduced motion has been ruled out.
281
+ const looping = videos.filter( t => /\bloop\b/.test( t ) );
282
+
283
+ expect( looping.length === 2 && looping.every( t => /\bmuted\b/.test( t ) ),
284
+ "gives both spellings of autoplay and loop the same attributes",
285
+ `found ${looping.length} looping video(s), expected 2` );
286
+ expect( videos.every( t => !/\bautoplay\b/.test( t ) ), "leaves starting an autoplay video to the script" );
287
+ expect( videos.every( t => /\bcontrols\b/.test( t ) ), "gives every video its playback controls" );
288
+
289
+ // ── cover ────────────────────────────────────────────────────────────────────
290
+
291
+ console.log( "\ncover" );
292
+
293
+ expect( manifest.sections?.cover?.props?.src?.type === "media-file", "declares cover.src as a media file" );
294
+
295
+ const cover = built( "cover.html" );
296
+ const covers = cover.split( /<div class="scavold-cover[ "]/ ).slice( 1 );
297
+ const [ coverImage = "", coverVideo = "" ] = covers;
298
+
299
+ expect( covers.length === 2, "renders both covers of the page", `found ${covers.length}` );
300
+ expect( /<div class="scavold-cover[ "][^>]*\sdata-leading[\s=>]/.test( cover ) && cover.match( /\sdata-leading[\s=>]/g )?.length === 1,
301
+ "marks the cover opening the page, and only that one" );
302
+ expect( cover.includes( '<main data-leading-container="cover">' ),
303
+ "tells the layout before the content renders that the page opens with a cover" );
304
+ expect( !/<main[^>]*data-leading-container/.test( built( "media.html" ) ),
305
+ "tells it nothing on a page that opens with anything else" );
306
+ // The variants, not just the widest one: a cover is the largest image of a page and
307
+ // must not cost a phone the full 1920px file.
308
+ expect( /<source type="image\/webp" srcset="[^"]*wide--320w-[0-9a-f]+\.webp 320w[^"]*wide--1920w-[0-9a-f]+\.webp 1920w"/.test( coverImage ),
309
+ "renders a cover image from all its variants" );
310
+ // A 16:9 source cropped to an upright window is drawn wider than the window.
311
+ expect( coverImage.includes( 'sizes="(min-aspect-ratio: 2400/1350) 100vw, 177.78vh"' ),
312
+ "tells the browser how wide a cropped cover image is drawn" );
313
+ expect( /<img[^>]*loading="eager"/.test( coverImage ) && /<img[^>]*fetchpriority="high"/.test( coverImage ),
314
+ "loads a cover image at once rather than lazily" );
315
+ expect( /<img[^>]*alt="A wide blue field"/.test( coverImage ), "uses the label as the image's alternative text" );
316
+ expect( coverImage.includes( "--scavold-cover-focus:center top" ) || coverImage.includes( "--scavold-cover-focus: center top" ),
317
+ "passes the focal point on to the stylesheet" );
318
+ expect( /class="scavold-cover__overlay"[^>]*>\s*(?:<!--\[-->)?\s*<h1[^>]*>Over the cover image/.test( coverImage ),
319
+ "lays the section's content on top of the image" );
320
+
321
+ const coverVideoTag = tags( coverVideo, "video" )[0] ?? "";
322
+
323
+ expect( coverVideo.startsWith( "scavold-cover--video" ), "tells a video cover by its file type" );
324
+ // A mark for the theme to style, nothing more.
325
+ expect( /^scavold-cover--light"/.test( coverImage ), "marks the tone asked for the content on top" );
326
+ expect( !/scavold-cover--(light|dark)/.test( coverVideo ), "marks no tone where none is asked for" );
327
+ expect( !cover.includes( "data-poster-srcset" ), "keeps the variants of a still image out of the markup" );
328
+ expect( /src="\/media\/clip\.mp4"/.test( coverVideoTag ) && /poster="\/media\/wide--\d+w-[0-9a-f]+\.jpg"/.test( coverVideoTag ),
329
+ "resolves a cover video and its still image to their published URLs" );
330
+ // Started by script once reduced motion has been ruled out, never by the markup.
331
+ expect( /muted/.test( coverVideoTag ) && /loop/.test( coverVideoTag ) && !/autoplay/.test( coverVideoTag ),
332
+ "renders a cover video muted and looping, without autoplay in the markup" );
333
+ expect( /<button type="button" class="scavold-cover__toggle" aria-label="[^"]+"/.test( coverVideo ),
334
+ "offers a labelled button to pause a cover video" );
222
335
 
223
336
  // ── page list ────────────────────────────────────────────────────────────────
224
337
 
@@ -239,6 +352,36 @@ expect( menu.includes( '<a href="/containers.html">' ),
239
352
  expect( crumbs.includes( '<a href="/blog/index.html"' ) && crumbs.includes( 'aria-current="page"' ),
240
353
  "marks the page the breadcrumb ends on" );
241
354
 
355
+ console.log( "\nnamed menus" );
356
+
357
+ const footer = ( blogPage.match( /<nav aria-label="Footer"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
358
+ const footerTree = ( blogPage.match( /<nav aria-label="Footer tree"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
359
+
360
+ /**
361
+ * @param {string} nav one rendered menu
362
+ * @param {string} label link text of one entry
363
+ * @returns {number} how often the menu links that entry
364
+ */
365
+ function linked( nav, label ) {
366
+ return nav.split( `>${label}</a>` ).length - 1;
367
+ }
368
+
369
+ expect( linked( footer, "Imprint" ) === 1 && linked( footer, "Contact" ) === 1,
370
+ "lists in a named menu the pages choosing it" );
371
+ expect( linked( menu, "Imprint" ) === 0 && linked( menu, "About" ) === 0,
372
+ "leaves a page choosing only a named menu out of the page tree's menus" );
373
+ expect( linked( menu, "Contact" ) === 1,
374
+ "keeps a page in the page tree's menus when it chooses them along with a named one" );
375
+ expect( linked( footer, "Team" ) === 1 && footer.split( "<ul" ).length === 2,
376
+ "keeps a menu without sub-pages flat, listing a page chosen along with its parent beside it" );
377
+ expect( linked( footerTree, "Team" ) === 1 &&
378
+ />About<\/a><ul>(?:<!--\[-->)?<li[^>]*><a [^>]*>Team<\/a>/.test( footerTree ),
379
+ "lists a page chosen along with its parent below that parent where the menu shows sub-pages, not twice" );
380
+ expect( linked( footer, "Secret" ) === 0 && linked( menu, "Secret" ) === 0,
381
+ "lets hiding from menus rule over choosing one" );
382
+ expect( /chooses menu 'sidebar', which \.cratly\.config\.yaml does not declare/.test( log ),
383
+ "warns about a menu name the site does not declare" );
384
+
242
385
  const sitemap = built( "sitemap.xml" );
243
386
 
244
387
  expect( sitemap.includes( "<loc>https://fixture.example.com/index.html</loc>" ) &&