@escape-game-over/atlas 0.1.1
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/README.md +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- package/src/xml.ts +103 -0
package/src/site/page.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { MetaContent, MetaTag } from "../meta/index.ts";
|
|
2
|
+
import type { HttpsUrl, UrlPath } from "../url.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The vocabulary of one page: what a view is handed, and what it hands back.
|
|
6
|
+
*
|
|
7
|
+
* Separate from `api.ts`, which describes the site as a whole. Everything here
|
|
8
|
+
* is about a single document — where it sits, what it says, which slice of a
|
|
9
|
+
* list it is — and none of it mentions a route registry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface LinkOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Which page of a paginated list to link to, from 1.
|
|
15
|
+
*
|
|
16
|
+
* `1` and `undefined` both give the bare path, because page one *is* the
|
|
17
|
+
* bare path — so a loop over page numbers needs no special case for the
|
|
18
|
+
* first, and cannot produce a `/page/1` that was never built.
|
|
19
|
+
*/
|
|
20
|
+
readonly page?: number;
|
|
21
|
+
/** Without the leading `#`. */
|
|
22
|
+
readonly hash?: string;
|
|
23
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Props for one generated page.
|
|
28
|
+
*
|
|
29
|
+
* A type alias, not an interface: SSG frameworks require props to be assignable
|
|
30
|
+
* to an index-signature type, which interfaces are not.
|
|
31
|
+
*/
|
|
32
|
+
export type PageProps<L extends string, Id extends string> = {
|
|
33
|
+
readonly routeId: Id;
|
|
34
|
+
readonly locale: L;
|
|
35
|
+
/**
|
|
36
|
+
* Which page of this route's list to render, from 1.
|
|
37
|
+
*
|
|
38
|
+
* Always present, and `1` for a route that does not paginate — a view that
|
|
39
|
+
* ignores it renders the only page there is, and one that slices a list by
|
|
40
|
+
* it needs no guard.
|
|
41
|
+
*/
|
|
42
|
+
readonly page: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One `{ params, props }` pair, shaped for a static-site router. */
|
|
46
|
+
export type StaticPath<L extends string, Id extends string> = {
|
|
47
|
+
readonly params: Readonly<Record<string, string | undefined>>;
|
|
48
|
+
readonly props: PageProps<L, Id>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Everything one page's `<html>` and `<head>` need. */
|
|
52
|
+
export interface PageMeta {
|
|
53
|
+
/** For `<html lang dir>` — this page's locale, not the site default. */
|
|
54
|
+
readonly lang: string;
|
|
55
|
+
readonly dir: "ltr" | "rtl";
|
|
56
|
+
/** Everything for `<head>`, which is nearly everything. */
|
|
57
|
+
readonly tags: MetaTag[];
|
|
58
|
+
/**
|
|
59
|
+
* For immediately after `<body>`, and usually empty.
|
|
60
|
+
*
|
|
61
|
+
* Its own list because one thing lib emits genuinely belongs there — Tag
|
|
62
|
+
* Manager's `<noscript>` iframe, which the head ignores. A layout renders
|
|
63
|
+
* both, and cannot put either in the other's place.
|
|
64
|
+
*/
|
|
65
|
+
readonly bodyTags: MetaTag[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a page passes to `metaFor`: its words, its picture, its trail, and which
|
|
70
|
+
* page of a list it is.
|
|
71
|
+
*
|
|
72
|
+
* `MetaContent` is about a *document* — the title, the description, the share
|
|
73
|
+
* image — and knows nothing of a site around it. The two fields here are what a
|
|
74
|
+
* document cannot answer alone: where it sits among other pages, and which
|
|
75
|
+
* slice of a list it shows.
|
|
76
|
+
*
|
|
77
|
+
* No longer generic over `RouteId`. It was, while a callback named each step of
|
|
78
|
+
* the trail and had to be handed ids this project actually builds. The trail
|
|
79
|
+
* now arrives finished, so nothing here mentions a route.
|
|
80
|
+
*/
|
|
81
|
+
export type PageContent = MetaContent & PageExtras;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The two things a document cannot answer about itself.
|
|
85
|
+
*
|
|
86
|
+
* Split out because `MetaContent` is an intersection with a union — an
|
|
87
|
+
* interface cannot extend one — and the union is what stops a page being both
|
|
88
|
+
* an article and something else.
|
|
89
|
+
*/
|
|
90
|
+
interface PageExtras {
|
|
91
|
+
/**
|
|
92
|
+
* Which page of a paginated list this is, from 1.
|
|
93
|
+
*
|
|
94
|
+
* Pass `Astro.props.page` straight through. It is what makes page 2
|
|
95
|
+
* canonical to itself rather than to the list — *"don't use the first page
|
|
96
|
+
* of a paginated sequence as the canonical page"* — and what points its
|
|
97
|
+
* `hreflang` alternates at page 2 of each other language instead of at
|
|
98
|
+
* their first.
|
|
99
|
+
*
|
|
100
|
+
* Omitted is page one, so an unpaginated view says nothing and is right.
|
|
101
|
+
*/
|
|
102
|
+
readonly page?: number;
|
|
103
|
+
/**
|
|
104
|
+
* The trail, already built — by `breadcrumbFor`, which is what knows a
|
|
105
|
+
* route's ancestors.
|
|
106
|
+
*
|
|
107
|
+
* Handed over rather than derived here from callbacks, because the page
|
|
108
|
+
* renders this same trail and the two have to be one array. Callbacks meant
|
|
109
|
+
* two places building it from the same rules: two places to keep in step,
|
|
110
|
+
* and one of them wrong the moment a rule gained a case. The version this
|
|
111
|
+
* replaced pointed a paginated list's own crumb at page one, so the
|
|
112
|
+
* structured data on page three claimed to be page one.
|
|
113
|
+
*
|
|
114
|
+
* Omit it and no breadcrumb is emitted.
|
|
115
|
+
*/
|
|
116
|
+
readonly breadcrumb?: readonly Crumb[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One step of a breadcrumb trail: what it is called, and where it goes.
|
|
121
|
+
*
|
|
122
|
+
* Both forms of the destination, as `Alternate` carries both, because the two
|
|
123
|
+
* readers want different ones. A link in the page wants the path — an absolute
|
|
124
|
+
* one sends a developer from localhost to the production host mid-click — while
|
|
125
|
+
* structured data requires the absolute URL.
|
|
126
|
+
*/
|
|
127
|
+
export interface Crumb {
|
|
128
|
+
readonly name: string;
|
|
129
|
+
/** Root-relative, for an `href`. */
|
|
130
|
+
readonly path: UrlPath;
|
|
131
|
+
/** Absolute, for structured data. */
|
|
132
|
+
readonly url: HttpsUrl;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface Alternate<L extends string> {
|
|
136
|
+
readonly locale: L;
|
|
137
|
+
readonly path: UrlPath;
|
|
138
|
+
readonly url: HttpsUrl;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One entry in a language switcher: where it goes, and how to label it. */
|
|
142
|
+
export interface LocaleLink<L extends string> extends Alternate<L> {
|
|
143
|
+
/** The language's name, written in that language. */
|
|
144
|
+
readonly label: string;
|
|
145
|
+
readonly htmlLang: string;
|
|
146
|
+
readonly dir: "ltr" | "rtl";
|
|
147
|
+
readonly isCurrent: boolean;
|
|
148
|
+
}
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { ResolvedLocaleMeta } from "./config.ts";
|
|
2
|
+
import type { GeneratedFile } from "./file.ts";
|
|
3
|
+
import type { RouteEntry } from "./routes/resolve.ts";
|
|
4
|
+
import { type HttpsUrl, joinUrl } from "./url.ts";
|
|
5
|
+
import { el, renderXmlDocument, type XmlElement } from "./xml.ts";
|
|
6
|
+
|
|
7
|
+
const SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
|
8
|
+
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
9
|
+
const XML_CONTENT_TYPE = "application/xml; charset=utf-8";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the sitemaps.org spec allows in one file. A file above it is not a
|
|
13
|
+
* large sitemap, it is a rejected one.
|
|
14
|
+
*
|
|
15
|
+
* A value rather than a sentence in two doc comments, because two things need
|
|
16
|
+
* it: the default below sits under it, and `checkEntryLimit` refuses anything
|
|
17
|
+
* over it. Written once, so the number in the error cannot drift from the
|
|
18
|
+
* number that produced it.
|
|
19
|
+
*/
|
|
20
|
+
const SPEC_MAX_ENTRIES = 50_000;
|
|
21
|
+
|
|
22
|
+
/** Under the cap, with headroom — see `SPEC_MAX_ENTRIES`. */
|
|
23
|
+
const DEFAULT_ENTRY_LIMIT = 45_000;
|
|
24
|
+
|
|
25
|
+
/** How a site configures its sitemap. Part of `site.config.ts`. */
|
|
26
|
+
export interface SitemapConfig {
|
|
27
|
+
/** Filename the sitemap is served under. Defaults to `sitemap.xml`. */
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Maximum URLs per file before the sitemap splits into an index plus
|
|
31
|
+
* numbered parts. Defaults to 45000 (the spec caps a file at 50000).
|
|
32
|
+
*
|
|
33
|
+
* A whole number from 1 to 50000, the most the spec allows in one file.
|
|
34
|
+
* Anything else throws at build rather than being clamped — see
|
|
35
|
+
* `checkEntryLimit` for why this one is not forgiving, and for what a value
|
|
36
|
+
* on either side of the range would otherwise do.
|
|
37
|
+
*/
|
|
38
|
+
readonly entryLimit?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One `{ params, props }` pair for a sitemap file, shaped for a static router. */
|
|
42
|
+
export type SitemapStaticPath = {
|
|
43
|
+
readonly params: Readonly<Record<string, string>>;
|
|
44
|
+
readonly props: { readonly file: GeneratedFile };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface Sitemap {
|
|
48
|
+
/**
|
|
49
|
+
* The file to submit and to reference from `robots.txt`.
|
|
50
|
+
*
|
|
51
|
+
* Always keeps the configured name, so the public URL is stable whether or
|
|
52
|
+
* not the sitemap ended up split: it is the single sitemap when everything
|
|
53
|
+
* fits, and the index when it does not.
|
|
54
|
+
*/
|
|
55
|
+
readonly entry: GeneratedFile;
|
|
56
|
+
/** Every file that must be served, `entry` included. */
|
|
57
|
+
readonly files: readonly GeneratedFile[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every file to generate, as `{ params, props }` for a dynamic route.
|
|
61
|
+
*
|
|
62
|
+
* `param` is the name of the parameter in the router's filename, e.g.
|
|
63
|
+
* `"sitemap"` for `[sitemap].ts`. One route therefore serves the single
|
|
64
|
+
* sitemap or the index plus its parts, without the caller knowing which
|
|
65
|
+
* case it is in.
|
|
66
|
+
*
|
|
67
|
+
* Returns a fresh mutable array, because SSG routers typically demand one.
|
|
68
|
+
*/
|
|
69
|
+
staticPaths(param: string): SitemapStaticPath[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toSitemap(
|
|
73
|
+
entry: GeneratedFile,
|
|
74
|
+
files: readonly GeneratedFile[]
|
|
75
|
+
): Sitemap {
|
|
76
|
+
return {
|
|
77
|
+
entry,
|
|
78
|
+
files,
|
|
79
|
+
staticPaths: (param) =>
|
|
80
|
+
files.map((file) => ({
|
|
81
|
+
params: { [param]: file.name },
|
|
82
|
+
props: { file },
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SitemapInput<L extends string, Id extends string> {
|
|
88
|
+
readonly siteUrl: HttpsUrl;
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly entryLimit?: number;
|
|
91
|
+
readonly entries: readonly RouteEntry<L, Id>[];
|
|
92
|
+
readonly localeMeta: Readonly<Record<L, ResolvedLocaleMeta>>;
|
|
93
|
+
/**
|
|
94
|
+
* Alternates for one route, so each URL can advertise its translations.
|
|
95
|
+
*
|
|
96
|
+
* Takes the page as well as the id, and must: a paginated route emits an
|
|
97
|
+
* entry per page, and asking without one would answer for page 1 every
|
|
98
|
+
* time — so `/news/page/2` would advertise `/news` as its own translation,
|
|
99
|
+
* never reference itself, and declare the same alternate cluster the list
|
|
100
|
+
* does. Google discards a cluster whose members disagree rather than
|
|
101
|
+
* picking a winner, so that is the whole route's `hreflang` lost, not one
|
|
102
|
+
* URL's.
|
|
103
|
+
*
|
|
104
|
+
* Structural rather than `LinkOptions`, which lives in `site/` and imports
|
|
105
|
+
* this module — the shape is the contract, and naming the type would be a
|
|
106
|
+
* cycle.
|
|
107
|
+
*/
|
|
108
|
+
readonly alternatesFor: (
|
|
109
|
+
id: Id,
|
|
110
|
+
options?: { readonly page?: number }
|
|
111
|
+
) => readonly { readonly locale: L; readonly url: HttpsUrl }[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `sitemap.xml` -> `sitemap-0.xml`, keeping whatever extension was configured. */
|
|
115
|
+
function partName(name: string, index: number): string {
|
|
116
|
+
const dot = name.lastIndexOf(".");
|
|
117
|
+
return dot === -1
|
|
118
|
+
? `${name}-${index}`
|
|
119
|
+
: `${name.slice(0, dot)}-${index}${name.slice(dot)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The cap a sitemap splits at, checked before anything divides by it.
|
|
124
|
+
*
|
|
125
|
+
* Two ways to get it wrong, and both are caught here because both fail
|
|
126
|
+
* silently in their own way.
|
|
127
|
+
*
|
|
128
|
+
* *Too small* — zero or less, or a fraction. Thrown rather than clamped, which
|
|
129
|
+
* is the opposite of what `pages` on a route wants, and the difference is
|
|
130
|
+
* whether the wrong value means anything: a route computing `pages: 0` from an
|
|
131
|
+
* empty list plainly means "one page", so lib normalises it, while "at most
|
|
132
|
+
* zero URLs per file" describes no sitemap at all. And the consequence is not a
|
|
133
|
+
* bad file, it is no build — `chunk` advances by this value, so zero or less
|
|
134
|
+
* loops forever, allocating until the process is killed with nothing said about
|
|
135
|
+
* why.
|
|
136
|
+
*
|
|
137
|
+
* *Too large* — over `SPEC_MAX_ENTRIES`. This one builds perfectly and produces
|
|
138
|
+
* a file a search engine refuses to read, which is the worse failure of the
|
|
139
|
+
* two: splitting is the whole point of the field, and a limit above the cap
|
|
140
|
+
* quietly switches it off.
|
|
141
|
+
*/
|
|
142
|
+
function checkEntryLimit(limit: number): void {
|
|
143
|
+
if (Number.isInteger(limit) && limit > 0 && limit <= SPEC_MAX_ENTRIES) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(
|
|
148
|
+
`sitemap.entryLimit is ${limit}, which is not a number of URLs one file can hold: it wants a whole number from 1 to ${SPEC_MAX_ENTRIES}, the most the sitemaps.org spec allows in a file.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function chunk<T>(
|
|
153
|
+
items: readonly T[],
|
|
154
|
+
size: number
|
|
155
|
+
): readonly (readonly T[])[] {
|
|
156
|
+
if (items.length <= size) return [items];
|
|
157
|
+
const chunks: T[][] = [];
|
|
158
|
+
for (let i = 0; i < items.length; i += size) {
|
|
159
|
+
chunks.push(items.slice(i, i + size));
|
|
160
|
+
}
|
|
161
|
+
return chunks;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function file(
|
|
165
|
+
siteUrl: HttpsUrl,
|
|
166
|
+
name: string,
|
|
167
|
+
root: XmlElement
|
|
168
|
+
): GeneratedFile {
|
|
169
|
+
return {
|
|
170
|
+
name,
|
|
171
|
+
url: joinUrl(siteUrl, `/${name}`),
|
|
172
|
+
body: renderXmlDocument(root),
|
|
173
|
+
contentType: XML_CONTENT_TYPE,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Renders the sitemap from the same route entries the pages are built from, so
|
|
179
|
+
* a retranslated slug or a switched-off page can never drift out of sync with it.
|
|
180
|
+
*
|
|
181
|
+
* Each URL carries `xhtml:link` alternates for its other locales, which is how
|
|
182
|
+
* search engines are told these are translations of one page rather than
|
|
183
|
+
* duplicates. Astro's own sitemap integration cannot express this: it pairs
|
|
184
|
+
* locales by matching the path after the locale prefix, which translated slugs
|
|
185
|
+
* never share.
|
|
186
|
+
*
|
|
187
|
+
* Above `entryLimit` URLs the result splits into an index plus numbered parts.
|
|
188
|
+
*/
|
|
189
|
+
export function buildSitemap<L extends string, Id extends string>(
|
|
190
|
+
input: SitemapInput<L, Id>
|
|
191
|
+
): Sitemap {
|
|
192
|
+
// Before anything divides by it. A project supplies this as a plain
|
|
193
|
+
// `number` and nothing else stands between the config and the loop.
|
|
194
|
+
const entryLimit = input.entryLimit ?? DEFAULT_ENTRY_LIMIT;
|
|
195
|
+
checkEntryLimit(entryLimit);
|
|
196
|
+
|
|
197
|
+
const included = input.entries.filter((entry) => !entry.sitemap?.exclude);
|
|
198
|
+
|
|
199
|
+
const urlFor = (entry: RouteEntry<L, Id>): XmlElement => {
|
|
200
|
+
return el("url", [
|
|
201
|
+
el("loc", joinUrl(input.siteUrl, entry.path)),
|
|
202
|
+
|
|
203
|
+
// This entry's own page, so each URL advertises the same-numbered
|
|
204
|
+
// page of every other locale. Safe for an unpaginated route:
|
|
205
|
+
// `pathFor` reads page 1 and the bare path as the same thing, and
|
|
206
|
+
// every locale of a route shares one `pages` count — so a page that
|
|
207
|
+
// produced an entry here exists in all of them.
|
|
208
|
+
...input
|
|
209
|
+
.alternatesFor(entry.routeId, { page: entry.page })
|
|
210
|
+
.map((alternate) =>
|
|
211
|
+
el("xhtml:link", {
|
|
212
|
+
rel: "alternate",
|
|
213
|
+
hreflang: input.localeMeta[alternate.locale].htmlLang,
|
|
214
|
+
href: alternate.url,
|
|
215
|
+
})
|
|
216
|
+
),
|
|
217
|
+
|
|
218
|
+
entry.sitemap?.lastmod !== undefined &&
|
|
219
|
+
el("lastmod", entry.sitemap.lastmod),
|
|
220
|
+
entry.sitemap?.changefreq !== undefined &&
|
|
221
|
+
el("changefreq", entry.sitemap.changefreq),
|
|
222
|
+
entry.sitemap?.priority !== undefined &&
|
|
223
|
+
el("priority", entry.sitemap.priority.toFixed(1)),
|
|
224
|
+
]);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const urlset = (entries: readonly RouteEntry<L, Id>[]): XmlElement =>
|
|
228
|
+
el(
|
|
229
|
+
"urlset",
|
|
230
|
+
{ xmlns: SITEMAP_NS, "xmlns:xhtml": XHTML_NS },
|
|
231
|
+
entries.map(urlFor)
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const chunks = chunk(included, entryLimit);
|
|
235
|
+
|
|
236
|
+
if (chunks.length <= 1) {
|
|
237
|
+
const only = file(input.siteUrl, input.name, urlset(included));
|
|
238
|
+
return toSitemap(only, [only]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parts = chunks.map((entries, index) =>
|
|
242
|
+
file(input.siteUrl, partName(input.name, index), urlset(entries))
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// The index keeps the configured name, so the submitted URL never changes.
|
|
246
|
+
const index = file(
|
|
247
|
+
input.siteUrl,
|
|
248
|
+
input.name,
|
|
249
|
+
el(
|
|
250
|
+
"sitemapindex",
|
|
251
|
+
{ xmlns: SITEMAP_NS },
|
|
252
|
+
parts.map((part) => el("sitemap", [el("loc", part.url)]))
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return toSitemap(index, [index, ...parts]);
|
|
257
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small type utilities shared across `lib/`.
|
|
3
|
+
*
|
|
4
|
+
* Each one names an idiom that is otherwise easy to misread — or, in the case of
|
|
5
|
+
* `IsNever` and `NoExcessKeys`, easy to get subtly wrong.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The string keys of `T`.
|
|
10
|
+
*
|
|
11
|
+
* Plain `keyof T` is `string | number | symbol`, which breaks template literal
|
|
12
|
+
* types like `` `route.${K}.title` ``. Every key we care about is a string.
|
|
13
|
+
*/
|
|
14
|
+
export type StringKeys<T> = keyof T & string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `true` when `T` is exactly `never`.
|
|
18
|
+
*
|
|
19
|
+
* The tuple wrappers are load-bearing: a bare `T extends never ? …` is a
|
|
20
|
+
* *distributive* conditional, and distributing over `never` produces `never` —
|
|
21
|
+
* so the check silently never runs. Wrapping both sides turns off distribution.
|
|
22
|
+
*/
|
|
23
|
+
export type IsNever<T> = [T] extends [never] ? true : false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rejects any key of `T` that is not in `Allowed`, by demanding those keys hold
|
|
27
|
+
* `never` — a type no value has.
|
|
28
|
+
*
|
|
29
|
+
* Needed because these helpers *infer* `T` from the argument: the object literal
|
|
30
|
+
* becomes the type rather than being checked against a fixed target, so ordinary
|
|
31
|
+
* excess-property checking has nothing to compare against. Use it in a
|
|
32
|
+
* self-referential constraint:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* function f<const T extends Shape & NoExcessKeys<T, KnownIds>>(value: T): T
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export type NoExcessKeys<T, Allowed extends PropertyKey> = Readonly<
|
|
39
|
+
Record<Exclude<keyof T, Allowed>, never>
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
type Month =
|
|
43
|
+
| "01"
|
|
44
|
+
| "02"
|
|
45
|
+
| "03"
|
|
46
|
+
| "04"
|
|
47
|
+
| "05"
|
|
48
|
+
| "06"
|
|
49
|
+
| "07"
|
|
50
|
+
| "08"
|
|
51
|
+
| "09"
|
|
52
|
+
| "10"
|
|
53
|
+
| "11"
|
|
54
|
+
| "12";
|
|
55
|
+
|
|
56
|
+
type Day =
|
|
57
|
+
| "01"
|
|
58
|
+
| "02"
|
|
59
|
+
| "03"
|
|
60
|
+
| "04"
|
|
61
|
+
| "05"
|
|
62
|
+
| "06"
|
|
63
|
+
| "07"
|
|
64
|
+
| "08"
|
|
65
|
+
| "09"
|
|
66
|
+
| "10"
|
|
67
|
+
| "11"
|
|
68
|
+
| "12"
|
|
69
|
+
| "13"
|
|
70
|
+
| "14"
|
|
71
|
+
| "15"
|
|
72
|
+
| "16"
|
|
73
|
+
| "17"
|
|
74
|
+
| "18"
|
|
75
|
+
| "19"
|
|
76
|
+
| "20"
|
|
77
|
+
| "21"
|
|
78
|
+
| "22"
|
|
79
|
+
| "23"
|
|
80
|
+
| "24"
|
|
81
|
+
| "25"
|
|
82
|
+
| "26"
|
|
83
|
+
| "27"
|
|
84
|
+
| "28"
|
|
85
|
+
| "29"
|
|
86
|
+
| "30"
|
|
87
|
+
| "31";
|
|
88
|
+
|
|
89
|
+
export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One uppercase letter, for the code standards that are spelled in them.
|
|
93
|
+
*
|
|
94
|
+
* Beside `Digit` because it is the same kind of thing: a character class the
|
|
95
|
+
* template-literal types elsewhere are built from, rather than anything about
|
|
96
|
+
* this library's own subject.
|
|
97
|
+
*/
|
|
98
|
+
export type Letter =
|
|
99
|
+
| "A"
|
|
100
|
+
| "B"
|
|
101
|
+
| "C"
|
|
102
|
+
| "D"
|
|
103
|
+
| "E"
|
|
104
|
+
| "F"
|
|
105
|
+
| "G"
|
|
106
|
+
| "H"
|
|
107
|
+
| "I"
|
|
108
|
+
| "J"
|
|
109
|
+
| "K"
|
|
110
|
+
| "L"
|
|
111
|
+
| "M"
|
|
112
|
+
| "N"
|
|
113
|
+
| "O"
|
|
114
|
+
| "P"
|
|
115
|
+
| "Q"
|
|
116
|
+
| "R"
|
|
117
|
+
| "S"
|
|
118
|
+
| "T"
|
|
119
|
+
| "U"
|
|
120
|
+
| "V"
|
|
121
|
+
| "W"
|
|
122
|
+
| "X"
|
|
123
|
+
| "Y"
|
|
124
|
+
| "Z";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A calendar date, `YYYY-MM-DD`.
|
|
128
|
+
*
|
|
129
|
+
* Checked by the type system rather than at build time, so `"2026-3-1"`,
|
|
130
|
+
* `"01/03/2026"`, `"2026-13-01"` and `"2026-03-01T00:00:00Z"` are all rejected
|
|
131
|
+
* on the line where they were written — as is a bare `string`, which is the case
|
|
132
|
+
* a runtime check would have had to exist for.
|
|
133
|
+
*
|
|
134
|
+
* Every part is spelled out digit by digit rather than left as a `${number}`
|
|
135
|
+
* pattern, which would also admit `"1e3-03-01"` and five-digit years. That costs
|
|
136
|
+
* 100 × 12 × 31 = 37 200 members, comfortably inside what TypeScript will hold;
|
|
137
|
+
* a full four-digit year would be 10 000 × 372 and far past it. The price is
|
|
138
|
+
* that this needs one more digit in 2100.
|
|
139
|
+
*/
|
|
140
|
+
export type IsoDate = `20${Digit}${Digit}-${Month}-${Day}`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A whole percentage, `0` to `100`.
|
|
144
|
+
*
|
|
145
|
+
* Built by counting rather than written out, which is the one place this file
|
|
146
|
+
* departs from spelling a union in full: a hundred and one literals is not
|
|
147
|
+
* readable, and the rule producing them is. `Acc["length"]` grows by one per
|
|
148
|
+
* step until it reaches the target, and every length along the way is a member.
|
|
149
|
+
*
|
|
150
|
+
* The ceiling is TypeScript's instantiation depth, and it is lower in practice
|
|
151
|
+
* than the thousand the limit suggests — measured in this project, `Upto<1000>`
|
|
152
|
+
* reports "excessively deep" and `Upto<512>` does not. Well above a percentage,
|
|
153
|
+
* and worth knowing before reaching for a wider range: pick the bound the
|
|
154
|
+
* domain justifies and check it compiles.
|
|
155
|
+
*/
|
|
156
|
+
type Upto<N extends number, Acc extends number[] = []> = Acc["length"] extends N
|
|
157
|
+
? Acc[number]
|
|
158
|
+
: Upto<N, [...Acc, Acc["length"]]>;
|
|
159
|
+
|
|
160
|
+
export type Percentage = Upto<101>;
|