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 +37 -0
- package/COMPONENTS.md +60 -6
- package/composables/hierarchy.ts +3 -3
- package/composables/useI18n.js +28 -3
- package/composables/usePageList.js +32 -35
- package/lib/config.js +15 -1
- package/lib/dateFormat.js +125 -0
- package/lib/feed.js +2 -15
- package/lib/href.js +62 -0
- package/lib/pageList.js +38 -0
- package/lib/redirectTarget.js +6 -12
- package/lib/sectionManifest.js +14 -2
- package/package.json +1 -1
- package/scripts/check-fixture.js +45 -2
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`
|
|
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 "
|
|
813
|
+
import { usePageList, pageListProps } from "scavold/composables/usePageList.js";
|
|
812
814
|
```
|
|
813
815
|
|
|
814
|
-
Composable for custom page-list components
|
|
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` |
|
|
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/composables/hierarchy.ts
CHANGED
|
@@ -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,
|
|
405
|
+
links.set( loc, pageHref( String( path ) ) );
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
}
|
package/composables/useI18n.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
79
|
-
const DATE_STYLES = new Set(
|
|
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( () =>
|
|
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
|
-
*
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 =
|
|
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, "'" );
|
|
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 +
|
|
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
|
+
}
|
package/lib/pageList.js
ADDED
|
@@ -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
|
+
}
|
package/lib/redirectTarget.js
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/lib/sectionManifest.js
CHANGED
|
@@ -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
package/scripts/check-fixture.js
CHANGED
|
@@ -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 =
|
|
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" );
|