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.
- package/CHANGELOG.md +79 -3
- package/COMPONENTS.md +629 -429
- package/FRONTMATTER.md +30 -0
- package/components/ScavoldCover.vue +162 -0
- package/components/ScavoldImage.vue +5 -1
- package/components/ScavoldMenu.vue +20 -1
- package/components/ScavoldVideo.vue +12 -53
- package/composables/hierarchy.ts +72 -1
- package/composables/useAutoplay.js +128 -0
- package/composables/useCover.js +128 -0
- package/composables/useVideo.js +11 -21
- package/index.d.ts +3 -0
- package/l10n/de.json +4 -0
- package/l10n/en.json +4 -0
- package/lib/config.js +28 -6
- package/lib/containers.js +53 -7
- package/lib/media.js +9 -2
- package/lib/menus.d.ts +17 -0
- package/lib/menus.js +73 -0
- package/lib/sectionManifest.js +40 -10
- package/package.json +1 -1
- package/scripts/check-fixture.js +87 -8
|
@@ -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
|
+
}
|
package/composables/useVideo.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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,
|
|
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
package/l10n/en.json
CHANGED
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
|
|
556
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
+
}
|
package/lib/sectionManifest.js
CHANGED
|
@@ -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
|
-
|
|
44
|
+
autoplay: {
|
|
44
45
|
type: "boolean",
|
|
45
|
-
label: "
|
|
46
|
-
hint: "
|
|
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.",
|