scavold 0.2.1 → 0.4.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,40 @@
1
+ import { computed } from "vue";
2
+ import { GRID_COLUMNS } from "../lib/containers.js";
3
+ import { containerProps } from "./useContainer.js";
4
+
5
+ export { GRID_COLUMNS };
6
+
7
+ /**
8
+ * Props definition for grid container components. Spread into `defineProps`
9
+ * alongside any component-specific props.
10
+ */
11
+ export const gridProps = {
12
+ ...containerProps,
13
+
14
+ /** Most columns side by side: "2", "3" or "4". */
15
+ dataColumns: { type: String, default: null },
16
+ };
17
+
18
+ /**
19
+ * Composable for grid container components.
20
+ *
21
+ * Parses the props emitted by the markdown-it container renderer for a
22
+ * `:::grid` block and exposes derived values ready for binding:
23
+ *
24
+ * - `columns` — most columns side by side, one of GRID_COLUMNS
25
+ *
26
+ * Custom grid components should call this composable so they share identical
27
+ * attribute resolution without duplicating the logic.
28
+ *
29
+ * @param {object} props reactive props from defineProps
30
+ * @returns {{ columns }}
31
+ */
32
+ export function useGrid( props ) {
33
+ const columns = computed( () => {
34
+ const count = Number( props.dataColumns );
35
+
36
+ return GRID_COLUMNS.includes( count ) ? count : GRID_COLUMNS[0];
37
+ } );
38
+
39
+ return { columns };
40
+ }
@@ -0,0 +1,42 @@
1
+ import { computed, inject, provide, unref } from "vue";
2
+
3
+ /**
4
+ * How large an image is drawn is a matter of the layout around it, not of the image
5
+ * and not of its author. A layout component that knows the width it gives its content
6
+ * provides a `sizes` value here, and every image inside that takes the site's default
7
+ * only takes this one instead. Images given a `sizes` of their own — a per-image
8
+ * override, or by a component like the cover — keep theirs.
9
+ *
10
+ * Resolved while rendering, so it is in the markup the server sends: no layout shift,
11
+ * and the browser's preload scanner sees it.
12
+ */
13
+ const IMAGE_SIZES = Symbol( "scavold:image-sizes" );
14
+
15
+ /**
16
+ * App-level map of column count → `sizes` of images in a grid of that many columns,
17
+ * given by the theme through `enhanceApp( context, { gridImageSizes } )`.
18
+ */
19
+ export const GRID_IMAGE_SIZES = Symbol( "scavold:grid-image-sizes" );
20
+
21
+ /**
22
+ * Provides a `sizes` value for the images inside the calling component. An empty
23
+ * value leaves them with what the layout around provides, or the site's default.
24
+ *
25
+ * @param {string|import("vue").Ref<string>} sizes CSS sizes descriptor
26
+ */
27
+ export function provideImageSizes( sizes ) {
28
+ const outer = inject( IMAGE_SIZES, null );
29
+
30
+ provide( IMAGE_SIZES, computed( () => unref( sizes ) || outer?.value || "" ) );
31
+ }
32
+
33
+ /**
34
+ * Reads the `sizes` value the layout around provides.
35
+ *
36
+ * @returns {import("vue").ComputedRef<string>} empty when no layout provides one
37
+ */
38
+ export function useImageSizes() {
39
+ const provided = inject( IMAGE_SIZES, null );
40
+
41
+ return computed( () => provided?.value || "" );
42
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 64 32 32"><rect x="2" y="66" width="12" height="12" rx="3" fill="currentColor"/><rect x="18" y="66" width="12" height="4" rx="2" fill="currentColor"/><rect x="18" y="74" width="12" height="4" rx="2" fill="currentColor"/><rect x="2" y="82" width="28" height="4" rx="2" fill="currentColor"/><rect x="2" y="90" width="28" height="4" rx="2" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="64 64 32 32"><rect x="82" y="66" width="12" height="12" rx="3" fill="currentColor"/><rect x="66" y="66" width="12" height="4" rx="2" fill="currentColor"/><rect x="66" y="74" width="12" height="4" rx="2" fill="currentColor"/><rect x="66" y="82" width="28" height="4" rx="2" fill="currentColor"/><rect x="66" y="90" width="28" height="4" rx="2" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 128 32 32"><rect x="2" y="130" width="12" height="12" rx="3" fill="currentColor"/><rect x="18" y="130" width="12" height="4" rx="2" fill="currentColor"/><rect x="18" y="138" width="12" height="4" rx="2" fill="currentColor"/><rect x="18" y="146" width="12" height="4" rx="2" fill="currentColor"/><rect x="18" y="154" width="12" height="4" rx="2" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="64 128 32 32"><rect x="82" y="130" width="12" height="12" rx="3" fill="currentColor"/><rect x="66" y="130" width="12" height="4" rx="2" fill="currentColor"/><rect x="66" y="138" width="12" height="4" rx="2" fill="currentColor"/><rect x="66" y="146" width="12" height="4" rx="2" fill="currentColor"/><rect x="66" y="154" width="12" height="4" rx="2" fill="currentColor"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="2" y="2" width="28" height="12" rx="3" fill="currentColor"/><rect x="2" y="18" width="28" height="4" rx="2" fill="currentColor"/><rect x="2" y="26" width="28" height="4" rx="2" fill="currentColor"/></svg>
package/l10n/de.json CHANGED
@@ -13,5 +13,11 @@
13
13
  "cover": {
14
14
  "play": "Video abspielen",
15
15
  "pause": "Video anhalten"
16
+ },
17
+ "details": {
18
+ "summary": "Details"
19
+ },
20
+ "card": {
21
+ "more": "Weiterlesen"
16
22
  }
17
23
  }
package/l10n/en.json CHANGED
@@ -13,5 +13,11 @@
13
13
  "cover": {
14
14
  "play": "Play video",
15
15
  "pause": "Pause video"
16
+ },
17
+ "details": {
18
+ "summary": "Details"
19
+ },
20
+ "card": {
21
+ "more": "Read more"
16
22
  }
17
23
  }
package/lib/config.js CHANGED
@@ -7,9 +7,10 @@ import {
7
7
  import { extractFrontmatterMediaSrcs } from "./excerpt.js";
8
8
  import { DEFAULT_FEED_LIMIT, collectFeedPages, renderRss } from "./feed.js";
9
9
  import { useMedia } from "./media.js";
10
+ import { parseImageTitle, registerFigures } from "./figures.js";
10
11
  import { patchRenderer } from "./markdown.js";
11
12
  import { extractContainerMediaSrcs, leadingContainerOf, mediaPropsOf, registerContainers, resolveContainerMap } from "./containers.js";
12
- import { buildSectionManifest, writeSectionManifest } from "./sectionManifest.js";
13
+ import { buildSectionManifest, embedIcons, readIcons, writeSectionManifest } from "./sectionManifest.js";
13
14
  import { isExternalUrl, servableRedirectTarget } from "./redirectTarget.js";
14
15
  import { assertUniquePageKeys } from "./pageKeys.js";
15
16
  import { undeclaredMenus } from "./menus.js";
@@ -123,25 +124,6 @@ function createExternalRedirectMapPlugin( pagesDir ) {
123
124
  };
124
125
  }
125
126
 
126
- // ─────────────────────────────────────────────────────────────────────────────
127
-
128
- /**
129
- * Extracts an optional sizes override from a markdown image title string.
130
- * Recognises the pattern: sizes=VALUE or sizes="VALUE"
131
- *
132
- * @param {string|null} title
133
- * @returns {string|undefined}
134
- */
135
- function parseSizesFromTitle( title ) {
136
- if ( !title ) {
137
- return undefined;
138
- }
139
-
140
- const match = title.match( /(?:^|\s)sizes=(?:"([^"]+)"|(\S+))/ );
141
-
142
- return match?.[1] ?? match?.[2] ?? undefined;
143
- }
144
-
145
127
  /**
146
128
  * Resolves the three content folders from the VitePress config and the declarations
147
129
  * of `.cratly.config.yaml`. An explicit value in the VitePress config always wins,
@@ -487,7 +469,8 @@ export async function augmentConfig( rawConfig, options = {} ) {
487
469
 
488
470
  // Emit the section-type manifest (.cratly/sections.json) so the cratly
489
471
  // editor can render typed controls per section kind. Built from Scavold's
490
- // built-in sections merged with the site's container declarations. Written
472
+ // built-in sections merged with the site's container declarations, and the
473
+ // icons they refer to from .cratly/icons/. Written
491
474
  // at build start and once when the dev server starts listening.
492
475
  /**
493
476
  *
@@ -495,6 +478,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
495
478
  async function emitSectionManifest() {
496
479
  try {
497
480
  const manifest = buildSectionManifest( declaredContainers, { adapterVersion: SCAVOLD_VERSION } );
481
+ embedIcons( manifest, await readIcons( process.cwd() ) );
498
482
  await writeSectionManifest( process.cwd(), manifest );
499
483
  } catch ( error ) {
500
484
  console.warn( "[scavold] could not write .cratly/sections.json —", error.message );
@@ -713,9 +697,13 @@ export async function augmentConfig( rawConfig, options = {} ) {
713
697
  config( md ) {
714
698
  // see https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs
715
699
 
700
+ // Splits image titles into the sizes override and the caption, turning an
701
+ // image standing alone with a caption into a <figure>; see figures.js.
702
+ registerFigures( md );
703
+
716
704
  patchRenderer( md, "image", ( defaultHandler, tokens, idx, mdOptions, ...args ) => {
717
705
  const token = tokens[idx];
718
- const sizesOverride = parseSizesFromTitle( token.attrGet( "title" ) );
706
+ const sizesOverride = token.meta?.sizes ?? parseImageTitle( token.attrGet( "title" ) ).sizes;
719
707
  const data = media.collectImage( token.attrGet( "src" ), sizesOverride );
720
708
 
721
709
  if ( data ) {
@@ -724,6 +712,11 @@ export async function augmentConfig( rawConfig, options = {} ) {
724
712
  token.attrSet( "srcset", data.srcset );
725
713
  token.attrSet( "webp-srcset", data.webpSrcset );
726
714
  token.attrSet( "sizes", data.sizes );
715
+
716
+ // Only the site's default: a layout around the image may know better.
717
+ if ( !sizesOverride ) {
718
+ token.attrSet( "sizes-fallback", "" );
719
+ }
727
720
  mdOptions = { ...mdOptions, xhtmlOut: true };
728
721
  }
729
722
 
package/lib/containers.js CHANGED
@@ -15,6 +15,9 @@ export const SECTIONING_ELEMENTS = new Set( [
15
15
  "section",
16
16
  ] );
17
17
 
18
+ /** Column counts a `:::grid` offers; the first is the default. */
19
+ export const GRID_COLUMNS = [ 2, 3, 4 ];
20
+
18
21
  /**
19
22
  * Builds the default container-name → component-name map from the set of
20
23
  * built-in sectioning element names plus other built-in containers.
@@ -29,7 +32,10 @@ function defaultContainerMap() {
29
32
  }
30
33
 
31
34
  map.video = "ScavoldVideo";
35
+ map.card = "ScavoldCard";
32
36
  map.cover = "ScavoldCover";
37
+ map.details = "ScavoldDetails";
38
+ map.grid = "ScavoldGrid";
33
39
  map.pagelist = "ScavoldPageList";
34
40
 
35
41
  return map;
package/lib/figures.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The title of a Markdown image, `![alt](/photo.jpg "…")`, carries two things: an
3
+ * optional `sizes=` override, which is for the build only, and the image's caption,
4
+ * which is everything else. Neither reaches the page as a `title` attribute — the
5
+ * override would show up as a tooltip, and a caption is shown as such where it can be.
6
+ */
7
+ const SIZES = /(?:^|\s)sizes=(?:"([^"]+)"|(\S+))/;
8
+
9
+ /**
10
+ * Splits an image title into its `sizes=` override and its caption.
11
+ *
12
+ * @param {string|null|undefined} title title of a Markdown image
13
+ * @returns {{sizes: string|undefined, caption: string}}
14
+ */
15
+ export function parseImageTitle( title ) {
16
+ const text = String( title ?? "" );
17
+ const match = text.match( SIZES );
18
+
19
+ return {
20
+ sizes: match ? match[1] ?? match[2] : undefined,
21
+ caption: ( match ? text.replace( match[0], " " ) : text ).replace( /\s+/g, " " ).trim(),
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Tells whether the children of an inline token are one image and nothing else but
27
+ * blank text, i.e. whether the paragraph holding them is an image standing alone.
28
+ *
29
+ * @param {Array<object>} children
30
+ * @returns {object|null} the image token, or null
31
+ */
32
+ function soleImage( children = [] ) {
33
+ const significant = children.filter( child =>
34
+ !( child.type === "softbreak" || ( child.type === "text" && child.content.trim() === "" ) ) );
35
+
36
+ return significant.length === 1 && significant[0].type === "image" ? significant[0] : null;
37
+ }
38
+
39
+ /**
40
+ * Registers a core rule turning the image titles of a page into what they mean: the
41
+ * `sizes=` override goes to `meta.sizes` of the image token for the renderer, and the
42
+ * caption of an image standing alone in its paragraph turns that paragraph into a
43
+ * `<figure>` with a `<figcaption>`. An image amid text cannot have a figure around it;
44
+ * its caption stays its title, shown as a tooltip.
45
+ *
46
+ * @param {import("markdown-it")} md markdown-it instance
47
+ */
48
+ export function registerFigures( md ) {
49
+ md.core.ruler.after( "inline", "scavold_figures", state => {
50
+ const { tokens } = state;
51
+
52
+ for ( let i = 0; i < tokens.length; i++ ) {
53
+ const inline = tokens[i];
54
+
55
+ if ( inline.type !== "inline" ) {
56
+ continue;
57
+ }
58
+
59
+ for ( const child of inline.children ?? [] ) {
60
+ if ( child.type === "image" ) {
61
+ const { sizes, caption } = parseImageTitle( child.attrGet( "title" ) );
62
+
63
+ child.meta = { ...child.meta, sizes, caption };
64
+
65
+ if ( caption ) {
66
+ child.attrSet( "title", caption );
67
+ } else {
68
+ child.attrs = ( child.attrs ?? [] ).filter( ( [name] ) => name !== "title" );
69
+ }
70
+ }
71
+ }
72
+
73
+ const open = tokens[i - 1];
74
+ const close = tokens[i + 1];
75
+ const image = soleImage( inline.children );
76
+
77
+ // A paragraph of a tight list item is not rendered as an element, so it
78
+ // cannot become a figure either.
79
+ if ( open?.type !== "paragraph_open" || close?.type !== "paragraph_close" || open.hidden ||
80
+ !image?.meta.caption ) {
81
+ continue;
82
+ }
83
+
84
+ // The caption is shown below the image, so it is no tooltip as well.
85
+ image.attrs = image.attrs.filter( ( [name] ) => name !== "title" );
86
+
87
+ open.tag = close.tag = "figure";
88
+ open.attrJoin( "class", "scavold-figure" );
89
+
90
+ const captionOpen = new state.Token( "figcaption_open", "figcaption", 1 );
91
+ captionOpen.attrSet( "class", "scavold-figure__caption" );
92
+
93
+ const text = new state.Token( "text", "", 0 );
94
+ text.content = image.meta.caption;
95
+
96
+ inline.children.push( captionOpen, text, new state.Token( "figcaption_close", "figcaption", -1 ) );
97
+ }
98
+ } );
99
+ }
package/lib/icons.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Turns an icon sheet — one SVG document holding every icon as a group named by its
3
+ * id, each in a 32x32 cell at a multiple of 64 — into one SVG per icon, in the form the
4
+ * cratly editor shows unchanged. design/icons.svg is such a sheet, edited in Affinity;
5
+ * scripts/build-icons.js writes what this returns to icons/.
6
+ *
7
+ * The editor rebuilds every icon from a fixed subset of SVG and drops the rest (see
8
+ * "Icon markup is untrusted" on https://cratly.io/reference/section-types#icons). An
9
+ * icon relying on anything else would look broken there without a word, so this
10
+ * refuses it instead. Affinity writes paint into `style` attributes and inherits some
11
+ * of it from the root element; both are turned into plain attributes, and black into
12
+ * `currentColor`, so an icon takes the colour of the control showing it.
13
+ */
14
+
15
+ /** Edge of an icon's cell. */
16
+ export const ICON_SIZE = 32;
17
+
18
+ /** Distance between the cells of the sheet. */
19
+ export const ICON_GRID = 64;
20
+
21
+ /** Elements the editor keeps, besides the root and groups. */
22
+ const SHAPES = new Set( [ "path", "circle", "ellipse", "line", "polyline", "polygon", "rect" ] );
23
+
24
+ /** Attributes the editor keeps on a shape. */
25
+ const GEOMETRY = new Set( [
26
+ "d", "points", "x", "y", "x1", "y1", "x2", "y2", "cx", "cy", "r", "rx", "ry", "width", "height", "transform",
27
+ ] );
28
+ const PAINT = new Set( [
29
+ "fill", "fill-rule", "fill-opacity", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
30
+ "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "clip-rule",
31
+ ] );
32
+
33
+ /** Spellings of black, the colour an icon is drawn in. */
34
+ const BLACK = /^(?:#000(?:000)?|black|rgb\(\s*0\s*,\s*0\s*,\s*0\s*\))$/i;
35
+
36
+ /**
37
+ * Reads the attributes of one tag, its `style` declarations included.
38
+ *
39
+ * @param {string} tag opening tag, e.g. `<rect x="1" style="fill:none"/>`
40
+ * @returns {Map<string,string>}
41
+ */
42
+ function attributesOf( tag ) {
43
+ const attributes = new Map();
44
+
45
+ for ( const [ , name, value ] of tag.matchAll( /([\w:-]+)="([^"]*)"/g ) ) {
46
+ if ( name === "style" ) {
47
+ for ( const declaration of value.split( ";" ) ) {
48
+ const [ property, ...rest ] = declaration.split( ":" );
49
+
50
+ if ( property.trim() && rest.length ) {
51
+ attributes.set( property.trim(), rest.join( ":" ).trim() );
52
+ }
53
+ }
54
+ } else {
55
+ attributes.set( name, value );
56
+ }
57
+ }
58
+
59
+ return attributes;
60
+ }
61
+
62
+ /**
63
+ * Normalises the paint of an icon: black becomes `currentColor`, lengths lose their
64
+ * unit. Any other colour is refused, since an icon has one colour, the control's.
65
+ *
66
+ * @param {string} name icon name, for the error message
67
+ * @param {string} property
68
+ * @param {string} value
69
+ * @returns {string}
70
+ */
71
+ function paintValue( name, property, value ) {
72
+ if ( property === "fill" || property === "stroke" ) {
73
+ if ( value === "none" || value === "currentColor" ) {
74
+ return value;
75
+ }
76
+
77
+ if ( BLACK.test( value ) ) {
78
+ return "currentColor";
79
+ }
80
+
81
+ throw new Error( `icon '${name}' is painted ${value}; draw it in black, it takes the colour of the control` );
82
+ }
83
+
84
+ return value.replace( /^(-?[\d.]+)px$/, "$1" );
85
+ }
86
+
87
+ /**
88
+ * Finds where a shape starts, to tell which cell of the sheet it is in.
89
+ *
90
+ * @param {string} element
91
+ * @param {Map<string,string>} attributes
92
+ * @returns {[number, number]|null}
93
+ */
94
+ function originOf( element, attributes ) {
95
+ const number = key => Number.parseFloat( attributes.get( key ) );
96
+
97
+ switch ( element ) {
98
+ case "rect" :
99
+ return [ number( "x" ) || 0, number( "y" ) || 0 ];
100
+ case "circle" :
101
+ case "ellipse" :
102
+ return [ number( "cx" ), number( "cy" ) ];
103
+ case "line" :
104
+ return [ number( "x1" ), number( "y1" ) ];
105
+ case "polyline" :
106
+ case "polygon" : {
107
+ const [ x, y ] = ( attributes.get( "points" ) ?? "" ).trim().split( /[\s,]+/ ).map( Number.parseFloat );
108
+ return [ x, y ];
109
+ }
110
+ case "path" : {
111
+ const match = /^\s*M\s*(-?[\d.]+)[\s,]*(-?[\d.]+)/.exec( attributes.get( "d" ) ?? "" );
112
+ return match ? [ Number.parseFloat( match[1] ), Number.parseFloat( match[2] ) ] : null;
113
+ }
114
+ default :
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Splits an icon sheet into one SVG document per icon.
121
+ *
122
+ * @param {string} sheet SVG source of the sheet
123
+ * @returns {Map<string,string>} icon name → SVG markup, in the order of the sheet
124
+ */
125
+ export function splitIconSheet( sheet ) {
126
+ const source = String( sheet ).replace( /<!--[\s\S]*?-->/g, "" );
127
+ const root = /<svg\b[^>]*>/.exec( source );
128
+
129
+ if ( !root ) {
130
+ throw new Error( "the icon sheet has no <svg> element" );
131
+ }
132
+
133
+ // What the root paints with, inherited by every shape that says nothing else.
134
+ const inherited = new Map( [...attributesOf( root[0] )].filter( ( [property] ) => PAINT.has( property ) ) );
135
+ const icons = new Map();
136
+
137
+ for ( const [ , name, body ] of source.matchAll( /<g\b[^>]*\sid="([^"]+)"[^>]*>([\s\S]*?)<\/g>/g ) ) {
138
+ if ( !/^[a-z0-9][a-z0-9-]*$/.test( name ) ) {
139
+ throw new Error( `icon '${name}' needs a name of lower-case letters, digits and dashes` );
140
+ }
141
+
142
+ if ( /<g\b/.test( body ) ) {
143
+ throw new Error( `icon '${name}' nests groups; keep its shapes in one group` );
144
+ }
145
+
146
+ const shapes = [];
147
+ let origin = null;
148
+
149
+ for ( const [ tag, element ] of body.matchAll( /<([\w:-]+)\b[^>]*?\/?>/g ) ) {
150
+ if ( tag.startsWith( "</" ) ) {
151
+ continue;
152
+ }
153
+
154
+ if ( !SHAPES.has( element ) ) {
155
+ throw new Error( `icon '${name}' uses <${element}>, which the editor drops` );
156
+ }
157
+
158
+ const attributes = attributesOf( tag );
159
+ const kept = [];
160
+
161
+ for ( const [ property, value ] of attributes ) {
162
+ if ( GEOMETRY.has( property ) ) {
163
+ kept.push( [ property, value ] );
164
+ } else if ( PAINT.has( property ) ) {
165
+ kept.push( [ property, paintValue( name, property, value ) ] );
166
+ } else if ( property !== "id" && !property.includes( ":" ) ) {
167
+ throw new Error( `icon '${name}' sets ${property} on <${element}>, which the editor drops` );
168
+ }
169
+ }
170
+
171
+ const [ x, y ] = originOf( element, attributes ) ?? [];
172
+
173
+ if ( Number.isFinite( x ) && Number.isFinite( y ) ) {
174
+ origin = origin ? [ Math.min( origin[0], x ), Math.min( origin[1], y ) ] : [ x, y ];
175
+ }
176
+
177
+ shapes.push( `<${element} ${kept.map( ( [ k, v ] ) => `${k}="${v}"` ).join( " " )}/>` );
178
+ }
179
+
180
+ if ( !shapes.length || !origin ) {
181
+ throw new Error( `icon '${name}' draws nothing` );
182
+ }
183
+
184
+ const cell = origin.map( value => Math.floor( value / ICON_GRID ) * ICON_GRID );
185
+ const rootPaint = [...inherited].map( ( [ k, v ] ) => ` ${k}="${paintValue( name, k, v )}"` ).join( "" );
186
+
187
+ icons.set( name,
188
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${cell[0]} ${cell[1]} ${ICON_SIZE} ${ICON_SIZE}"${rootPaint}>` +
189
+ shapes.join( "" ) + "</svg>\n" );
190
+ }
191
+
192
+ if ( !icons.size ) {
193
+ throw new Error( "the icon sheet holds no group with an id" );
194
+ }
195
+
196
+ return icons;
197
+ }
package/lib/index.d.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import type { App } from "vue";
2
2
  import type { Router } from "vitepress";
3
3
 
4
+ export interface EnhanceAppOptions {
5
+ /**
6
+ * `sizes` of the images in a grid, by its column count — e.g.
7
+ * `{ 3: "(min-width: 72rem) 22rem, (min-width: 48rem) 50vw, 100vw" }`. The theme
8
+ * knows how wide a cell is at which screen width; a column count left out keeps
9
+ * the site's `image_sizes` for its images.
10
+ */
11
+ gridImageSizes?: Partial<Record<2 | 3 | 4, string>>;
12
+ }
13
+
4
14
  /**
5
15
  * Theme entry point: registers Scavold's i18n catalogue, makes every Scavold
6
16
  * component globally available, and installs the runtime redirect handling.
7
17
  * Call it from the site theme's own `enhanceApp()`.
8
18
  */
9
- export function enhanceApp( context: { app: App; router: Router } ): Promise<void>;
19
+ export function enhanceApp( context: { app: App; router: Router }, options?: EnhanceAppOptions ): Promise<void>;
package/lib/index.js CHANGED
@@ -1,14 +1,23 @@
1
1
  import { registerScavoldI18n } from "../composables/useI18n.js";
2
+ import { GRID_IMAGE_SIZES } from "../composables/useImageSizes.js";
3
+ // Layout variants of the sectioning wrappers, `layout=` on their container line.
4
+ import "../styles/layouts.css";
2
5
  // Populated at build time by the scavold:external-redirect-map Vite plugin.
3
6
  // Maps site-relative paths ("/de/links") to their external redirect targets.
4
7
  import externalRedirectMap from "virtual:scavold/external-redirect-map";
5
8
 
6
9
  /**
10
+ * Theme entry point; see index.d.ts.
7
11
  *
12
+ * @param {{app: import("vue").App, router: import("vitepress").Router}} context
13
+ * @param {object} [options]
14
+ * @param {Object<number,string>} [options.gridImageSizes] column count → sizes of images in a grid
8
15
  */
9
- export async function enhanceApp( { app, router } ) {
16
+ export async function enhanceApp( { app, router }, { gridImageSizes = {} } = {} ) {
10
17
  registerScavoldI18n();
11
18
 
19
+ app.provide( GRID_IMAGE_SIZES, { ...gridImageSizes } );
20
+
12
21
  const items = Object.entries( import.meta.glob( "../components/*.vue" ) );
13
22
 
14
23
  await Promise.all( items.map( ( [ path, importer ] ) => {