scavold 0.2.0-rc.4 → 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
@@ -11,6 +11,42 @@ changes may occur in any release.
11
11
 
12
12
  ### Added
13
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
+
14
50
  - `metaChunk` is on by default, keeping VitePress's page-hash map out of the inline
15
51
  scripts. Its content changes with every build, so under a content security policy that
16
52
  names script hashes it has to be updated on the server for every deploy — and when it
@@ -143,6 +179,7 @@ First published release. Version `0.1.0` existed in-tree only.
143
179
  - Only images are processed out of `media_folder`; other file types (video, documents)
144
180
  are never copied into the build output and have to live in the static folder.
145
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
146
183
  [0.2.0-rc.3]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.2...v0.2.0-rc.3
147
184
  [0.2.0-rc.2]: https://gitlab.com/cepharum-foss/cratly/scavold/-/compare/v0.2.0-rc.1...v0.2.0-rc.2
148
185
  [0.2.0-rc.1]: https://gitlab.com/cepharum-foss/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
@@ -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 {
package/lib/config.js CHANGED
@@ -9,6 +9,7 @@ import { patchRenderer } from "./markdown.js";
9
9
  import { extractContainerMediaSrcs, mediaPropsOf, registerContainers, resolveContainerMap } from "./containers.js";
10
10
  import { buildSectionManifest, writeSectionManifest } from "./sectionManifest.js";
11
11
  import { isExternalUrl, servableRedirectTarget } from "./redirectTarget.js";
12
+ import { pageHref, servableUrl } from "./href.js";
12
13
 
13
14
  // Scavold's own version, reported under `adapter.version` in the emitted
14
15
  // section-type manifest.
@@ -417,6 +418,19 @@ export async function augmentConfig( rawConfig, options = {} ) {
417
418
  // site that would rather not can say so in its own config.
418
419
  metaChunk: rawConfig.metaChunk ?? true,
419
420
 
421
+ // The sitemap names the same URL as every link on the site: the page's own file.
422
+ // VitePress lists a folder's index page as the folder ("/blog/") and the site's
423
+ // own as "/", which would make the same page appear under two spellings.
424
+ sitemap: rawConfig.sitemap && {
425
+ ...rawConfig.sitemap,
426
+
427
+ transformItems( items ) {
428
+ const named = items.map( item => ( { ...item, url: servableUrl( `/${item.url}` ).slice( 1 ) } ) );
429
+
430
+ return rawConfig.sitemap.transformItems?.( named ) ?? named;
431
+ },
432
+ },
433
+
420
434
  vite: {
421
435
  ...resolvedConfig.vite,
422
436
  plugins: [
@@ -473,7 +487,7 @@ export async function augmentConfig( rawConfig, options = {} ) {
473
487
  } else {
474
488
  const dir = dirname( pageData.relativePath );
475
489
  const resolved = joinPosix( dir === "." ? "" : dir, redirect );
476
- target = servableRedirectTarget( "/" + resolved.replace( /\/index\.md$/, "/" ).replace( /\.md$/, "" ) );
490
+ target = pageHref( resolved );
477
491
  }
478
492
  pageData.frontmatter.head = [
479
493
  ...pageData.frontmatter.head ?? [] ,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Rendering of timestamps for display.
3
+ *
4
+ * Kept free of Vue and VitePress so it can be used from components, from a
5
+ * theme's own components and from build-time code alike.
6
+ */
7
+
8
+ /** Date and time styles Intl.DateTimeFormat accepts. */
9
+ export const INTL_STYLES = [ "short", "medium", "long", "full" ];
10
+
11
+ /** Style rendering a date as 2020-10-20 and a time as 12:16, in any locale. */
12
+ export const ISO_STYLE = "iso";
13
+
14
+ /** Styles a timestamp can be rendered in: Intl's own plus the locale-independent one. */
15
+ export const TIMESTAMP_STYLES = [ ...INTL_STYLES, ISO_STYLE ];
16
+
17
+ /** Zone timestamps are read in unless the caller names another one. Dates are
18
+ * normalised into UTC at build time, so a front matter value that carries no time
19
+ * of day never shifts to its neighbouring day. */
20
+ export const DEFAULT_TIMEZONE = "UTC";
21
+
22
+ /**
23
+ * Reads a timestamp in a named zone, as the numeric fields it consists of.
24
+ *
25
+ * @param {Date} date timestamp to read
26
+ * @param {string} timeZone IANA name of the zone to read it in
27
+ * @returns {Object<string,string>} fields of the timestamp, zero-padded
28
+ */
29
+ function zonedParts( date, timeZone ) {
30
+ const parts = new Intl.DateTimeFormat( "en-GB", {
31
+ timeZone,
32
+ year: "numeric",
33
+ month: "2-digit",
34
+ day: "2-digit",
35
+ hour: "2-digit",
36
+ minute: "2-digit",
37
+ hour12: false,
38
+ } ).formatToParts( date );
39
+
40
+ return Object.fromEntries( parts.map( part => [ part.type, part.value ] ) );
41
+ }
42
+
43
+ /**
44
+ * Writes the date of a timestamp as 2020-10-20, in every locale alike.
45
+ *
46
+ * @param {Date} date timestamp to write
47
+ * @param {string} timeZone IANA name of the zone to read it in
48
+ * @returns {string}
49
+ */
50
+ export function isoDatePart( date, timeZone ) {
51
+ const { year, month, day } = zonedParts( date, timeZone );
52
+
53
+ return `${year}-${month}-${day}`;
54
+ }
55
+
56
+ /**
57
+ * Writes the time of day of a timestamp as 12:16, in every locale alike.
58
+ *
59
+ * @param {Date} date timestamp to write
60
+ * @param {string} timeZone IANA name of the zone to read it in
61
+ * @returns {string}
62
+ */
63
+ export function isoTimePart( date, timeZone ) {
64
+ const { hour, minute } = zonedParts( date, timeZone );
65
+
66
+ // Midnight comes back as hour 24 in some engines' en-GB output.
67
+ return `${hour === "24" ? "00" : hour}:${minute}`;
68
+ }
69
+
70
+ /**
71
+ * Renders a timestamp for display.
72
+ *
73
+ * The result depends on the arguments only, never on the machine it runs on, so
74
+ * pre-rendered markup and the browser's own rendering agree — a timestamp that
75
+ * changes on hydration would tear the markup apart.
76
+ *
77
+ * @param {string|Date} timestamp ISO 8601 timestamp, or a date
78
+ * @param {object} options
79
+ * @param {string} [options.locale] locale to phrase the result in
80
+ * @param {string} [options.dateStyle] one of TIMESTAMP_STYLES
81
+ * @param {string} [options.timeStyle] one of TIMESTAMP_STYLES; omitted, no time of day is shown
82
+ * @param {string} [options.timeZone] IANA name of the zone to read the timestamp in
83
+ * @returns {string} rendered timestamp, empty for one that cannot be read
84
+ */
85
+ export function formatTimestamp( timestamp, {
86
+ locale = undefined, dateStyle = "long", timeStyle = null, timeZone = DEFAULT_TIMEZONE,
87
+ } = {} ) {
88
+ const date = timestamp instanceof Date ? timestamp : new Date( timestamp );
89
+
90
+ if ( Number.isNaN( date.getTime() ) ) {
91
+ return "";
92
+ }
93
+
94
+ try {
95
+ // Intl phrases a date and a time together the way a locale wants them —
96
+ // "20 October 2020 at 12:16" — so it gets both at once where it can.
97
+ if ( dateStyle !== ISO_STYLE && timeStyle !== ISO_STYLE ) {
98
+ const options = { dateStyle, timeZone };
99
+
100
+ if ( timeStyle ) {
101
+ options.timeStyle = timeStyle;
102
+ }
103
+
104
+ return new Intl.DateTimeFormat( locale, options ).format( date );
105
+ }
106
+
107
+ const parts = [
108
+ dateStyle === ISO_STYLE
109
+ ? isoDatePart( date, timeZone )
110
+ : new Intl.DateTimeFormat( locale, { dateStyle, timeZone } ).format( date ),
111
+ ];
112
+
113
+ if ( timeStyle ) {
114
+ parts.push( timeStyle === ISO_STYLE
115
+ ? isoTimePart( date, timeZone )
116
+ : new Intl.DateTimeFormat( locale, { timeStyle, timeZone } ).format( date ) );
117
+ }
118
+
119
+ return parts.join( " " );
120
+ } catch {
121
+ // Neither an unusable locale tag nor an unknown zone must take the page down
122
+ // with it.
123
+ return date.toISOString().slice( 0, 10 );
124
+ }
125
+ }
package/lib/feed.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { forEachPage } from "./pages.js";
2
2
  import { hidesFrom } from "./hide.js";
3
+ import { nodePath } from "./href.js";
3
4
 
4
5
  /** How many entries a feed carries unless the site says otherwise. */
5
6
  export const DEFAULT_FEED_LIMIT = 20;
@@ -19,20 +20,6 @@ export function escapeXml( value ) {
19
20
  .replace( /'/g, "&apos;" );
20
21
  }
21
22
 
22
- /**
23
- * Converts a hierarchy node's source path into the path the built site serves, preferring
24
- * the alias URL when the page declares one.
25
- *
26
- * Mirrors nodeHref() of the hierarchy composable, which cannot be shared: that one runs in
27
- * the browser, this one during the build.
28
- *
29
- * @param {object} node hierarchy node of a page
30
- * @returns {string} root-relative path, e.g. `/de/blog/post`
31
- */
32
- export function pagePath( node ) {
33
- return "/" + ( node.url ?? node.path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" );
34
- }
35
-
36
23
  /**
37
24
  * Collects the pages a feed announces: everything below the given path that carries a
38
25
  * date, newest first.
@@ -91,7 +78,7 @@ export function collectFeedPages( hierarchy, { from = "", limit = DEFAULT_FEED_L
91
78
  export function renderRss( { title, description = "", hostname, path, pages, now = new Date() } ) {
92
79
  const origin = String( hostname ).replace( /\/$/, "" );
93
80
  const items = pages.map( node => {
94
- const link = origin + pagePath( node );
81
+ const link = origin + nodePath( node );
95
82
  const text = node.excerpt ?? "";
96
83
 
97
84
  return [
package/lib/href.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The URL a page is served under.
3
+ *
4
+ * Kept free of Vue and VitePress: the same rule is needed in the browser, where menus
5
+ * and breadcrumbs link pages, and during the build, where the feed, the sitemap and
6
+ * the redirects name them. Two copies of a rule are two chances to get it wrong — the
7
+ * site's own index page used to be linked as `/index`, a URL no server has to answer.
8
+ *
9
+ * Every page is linked as the file it is built into, extension and all: `/blog/` and
10
+ * `/blog/post` would be answered by a server that looks for an index file or tries the
11
+ * `.html` extension, but only after it has looked for something that is not there.
12
+ * `/blog/index.html` is a hit on the first attempt, and it is the spelling readers are
13
+ * used to seeing.
14
+ */
15
+
16
+ /** Extension every page is built into and therefore linked with. */
17
+ export const PAGE_EXTENSION = ".html";
18
+
19
+ /**
20
+ * Converts a page's source path into the root-relative URL the built site serves.
21
+ *
22
+ * @param {string} path source path of a page, or the alias URL it declares
23
+ * @returns {string} root-relative URL, e.g. `/de/blog/post.html` or `/blog/index.html`
24
+ */
25
+ export function pageHref( path ) {
26
+ return "/" + String( path ?? "" ).replace( /\.md$/, "" ) + PAGE_EXTENSION;
27
+ }
28
+
29
+ /**
30
+ * Converts a hierarchy node into the URL the built site serves it under, preferring
31
+ * the alias URL when the page declares one.
32
+ *
33
+ * @param {object} node hierarchy node of a page
34
+ * @returns {string} root-relative URL
35
+ */
36
+ export function nodePath( node ) {
37
+ return pageHref( node?.url ?? node?.path );
38
+ }
39
+
40
+ /**
41
+ * Rewrites a site-relative URL to the file that answers it: a folder URL to the index
42
+ * page inside it, an extension-less path to its page.
43
+ *
44
+ * Left alone are external URLs and anything already naming a file, and a query string
45
+ * or fragment stays at the end where it belongs.
46
+ *
47
+ * @param {string} url URL as written, e.g. in a page's `redirect` front matter
48
+ * @returns {string} URL naming the file that answers it
49
+ */
50
+ export function servableUrl( url ) {
51
+ const [ , path, suffix = "" ] = /^([^?#]*)([?#].*)?$/.exec( String( url ?? "" ) );
52
+
53
+ if ( path.endsWith( PAGE_EXTENSION ) ) {
54
+ return path + suffix;
55
+ }
56
+
57
+ if ( path === "" || path.endsWith( "/" ) ) {
58
+ return `${path}index${PAGE_EXTENSION}${suffix}`;
59
+ }
60
+
61
+ return path + PAGE_EXTENSION + suffix;
62
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Rules a page list selects by, kept free of Vue and VitePress so they can be
3
+ * checked on their own and reused by a theme's own list components.
4
+ */
5
+
6
+ /** Entries a list shows when it neither limits nor paginates its selection. */
7
+ export const DEFAULT_LIMIT = 5;
8
+
9
+ /**
10
+ * Reads an argument that carries a number.
11
+ *
12
+ * @param {string|number|null} value raw attribute value
13
+ * @param {number} fallback value to use when absent or unusable
14
+ * @returns {number}
15
+ */
16
+ export function asNumber( value, fallback ) {
17
+ const parsed = Number.parseInt( value, 10 );
18
+
19
+ return Number.isNaN( parsed ) ? fallback : parsed;
20
+ }
21
+
22
+ /**
23
+ * Determines how many pages a list selects before its pager divides them.
24
+ *
25
+ * A list that paginates is meant to reach every page it selected: capping the
26
+ * selection at the default would leave a pager turning one short page. A limit the
27
+ * author set still wins — it says how far the list reaches, and the pager then
28
+ * divides that.
29
+ *
30
+ * @param {string|number|null} limit `limit` argument as written, if any
31
+ * @param {string|number|null} perPage `per-page` argument as written, if any
32
+ * @returns {number} number of entries to select, 0 for all of them
33
+ */
34
+ export function selectionLimit( limit, perPage ) {
35
+ const fallback = perPage == null ? DEFAULT_LIMIT : 0;
36
+
37
+ return Math.max( 0, asNumber( limit, fallback ) );
38
+ }
@@ -1,3 +1,5 @@
1
+ import { servableUrl } from "./href.js";
2
+
1
3
  /**
2
4
  * Returns true for absolute URLs (any scheme, e.g. "https://", "mailto:") and
3
5
  * protocol-relative URLs ("//cdn.example.com"). These must be redirected
@@ -22,10 +24,9 @@ export function isExternalUrl( value ) {
22
24
  * `file_server` web server — for that exact path. A clean leaf URL maps to no
23
25
  * file there and 404s.
24
26
  *
25
- * Appending `.html` to leaf paths (while leaving trailing-slash folder URLs to
26
- * the server's `index.html` rule) yields a path that maps to a real file on any
27
- * static server. Absolute/external URLs, query strings and fragments are left
28
- * untouched.
27
+ * Naming the file itself yields a path every static server answers directly, and
28
+ * one that matches the links the rest of the site carries. Absolute/external URLs
29
+ * are left untouched, and a query string or fragment stays at the end.
29
30
  *
30
31
  * @param {string} target site-relative path or absolute URL
31
32
  * @returns {string} a path/URL a plain static server can serve directly
@@ -35,12 +36,5 @@ export function servableRedirectTarget( target ) {
35
36
 
36
37
  if ( isExternalUrl( target ) ) return target;
37
38
 
38
- // Split off any query string / fragment so the extension lands on the path.
39
- const [ , path, suffix ] = /^([^?#]*)([?#].*)?$/.exec( target );
40
-
41
- // Folder URLs ("/de/jobs/") rely on the server's index.html rule; paths that
42
- // already carry the ".html" extension are served as-is.
43
- if ( path === "" || path.endsWith( "/" ) || path.endsWith( ".html" ) ) return path + ( suffix ?? "" );
44
-
45
- return path + ".html" + ( suffix ?? "" );
39
+ return servableUrl( target );
46
40
  }
@@ -95,7 +95,7 @@ export const BUILTIN_SECTIONS = {
95
95
  limit: {
96
96
  type: "number",
97
97
  label: "Number of entries",
98
- hint: "0 lists every page found.",
98
+ hint: "0 lists every page found. Left unset on a list with entries per page, every page found is paged through.",
99
99
  default: 5,
100
100
  },
101
101
  offset: {
@@ -126,8 +126,20 @@ export const BUILTIN_SECTIONS = {
126
126
  "date-style": {
127
127
  type: "enum",
128
128
  label: "Date format",
129
- values: [ "short", "medium", "long", "full" ],
129
+ values: [ "short", "medium", "long", "full", "iso" ],
130
130
  default: "long",
131
+ hint: "\"iso\" writes 2020-10-20 in every language; the others follow the page's language.",
132
+ },
133
+ "time-style": {
134
+ type: "enum",
135
+ label: "Time format",
136
+ values: [ "short", "medium", "long", "full", "iso" ],
137
+ hint: "Left unset, entries show their date only. \"iso\" writes 12:16.",
138
+ },
139
+ timezone: {
140
+ type: "text",
141
+ label: "Time zone",
142
+ hint: "Zone the dates are read in, e.g. Europe/Berlin. Defaults to UTC, which is what a date without a time of day should be shown in.",
131
143
  },
132
144
  "heading-level": {
133
145
  type: "number",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scavold",
3
- "version": "0.2.0-rc.4",
3
+ "version": "0.2.0-rc.5",
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": [
@@ -218,9 +218,32 @@ expect( backgrounds.length === 2 && backgrounds.every( t => /autoplay/.test( t )
218
218
 
219
219
  // ── page list ────────────────────────────────────────────────────────────────
220
220
 
221
+ console.log( "\nnavigation" );
222
+
223
+ const blogPage = built( join( "blog", "index.html" ) );
224
+ const menu = ( blogPage.match( /<nav aria-label="Main navigation"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
225
+ const crumbs = ( blogPage.match( /<nav class="breadcrumb"[\s\S]*?<\/nav>/ ) ?? [""] )[0];
226
+
227
+ // Every link names the file that answers it, so a static server hits it on its
228
+ // first attempt and the sitemap can name the very same URL.
229
+ expect( menu.includes( '<a href="/index.html">Home</a>' ),
230
+ "links the site's own index page as the file it is built into" );
231
+ expect( menu.includes( '<a href="/blog/index.html"' ),
232
+ "links a folder's index page as that file, not as the folder" );
233
+ expect( menu.includes( '<a href="/containers.html">' ),
234
+ "links a plain page as its own file" );
235
+ expect( crumbs.includes( '<a href="/blog/index.html"' ) && crumbs.includes( 'aria-current="page"' ),
236
+ "marks the page the breadcrumb ends on" );
237
+
238
+ const sitemap = built( "sitemap.xml" );
239
+
240
+ expect( sitemap.includes( "<loc>https://fixture.example.com/index.html</loc>" ) &&
241
+ sitemap.includes( "<loc>https://fixture.example.com/blog/index.html</loc>" ),
242
+ "lists in the sitemap the same URLs the pages link to" );
243
+
221
244
  console.log( "\npage list" );
222
245
 
223
- const blog = built( join( "blog", "index.html" ) );
246
+ const blog = blogPage;
224
247
  const lists = blog.split( '<ul class="scavold-pagelist__items"' );
225
248
  const dated = lists[1] ?? "";
226
249
  const titled = lists[2] ?? "";
@@ -269,6 +292,10 @@ const paged = lists[3] ?? "";
269
292
 
270
293
  expect( entryTitles( paged ).length === 2, "renders one page worth of entries, not the whole list",
271
294
  `found ${entryTitles( paged ).length}` );
295
+ // The list names no limit: one that paginates reaches every page it found, or the
296
+ // pager would turn the pages of a five-entry selection.
297
+ expect( /<time class="scavold-pagelist__date" datetime="2026-06-21T00:00:00\.000Z">2026-06-21 02:00</.test( paged ),
298
+ "writes dates in the style and the zone the list asked for" );
272
299
  expect( /<nav class="scavold-pagelist__pager" aria-label="[^"]+"/.test( blog ),
273
300
  "gives the pager a landmark a screen reader can announce" );
274
301
  expect( /<p class="scavold-pagelist__status" role="status">[^<]*1[^<]*2<\/p>/.test( blog ),
@@ -278,6 +305,22 @@ expect( /<button[^>]*class="[^"]*\bcurrent\b[^"]*scavold-pagelist__page[^"]*"[^>
278
305
  expect( /scavold-pagelist__page--previous[^>]*disabled|disabled[^>]*scavold-pagelist__page--previous/.test( blog ),
279
306
  "offers no step back from the first page" );
280
307
 
308
+ // A theme is not confined to restyling what Scavold renders: the fixture maps a
309
+ // container to a component of its own, which reuses the very same selection and
310
+ // formatting through the exported composable.
311
+ const ownList = ( blog.match( /<ol class="site-postlist"[\s\S]*?<\/ol>/ ) ?? [""] )[0];
312
+
313
+ expect( ownList.includes( ">Newest post<" ) && ownList.includes( ">Middle post<" ),
314
+ "lets a site's own component render a page list, selection and order included" );
315
+ // The weekday and the month name are what tell "full" apart from the other styles.
316
+ // Their arrangement and punctuation are not asserted: those follow the locale data of
317
+ // whichever runtime builds the site, and differ between one Bun release and the next.
318
+ const ownDate = ( ownList.match( /<time[^>]*class="site-postlist__date"[^>]*>([^<]*)</ ) ?? [ "", "" ] )[1].trim();
319
+
320
+ expect( [ "Sunday", "21", "June", "2026" ].every( part => ownDate.includes( part ) ),
321
+ "hands a site's own component the timestamp formatting as well",
322
+ `the entry reads ${JSON.stringify( ownDate )}` );
323
+
281
324
  // ── content security policy ──────────────────────────────────────────────────
282
325
 
283
326
  console.log( "\ncontent security policy" );
@@ -303,7 +346,7 @@ expect( feed.startsWith( '<?xml version="1.0" encoding="UTF-8"?>' ), "writes a f
303
346
  expect( /<atom:link href="https:\/\/fixture\.example\.com\/feed\.xml"/.test( feed ),
304
347
  "points the feed at itself, absolutely" );
305
348
  expect( feedItems.length === 3, "honours the limit the site set", `found ${feedItems.length} entries, expected 3` );
306
- expect( /<link>https:\/\/fixture\.example\.com\/blog\/draft<\/link>/.test( feedItems[0] ?? "" ),
349
+ expect( /<link>https:\/\/fixture\.example\.com\/blog\/draft\.html<\/link>/.test( feedItems[0] ?? "" ),
307
350
  "leads with the newest announced post and links it absolutely" );
308
351
  expect( /<pubDate>[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT<\/pubDate>/.test( feed ),
309
352
  "dates entries the way a reader expects" );