scavold 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.1",
3
+ "version": "0.5.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": [
@@ -45,9 +45,11 @@
45
45
  "files": [
46
46
  "components",
47
47
  "composables",
48
+ "icons",
48
49
  "l10n",
49
50
  "lib",
50
51
  "scripts",
52
+ "styles",
51
53
  "index.d.ts",
52
54
  "CHANGELOG.md",
53
55
  "COMPONENTS.md",
@@ -65,6 +67,7 @@
65
67
  "docs:preview": "vitepress preview docs",
66
68
  "test:unit": "bun test --coverage",
67
69
  "test:fixture": "bun scripts/check-fixture.js",
70
+ "icons": "bun scripts/build-icons.js",
68
71
  "test:unit:dev": "bun test --watch",
69
72
  "lint": "eslint .",
70
73
  "lint:fix": "eslint . --fix",
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bun
2
+ // Writes the icons of design/icons.svg — the sheet edited in Affinity — to icons/, one
3
+ // SVG per icon in the form the cratly editor shows unchanged. Run it after exporting
4
+ // the sheet again; a unit test fails while icons/ lags behind it.
5
+ //
6
+ // bun run icons
7
+
8
+ import { readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { splitIconSheet } from "../lib/icons.js";
11
+
12
+ const ROOT = resolve( import.meta.dirname, ".." );
13
+ const OUT = join( ROOT, "icons" );
14
+
15
+ const icons = splitIconSheet( readFileSync( join( ROOT, "design", "icons.svg" ), "utf-8" ) );
16
+
17
+ for ( const file of readdirSync( OUT ) ) {
18
+ if ( file.endsWith( ".svg" ) && !icons.has( file.slice( 0, -4 ) ) ) {
19
+ rmSync( join( OUT, file ) );
20
+ console.log( `removed icons/${file}` );
21
+ }
22
+ }
23
+
24
+ for ( const [ name, markup ] of icons ) {
25
+ writeFileSync( join( OUT, `${name}.svg` ), markup );
26
+ console.log( `wrote icons/${name}.svg` );
27
+ }
@@ -115,14 +115,30 @@ function tags( html, tag ) {
115
115
  return html.match( new RegExp( `<${tag}[^>]*>`, "g" ) ) ?? [];
116
116
  }
117
117
 
118
+ /**
119
+ * Lists the distinct `sizes` attributes in a piece of markup.
120
+ *
121
+ * @param {string} html
122
+ * @returns {string[]} each as written, e.g. `sizes="100vw"`
123
+ */
124
+ function sizesOf( html ) {
125
+ return [...new Set( html.match( /sizes="[^"]*"/g ) ?? [] )];
126
+ }
127
+
118
128
  // ── build ────────────────────────────────────────────────────────────────────
119
129
 
120
130
  linkSelf();
121
131
  await writeMedia();
122
132
 
123
133
  // Remove everything the build is supposed to produce, so no assertion can pass on a
124
- // leftover from an earlier run.
125
- for ( const path of [ DIST, MEDIA_OUT, join( SITE, ".cratly" ), join( SITE, ".vitepress", "cache" ) ] ) {
134
+ // leftover from an earlier run. .cratly/icons/ is the site's own source and stays.
135
+ for ( const path of [
136
+ DIST,
137
+ MEDIA_OUT,
138
+ join( SITE, ".cratly", "sections.json" ),
139
+ join( SITE, ".cratly", "build-report.json" ),
140
+ join( SITE, ".vitepress", "cache" ),
141
+ ] ) {
126
142
  rmSync( path, { recursive: true, force: true } );
127
143
  }
128
144
 
@@ -152,13 +168,32 @@ const version = JSON.parse( readFileSync( join( ROOT, "package.json" ), "utf-8"
152
168
  expect( manifest.adapter?.version === version, "reports this package's version as the adapter version",
153
169
  `found ${manifest.adapter?.version}, expected ${version}` );
154
170
  expect( manifest.sections?.video?.props?.src?.type === "media-file", "declares video.src as a media file" );
155
- expect( manifest.sections?.pagelist?.props?.sort?.values?.join() === "order,date,date-asc,title",
171
+ expect( manifest.sections?.section?.props?.layout?.label?.de === "Anordnung" &&
172
+ manifest.sections?.section?.props?.layout?.label?.["*"] === "Layout",
173
+ "brings the texts of its section kinds in English and German" );
174
+ expect( manifest.sections?.card?.docs?.de === "https://scavold.io/de/authors/sections.html#card" &&
175
+ Boolean( manifest.sections?.card?.hint?.de ),
176
+ "explains its section kinds and links where they are described at length" );
177
+ expect( manifest.sections?.pagelist?.props?.sort?.values?.map( choice => choice.value ).join() === "order,date,date-asc,title",
156
178
  "declares the page list's ordering options" );
157
179
  expect( manifest.sections?.video?.props?.overlay === undefined, "offers no background mode for a video; that is a cover's" );
158
180
  expect( manifest.sections?.teaser?.props?.background?.type === "media-file",
159
181
  "carries the site's own container with its typed props" );
160
182
  expect( manifest.sections?.teaser?.props?.variant?.values?.join() === "plain,boxed",
161
183
  "keeps the enum values of a declared prop" );
184
+ expect( manifest.sections?.teaser?.props?.framed?.icon === "frame" &&
185
+ manifest.sections?.teaser?.props?.align?.values?.map( choice => choice.icon ).join() === "align-left,align-right",
186
+ "keeps the icons of a boolean prop and of enum choices" );
187
+ expect( /^<svg[^>]*>[\s\S]*<\/svg>$/.test( manifest.icons?.frame ?? "" ) &&
188
+ /^<svg/.test( manifest.icons?.["align-left"] ?? "" ),
189
+ "embeds the icons it refers to from .cratly/icons/" );
190
+ expect( !( "unused" in ( manifest.icons ?? {} ) ), "leaves out an icon nothing refers to" );
191
+ expect( [ "stacked", "float-left", "float-right", "split-left", "split-right" ].every( n => /^<svg/.test( manifest.icons?.[n] ?? "" ) ) &&
192
+ manifest.sections?.section?.props?.layout?.values?.length === 4,
193
+ "offers the layout of a section with Scavold's own icons" );
194
+ expect( !( "align-right" in ( manifest.icons ?? {} ) ) &&
195
+ /icon 'align-right' is used in \.cratly\.config\.yaml, but there is no \.cratly\/icons\/align-right\.svg/.test( log ),
196
+ "reports an icon that is referred to but missing" );
162
197
 
163
198
  // ── containers ───────────────────────────────────────────────────────────────
164
199
 
@@ -177,6 +212,52 @@ expect( !containers.includes( ":::" ), "leaves no container fence unparsed",
177
212
  "a fence survived into the output" );
178
213
  expect( /<div class="teaser dark"[^>]*data-background="\/media\/wide--\d+w-[0-9a-f]+\.jpg"/.test( containers ),
179
214
  "resolves a media-file argument of a declared container to its published URL" );
215
+ const details = tags( containers, "details" );
216
+ expect( details.length === 3 && details.every( t => t.includes( 'class="scavold-details' ) ),
217
+ "renders a details container as a native <details>", `found ${details.length}` );
218
+ expect( details.filter( t => /\sname="faq"/.test( t ) ).length === 2,
219
+ "names the group of a details block, so the browser closes the others" );
220
+ expect( details.filter( t => /\sopen(=""|\s|>)/.test( t ) ).length === 1,
221
+ "expands only the details block asking for it" );
222
+ expect( /<summary class="scavold-details__summary">Questions &amp; answers<\/summary>/.test( containers ),
223
+ "shows the title of a details block, an ampersand included" );
224
+ expect( /<summary class="scavold-details__summary">Details<\/summary>/.test( containers ),
225
+ "gives a details block without a title a translated one" );
226
+ const grids = tags( containers, "div" ).filter( t => t.includes( "scavold-grid" ) );
227
+ expect( grids.length === 3 &&
228
+ /class="scavold-grid scavold-grid--3"/.test( grids[0] ) && /--scavold-grid-columns:\s*3/.test( grids[0] ) &&
229
+ /class="scavold-grid scavold-grid--2"/.test( grids[1] ) && /--scavold-grid-columns:\s*2/.test( grids[1] ),
230
+ "renders a grid with the columns asked for, and two without", grids.join( "\n " ) );
231
+ const firstGrid = containers.slice( containers.indexOf( "scavold-grid--3" ), containers.indexOf( "scavold-grid--2" ) );
232
+ expect( /<section class="cell"/.test( firstGrid ) && firstGrid.includes( "Fourth cell" ),
233
+ "keeps an embedded section and the blocks after it inside the grid" );
234
+ expect( /alt="Nested in a cell"/.test( firstGrid ) &&
235
+ sizesOf( firstGrid.slice( firstGrid.indexOf( "<picture" ), firstGrid.indexOf( "Nested in a cell" ) ) ).join() ===
236
+ 'sizes="(min-width: 40rem) 10rem, 100vw"',
237
+ "hands the grid's image sizes down to an image nested deeper in a cell" );
238
+ const cardGrid = containers.slice( containers.lastIndexOf( "scavold-grid--2" ) );
239
+ expect( sizesOf( cardGrid.slice( 0, cardGrid.indexOf( "Get in touch" ) ) ).join() === 'sizes="(min-width: 40rem) 32rem, 100vw"',
240
+ "keeps the site's image sizes in a grid whose column count the theme leaves out" );
241
+ const cards = tags( containers, "div" ).filter( t => /class="scavold-card[\s"]/.test( t ) );
242
+ const cardLinks = tags( containers, "a" ).filter( t => t.includes( "scavold-card__link" ) );
243
+ expect( cards.length === 3, "renders each card container as a card", `found ${cards.length}` );
244
+ expect( cards.filter( t => t.includes( "scavold-card--linked" ) ).length === 1 && cardLinks.length === 1,
245
+ "links only the card naming an existing page", cardLinks.join( "\n " ) );
246
+ expect( /href="\/contact\.html"/.test( cardLinks[0] ?? "" ) && /aria-label="Read more: Contact"/.test( cardLinks[0] ?? "" ),
247
+ "names the linked page in the accessible name of a card's link" );
248
+ expect( />Read more<\/a>/.test( containers ), "shows a translated text on a card's link" );
249
+ expect( tags( containers, "section" ).some( t => /data-layout="float-left"/.test( t ) ) &&
250
+ tags( containers, "aside" ).some( t => /data-layout="split-right"/.test( t ) ),
251
+ "marks a sectioning container with the layout asked for" );
252
+ expect( tags( containers, "div" ).some( t => /class="group"/.test( t ) && /data-layout="float-right"/.test( t ) ),
253
+ "renders a group as a plain <div> carrying its layout" );
254
+ expect( !/container ':::group' is not declared/.test( log ) &&
255
+ manifest.sections?.group?.props?.layout?.values?.length === 4 &&
256
+ manifest.sections?.details?.props?.group?.label?.de === "Akkordeon",
257
+ "offers the group as a type of its own, and calls the details option an accordion" );
258
+ const css = new Bun.Glob( "assets/**/*.css" ).scanSync( { cwd: DIST } ).map( f => readFileSync( join( DIST, f ), "utf-8" ) ).toArray().join( "\n" );
259
+ expect( /\[data-layout=(["']?)split-left\1\]/.test( css ) && /@container/.test( css ),
260
+ "ships the stylesheet of the layout variants with the theme" );
180
261
  expect( /<div class="not-declared-anywhere"/.test( containers ),
181
262
  "renders an undeclared container through the catch-all" );
182
263
  expect( /container ':::not-declared-anywhere' is not declared/.test( log ),
@@ -211,6 +292,29 @@ expect( media.includes( 'sizes="(min-width: 40rem) 32rem, 100vw"' ),
211
292
  "applies the sizes value from the site configuration" );
212
293
  expect( media.includes( 'sizes="100vw"' ), "lets a single image override sizes through its title" );
213
294
  expect( media.includes( 'src="https://example.com/absent.png"' ), "leaves a remote image untouched" );
295
+ expect( !/title="sizes=/.test( media ), "keeps a sizes override out of the page, where it would show as a tooltip" );
296
+
297
+ const figures = media.match( /<figure class="scavold-figure"[^>]*>[\s\S]*?<\/figure>/g ) ?? [];
298
+ const caption = text => figures.find( f => new RegExp( `<figcaption class="scavold-figure__caption"[^>]*>${text}</figcaption>` ).test( f ) );
299
+ expect( figures.length === 6, "turns each image standing alone with a caption into a figure", `found ${figures.length}` );
300
+ expect( /<picture[\s\S]*alt="Wide gradient, captioned"/.test( caption( "A gradient &amp; its caption" ) ?? "" ),
301
+ "puts the image and its caption into the figure, keeping the alt text apart" );
302
+ expect( !figures.some( f => /\stitle="/.test( f ) ), "gives a figure's image no tooltip repeating the caption" );
303
+ expect( /sizes="50vw"/.test( caption( "The narrow one" ) ?? "" ),
304
+ "reads an override and a caption from the same title" );
305
+ expect( /<p>Amid text, an image <picture title="Inline caption">/.test( media ),
306
+ "leaves an image amid text in its paragraph, its caption as a tooltip" );
307
+ expect( /<div class="scavold-grid scavold-grid--3"[^>]*>(?:\s|<!--\[-->)*<figure class="scavold-figure"/.test( media ) &&
308
+ [ "First", "Second", "Third" ].every( n => caption( `${n} of the gallery` ) ),
309
+ "builds a gallery from a grid of captioned images" );
310
+
311
+ const GRID_SIZES = 'sizes="(min-width: 40rem) 10rem, 100vw"';
312
+ const SITE_SIZES = 'sizes="(min-width: 40rem) 32rem, 100vw"';
313
+ expect( [ "First", "Second", "Third" ].every( n => sizesOf( caption( `${n} of the gallery` ) ?? "" ).join() === GRID_SIZES ),
314
+ "gives the images of a grid the sizes the theme declares for its column count",
315
+ sizesOf( caption( "First of the gallery" ) ?? "" ).join() );
316
+ expect( sizesOf( caption( "Fourth of the gallery" ) ?? "" ).join() === 'sizes="50vw"',
317
+ "keeps an image's own sizes override inside a grid" );
214
318
 
215
319
  /**
216
320
  * Tells which edge of a published variant is red, the colour marking the top.
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Layout variants of a section, article, aside or group — `::: group layout=float-left` —
3
+ * arranging its first block, an image usually, and the content after it:
4
+ *
5
+ * float-left / float-right the content flows around the block and on below it
6
+ * split-left / split-right block and content side by side; nothing runs below
7
+ *
8
+ * Without a layout, the block stands above the content. Functional only: themes set
9
+ * the block's width and the gap through the variables, or replace the rules. A section
10
+ * narrower than 30rem stacks its block above the content again, whatever its layout.
11
+ */
12
+
13
+ [data-layout] {
14
+ --_width: var(--scavold-layout-width, 40%);
15
+ --_gap: var(--scavold-layout-gap, 1.5rem);
16
+
17
+ display: flow-root;
18
+ container-type: inline-size;
19
+ }
20
+
21
+ [data-layout] > :first-child img {
22
+ width: 100%;
23
+ height: auto;
24
+ }
25
+
26
+ [data-layout="float-left"] > :first-child,
27
+ [data-layout="split-left"] > :first-child {
28
+ float: left;
29
+ width: var(--_width);
30
+ margin: 0 var(--_gap) var(--_gap) 0;
31
+ }
32
+
33
+ [data-layout="float-right"] > :first-child,
34
+ [data-layout="split-right"] > :first-child {
35
+ float: right;
36
+ width: var(--_width);
37
+ margin: 0 0 var(--_gap) var(--_gap);
38
+ }
39
+
40
+ /* Indented by the block's width, the content of a split does not run below it, and
41
+ the margins between its paragraphs collapse as they would anywhere else. */
42
+ [data-layout="split-left"] > :not(:first-child) {
43
+ margin-inline-start: calc(var(--_width) + var(--_gap));
44
+ }
45
+
46
+ [data-layout="split-right"] > :not(:first-child) {
47
+ margin-inline-end: calc(var(--_width) + var(--_gap));
48
+ }
49
+
50
+ @container (max-width: 30rem) {
51
+ [data-layout] > :first-child {
52
+ float: none;
53
+ width: auto;
54
+ margin-inline: 0;
55
+ }
56
+
57
+ [data-layout="split-left"] > :not(:first-child) {
58
+ margin-inline-start: 0;
59
+ }
60
+
61
+ [data-layout="split-right"] > :not(:first-child) {
62
+ margin-inline-end: 0;
63
+ }
64
+ }