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.
- package/CHANGELOG.md +64 -1
- package/COMPONENTS.md +263 -0
- package/components/ScavoldCard.vue +51 -0
- package/components/ScavoldDetails.vue +23 -0
- package/components/ScavoldGrid.vue +53 -0
- package/components/ScavoldImage.vue +13 -4
- package/composables/useCard.js +45 -0
- package/composables/useDetails.js +43 -0
- package/composables/useGrid.js +40 -0
- package/composables/useImageSizes.js +42 -0
- package/icons/float-left.svg +1 -0
- package/icons/float-right.svg +1 -0
- package/icons/split-left.svg +1 -0
- package/icons/split-right.svg +1 -0
- package/icons/stacked.svg +1 -0
- package/l10n/de.json +6 -0
- package/l10n/en.json +6 -0
- package/lib/config.js +15 -22
- package/lib/containers.js +6 -0
- package/lib/figures.js +99 -0
- package/lib/icons.js +197 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +10 -1
- package/lib/sectionManifest.js +488 -74
- package/package.json +4 -1
- package/scripts/build-icons.js +27 -0
- package/scripts/check-fixture.js +101 -3
- package/styles/layouts.css +64 -0
|
@@ -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
package/l10n/en.json
CHANGED
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
|
|
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 =
|
|
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, ``, 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 ] ) => {
|