scavold 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.0",
3
+ "version": "0.4.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,46 @@ 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
+ const css = new Bun.Glob( "assets/**/*.css" ).scanSync( { cwd: DIST } ).map( f => readFileSync( join( DIST, f ), "utf-8" ) ).toArray().join( "\n" );
253
+ expect( /\[data-layout=(["']?)split-left\1\]/.test( css ) && /@container/.test( css ),
254
+ "ships the stylesheet of the layout variants with the theme" );
180
255
  expect( /<div class="not-declared-anywhere"/.test( containers ),
181
256
  "renders an undeclared container through the catch-all" );
182
257
  expect( /container ':::not-declared-anywhere' is not declared/.test( log ),
@@ -211,6 +286,29 @@ expect( media.includes( 'sizes="(min-width: 40rem) 32rem, 100vw"' ),
211
286
  "applies the sizes value from the site configuration" );
212
287
  expect( media.includes( 'sizes="100vw"' ), "lets a single image override sizes through its title" );
213
288
  expect( media.includes( 'src="https://example.com/absent.png"' ), "leaves a remote image untouched" );
289
+ expect( !/title="sizes=/.test( media ), "keeps a sizes override out of the page, where it would show as a tooltip" );
290
+
291
+ const figures = media.match( /<figure class="scavold-figure"[^>]*>[\s\S]*?<\/figure>/g ) ?? [];
292
+ const caption = text => figures.find( f => new RegExp( `<figcaption class="scavold-figure__caption"[^>]*>${text}</figcaption>` ).test( f ) );
293
+ expect( figures.length === 6, "turns each image standing alone with a caption into a figure", `found ${figures.length}` );
294
+ expect( /<picture[\s\S]*alt="Wide gradient, captioned"/.test( caption( "A gradient &amp; its caption" ) ?? "" ),
295
+ "puts the image and its caption into the figure, keeping the alt text apart" );
296
+ expect( !figures.some( f => /\stitle="/.test( f ) ), "gives a figure's image no tooltip repeating the caption" );
297
+ expect( /sizes="50vw"/.test( caption( "The narrow one" ) ?? "" ),
298
+ "reads an override and a caption from the same title" );
299
+ expect( /<p>Amid text, an image <picture title="Inline caption">/.test( media ),
300
+ "leaves an image amid text in its paragraph, its caption as a tooltip" );
301
+ expect( /<div class="scavold-grid scavold-grid--3"[^>]*>(?:\s|<!--\[-->)*<figure class="scavold-figure"/.test( media ) &&
302
+ [ "First", "Second", "Third" ].every( n => caption( `${n} of the gallery` ) ),
303
+ "builds a gallery from a grid of captioned images" );
304
+
305
+ const GRID_SIZES = 'sizes="(min-width: 40rem) 10rem, 100vw"';
306
+ const SITE_SIZES = 'sizes="(min-width: 40rem) 32rem, 100vw"';
307
+ expect( [ "First", "Second", "Third" ].every( n => sizesOf( caption( `${n} of the gallery` ) ?? "" ).join() === GRID_SIZES ),
308
+ "gives the images of a grid the sizes the theme declares for its column count",
309
+ sizesOf( caption( "First of the gallery" ) ?? "" ).join() );
310
+ expect( sizesOf( caption( "Fourth of the gallery" ) ?? "" ).join() === 'sizes="50vw"',
311
+ "keeps an image's own sizes override inside a grid" );
214
312
 
215
313
  /**
216
314
  * Tells which edge of a published variant is red, the colour marking the top.
@@ -352,6 +450,21 @@ expect( menu.includes( '<a href="/containers.html">' ),
352
450
  expect( crumbs.includes( '<a href="/blog/index.html"' ) && crumbs.includes( 'aria-current="page"' ),
353
451
  "marks the page the breadcrumb ends on" );
354
452
 
453
+ console.log( "\nfolders without an index page" );
454
+
455
+ const sitemapNav = ( blogPage.match( /<nav aria-label="Sitemap"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
456
+ const guidePage = built( join( "guides", "first.html" ) );
457
+ const guideCrumbs = ( guidePage.match( /<nav class="breadcrumb"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
458
+
459
+ expect( ![ blogPage, guidePage ].some( page => page.includes( 'href="/guides.html"' ) ),
460
+ "never links a folder that has no page of its own" );
461
+ expect( !/>guides</.test( menu ),
462
+ "leaves such a folder out of a menu that shows none of the pages below it" );
463
+ expect( /<span>guides<\/span><ul>(?:<!--\[-->)?<li[^>]*><a href="\/guides\/first\.html"/.test( sitemapNav ),
464
+ "names such a folder as text above its pages where the menu shows them" );
465
+ expect( /<span>guides<\/span>/.test( guideCrumbs ) && guideCrumbs.includes( 'aria-current="page"' ),
466
+ "names such a folder as text in the breadcrumb" );
467
+
355
468
  console.log( "\nnamed menus" );
356
469
 
357
470
  const footer = ( blogPage.match( /<nav aria-label="Footer"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
@@ -413,7 +526,9 @@ expect( entryTitles( dated ).join( " | " ) === "Newest post | Middle post",
413
526
  expect( entryTitles( titled ).join( " | " ) === "Middle post | Newest post | Not announced | Oldest post",
414
527
  "orders by title and lists every page when the limit is 0",
415
528
  `found ${entryTitles( titled ).join( " | " ) || "nothing"}` );
416
- expect( !blog.includes( "Unlisted draft" ), "leaves out a page whose front matter says hide: list" );
529
+ // Menus are left aside: hide: list keeps a page out of lists, not navigation.
530
+ expect( !blog.replace( /<nav[\s\S]*?<\/nav>/g, "" ).includes( "Unlisted draft" ),
531
+ "leaves out a page whose front matter says hide: list" );
417
532
  expect( /<h3 class="scavold-pagelist__title"/.test( dated ) && !/<h\d class="scavold-pagelist__title"/.test( titled ),
418
533
  "uses the requested heading level, and no heading at all without one" );
419
534
  // The displayed text follows the page's locale, which the fixture leaves to the
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Layout variants of a section, article or aside — `::: section 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
+ }