scavold 0.2.0-rc.9 → 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.
@@ -0,0 +1,128 @@
1
+ import { computed } from "vue";
2
+ import { containerProps } from "./useContainer.js";
3
+
4
+ /**
5
+ * File types a cover renders as a video. Anything else is taken for an image.
6
+ */
7
+ const VIDEO_EXTENSIONS = new Set( [ "mp4", "m4v", "webm", "ogv", "mov" ] );
8
+
9
+ /**
10
+ * Props definition for cover container components. Spread into `defineProps`
11
+ * alongside any component-specific props.
12
+ */
13
+ export const coverProps = {
14
+ ...containerProps,
15
+
16
+ /** URL of the image or video. */
17
+ dataSrc: { type: String, default: "" },
18
+
19
+ /** Variants of an image `src`, provided by the build. */
20
+ dataSrcSrcset: { type: String, default: "" },
21
+ dataSrcWebpSrcset: { type: String, default: "" },
22
+
23
+ /** Upright dimensions of an image `src`, provided by the build. */
24
+ dataSrcWidth: { type: String, default: "" },
25
+ dataSrcHeight: { type: String, default: "" },
26
+
27
+ /** Still image of a video, shown until it plays. */
28
+ dataPoster: { type: String, default: "" },
29
+
30
+ /** Variants of the still image, provided by the build. Declared only so they do
31
+ * not end up in the markup: a poster takes a single URL. */
32
+ dataPosterSrcset: { type: String, default: "" },
33
+ dataPosterWebpSrcset: { type: String, default: "" },
34
+ dataPosterWidth: { type: String, default: "" },
35
+ dataPosterHeight: { type: String, default: "" },
36
+
37
+ /** Part of the picture kept visible when cropped, as a CSS `object-position`. */
38
+ dataFocus: { type: String, default: "" },
39
+
40
+ /** Alternative text of an image, accessible label of a video. */
41
+ dataLabel: { type: String, default: "" },
42
+
43
+ /** Whether the content on top is meant to be light or dark, see COVER_TONES. */
44
+ dataTone: { type: String, default: "" },
45
+ };
46
+
47
+ /**
48
+ * Tones an author may ask the content on top of a cover to take. The cover only
49
+ * marks its choice; the theme decides what light or dark content looks like.
50
+ */
51
+ export const COVER_TONES = [ "light", "dark" ];
52
+
53
+ /**
54
+ * Tells whether a URL names a video by its file extension.
55
+ *
56
+ * @param {string} url
57
+ * @returns {boolean}
58
+ */
59
+ export function isVideoUrl( url ) {
60
+ const match = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec( String( url ?? "" ) );
61
+
62
+ return Boolean( match ) && VIDEO_EXTENSIONS.has( match[1].toLowerCase() );
63
+ }
64
+
65
+ /**
66
+ * Describes the width an image is drawn at when it covers the whole window,
67
+ * cropped to its height: the window's width as long as the window is wider than
68
+ * the image, and the image's own aspect ratio applied to the window's height
69
+ * otherwise. A phone held upright thus fetches a variant wide enough for the
70
+ * part of the image that ends up outside the screen.
71
+ *
72
+ * Expressed as a media query rather than `max()`, which Safari does not accept in
73
+ * `sizes`.
74
+ *
75
+ * @param {number} width upright width of the source
76
+ * @param {number} height upright height of the source
77
+ * @returns {string} value for the `sizes` attribute
78
+ */
79
+ export function coverSizes( width, height ) {
80
+ if ( !( width > 0 ) || !( height > 0 ) ) {
81
+ return "100vw";
82
+ }
83
+
84
+ const ratio = Math.round( width / height * 10000 ) / 100;
85
+
86
+ return `(min-aspect-ratio: ${width}/${height}) 100vw, ${ratio}vh`;
87
+ }
88
+
89
+ /**
90
+ * Composable for cover container components.
91
+ *
92
+ * Parses the props emitted by the markdown-it container renderer for a `:::cover`
93
+ * block and exposes derived values ready for binding:
94
+ *
95
+ * - `src` — URL of the image or video
96
+ * - `isVideo` — whether `src` names a video
97
+ * - `srcset` — variants of an image in its original format
98
+ * - `webpSrcset` — variants of an image as WebP
99
+ * - `sizes` — `sizes` value for an image covering the window
100
+ * - `poster` — still image of a video, or empty string
101
+ * - `focus` — CSS `object-position` value, or empty string
102
+ * - `label` — alternative text or accessible label, or empty string
103
+ * - `tone` — `"light"` or `"dark"` for the content on top, or empty string
104
+ *
105
+ * @param {object} props reactive props from defineProps
106
+ * @returns {{ src, isVideo, srcset, webpSrcset, sizes, poster, focus, label, tone }}
107
+ */
108
+ export function useCover( props ) {
109
+ const src = computed( () => props.dataSrc ?? "" );
110
+ const isVideo = computed( () => isVideoUrl( src.value ) );
111
+
112
+ const srcset = computed( () => props.dataSrcSrcset ?? "" );
113
+ const webpSrcset = computed( () => props.dataSrcWebpSrcset ?? "" );
114
+ const sizes = computed( () => coverSizes( Number( props.dataSrcWidth ), Number( props.dataSrcHeight ) ) );
115
+
116
+ const poster = computed( () => props.dataPoster ?? "" );
117
+ const focus = computed( () => props.dataFocus ?? "" );
118
+ const label = computed( () => props.dataLabel ?? "" );
119
+
120
+ // Anything but a known tone leaves the choice to the theme.
121
+ const tone = computed( () => {
122
+ const value = String( props.dataTone ?? "" ).trim().toLowerCase();
123
+
124
+ return COVER_TONES.includes( value ) ? value : "";
125
+ } );
126
+
127
+ return { src, isVideo, srcset, webpSrcset, sizes, poster, focus, label, tone };
128
+ }
@@ -14,7 +14,15 @@ export const videoProps = {
14
14
  /** URL of the poster image shown before playback. */
15
15
  dataPoster: { type: String, default: "" },
16
16
 
17
- /** Play automatically when the page loads. Implies muted. */
17
+ /** Variants of the poster image, provided by the build. Declared only so they do
18
+ * not end up in the markup: a poster takes a single URL. */
19
+ dataPosterSrcset: { type: String, default: "" },
20
+ dataPosterWebpSrcset: { type: String, default: "" },
21
+ dataPosterWidth: { type: String, default: "" },
22
+ dataPosterHeight: { type: String, default: "" },
23
+
24
+ /** Play automatically once the page is up, unless the visitor asks for reduced
25
+ * motion. Implies muted. */
18
26
  dataAutoplay: { type: String, default: null },
19
27
 
20
28
  /** Loop the video when it ends. */
@@ -23,14 +31,6 @@ export const videoProps = {
23
31
  /** Mute the audio track. */
24
32
  dataMuted: { type: String, default: null },
25
33
 
26
- /** Render as a background: the video fills the block and the container's
27
- * body content is layered on top of it. */
28
- dataOverlay: { type: String, default: null },
29
-
30
- /** Show the native playback controls. Always present in the default player;
31
- * opt-in in overlay mode, where a background video is normally chrome-free. */
32
- dataControls: { type: String, default: null },
33
-
34
34
  /** Hint for preloading: "none", "metadata" (default), or "auto". */
35
35
  dataPreload: { type: String, default: null },
36
36
 
@@ -51,14 +51,12 @@ export const videoProps = {
51
51
  * - `loop` — boolean
52
52
  * - `muted` — boolean (forced true when autoplay is true)
53
53
  * - `preload` — "none" | "metadata" | "auto"
54
- * - `overlay` — boolean, render the video as a background behind the slot
55
- * - `controls` — boolean, always true in the default player; opt-in in overlay mode
56
54
  *
57
55
  * Custom video components should call this composable so they share identical
58
56
  * attribute resolution without duplicating the logic.
59
57
  *
60
58
  * @param {object} props reactive props from defineProps
61
- * @returns {{ src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel }}
59
+ * @returns {{ src, poster, autoplay, loop, muted, preload, ariaLabel }}
62
60
  */
63
61
  export function useVideo( props ) {
64
62
  const src = computed( () => props.dataSrc ?? "" );
@@ -75,15 +73,7 @@ export function useVideo( props ) {
75
73
  ( PRELOAD_VALUES.has( props.dataPreload ) ? props.dataPreload : "metadata" )
76
74
  );
77
75
 
78
- const overlay = computed( () => props.dataOverlay != null );
79
-
80
- // The default player always shows controls; a background video is chrome-free
81
- // unless the author opts back in with the `controls` flag.
82
- const controls = computed( () =>
83
- ( overlay.value ? props.dataControls != null : true )
84
- );
85
-
86
76
  const ariaLabel = computed( () => props.dataLabel ?? undefined );
87
77
 
88
- return { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel };
78
+ return { src, poster, autoplay, loop, muted, preload, ariaLabel };
89
79
  }
package/index.d.ts CHANGED
@@ -37,6 +37,9 @@ export module Scavold {
37
37
 
38
38
  interface AugmentedPageData extends PageData {
39
39
  hierarchy: HierarchyNode;
40
+
41
+ /** Name of the container the page opens with, e.g. `"cover"`, or null when it opens with anything else. Known before the content renders, so a layout can arrange its own parts around it. */
42
+ leadingContainer: string | null;
40
43
  }
41
44
 
42
45
  interface MenuItem {
package/l10n/de.json CHANGED
@@ -9,5 +9,9 @@
9
9
  "previous": "Vorherige Seite",
10
10
  "next": "Nächste Seite",
11
11
  "page": "Seite {number}"
12
+ },
13
+ "cover": {
14
+ "play": "Video abspielen",
15
+ "pause": "Video anhalten"
12
16
  }
13
17
  }
package/l10n/en.json CHANGED
@@ -9,5 +9,9 @@
9
9
  "previous": "Previous page",
10
10
  "next": "Next page",
11
11
  "page": "Page {number}"
12
+ },
13
+ "cover": {
14
+ "play": "Play video",
15
+ "pause": "Pause video"
12
16
  }
13
17
  }
package/lib/config.js CHANGED
@@ -8,10 +8,11 @@ import { extractFrontmatterMediaSrcs } from "./excerpt.js";
8
8
  import { DEFAULT_FEED_LIMIT, collectFeedPages, renderRss } from "./feed.js";
9
9
  import { useMedia } from "./media.js";
10
10
  import { patchRenderer } from "./markdown.js";
11
- import { extractContainerMediaSrcs, mediaPropsOf, registerContainers, resolveContainerMap } from "./containers.js";
11
+ import { extractContainerMediaSrcs, leadingContainerOf, mediaPropsOf, registerContainers, resolveContainerMap } from "./containers.js";
12
12
  import { buildSectionManifest, writeSectionManifest } from "./sectionManifest.js";
13
13
  import { isExternalUrl, servableRedirectTarget } from "./redirectTarget.js";
14
14
  import { assertUniquePageKeys } from "./pageKeys.js";
15
+ import { undeclaredMenus } from "./menus.js";
15
16
  import {
16
17
  BUILD_REPORT_DIR, BUILD_REPORT_FILE, formatProblems, renderBuildReport, verifyBuild,
17
18
  } from "./verifyBuild.js";
@@ -190,6 +191,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
190
191
  let declaredImageWidths = null;
191
192
  let declaredExcerptLength = null;
192
193
  let declaredRetiredUrls = [];
194
+ let declaredMenus = {};
193
195
 
194
196
  try {
195
197
  const { readFile } = await import( "node:fs/promises" );
@@ -204,6 +206,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
204
206
  declaredImageWidths = cratlyConfig.image_widths ?? null;
205
207
  declaredExcerptLength = cratlyConfig.excerpt_length ?? null;
206
208
  declaredRetiredUrls = cratlyConfig.retired_urls ?? [];
209
+ declaredMenus = cratlyConfig.menus ?? {};
207
210
  } catch {
208
211
  // file absent or unparseable — proceed with defaults
209
212
  }
@@ -552,13 +555,17 @@ export async function augmentConfig( rawConfig, options = {} ) {
552
555
  // author is still looking at it, and written out at the end of the build for
553
556
  // whoever configures the server. What Scavold produces is data, not server
554
557
  // configuration: cratly does not assume who serves the site.
555
- const legacyRedirects = compileLegacyRedirects(
556
- await collectFrontmatterOfAllPages( resolvedConfig ),
557
- { rewrites, retired: declaredRetiredUrls },
558
- );
558
+ const frontmatters = await collectFrontmatterOfAllPages( resolvedConfig );
559
+ const legacyRedirects = compileLegacyRedirects( frontmatters, { rewrites, retired: declaredRetiredUrls } );
559
560
 
560
561
  assertLegacyRedirects( legacyRedirects.problems );
561
562
 
563
+ // A page choosing a menu the site does not declare is in a menu no theme renders.
564
+ // Worth telling, not worth failing over: the page itself is fine.
565
+ for ( const { page, menu } of undeclaredMenus( frontmatters, declaredMenus ) ) {
566
+ console.warn( "[scavold] %s chooses menu '%s', which .cratly.config.yaml does not declare", page, menu );
567
+ }
568
+
562
569
  /**
563
570
  * Writes the redirect manifest, unless the site declares no former addresses.
564
571
  *
@@ -686,7 +693,21 @@ export async function augmentConfig( rawConfig, options = {} ) {
686
693
  url => media.collectImage( url ),
687
694
  );
688
695
 
689
- return { hierarchy };
696
+ // A layout renders its own parts — a breadcrumb, say — before the page
697
+ // content, so whether the page opens with a container has to be known before
698
+ // that content is rendered. Read from the source rather than the rendered
699
+ // page, which does not exist yet when the layout needs it.
700
+ let leadingContainer = null;
701
+
702
+ try {
703
+ leadingContainer = leadingContainerOf(
704
+ readFileSync( join( sourceFolder( resolvedConfig ), pageData.filePath ), "utf-8" ),
705
+ );
706
+ } catch {
707
+ // a page without a source file of its own, e.g. a generated one
708
+ }
709
+
710
+ return { hierarchy, leadingContainer };
690
711
  },
691
712
  markdown: {
692
713
  config( md ) {
@@ -744,6 +765,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
744
765
  registerContainers( md, containerMap, {
745
766
  mediaProps,
746
767
  resolveMedia: url => media.collectAsset( url ),
768
+ resolveImage: url => media.collectImage( url ),
747
769
  } );
748
770
  },
749
771
  },
package/lib/containers.js CHANGED
@@ -29,6 +29,7 @@ function defaultContainerMap() {
29
29
  }
30
30
 
31
31
  map.video = "ScavoldVideo";
32
+ map.cover = "ScavoldCover";
32
33
  map.pagelist = "ScavoldPageList";
33
34
 
34
35
  return map;
@@ -54,7 +55,7 @@ function resolveTag( name, map ) {
54
55
  * @param {string} containerName the original container name from markdown
55
56
  * @returns {string}
56
57
  */
57
- function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia } = {} ) {
58
+ function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia, resolveImage } = {}, leading = false ) {
58
59
  if ( token.nesting !== 1 ) {
59
60
  return `</${tag}>\n`;
60
61
  }
@@ -63,13 +64,28 @@ function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia
63
64
 
64
65
  // Arguments declared as `media-file` carry a path relative to the media folder, as
65
66
  // the editor writes them. Turn them into the URL the built site serves.
67
+ //
68
+ // One naming an image additionally brings its variants and its upright dimensions
69
+ // as `<key>-srcset`, `<key>-webp-srcset`, `<key>-width` and `<key>-height`, so a
70
+ // component can render it responsively instead of loading the widest variant.
66
71
  if ( resolveMedia ) {
67
72
  for ( const key of mediaProps[containerName] ?? [] ) {
68
73
  const value = args[key];
69
74
 
70
- if ( typeof value === "string" ) {
71
- args[key] = resolveMedia( value ) ?? value;
75
+ if ( typeof value !== "string" ) {
76
+ continue;
72
77
  }
78
+
79
+ const image = resolveImage?.( value );
80
+
81
+ if ( image ) {
82
+ args[`${key}-srcset`] = image.srcset;
83
+ args[`${key}-webp-srcset`] = image.webpSrcset;
84
+ args[`${key}-width`] = image.width;
85
+ args[`${key}-height`] = image.height;
86
+ }
87
+
88
+ args[key] = resolveMedia( value ) ?? value;
73
89
  }
74
90
  }
75
91
 
@@ -91,6 +107,13 @@ function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia
91
107
 
92
108
  attrs.push( `data-container="${containerName}"` );
93
109
 
110
+ // A container opening the page is marked as such, so a theme can tell a cover at
111
+ // the top of a page from one further down. It is information only; see
112
+ // leadingContainerOf() for the page-level counterpart.
113
+ if ( leading ) {
114
+ attrs.push( 'data-leading=""' );
115
+ }
116
+
94
117
  if ( classes ) {
95
118
  attrs.push( `class="${classes}"` );
96
119
  }
@@ -108,13 +131,16 @@ function renderToken( tag, token, containerName, { mediaProps = {}, resolveMedia
108
131
  * @param {Object<string,string>} containerMap resolved name→component map
109
132
  * @param {object} [options]
110
133
  * @param {Function} [options.warn] sink for the undeclared-name reports
134
+ * @param {Object<string,string[]>} [options.mediaProps] container name → names of its media-file props
135
+ * @param {function(string): (string|null)} [options.resolveMedia] published URL of a media reference
136
+ * @param {function(string): (object|null)} [options.resolveImage] variant data of an image reference
111
137
  */
112
- export function registerContainers( md, containerMap, { warn = console.warn, mediaProps, resolveMedia } = {} ) {
113
- const renderOptions = { mediaProps, resolveMedia };
138
+ export function registerContainers( md, containerMap, { warn = console.warn, mediaProps, resolveMedia, resolveImage } = {} ) {
139
+ const renderOptions = { mediaProps, resolveMedia, resolveImage };
114
140
 
115
141
  for ( const [ name, tag ] of Object.entries( containerMap ) ) {
116
142
  md.use( ContainerPlugin, name, {
117
- render: ( tokens, index ) => renderToken( tag, tokens[index], name, renderOptions ),
143
+ render: ( tokens, index ) => renderToken( tag, tokens[index], name, renderOptions, index === 0 ),
118
144
  } );
119
145
  }
120
146
 
@@ -145,11 +171,31 @@ export function registerContainers( md, containerMap, { warn = console.warn, med
145
171
  );
146
172
  }
147
173
 
148
- return renderToken( resolveTag( name, containerMap ), tokens[index], name, renderOptions );
174
+ return renderToken( resolveTag( name, containerMap ), tokens[index], name, renderOptions, index === 0 );
149
175
  },
150
176
  } );
151
177
  }
152
178
 
179
+ /**
180
+ * Names the container a page opens with, i.e. the one whose fence is the first thing
181
+ * in the page body, or null when the page opens with anything else. Follows
182
+ * markdown-it: the front matter does not count, blank lines do not either, and a
183
+ * fence may be indented by up to three spaces.
184
+ *
185
+ * This is the page-level counterpart of the `data-leading` attribute: a layout
186
+ * renders its own parts before the page content, so it has to know in advance.
187
+ *
188
+ * @param {string} source markdown source of one page, front matter included
189
+ * @returns {string|null}
190
+ */
191
+ export function leadingContainerOf( source ) {
192
+ const body = String( source ?? "" ).replace( /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, "" );
193
+ const first = body.split( /\r?\n/ ).find( line => line.trim() !== "" ) ?? "";
194
+ const match = /^ {0,3}:{3,}\s*(.*)$/.exec( first );
195
+
196
+ return match ? containerNameOf( match[1] ) || null : null;
197
+ }
198
+
153
199
  /**
154
200
  * Extracts the container name from a container fence's info string, i.e. the first
155
201
  * word after the colons. Returns an empty string when it is not a usable name.
package/lib/media.js CHANGED
@@ -72,7 +72,7 @@ export function toKebabCase( name ) {
72
72
  * @param {string} srcPath absolute path to source image
73
73
  * @param {string} outDir absolute path to public output folder
74
74
  * @param {number[]} widths list of target widths
75
- * @returns {Promise<{srcset: string, webpSrcset: string, fallbackSrc: string}>}
75
+ * @returns {Promise<{srcset: string, webpSrcset: string, fallbackSrc: string, width: number, height: number}>}
76
76
  */
77
77
  async function generateVariants( srcPath, outDir, widths ) {
78
78
  mkdirSync( outDir, { recursive: true } );
@@ -86,6 +86,7 @@ async function generateVariants( srcPath, outDir, widths ) {
86
86
  const image = sharp( srcPath ).autoOrient();
87
87
  const meta = await image.metadata();
88
88
  const sourceWidth = meta.autoOrient?.width ?? meta.width ?? Infinity;
89
+ const sourceHeight = meta.autoOrient?.height ?? meta.height;
89
90
 
90
91
  const targets = widths.filter( w => w <= sourceWidth );
91
92
 
@@ -123,6 +124,10 @@ async function generateVariants( srcPath, outDir, widths ) {
123
124
  srcset: srcsetParts.map( ( { width, url } ) => `${url} ${width}w` ).join( ", " ),
124
125
  webpSrcset: webpSrcsetParts.map( ( { width, url } ) => `${url} ${width}w` ).join( ", " ),
125
126
  fallbackSrc: srcsetParts[srcsetParts.length - 1].url,
127
+ // Upright dimensions of the source. A component cropping the image to a box of
128
+ // its own needs the aspect ratio to tell how wide the image is drawn.
129
+ width: sourceWidth,
130
+ height: sourceHeight,
126
131
  };
127
132
  }
128
133
 
@@ -253,6 +258,8 @@ export function useMedia( config, options = {} ) {
253
258
  srcset: variants.srcset,
254
259
  webpSrcset: variants.webpSrcset,
255
260
  sizes: defaultSizes,
261
+ width: variants.width,
262
+ height: variants.height,
256
263
  } );
257
264
  }
258
265
 
@@ -300,7 +307,7 @@ export function useMedia( config, options = {} ) {
300
307
  *
301
308
  * @param {string} imageUrl value of the src attribute from markdown
302
309
  * @param {string} [sizesOverride] per-image sizes attribute override
303
- * @returns {{src: string, srcset: string, webpSrcset: string, sizes: string}|null}
310
+ * @returns {{src: string, srcset: string, webpSrcset: string, sizes: string, width: number, height: number}|null}
304
311
  */
305
312
  collectImage( imageUrl, sizesOverride ) {
306
313
  const data = cache.get( imageUrl );
package/lib/menus.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Reserved menu name standing for the menus that follow the page tree. */
2
+ export const AUTO_MENU: "auto";
3
+
4
+ /** Normalises a page's `menus` front matter into a list of menu names, `auto` when absent. */
5
+ export function menusOf( menus: string | string[] | undefined | null ): string[];
6
+
7
+ /**
8
+ * Decides whether a page appears in one menu. A named menu replaces the page tree's
9
+ * menus rather than adding to it; hiding from menus rules over any choice.
10
+ */
11
+ export function appearsIn( frontmatter: { [key: string]: any } | undefined, name: string ): boolean;
12
+
13
+ /** Lists the menu names pages choose that the site does not declare. */
14
+ export function undeclaredMenus(
15
+ frontmatters: { [page: string]: { [key: string]: any } } | undefined,
16
+ declared: { [name: string]: unknown } | undefined,
17
+ ): Array<{ page: string; menu: string }>;
package/lib/menus.js ADDED
@@ -0,0 +1,73 @@
1
+ import { hidesFrom } from "./hide.js";
2
+
3
+ /**
4
+ * Reserved menu name standing for the menus that follow the page tree — the ones
5
+ * `<ScavoldMenu>` renders without a `menu` prop.
6
+ */
7
+ export const AUTO_MENU = "auto";
8
+
9
+ /**
10
+ * Normalises a page's `menus` front matter into a list of menu names.
11
+ *
12
+ * Absent or empty means the page tree's menus only, which is how every page behaved
13
+ * before named menus existed. A single name may be written without a list.
14
+ *
15
+ * @param {string|string[]|undefined} menus value of the page's `menus` front matter
16
+ * @returns {string[]} names of the menus the page is to appear in
17
+ */
18
+ export function menusOf( menus ) {
19
+ const names = ( Array.isArray( menus ) ? menus : [menus] )
20
+ .filter( name => typeof name === "string" && name.trim() )
21
+ .map( name => name.trim() );
22
+
23
+ return names.length ? names : [AUTO_MENU];
24
+ }
25
+
26
+ /**
27
+ * Decides whether a page appears in one menu.
28
+ *
29
+ * Choosing a named menu replaces the page tree's menus rather than adding to it, so a
30
+ * page can sit among the folders where it belongs and still be listed elsewhere only.
31
+ * Hiding a page from menus rules over any choice.
32
+ *
33
+ * Shared by the build and the browser, hence its own module, like hide.js.
34
+ *
35
+ * @param {object|undefined} frontmatter the page's front matter
36
+ * @param {string} name menu name, {@link AUTO_MENU} for the page tree's menus
37
+ * @returns {boolean} true when the page is to be listed in that menu
38
+ */
39
+ export function appearsIn( frontmatter, name ) {
40
+ if ( hidesFrom( frontmatter?.hide, "menu" ) ) {
41
+ return false;
42
+ }
43
+
44
+ return menusOf( frontmatter?.menus ).includes( name );
45
+ }
46
+
47
+ /**
48
+ * Lists the menu names pages choose that the site does not declare in its
49
+ * `.cratly.config.yaml`. Such a page is in a menu no theme renders, which is worth
50
+ * telling the author, not failing the build over.
51
+ *
52
+ * @param {Object<string,object>} frontmatters front matter per page file
53
+ * @param {object|undefined} declared `menus` block of `.cratly.config.yaml`
54
+ * @returns {Array<{page: string, menu: string}>} each undeclared name, by page
55
+ */
56
+ export function undeclaredMenus( frontmatters, declared ) {
57
+ const known = new Set( [ AUTO_MENU, ...Object.keys( declared ?? {} ) ] );
58
+ const found = [];
59
+
60
+ for ( const [ page, frontmatter ] of Object.entries( frontmatters ?? {} ) ) {
61
+ if ( frontmatter?.menus == null ) {
62
+ continue;
63
+ }
64
+
65
+ for ( const menu of menusOf( frontmatter.menus ) ) {
66
+ if ( !known.has( menu ) ) {
67
+ found.push( { page, menu } );
68
+ }
69
+ }
70
+ }
71
+
72
+ return found;
73
+ }
@@ -22,7 +22,8 @@ export const SECTION_MANIFEST_FILE = "sections.json";
22
22
  *
23
23
  * Keep the `video` props in sync with `videoProps` in
24
24
  * composables/useVideo.js — that composable defines the runtime (data-*) props;
25
- * this object describes the same options in the editor's typed vocabulary.
25
+ * this object describes the same options in the editor's typed vocabulary. The
26
+ * same goes for `cover` and `coverProps` in composables/useCover.js.
26
27
  */
27
28
  export const BUILTIN_SECTIONS = {
28
29
  // The seven sectioning wrappers are generic content containers with no
@@ -40,19 +41,14 @@ export const BUILTIN_SECTIONS = {
40
41
  props: {
41
42
  src: { type: "media-file", label: "Video file", required: true },
42
43
  poster: { type: "media-file", label: "Poster image" },
43
- overlay: {
44
+ autoplay: {
44
45
  type: "boolean",
45
- label: "Background mode",
46
- hint: "Play the video as a background and lay this container's content on top of it.",
46
+ label: "Autoplay (muted)",
47
+ hint: "Not for visitors who asked for reduced motion. For a video across the whole window, use a cover instead.",
48
+ default: false,
47
49
  },
48
- autoplay: { type: "boolean", label: "Autoplay (muted)", default: false },
49
50
  loop: { type: "boolean", label: "Loop" },
50
51
  muted: { type: "boolean", label: "Muted" },
51
- controls: {
52
- type: "boolean",
53
- label: "Show controls",
54
- hint: "Always shown for a normal video; in background mode the controls are hidden unless you enable this.",
55
- },
56
52
  preload: {
57
53
  type: "enum",
58
54
  label: "Preload",
@@ -67,6 +63,40 @@ export const BUILTIN_SECTIONS = {
67
63
  },
68
64
  },
69
65
 
66
+ cover: {
67
+ label: "Cover",
68
+ hint: "An image or a video across the full width and height of the window. Anything written inside the section is laid on top of it.",
69
+ props: {
70
+ src: {
71
+ type: "media-file",
72
+ label: "Image or video",
73
+ hint: "A video plays muted in a loop, with a button to pause it.",
74
+ required: true,
75
+ },
76
+ poster: {
77
+ type: "media-file",
78
+ label: "Still image",
79
+ hint: "Only for a video: shown until it plays, and instead of it for visitors who asked for reduced motion.",
80
+ },
81
+ focus: {
82
+ type: "text",
83
+ label: "Focal point",
84
+ hint: "Part of the picture that stays visible when the window crops it, e.g. \"center top\" or \"30% 60%\".",
85
+ },
86
+ label: {
87
+ type: "text",
88
+ label: "Description",
89
+ hint: "Alternative text of the image, or what the video shows. Leave empty when it is decoration only.",
90
+ },
91
+ tone: {
92
+ type: "enum",
93
+ label: "Content on top",
94
+ values: [ "light", "dark" ],
95
+ hint: "\"light\" over a dark picture, \"dark\" over a bright one. Left unset, the site's design decides.",
96
+ },
97
+ },
98
+ },
99
+
70
100
  pagelist: {
71
101
  label: "Page list",
72
102
  hint: "Links a configurable number of the site's own pages — the latest posts of a blog, for example. Anything written inside the section is rendered above the list.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.0-rc.9",
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": [