scavold 0.2.0-rc.3 → 0.2.0-rc.5

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 CHANGED
@@ -7,6 +7,94 @@ All notable changes to Scavold are documented here. The format follows
7
7
  While the version stays below `1.0.0` and carries a pre-release suffix, breaking
8
8
  changes may occur in any release.
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ### Added
13
+
14
+ - `:::pagelist` dates its entries the way the site wants them: `time-style` adds the
15
+ time of day, `timezone` names the zone the timestamps are read in, and both accept
16
+ `iso` alongside the `Intl` styles, writing `2020-10-20 12:16` in every language. The
17
+ default is unchanged — the date alone, read in UTC, which is where a front matter
18
+ value carrying no time of day belongs.
19
+ - `formatTimestamp()` in `scavold/lib/dateFormat.js` renders timestamps the same way
20
+ for a theme's own components — a byline under a post's title, say. It is free of Vue
21
+ and VitePress and depends on its arguments alone, so pre-rendered markup and the
22
+ browser agree.
23
+
24
+ ### Changed
25
+
26
+ - A `:::pagelist` that paginates reaches every page it found: `limit` now defaults to
27
+ `0` when `per-page` is set, rather than capping the selection at five entries and
28
+ leaving a pager to turn the pages of those five. A limit the author sets still wins.
29
+ - Every link a site generates — menus, breadcrumbs, page lists, the feed, redirect targets
30
+ — names the file the page is built into: `/blog/post.html`, `/blog/index.html`,
31
+ `/index.html`. Links used to leave the extension off while VitePress's own markdown
32
+ links carried it, so one site spoke two dialects and the shorter one only worked on
33
+ servers that go looking for the file. Naming it is answered on the first attempt, and
34
+ the sitemap now lists the same URLs. The address bar still shows `/` and `/blog/`:
35
+ VitePress's router shortens them while navigating.
36
+
37
+ ### Fixed
38
+
39
+ - The site's own `index.md` is no longer linked as `/index`, a URL no server has to
40
+ answer. The rule for a page's URL existed in three copies and now lives in
41
+ `lib/href.js` alone.
42
+ - Scavold's built-in translations come from the bundle. They used to be fetched from a
43
+ URL that no build emits, so every page requested a file that was not there and the
44
+ interface silently fell back to English, whatever language the site is in.
45
+
46
+ ## [0.2.0-rc.4] — 2026-08-15
47
+
48
+ ### Added
49
+
50
+ - `metaChunk` is on by default, keeping VitePress's page-hash map out of the inline
51
+ scripts. Its content changes with every build, so under a content security policy that
52
+ names script hashes it has to be updated on the server for every deploy — and when it
53
+ is not, the map is blocked and the client side of the site stops working while the
54
+ pre-rendered HTML still shows. What stays inline are VitePress's own two snippets,
55
+ whose hashes hold still. `metaChunk: false` in the site's own config wins.
56
+ - An RSS feed, written at the end of the build when `augmentConfig` is given a `feed`
57
+ option: the pages of a section, newest first, with their excerpt as the entry text.
58
+ Announced are pages carrying a `date` — a feed is a chronology, and an entry a reader
59
+ cannot place in it is noise. Absolute links need a hostname, taken from
60
+ `sitemap.hostname` unless the feed names its own; without one the build says so and
61
+ writes nothing. Several feeds may be declared, one per language say. The feed is
62
+ compiled from the page hierarchy and is unrelated to `:::pagelist` — a page may appear
63
+ in three lists and in the feed, in the feed alone, or in neither.
64
+ - Front matter `hide` names surfaces: `"menu"`, `"breadcrumb"`, `"list"`, `"feed"`, a
65
+ list of several, or `true` for all of them. A page can now be kept out of the
66
+ navigation and the feed while staying in the lists.
67
+ - `:::pagelist` pages its entries in the browser with `per-page`: only the current page's
68
+ entries are rendered, the page shown is remembered as `?page=2` so returning from an
69
+ entry lands where the reader left off, and the pager is a labelled `<nav>` with a
70
+ status line and buttons.
71
+ - New built-in section `:::pagelist`, rendered by `<ScavoldPageList>`: links a
72
+ configurable number of the site's own pages — the latest posts of a blog, a section's
73
+ sub-pages, a list of downloads. Which pages are listed is addressed as in
74
+ `<ScavoldMenu>` (`from`, `from-root`, `from-path`, plus `deep` for pages filed in
75
+ sub-folders), and `limit`, `offset`, `sort` and `reverse` decide which of them appear
76
+ in what order. Entries are a plain list of links by default; the `teaser`, `images`,
77
+ `dates` and `heading-level` arguments turn them into teaser cards. Anything written
78
+ inside the block is rendered above the list. Each entry is a single link around all
79
+ of its parts, so a screen reader announces it once rather than three times.
80
+ - Pages carry teaser data on their hierarchy node, so a list shows content maintained
81
+ in the target page rather than repeated in the list: `date` (normalised to an ISO
82
+ timestamp and rendered in UTC, so a date-only value never shifts a day), `excerpt` —
83
+ from `excerpt` or `description` front matter, otherwise derived from the page's
84
+ opening prose and trimmed at a word boundary — and `image`, from `image` front matter
85
+ or the first image in the page body. Front matter images now go through the
86
+ responsive image pipeline too, which no pass reached before, as no Markdown syntax
87
+ reveals them.
88
+ - `.cratly.config.yaml` accepts `excerpt_length` (default `200`) to control how long
89
+ derived excerpts get; `0` switches the derivation off for sites that would rather not
90
+ carry it in every page's payload.
91
+ - Front matter `hide` accepts `"list"`, hiding a page from page lists only — a draft
92
+ post stays reachable by URL without being teased anywhere.
93
+ - `useHierarchy()` exposes `collectPages()`, `nodeHref()`, `nodeLabel()` and
94
+ `nodeTitle()`, the last two separating the short navigation text menus want from the
95
+ full title a teaser heading wants. `<ScavoldMenuItems>` uses them instead of its own
96
+ copies.
97
+
10
98
  ## [0.2.0-rc.3] — 2026-08-09
11
99
 
12
100
  ### Added
@@ -91,6 +179,7 @@ First published release. Version `0.1.0` existed in-tree only.
91
179
  - Only images are processed out of `media_folder`; other file types (video, documents)
92
180
  are never copied into the build output and have to live in the static folder.
93
181
 
182
+ [0.2.0-rc.4]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.3...v0.2.0-rc.4
94
183
  [0.2.0-rc.3]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
95
184
  [0.2.0-rc.2]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
96
185
  [0.2.0-rc.1]: https://gitlab.com/cepharum-foss/cratly/scavold/-/tags/v0.2.0-rc.1
package/COMPONENTS.md CHANGED
@@ -72,9 +72,12 @@ A page can opt out of appearing in menus by setting `hide` in its front matter:
72
72
  | Value | Effect |
73
73
  |---|---|
74
74
  | `false` / absent | Visible everywhere (default) |
75
- | `true` | Hidden in menus **and** breadcrumbs |
76
- | `"menu"` | Hidden in menus only |
77
- | `"breadcrumb"` | Hidden in breadcrumbs only |
75
+ | `true` | Kept off every surface: menus, breadcrumbs, page lists and the feed |
76
+ | `"menu"` | Kept out of menus only |
77
+ | `"breadcrumb"` | Kept out of breadcrumbs only |
78
+ | `"list"` | Kept out of page lists only |
79
+ | `"feed"` | Left out of the RSS feed only |
80
+ | `[ "menu", "feed" ]` | Kept off exactly the surfaces named |
78
81
 
79
82
  ```yaml
80
83
  ---
@@ -460,6 +463,141 @@ const { src, poster, autoplay, loop, muted, preload, overlay, controls } = useVi
460
463
 
461
464
  ---
462
465
 
466
+ ### `<ScavoldPageList>`
467
+
468
+ Links a configurable number of the site's own pages — the latest posts of a blog, a
469
+ teaser row of a section's sub-pages, a list of downloads. Rendered for the
470
+ `:::pagelist` container block. Anything written inside the block (a heading, an
471
+ introduction) is rendered above the list; the list itself is left out when no page
472
+ matches.
473
+
474
+ Every entry can carry, besides its title, a date, an introductory text and a teaser
475
+ image. All three come from the **target** page, so authors maintain them where the
476
+ content lives instead of repeating them in the list.
477
+
478
+ #### Markdown syntax
479
+
480
+ A plain list of links, as a footer or sitemap-style block would use:
481
+
482
+ ```markdown
483
+ ::: pagelist from=1 limit=10
484
+ :::
485
+ ```
486
+
487
+ Teaser cards for the five most recent posts:
488
+
489
+ ```markdown
490
+ ::: pagelist from=1 limit=5 sort=date teaser images dates heading-level=3 label="Recent posts"
491
+ ## Recent posts
492
+
493
+ Everything we published lately.
494
+ :::
495
+ ```
496
+
497
+ #### Arguments
498
+
499
+ Which pages are listed:
500
+
501
+ | Argument | Type | Default | Description |
502
+ |---|---|---|---|
503
+ | `from` | `number` | `1` | Level relative to the current page. `1` = its sub-pages, `0` = its siblings, `-1` = the aunt/uncle level. Same addressing as `<ScavoldMenu>`. |
504
+ | `from-root` | `number` | — | Absolute level from the site root: `1` = top-level pages, `2` = second level. Takes precedence over `from`. |
505
+ | `from-path` | `string` | — | Path of the page whose sub-pages are listed, regardless of where the block sits. Supports the `{locale}` and `{lang}` placeholders. Takes precedence over both other selectors. |
506
+ | `deep` | flag | — | List pages from every level below the selected one, flattened into one list — for posts filed in year or month folders. |
507
+ | `limit` | `number` | `5`, or `0` with `per-page` | Maximum number of entries. `0` lists every page found. A paginated list reaches every page it found unless a limit says otherwise. |
508
+ | `offset` | `number` | `0` | Entries to skip at the start, so a second block can continue where the first one ended. |
509
+ | `sort` | `"order" \| "date" \| "date-asc" \| "title"` | `"order"` | `order` follows the site structure (the `order` front matter and file names), `date` is newest first, `title` is alphabetical in the page's locale. |
510
+ | `reverse` | flag | — | Reverse whichever order was selected. |
511
+
512
+ What each entry shows:
513
+
514
+ | Argument | Type | Default | Description |
515
+ |---|---|---|---|
516
+ | `teaser` | flag | — | Show the introductory text of each listed page. |
517
+ | `images` | flag | — | Show the teaser image of each listed page. |
518
+ | `dates` | flag | — | Show the publication date of each listed page. |
519
+ | `date-style` | `"short" \| "medium" \| "long" \| "full" \| "iso"` | `"long"` | Style of the dates. The `Intl.DateTimeFormat` styles follow the page's language; `iso` writes `2020-10-20` in every language. |
520
+ | `time-style` | `"short" \| "medium" \| "long" \| "full" \| "iso"` | — | Style of the time of day. Left out, entries show their date only. `iso` writes `12:16`. |
521
+ | `timezone` | `string` | `"UTC"` | IANA name of the zone the dates are read in, e.g. `Europe/Berlin`. A date carrying no time of day belongs in UTC, the zone the build normalises it into; a list that shows times wants the zone the site publishes from. |
522
+ | `heading-level` | `number` | `0` | Heading level of the entry titles. `0` keeps the entries a plain list of links; use `2`–`6` for teaser cards, one level below the heading above the list. |
523
+ | `image-sizes` | `string` | `"(min-width: 60rem) 30rem, 90vw"` | CSS `sizes` descriptor for the teaser images. Set it to match the column the list is shown in — see [Responsive images](#responsive-images-let-components-own-sizes-not-content-authors). |
524
+ | `label` | `string` | — | Accessible label (`aria-label`) for the list. Set it whenever more than one list appears on the same page so screen readers can tell them apart. |
525
+ | `per-page` | `number` | `0` | Entries per page. `0` shows all of them; anything higher adds a pager. |
526
+ | `param` | `string` | `"page"` | Query parameter the current page is remembered in. Only needs setting when one page carries several paginated lists. |
527
+
528
+ Pages without a date sort last in both date orders — an undated page is not the
529
+ oldest one, it is unplaced.
530
+
531
+ #### Where the entry data comes from
532
+
533
+ | Shown | Read from the target page |
534
+ |---|---|
535
+ | Title | `title` front matter, then `label`, then the first `#` heading, then the path segment. The full `title` wins here, unlike in menus, where the short `label` does. |
536
+ | Date | `date` front matter, normalised to an ISO timestamp. `date: 2026-06-21` is enough; it is rendered in UTC so a date-only value never shifts to the neighbouring day. |
537
+ | Text | `excerpt` front matter, then `description`, otherwise derived from the page's opening prose and trimmed at a word boundary — see [`excerpt_length`](./docs/reference/config.md#excerpt-length). |
538
+ | Image | `image` front matter, otherwise the first image in the page body. Both go through the responsive image pipeline. |
539
+
540
+ A page opts out of every list with `hide: list` (or `hide: true`) in its front
541
+ matter. The page holding the list is never an entry of it.
542
+
543
+ #### Rendered markup
544
+
545
+ ```html
546
+ <div class="scavold-pagelist">
547
+ <!-- slot content: heading, introduction, … -->
548
+ <ul class="scavold-pagelist__items" aria-label="Recent posts">
549
+ <li class="scavold-pagelist__item">
550
+ <a class="scavold-pagelist__link" href="/blog/newest">
551
+ <picture class="scavold-pagelist__image">…</picture>
552
+ <h3 class="scavold-pagelist__title">Newest post</h3>
553
+ <time class="scavold-pagelist__date" datetime="2026-06-21T00:00:00.000Z">21 June 2026</time>
554
+ <p class="scavold-pagelist__excerpt">Opening prose of the post…</p>
555
+ </a>
556
+ </li>
557
+ </ul>
558
+ </div>
559
+ ```
560
+
561
+ Each entry is a **single** link around all of its parts: image, title, date and text
562
+ address the same page, and separate links would make a screen reader announce every
563
+ entry three times over. The teaser image is therefore decorative (`alt=""`) — the
564
+ title inside the same link is the accessible name.
565
+
566
+ The component ships no CSS at all; the class names above are the hooks a theme styles.
567
+
568
+ #### Pagination
569
+
570
+ `per-page` turns the list into pages, and it happens entirely in the browser: every entry
571
+ is already part of the page's data, so turning a page is slicing an array — nothing is
572
+ fetched, and only the current page's entries are in the document, which keeps the images
573
+ of the others out of it.
574
+
575
+ ```markdown
576
+ ::: pagelist from=1 sort=date per-page=10 teaser images dates heading-level=3
577
+ ## Archive
578
+ :::
579
+ ```
580
+
581
+ The page shown is remembered in the address bar as `?page=2`, written with
582
+ `history.replaceState`: turning pages is not a step the back button should have to walk
583
+ through, while returning from an entry lands the reader where they left off. A page
584
+ carrying several paginated lists gives each one its own `param`.
585
+
586
+ What the reader gets is a `<nav>` labelled for screen readers, a status line saying which
587
+ page of how many is shown, and buttons — not links, because no navigation happens and a
588
+ link would promise a URL the built site does not serve. Every part carries a class and no
589
+ styling; the theme decides how a pager looks.
590
+
591
+ Two consequences worth knowing. The pre-rendered HTML always holds page 1, so arriving
592
+ through a shared `?page=3` link shows the first page for an instant before the browser
593
+ catches up — returning from an entry inside the site does not, that being client-side
594
+ navigation. And entries beyond the first page are not in the HTML, so a crawler that
595
+ executes no JavaScript will not see those links; the sitemap and the
596
+ [feed](./docs/reference/config.md#optionsfeed--an-rss-feed-for-the-site) are what makes
597
+ them discoverable.
598
+
599
+ ---
600
+
463
601
  ### `<ScavoldImage>`
464
602
 
465
603
  Renders a responsive `<picture>` element for local images. Not intended for direct
@@ -565,10 +703,14 @@ Must be called inside a component's `setup` function (or `<script setup>`).
565
703
  | `ancestorAtDepth` | `(node, targetDepth) => HierarchyNode \| undefined` | Returns the ancestor of `node` at `targetDepth` levels below root. Used by `ScavoldMenu` for `from-root` mode. Returns `undefined` if `node` is not deep enough. |
566
704
  | `isOnActivePath` | `(node) => boolean` | Returns `true` if `node` is the current page or one of its ancestors |
567
705
  | `collectItems` | `(parent, depth, activeOnly, expand) => MenuItem[]` | Recursively collects menu items from `parent`'s children. Used internally by `ScavoldMenu`. |
706
+ | `collectPages` | `(parent, deep?) => HierarchyNode[]` | Collects the pages below `parent` as a flat array in hierarchy order, skipping pages hidden from lists. With `deep` it descends through every level. Used internally by `ScavoldPageList`. |
568
707
  | `collectAncestors` | `(includeCurrent, includeRoot) => MenuItem[]` | Returns the ancestor chain from root to the current page as a flat `MenuItem` array. Used internally by `ScavoldBreadcrumb`. |
569
708
  | `currentLocale` | `ComputedRef<string>` | The resolved locale for the current page. Falls back to the browser locale when no page or ancestor declares one. |
570
709
  | `collectLocaleLinks` | `(detection, includeCurrent?) => LocaleLink[]` | Returns available locale links for the current page. See `ScavoldLocaleMenu` for detection mode details. |
571
710
  | `resolveByPath` | `(rawPath: string) => HierarchyNode \| undefined` | Resolves a path string to a hierarchy node. Supports `{locale}` (full BCP 47 tag) and `{lang}` (primary language subtag) placeholders. Tries the interpolated path as-is, then with `/index.md`, then with `.md` appended. |
711
+ | `nodeHref` | `(node) => string` | Root-relative link target of a node, preferring its alias URL. Every page is linked as the file it is built into: `/blog/post.html`, a folder's index page as `/blog/index.html`, the site's own as `/index.html`. |
712
+ | `nodeLabel` | `(node) => string` | Navigation text of a node: `label`, then `title`, then the path segment. For menus and breadcrumbs, where space is scarce. |
713
+ | `nodeTitle` | `(node) => string` | Full text of a node: `title`, then `label`, then the path segment. For entries that stand on their own, such as a teaser heading. |
572
714
 
573
715
  #### `HierarchyNode` properties
574
716
 
@@ -577,13 +719,17 @@ Must be called inside a component's `setup` function (or `<script setup>`).
577
719
  | `path` | `string` | Relative path of the Markdown source file |
578
720
  | `isPage` | `boolean` | `true` for actual pages, `false` for intermediate folder nodes |
579
721
  | `frontmatter` | `object` | Parsed front matter data |
580
- | `frontmatter.hide` | `boolean \| "menu" \| "breadcrumb"` | Excludes this node from menus, breadcrumbs, or both. See [Hiding pages via front matter](#hiding-pages-via-front-matter). |
722
+ | `frontmatter.hide` | `boolean \| string \| string[]` | Surfaces this node is kept off: `"menu"`, `"breadcrumb"`, `"list"`, `"feed"`, a list of them, or `true` for all. See [Hiding pages via front matter](#hiding-pages-via-front-matter). |
581
723
  | `frontmatter.order` | `number?` | Sort position among siblings. Pages with a lower value appear first; pages without `order` follow in filename order. |
582
724
  | `frontmatter.url` | `string?` | Alias output path. When set, VitePress builds the page at this URL instead of the path derived from the source file. Conflicts (two pages with the same alias) are a build error. |
583
725
  | `title` | `string?` | Display title — from frontmatter `title` or the first `#` heading |
584
726
  | `label` | `string?` | Navigation label — from frontmatter `label`; falls back to `title` in menus |
585
727
  | `url` | `string?` | Normalised alias output path (e.g. `de/impressum.md`) when `frontmatter.url` is set. Used by `nodeHref` as the page's href. |
586
728
  | `locale` | `string?` | Resolved locale (BCP 47), inherited from ancestors |
729
+ | `excerpt` | `string?` | Introductory text — from frontmatter `excerpt` or `description`, else derived from the page's opening prose |
730
+ | `image` | `string?` | Lead image reference as the author wrote it — from frontmatter `image`, else the first image in the page |
731
+ | `imageData` | `{ src, srcset, webpSrcset }?` | Responsive variant data for `image`, resolved at build time. Carries no `sizes` value: that belongs to the component showing the image. |
732
+ | `date` | `string?` | Publication date as an ISO 8601 timestamp — from frontmatter `date` |
587
733
  | `parent` | `HierarchyNode?` | Parent node — non-enumerable, not serialised to JSON |
588
734
  | `subs` | `object?` | Child nodes keyed by path segment, sorted by `order` then filename |
589
735
 
@@ -661,6 +807,101 @@ Spread into `defineProps` to declare all video-related props at once.
661
807
 
662
808
  ---
663
809
 
810
+ ### `usePageList( props )`
811
+
812
+ ```js
813
+ import { usePageList, pageListProps } from "scavold/composables/usePageList.js";
814
+ ```
815
+
816
+ Composable for custom page-list components — the way a theme renders lists its own
817
+ way instead of restyling Scavold's markup. Write a component, declare
818
+ `pageListProps`, call the composable and render whatever the design asks for; map a
819
+ container name to it in the site's configuration:
820
+
821
+ ```yaml
822
+ # .cratly.config.yaml
823
+ containers:
824
+ postlist:
825
+ label: "Posts, our way"
826
+ component: SitePostList
827
+ ```
828
+
829
+ ```js
830
+ // .vitepress/theme/index.js — register it after Scavold's own components
831
+ async function enhanceApp( context ) {
832
+ await scavoldEnhanceApp( context );
833
+
834
+ context.app.component( "SitePostList", SitePostList );
835
+ }
836
+ ```
837
+
838
+ Selection, ordering, paging and timestamp rendering stay Scavold's; only the markup
839
+ is the theme's. Registering a component under a name Scavold already uses
840
+ (`ScavoldPageList`) replaces that one everywhere instead. Resolves the `:::pagelist` container props
841
+ into the entries to render, each carrying its link target, title, date, introductory
842
+ text and responsive teaser image data — see the
843
+ [arguments](#scavoldpagelist) for what each prop means.
844
+
845
+ #### `pageListProps`
846
+
847
+ Spread into `defineProps` to declare all page-list props at once. Each container
848
+ argument arrives as `data…`, in the same way as `videoProps`: `dataFrom` for `from=…`,
849
+ `dataHeadingLevel` for `heading-level=…`, `dataTeaser` for the `teaser` flag, and so on.
850
+
851
+ #### Returns
852
+
853
+ | Name | Type | Description |
854
+ |---|---|---|
855
+ | `items` | `ComputedRef<PageListItem[]>` | The selected, ordered and limited entries |
856
+ | `headingTag` | `ComputedRef<string \| null>` | `"h2"`…`"h6"` when a heading level was requested, `null` for a plain list of links |
857
+ | `teaser` | `ComputedRef<boolean>` | `true` when the `teaser` flag is set |
858
+ | `images` | `ComputedRef<boolean>` | `true` when the `images` flag is set |
859
+ | `dates` | `ComputedRef<boolean>` | `true` when the `dates` flag is set |
860
+ | `imageSizes` | `ComputedRef<string>` | CSS `sizes` descriptor for the teaser images |
861
+ | `ariaLabel` | `ComputedRef<string \| undefined>` | Accessible label for the list, `undefined` when the author set none |
862
+ | `formatDate` | `(isoDate: string) => string` | Renders a timestamp in the styles and zone the list was given |
863
+ | `page` | `Ref<number>` | Page currently shown, `1` before the browser reads the query |
864
+ | `pages` | `ComputedRef<number[]>` | Page numbers to offer, `[ 1 ]` for an unpaginated list |
865
+ | `pageCount` | `ComputedRef<number>` | Number of pages the selection divides into |
866
+ | `goToPage` | `(page: number) => void` | Turns to a page and remembers it in the URL |
867
+
868
+ #### `PageListItem` properties
869
+
870
+ | Property | Type | Description |
871
+ |---|---|---|
872
+ | `node` | `HierarchyNode` | The listed page's hierarchy node |
873
+ | `href` | `string` | Root-relative link target, alias URL included |
874
+ | `title` | `string` | Full title of the page (`title` wins over `label` here) |
875
+ | `date` | `string?` | ISO 8601 publication date, when the page declares one |
876
+ | `excerpt` | `string?` | Introductory text, when the page has or yields one |
877
+ | `image` | `{ src, srcset, webpSrcset }?` | Responsive variant data of the teaser image |
878
+
879
+ ---
880
+
881
+ ### `formatTimestamp( timestamp, options )`
882
+
883
+ ```js
884
+ import { formatTimestamp } from "scavold/lib/dateFormat.js";
885
+ ```
886
+
887
+ Renders a timestamp the way page lists do, for a theme's own components — a byline
888
+ under a post's title, for instance. Free of Vue and VitePress, so build-time code can
889
+ use it too.
890
+
891
+ | Option | Type | Default | Description |
892
+ |---|---|---|---|
893
+ | `locale` | `string` | the runtime's | Locale to phrase the result in |
894
+ | `dateStyle` | `"short" \| "medium" \| "long" \| "full" \| "iso"` | `"long"` | Style of the date; `iso` writes `2020-10-20` in every language |
895
+ | `timeStyle` | same values | — | Style of the time of day; omitted, only the date is rendered |
896
+ | `timeZone` | `string` | `"UTC"` | IANA name of the zone the timestamp is read in |
897
+
898
+ The result depends on the arguments alone, never on the machine it runs on, so
899
+ pre-rendered markup and the browser agree — a timestamp that changes on hydration
900
+ would tear the markup apart. A timestamp that cannot be read renders as an empty
901
+ string rather than as `Invalid Date`.
902
+
903
+ ---
904
+
664
905
  ### `useContainer( props )`
665
906
 
666
907
  ```js
package/FRONTMATTER.md CHANGED
@@ -162,18 +162,86 @@ emits a warning and the explicit declaration takes precedence.
162
162
 
163
163
  ---
164
164
 
165
+ ## `date`
166
+
167
+ **Type:** `string` (ISO 8601) — e.g. `2026-06-21` or `2026-06-21T14:30:00Z`
168
+
169
+ Publication date of the page. Read by [`<ScavoldPageList>`](./COMPONENTS.md#scavoldpagelist)
170
+ to order entries chronologically and to show a date per entry, and by the site's RSS feed,
171
+ which announces only pages that carry one.
172
+
173
+ ```yaml
174
+ ---
175
+ date: 2026-06-21
176
+ ---
177
+ ```
178
+
179
+ A date-only value is normalised to midnight UTC and rendered in UTC as well, so it
180
+ never shows up as the neighbouring day in another time zone. Pages without a date
181
+ sort last in both date orders.
182
+
183
+ ---
184
+
185
+ ## `excerpt` / `description`
186
+
187
+ **Type:** `string`
188
+
189
+ Introductory text shown for this page wherever it is teased — in a
190
+ [`<ScavoldPageList>`](./COMPONENTS.md#scavoldpagelist) with the `teaser` flag, for
191
+ example.
192
+
193
+ ```yaml
194
+ ---
195
+ excerpt: Wie wir den Schulhof an einem Nachmittag umgestaltet haben.
196
+ ---
197
+ ```
198
+
199
+ When neither key is declared, Scavold derives the text from the page's opening prose
200
+ at build time: headings, images, code and container fences are dropped, the remaining
201
+ text is trimmed to [`excerpt_length`](./docs/reference/config.md#excerpt-length)
202
+ characters at a word boundary and marked with an ellipsis. Declaring `excerpt` is
203
+ therefore an override, not a prerequisite.
204
+
205
+ `description` is accepted as an alias, since sites often already maintain it for
206
+ search engines. `excerpt` wins when both are present.
207
+
208
+ ---
209
+
210
+ ## `image`
211
+
212
+ **Type:** `string` — path relative to the media folder
213
+
214
+ Lead image of the page, used as its teaser image in a
215
+ [`<ScavoldPageList>`](./COMPONENTS.md#scavoldpagelist) with the `images` flag.
216
+
217
+ ```yaml
218
+ ---
219
+ image: /schulhof.jpg
220
+ ---
221
+ ```
222
+
223
+ The file goes through the responsive image pipeline like any image in a page body, so
224
+ a teaser gets the same `srcset` and WebP variants. When the key is absent, the first
225
+ image in the page body is used instead.
226
+
227
+ ---
228
+
165
229
  ## `hide`
166
230
 
167
- **Type:** `boolean | "menu" | "breadcrumb"`
231
+ **Type:** `boolean | "menu" | "breadcrumb" | "list" | "feed" | string[]`
168
232
 
169
- Controls whether this page appears in menus, breadcrumbs, or both.
233
+ Controls the surfaces this page appears on. Each value names one of them; `true` covers
234
+ all — which is what an unfinished post wants, reachable by URL and announced nowhere.
170
235
 
171
236
  | Value | Effect |
172
237
  |---|---|
173
238
  | `false` or absent | Visible everywhere (default) |
174
- | `true` | Hidden in both menus and breadcrumbs |
239
+ | `true` | Hidden everywhere: menus, breadcrumbs, page lists and the feed |
175
240
  | `"menu"` | Hidden in menus only |
176
241
  | `"breadcrumb"` | Hidden in breadcrumbs only |
242
+ | `"list"` | Hidden in page lists only |
243
+ | `"feed"` | Left out of the RSS feed only |
244
+ | `[ "menu", "feed" ]` | Kept off exactly the surfaces named |
177
245
 
178
246
  ```yaml
179
247
  ---
@@ -41,7 +41,7 @@ const props = withDefaults( defineProps<LocaleMenuProps>(), {
41
41
  label: undefined,
42
42
  } );
43
43
 
44
- const { collectLocaleLinks, currentLocale } = useHierarchy();
44
+ const { collectLocaleLinks } = useHierarchy();
45
45
  const { t } = useScavoldI18n();
46
46
 
47
47
  const links = computed<Scavold.LocaleLink[]>( () =>
@@ -13,7 +13,7 @@ const { frontmatter } = useData();
13
13
  * @returns {string|null}
14
14
  */
15
15
  function pickTarget( map ) {
16
- for ( const lang of ( navigator.languages ?? [] ) ) {
16
+ for ( const lang of navigator.languages ?? [] ) {
17
17
  // exact match first (e.g. "de-AT" → "de-AT")
18
18
  if ( map[lang] ) return map[lang];
19
19
  // prefix match (e.g. "de-AT" → "de")
@@ -1,28 +1,12 @@
1
1
  <script setup lang="ts">
2
+ import { useHierarchy } from "../composables/hierarchy";
2
3
  import type { Scavold } from "../index";
3
4
 
4
5
  defineProps<{
5
6
  items: Scavold.MenuItem[];
6
7
  }>();
7
8
 
8
- function label( node: Scavold.HierarchyNode ): string {
9
- if ( node.label ) return node.label;
10
- if ( node.title ) return node.title;
11
-
12
- // Fall back to the last meaningful path segment (strip index.md, extension)
13
- const segment = node.path
14
- .replace( /\/index\.md$/, "" )
15
- .replace( /\.md$/, "" )
16
- .split( "/" )
17
- .filter( Boolean )
18
- .pop();
19
-
20
- return segment ?? node.path;
21
- }
22
-
23
- function href( node: Scavold.HierarchyNode ): string {
24
- return "/" + ( node.url ?? node.path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" );
25
- }
9
+ const { nodeLabel, nodeHref } = useHierarchy();
26
10
  </script>
27
11
 
28
12
  <template>
@@ -35,7 +19,7 @@ function href( node: Scavold.HierarchyNode ): string {
35
19
  current: item.current,
36
20
  }"
37
21
  >
38
- <a :href="href( item.node )" :aria-current="item.current ? 'page' : undefined">{{ label( item.node ) }}</a>
22
+ <a :href="nodeHref( item.node )" :aria-current="item.current ? 'page' : undefined">{{ nodeLabel( item.node ) }}</a>
39
23
  <ScavoldMenuItems
40
24
  v-if="item.children.length"
41
25
  :items="item.children"
@@ -0,0 +1,110 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useContainer } from "../composables/useContainer.js";
4
+ import { usePageList, pageListProps } from "../composables/usePageList.js";
5
+ import { useScavoldI18n } from "../composables/useI18n.js";
6
+ import ScavoldImage from "./ScavoldImage.vue";
7
+
8
+ const props = defineProps( pageListProps );
9
+
10
+ const { classes, dataAttrs } = useContainer( props );
11
+ const {
12
+ items, headingTag, teaser, images, dates, imageSizes, ariaLabel, formatDate,
13
+ page, pages, pageCount, goToPage,
14
+ } = usePageList( props );
15
+ const { t } = useScavoldI18n();
16
+
17
+ const pagerLabel = t( "nav.pages" );
18
+ const previousLabel = t( "pagelist.previous" );
19
+ const nextLabel = t( "pagelist.next" );
20
+ const status = t( "pagelist.status", computed( () => ( { current: page.value, total: pageCount.value } ) ) );
21
+ </script>
22
+
23
+ <template>
24
+ <div class="scavold-pagelist" :class="classes" v-bind="dataAttrs">
25
+ <!-- whatever the author wrote inside the section: a heading, an intro, anything -->
26
+ <slot />
27
+
28
+ <ul
29
+ v-if="items.length"
30
+ class="scavold-pagelist__items"
31
+ :aria-label="ariaLabel"
32
+ >
33
+ <li
34
+ v-for="item of items"
35
+ :key="item.node.path"
36
+ class="scavold-pagelist__item"
37
+ >
38
+ <!-- One link per entry rather than one per element: image, title, date and text
39
+ all address the same page, and repeating that target would make a screen
40
+ reader announce every entry three times. -->
41
+ <a class="scavold-pagelist__link" :href="item.href">
42
+ <ScavoldImage
43
+ v-if="images && item.image"
44
+ class="scavold-pagelist__image"
45
+ :src="item.image.src"
46
+ :srcset="item.image.srcset"
47
+ :webp-srcset="item.image.webpSrcset"
48
+ :sizes="imageSizes"
49
+ alt=""
50
+ />
51
+
52
+ <component
53
+ :is="headingTag"
54
+ v-if="headingTag"
55
+ class="scavold-pagelist__title"
56
+ >{{ item.title }}</component>
57
+ <span v-else class="scavold-pagelist__title">{{ item.title }}</span>
58
+
59
+ <time
60
+ v-if="dates && item.date"
61
+ class="scavold-pagelist__date"
62
+ :datetime="item.date"
63
+ >{{ formatDate( item.date ) }}</time>
64
+
65
+ <p
66
+ v-if="teaser && item.excerpt"
67
+ class="scavold-pagelist__excerpt"
68
+ >{{ item.excerpt }}</p>
69
+ </a>
70
+ </li>
71
+ </ul>
72
+
73
+ <!-- Turning a page swaps content in place, so the reader is told which page they are
74
+ on rather than being left to notice. Buttons, not links: no navigation happens,
75
+ and a link would promise a page the built site does not serve. -->
76
+ <nav
77
+ v-if="pageCount > 1"
78
+ class="scavold-pagelist__pager"
79
+ :aria-label="pagerLabel"
80
+ >
81
+ <p class="scavold-pagelist__status" role="status">{{ status }}</p>
82
+
83
+ <button
84
+ type="button"
85
+ class="scavold-pagelist__page scavold-pagelist__page--previous"
86
+ :disabled="page <= 1"
87
+ :aria-label="previousLabel"
88
+ @click="goToPage( page - 1 )"
89
+ >&lsaquo;</button>
90
+
91
+ <button
92
+ v-for="number of pages"
93
+ :key="number"
94
+ type="button"
95
+ class="scavold-pagelist__page"
96
+ :class="{ current: number === page }"
97
+ :aria-current="number === page ? 'page' : undefined"
98
+ @click="goToPage( number )"
99
+ >{{ number }}</button>
100
+
101
+ <button
102
+ type="button"
103
+ class="scavold-pagelist__page scavold-pagelist__page--next"
104
+ :disabled="page >= pageCount"
105
+ :aria-label="nextLabel"
106
+ @click="goToPage( page + 1 )"
107
+ >&rsaquo;</button>
108
+ </nav>
109
+ </div>
110
+ </template>