scavold 0.2.0-rc.4 → 0.2.0-rc.6

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
@@ -9,6 +9,113 @@ changes may occur in any release.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.2.0-rc.6] — 2026-09-13
13
+
14
+ ### Added
15
+
16
+ - `aliases` in a page's front matter names the former addresses it replaces, and
17
+ `retired_urls` in `.cratly.config.yaml` those that are not coming back. A site taking
18
+ over from an existing website declares them once and the build writes
19
+ `cratly-redirects.json` into its output: an address, where it went, and `301` or `410`.
20
+ The format names no server — only a server-side redirect passes a ranking on, and which
21
+ server that is cratly does not assume, so the deploy step translates the file for its
22
+ target. Keeping an address is still better than redirecting to it, which is what `url`
23
+ is for; the build refuses an alias that two pages claim, or that the site serves itself
24
+ and the redirect would therefore hide.
25
+ - Every build now ends by reading its own result. A page that the generator listed but
26
+ never wrote, or a script a built page names and the output does not contain, ends the
27
+ build with the file named — the two states a bundling fault leaves behind while the log
28
+ says "build complete". Links and images pointing at absent files are reported and left
29
+ to the site, since one may legitimately name a path the web server provides. Turn it
30
+ down with `verify: { content: false }` or off with `verify: false`. A reference is read
31
+ the way a browser reads it: a relative one against the address of the page holding it, so
32
+ a link that forgot its protocol — `[Website](./www.example.com)`, which the build turns
33
+ into a sibling page nobody wrote — is seen as well. Proven against the
34
+ case-collision that used to build green: the page missing from the output is the only
35
+ trace it leaves on a file system that ignores letter case, and that is now enough.
36
+ Everything found also goes to `.cratly/build-report.json`, written before the build is
37
+ ended rather than after, each entry naming the source file an author would open. A build
38
+ log is read by whoever has access to it; a file can be handed to whoever wrote the page.
39
+ - `ignoreDeadLinks` now defaults to `true`. VitePress ends a build on a dead link and does
40
+ so before writing anything, which leaves the reason in a job log that whoever wrote the
41
+ link often cannot read, and lets one typo stand between a finished page and its
42
+ publication. Scavold finds the same mistake in the finished output and reports it there
43
+ instead. Whether it should also stop a pipeline is a question whose answer differs per
44
+ branch, so it is left to CI: the site template fails a merge request over it and lets the
45
+ default branch deploy, since blocking there would answer a broken link with a stale site.
46
+ Ask for VitePress' abort back with `ignoreDeadLinks: false`.
47
+ - A site whose page paths collide in VitePress's eyes now learns that from Scavold, by
48
+ name, before the build starts. VitePress identifies a page by its path flattened into
49
+ a single name — slash becomes underscore, and the page-hash map lower-cases it — and
50
+ uses that name for the page's bundle entry, its server module and its client chunk.
51
+ Two pages meeting there overwrite each other: either the build dies at render time in
52
+ `pageChunk.imports`, naming no file, or it succeeds and ships two pages pointing at a
53
+ script that was never written. Scavold now reproduces that identity when it reads the
54
+ config and aborts with both file names, whether they collide through an underscore in
55
+ a file name (`de_kontakt.md` beside `de/kontakt.md`), through letter case alone, or
56
+ through a `url` alias landing on a path another page already owns as its file — the
57
+ case the previous alias check, which compared aliases only with each other, let pass.
58
+ - `date` and `datetime` join the type vocabulary of `frontmatter_fields` and of a
59
+ container's `props`. Scavold passes such a value through as the ISO 8601 text it is;
60
+ what changes is that the cratly editor now offers a date picker for it instead of a
61
+ plain text box. A `datetime` always carries an offset — a time of day without one is
62
+ a different moment on every machine that builds the site.
63
+
64
+ ### Changed
65
+
66
+ - `@cepharum/vue3-i18n` moves to `2.0.0`, two majors on from the `0.5.4` a site would
67
+ have installed. Scavold uses `useL10n`, `setLocale` and `setLoader`, none of which
68
+ changed; what 2.0 breaks are the locale helpers now returning full BCP-47 tags, and
69
+ Scavold calls none of them. A site inherits the new version with its next install, and
70
+ gains what 2.0 adds along the way: regional locales overlaying a bare language, plural
71
+ and gender selection resolved through `Intl`, and `Intl`-backed formatters in
72
+ placeholders.
73
+
74
+ ### Fixed
75
+
76
+ - `lib/href.js` ships the declarations it had been missing since it was split out, so
77
+ `typecheck` passes again — and the pipeline runs it now, which is why it could go
78
+ unnoticed at all. Nothing about the module changed; TypeScript simply had nothing to
79
+ read about it, and treated every href the hierarchy composes as `any`.
80
+
81
+ ## [0.2.0-rc.5] — 2026-08-17
82
+
83
+ ### Added
84
+
85
+ - `:::pagelist` dates its entries the way the site wants them: `time-style` adds the
86
+ time of day, `timezone` names the zone the timestamps are read in, and both accept
87
+ `iso` alongside the `Intl` styles, writing `2020-10-20 12:16` in every language. The
88
+ default is unchanged — the date alone, read in UTC, which is where a front matter
89
+ value carrying no time of day belongs.
90
+ - `formatTimestamp()` in `scavold/lib/dateFormat.js` renders timestamps the same way
91
+ for a theme's own components — a byline under a post's title, say. It is free of Vue
92
+ and VitePress and depends on its arguments alone, so pre-rendered markup and the
93
+ browser agree.
94
+
95
+ ### Changed
96
+
97
+ - A `:::pagelist` that paginates reaches every page it found: `limit` now defaults to
98
+ `0` when `per-page` is set, rather than capping the selection at five entries and
99
+ leaving a pager to turn the pages of those five. A limit the author sets still wins.
100
+ - Every link a site generates — menus, breadcrumbs, page lists, the feed, redirect targets
101
+ — names the file the page is built into: `/blog/post.html`, `/blog/index.html`,
102
+ `/index.html`. Links used to leave the extension off while VitePress's own markdown
103
+ links carried it, so one site spoke two dialects and the shorter one only worked on
104
+ servers that go looking for the file. Naming it is answered on the first attempt, and
105
+ the sitemap now lists the same URLs. The address bar still shows `/` and `/blog/`:
106
+ VitePress's router shortens them while navigating.
107
+
108
+ ### Fixed
109
+
110
+ - The site's own `index.md` is no longer linked as `/index`, a URL no server has to
111
+ answer. The rule for a page's URL existed in three copies and now lives in
112
+ `lib/href.js` alone.
113
+ - Scavold's built-in translations come from the bundle. They used to be fetched from a
114
+ URL that no build emits, so every page requested a file that was not there and the
115
+ interface silently fell back to English, whatever language the site is in.
116
+
117
+ ## [0.2.0-rc.4] — 2026-08-15
118
+
12
119
  ### Added
13
120
 
14
121
  - `metaChunk` is on by default, keeping VitePress's page-hash map out of the inline
@@ -143,6 +250,8 @@ First published release. Version `0.1.0` existed in-tree only.
143
250
  - Only images are processed out of `media_folder`; other file types (video, documents)
144
251
  are never copied into the build output and have to live in the static folder.
145
252
 
146
- [0.2.0-rc.3]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
147
- [0.2.0-rc.2]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
148
- [0.2.0-rc.1]: https://gitlab.com/cepharum-foss/cratly/scavold/-/tags/v0.2.0-rc.1
253
+ [0.2.0-rc.5]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.4...v0.2.0-rc.5
254
+ [0.2.0-rc.4]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.3...v0.2.0-rc.4
255
+ [0.2.0-rc.3]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
256
+ [0.2.0-rc.2]: https://gitlab.com/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
257
+ [0.2.0-rc.1]: https://gitlab.com/cratly/scavold/-/tags/v0.2.0-rc.1
package/COMPONENTS.md CHANGED
@@ -504,7 +504,7 @@ Which pages are listed:
504
504
  | `from-root` | `number` | — | Absolute level from the site root: `1` = top-level pages, `2` = second level. Takes precedence over `from`. |
505
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
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` | Maximum number of entries. `0` lists every page found. |
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
508
  | `offset` | `number` | `0` | Entries to skip at the start, so a second block can continue where the first one ended. |
509
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
510
  | `reverse` | flag | — | Reverse whichever order was selected. |
@@ -516,7 +516,9 @@ What each entry shows:
516
516
  | `teaser` | flag | — | Show the introductory text of each listed page. |
517
517
  | `images` | flag | — | Show the teaser image of each listed page. |
518
518
  | `dates` | flag | — | Show the publication date of each listed page. |
519
- | `date-style` | `"short" \| "medium" \| "long" \| "full"` | `"long"` | `Intl.DateTimeFormat` style used for the dates. |
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. |
520
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. |
521
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). |
522
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. |
@@ -706,7 +708,7 @@ Must be called inside a component's `setup` function (or `<script setup>`).
706
708
  | `currentLocale` | `ComputedRef<string>` | The resolved locale for the current page. Falls back to the browser locale when no page or ancestor declares one. |
707
709
  | `collectLocaleLinks` | `(detection, includeCurrent?) => LocaleLink[]` | Returns available locale links for the current page. See `ScavoldLocaleMenu` for detection mode details. |
708
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. |
709
- | `nodeHref` | `(node) => string` | Root-relative link target of a node, preferring its alias URL. |
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`. |
710
712
  | `nodeLabel` | `(node) => string` | Navigation text of a node: `label`, then `title`, then the path segment. For menus and breadcrumbs, where space is scarce. |
711
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. |
712
714
 
@@ -808,10 +810,34 @@ Spread into `defineProps` to declare all video-related props at once.
808
810
  ### `usePageList( props )`
809
811
 
810
812
  ```js
811
- import { usePageList, pageListProps } from "./scavold/composables/usePageList.js";
813
+ import { usePageList, pageListProps } from "scavold/composables/usePageList.js";
812
814
  ```
813
815
 
814
- Composable for custom page-list components. Resolves the `:::pagelist` container props
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
815
841
  into the entries to render, each carrying its link target, title, date, introductory
816
842
  text and responsive teaser image data — see the
817
843
  [arguments](#scavoldpagelist) for what each prop means.
@@ -833,7 +859,11 @@ argument arrives as `data…`, in the same way as `videoProps`: `dataFrom` for `
833
859
  | `dates` | `ComputedRef<boolean>` | `true` when the `dates` flag is set |
834
860
  | `imageSizes` | `ComputedRef<string>` | CSS `sizes` descriptor for the teaser images |
835
861
  | `ariaLabel` | `ComputedRef<string \| undefined>` | Accessible label for the list, `undefined` when the author set none |
836
- | `formatDate` | `(isoDate: string) => string` | Formats a timestamp in the page's locale, in UTC |
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 |
837
867
 
838
868
  #### `PageListItem` properties
839
869
 
@@ -848,6 +878,30 @@ argument arrives as `data…`, in the same way as `videoProps`: `dataFrom` for `
848
878
 
849
879
  ---
850
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
+
851
905
  ### `useContainer( props )`
852
906
 
853
907
  ```js
package/FRONTMATTER.md CHANGED
@@ -107,6 +107,20 @@ Menu links and breadcrumbs produced by Scavold automatically use the alias URL.
107
107
  with an error naming both conflicting files. Each alias must be unique across the
108
108
  entire site.
109
109
 
110
+ The same applies when an alias lands on a path another page already owns as its own
111
+ file — a `url: de/kontakt` next to a `pages/de/kontakt.md`. VitePress identifies a
112
+ page by its path flattened into one name, slash becoming underscore, and it uses that
113
+ name for the page's bundle entry, its server module and its client chunk. Two pages
114
+ meeting there overwrite each other's output; the alias does not win, it collides. So
115
+ does a page whose file name spells out a path another page reaches through folders
116
+ (`de_kontakt.md` beside `de/kontakt.md`), and a pair differing only in letter case.
117
+ Scavold checks all of these when it reads the config and names both files. Prefer `-`
118
+ over `_` as the word separator in page names and the class cannot arise.
119
+
120
+ To move a page and keep its URL, rename the file and declare the old URL as its
121
+ alias — the alias belongs to the page that carries the content, not to a second file
122
+ left behind at the old location.
123
+
110
124
  ---
111
125
 
112
126
  ## `locale` / `lang`
@@ -260,6 +274,44 @@ in the hierarchy tree — they are only excluded from rendered navigation.
260
274
 
261
275
  ---
262
276
 
277
+ ## `aliases`
278
+
279
+ **Type:** `string | string[]`
280
+
281
+ Former addresses this page replaces. Declared when a cratly site takes over from an
282
+ existing website: every address a search engine already knows and the new site does not
283
+ answer is a result its owner loses.
284
+
285
+ ```yaml
286
+ ---
287
+ aliases:
288
+ - /kontakt.html
289
+ - kontakt.php
290
+ ---
291
+ ```
292
+
293
+ Values are written as they stood in the browser, with or without a leading slash. A
294
+ fragment is dropped — it never reaches a server. A query string is kept; whether it can
295
+ be matched is up to whoever serves the site.
296
+
297
+ Scavold collects them and writes `cratly-redirects.json` into the build output: an
298
+ address, where it went, and a status — `301` for an inherited address, `410` for one a
299
+ site declares as gone for good in
300
+ [`retired_urls`](./docs/reference/config.md#retired-urls). Turning that into server
301
+ configuration is the deploy step's job, because cratly does not assume who serves the
302
+ site. Nothing about the file is Scavold-specific: another adapter writes the same format.
303
+
304
+ **Keeping the address beats redirecting to it.** A permanent redirect passes a ranking on;
305
+ a page that simply answers at the old address never spends it. Where the legacy path can
306
+ be a file, [`url`](#url) is the better tool, and it works on every static server, whether
307
+ or not it can redirect at all.
308
+
309
+ **Conflict detection:** the build stops when two pages claim the same former address, and
310
+ when a former address is one the new site serves itself — the redirect would win over the
311
+ page, leaving it unreachable while every menu still links to it.
312
+
313
+ ---
314
+
263
315
  ## `redirect`
264
316
 
265
317
  **Type:** `string | object`
@@ -2,6 +2,7 @@ import { useData } from "vitepress";
2
2
  import { computed } from "vue";
3
3
  import { useL10n } from "@cepharum/vue3-i18n";
4
4
  import { hidesFrom } from "../lib/hide.js";
5
+ import { nodePath, pageHref } from "../lib/href.js";
5
6
  import type { Scavold } from "../index";
6
7
 
7
8
  /**
@@ -262,8 +263,7 @@ export function useHierarchy() {
262
263
  * Converts a hierarchy node path to a root-relative href.
263
264
  * Prefers node.url (the alias output path) when declared.
264
265
  */
265
- const nodeHref = ( node: Scavold.HierarchyNode ): string =>
266
- "/" + ( node.url ?? node.path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" );
266
+ const nodeHref = ( node: Scavold.HierarchyNode ): string => nodePath( node );
267
267
 
268
268
  /**
269
269
  * The last meaningful segment of a node's source path, used wherever a page
@@ -402,7 +402,7 @@ export function useHierarchy() {
402
402
  if ( translations ) {
403
403
  for ( const [ loc, path ] of Object.entries( translations ) ) {
404
404
  if ( !links.has( loc ) ) {
405
- links.set( loc, "/" + String( path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" ) );
405
+ links.set( loc, pageHref( String( path ) ) );
406
406
  }
407
407
  }
408
408
  }
@@ -1,5 +1,32 @@
1
1
  import { useL10n } from "@cepharum/vue3-i18n";
2
2
 
3
+ import de from "../l10n/de.json";
4
+ import en from "../l10n/en.json";
5
+
6
+ /**
7
+ * Scavold's own translations, keyed by language. They are part of the bundle on
8
+ * purpose: fetching them at runtime would have to name a URL, and a package that
9
+ * ships as source inside another site's build cannot know one. Each file is a few
10
+ * hundred bytes, and a site is free to override any key through its own loader.
11
+ */
12
+ const TRANSLATIONS = { de, en };
13
+
14
+ /** Language served to anyone whose locale Scavold does not translate. */
15
+ const FALLBACK_LOCALE = "en";
16
+
17
+ /**
18
+ * Picks the translations for a locale, accepting regional tags: `de-AT` is served
19
+ * the German set rather than falling through to English.
20
+ *
21
+ * @param {string} locale locale tag as requested by the l10n context
22
+ * @returns {object} set of translations
23
+ */
24
+ export function translationsFor( locale ) {
25
+ const language = String( locale ?? "" ).toLowerCase().split( /[-_]/ )[0];
26
+
27
+ return TRANSLATIONS[language] ?? TRANSLATIONS[FALLBACK_LOCALE];
28
+ }
29
+
3
30
  /**
4
31
  * Registers Scavold's built-in translations into the shared l10n context under
5
32
  * the `@scavold` namespace. All keys are accessed as `@scavold.<key>`.
@@ -13,9 +40,7 @@ import { useL10n } from "@cepharum/vue3-i18n";
13
40
  */
14
41
  export function registerScavoldI18n() {
15
42
  useL10n().setNamespaceLoader( "@scavold", locale =>
16
- import( `../l10n/${locale}.json` ).catch( () =>
17
- import( "../l10n/en.json" )
18
- )
43
+ Promise.resolve( translationsFor( locale ) )
19
44
  );
20
45
  }
21
46
 
@@ -1,6 +1,8 @@
1
1
  import { computed, onMounted, ref } from "vue";
2
2
  import { containerProps } from "./useContainer.js";
3
3
  import { useHierarchy } from "./hierarchy";
4
+ import { formatTimestamp, TIMESTAMP_STYLES, DEFAULT_TIMEZONE } from "../lib/dateFormat.js";
5
+ import { asNumber, selectionLimit } from "../lib/pageList.js";
4
6
 
5
7
  /**
6
8
  * Props definition for page-list container components. Spread into `defineProps`
@@ -49,9 +51,20 @@ export const pageListProps = {
49
51
  /** Show the publication date of each listed page. */
50
52
  dataDates: { type: String, default: null },
51
53
 
52
- /** Intl date style for the dates: "short", "medium", "long" or "full". */
54
+ /** Style of the dates: "short", "medium", "long", "full" — as Intl formats them
55
+ * in the page's locale — or "iso" for the locale-independent 2020-10-20. */
53
56
  dataDateStyle: { type: String, default: null },
54
57
 
58
+ /** Style of the time of day, in the same values as `data-date-style`. Left out,
59
+ * entries show their date only. */
60
+ dataTimeStyle: { type: String, default: null },
61
+
62
+ /** IANA name of the zone the dates are read in, e.g. "Europe/Berlin". Defaults
63
+ * to UTC, the zone dates are normalised into at build time — which is what a
64
+ * date without a time of day should be shown in. A list that shows times wants
65
+ * the zone the site publishes from instead. */
66
+ dataTimezone: { type: String, default: null },
67
+
55
68
  /** Heading level of the entry titles, 2–6. 0 renders a plain list of links. */
56
69
  dataHeadingLevel: { type: String, default: null },
57
70
 
@@ -75,21 +88,8 @@ export const pageListProps = {
75
88
  /** Orders a page list understands. */
76
89
  const SORT_ORDERS = new Set( [ "order", "date", "date-asc", "title" ] );
77
90
 
78
- /** Date styles Intl.DateTimeFormat accepts. */
79
- const DATE_STYLES = new Set( [ "short", "medium", "long", "full" ] );
80
-
81
- /**
82
- * Reads a container argument that carries a number.
83
- *
84
- * @param {string|null} value raw attribute value
85
- * @param {number} fallback value to use when absent or unusable
86
- * @returns {number}
87
- */
88
- function asNumber( value, fallback ) {
89
- const parsed = Number.parseInt( value, 10 );
90
-
91
- return Number.isNaN( parsed ) ? fallback : parsed;
92
- }
91
+ /** Styles a page list renders its dates in. */
92
+ const DATE_STYLES = new Set( TIMESTAMP_STYLES );
93
93
 
94
94
  /**
95
95
  * Compares two nodes by date, newest first. Pages without a date sort last, in
@@ -130,7 +130,7 @@ export function usePageList( props ) {
130
130
  const deep = computed( () => props.dataDeep != null );
131
131
  const reverse = computed( () => props.dataReverse != null );
132
132
 
133
- const limit = computed( () => Math.max( 0, asNumber( props.dataLimit, 5 ) ) );
133
+ const limit = computed( () => selectionLimit( props.dataLimit, props.dataPerPage ) );
134
134
  const offset = computed( () => Math.max( 0, asNumber( props.dataOffset, 0 ) ) );
135
135
 
136
136
  const sort = computed( () =>
@@ -141,6 +141,14 @@ export function usePageList( props ) {
141
141
  ( DATE_STYLES.has( props.dataDateStyle ) ? props.dataDateStyle : "long" )
142
142
  );
143
143
 
144
+ // No time of day unless the list asks for one: most lists date an entry, they do
145
+ // not timestamp it.
146
+ const timeStyle = computed( () =>
147
+ ( DATE_STYLES.has( props.dataTimeStyle ) ? props.dataTimeStyle : null )
148
+ );
149
+
150
+ const timezone = computed( () => props.dataTimezone || DEFAULT_TIMEZONE );
151
+
144
152
  // A plain list of links carries no headings; teaser cards do, and their level
145
153
  // has to fit the document outline around them, which only the author knows.
146
154
  const headingTag = computed( () => {
@@ -313,29 +321,18 @@ export function usePageList( props ) {
313
321
  const pages = computed( () => Array.from( { length: pageCount.value }, ( _, index ) => index + 1 ) );
314
322
 
315
323
  /**
316
- * Formats an ISO timestamp for display in the current page's locale. Dates are
317
- * rendered in UTC, the zone they were normalised into at build time, so a
318
- * date-only front matter value never shifts to its neighbouring day.
324
+ * Renders an entry's timestamp in the styles and zone the list was given.
319
325
  *
320
326
  * @param {string} isoDate ISO 8601 timestamp
321
327
  * @returns {string}
322
328
  */
323
329
  function formatDate( isoDate ) {
324
- const date = new Date( isoDate );
325
-
326
- if ( Number.isNaN( date.getTime() ) ) {
327
- return "";
328
- }
329
-
330
- try {
331
- return new Intl.DateTimeFormat( currentLocale.value || undefined, {
332
- dateStyle: dateStyle.value,
333
- timeZone: "UTC",
334
- } ).format( date );
335
- } catch {
336
- // An unusable locale tag must not take the page down with it.
337
- return date.toISOString().slice( 0, 10 );
338
- }
330
+ return formatTimestamp( isoDate, {
331
+ locale: currentLocale.value || undefined,
332
+ dateStyle: dateStyle.value,
333
+ timeStyle: timeStyle.value,
334
+ timeZone: timezone.value,
335
+ } );
339
336
  }
340
337
 
341
338
  return {