@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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { ImageAsset } from "../image.ts";
|
|
2
|
+
import { absoluteUrl, type HttpsUrl } from "../url.ts";
|
|
3
|
+
import { warn } from "../warn.ts";
|
|
4
|
+
import { link, type MetaTag } from "./tag.ts";
|
|
5
|
+
|
|
6
|
+
export type ImageFormat = "png" | "jpg" | "webp";
|
|
7
|
+
|
|
8
|
+
const IMAGE_MIME: Readonly<Record<ImageFormat, string>> = {
|
|
9
|
+
png: "image/png",
|
|
10
|
+
jpg: "image/jpeg",
|
|
11
|
+
webp: "image/webp",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The MIME type of an asset's format, where lib knows one.
|
|
16
|
+
*
|
|
17
|
+
* `undefined` rather than a guess for anything else. Both the tags this feeds —
|
|
18
|
+
* `og:image:type` and the icon's `type` — are hints that let a client skip a
|
|
19
|
+
* fetch, so a missing one costs nothing and a wrong one sends it to the wrong
|
|
20
|
+
* decoder. An asset's `format` is a string off an import, so this is where the
|
|
21
|
+
* question of whether it is one lib recognises gets answered, once.
|
|
22
|
+
*/
|
|
23
|
+
function imageMime(format: string | undefined): string | undefined {
|
|
24
|
+
const key = format?.toLowerCase();
|
|
25
|
+
// `Object.hasOwn`, not `in`, for the reason `familyGuard` uses it: `in`
|
|
26
|
+
// walks the prototype, so `"constructor"` and `"toString"` answer true and
|
|
27
|
+
// the cast below then hands back a function as a MIME type. Absurd input,
|
|
28
|
+
// but the check costs the same either way and the guard should not depend
|
|
29
|
+
// on nobody producing it.
|
|
30
|
+
return key !== undefined && Object.hasOwn(IMAGE_MIME, key)
|
|
31
|
+
? IMAGE_MIME[key as ImageFormat]
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A page's share image and the text describing it.
|
|
37
|
+
*
|
|
38
|
+
* One unit on purpose: alt text describes *this* image, so the two are declared
|
|
39
|
+
* together and cannot drift apart or be supplied from different places.
|
|
40
|
+
*/
|
|
41
|
+
export interface ShareImage {
|
|
42
|
+
/**
|
|
43
|
+
* The image itself, at **1200×630** — near enough to 1.91:1, the box every
|
|
44
|
+
* large link preview uses: Facebook, LinkedIn, iMessage, Slack, Discord,
|
|
45
|
+
* Telegram.
|
|
46
|
+
*
|
|
47
|
+
* Wide even though some surfaces want a square — WhatsApp's chat thumbnail,
|
|
48
|
+
* Twitter's `summary` card — because there is only one `og:image` and the
|
|
49
|
+
* two crops are not symmetrical. A square surface takes the centre of a wide
|
|
50
|
+
* image, which for a logo padded to the box is the logo itself; a wide
|
|
51
|
+
* surface takes a band out of the middle of a square one, and cuts the top
|
|
52
|
+
* and bottom off. Wide degrades, square destroys.
|
|
53
|
+
*
|
|
54
|
+
* Anything much smaller is upscaled: the common mistake is reusing a
|
|
55
|
+
* favicon, which produces a card with a logo adrift in the middle of it.
|
|
56
|
+
* Below roughly 600×315 some platforms drop the large card entirely and
|
|
57
|
+
* fall back to a thumbnail — which is a reason to meet the size rather than
|
|
58
|
+
* an option to choose. lib always asks for the wide card and offers no way
|
|
59
|
+
* to request the square one; a logo belongs in the box via
|
|
60
|
+
* `shareImage(src, { fit: "contain", background })`, not in a smaller card.
|
|
61
|
+
*
|
|
62
|
+
* Nothing here enforces the size, because an imported image's real
|
|
63
|
+
* dimensions are a fact about the file rather than about its type. Passing
|
|
64
|
+
* `shareImage(source)` from the package's `astro/images` derives a correct one from
|
|
65
|
+
* any large-enough source and refuses to upscale.
|
|
66
|
+
*
|
|
67
|
+
* Format: PNG or JPEG. Both are read by every scraper; WebP is not.
|
|
68
|
+
*/
|
|
69
|
+
readonly asset: ImageAsset;
|
|
70
|
+
/**
|
|
71
|
+
* Describes what is *in* the image, for `og:image:alt` and
|
|
72
|
+
* `twitter:image:alt`. Never the page title unless the image renders it.
|
|
73
|
+
*/
|
|
74
|
+
readonly alt: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The site's icon: one square asset, used for every icon link.
|
|
79
|
+
*
|
|
80
|
+
* Site-wide rather than per page, because search engines use one favicon per
|
|
81
|
+
* hostname, read from the home page — a per-page icon changes the browser tab
|
|
82
|
+
* and nothing else.
|
|
83
|
+
*
|
|
84
|
+
* One asset rather than a set, because one square PNG already covers every
|
|
85
|
+
* consumer: browsers accept PNG for `rel="icon"`, and Apple scales it for
|
|
86
|
+
* `apple-touch-icon`. A `/favicon.ico` in `public/` is still requested by
|
|
87
|
+
* browsers on its own if you want the legacy fallback.
|
|
88
|
+
*
|
|
89
|
+
* Unlike `og:image`, the href stays as written — root-relative is correct for an
|
|
90
|
+
* icon, and absolutising it would only hard-code the origin.
|
|
91
|
+
*/
|
|
92
|
+
export type SiteIcon = ImageAsset;
|
|
93
|
+
|
|
94
|
+
/** One page's resolved share image. */
|
|
95
|
+
export interface OgImage {
|
|
96
|
+
readonly url: HttpsUrl;
|
|
97
|
+
readonly width: number;
|
|
98
|
+
readonly height: number;
|
|
99
|
+
/** Only when the format is known; `og:image:type` is the least important. */
|
|
100
|
+
readonly mime?: string;
|
|
101
|
+
readonly alt: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The box every large link preview crops to, and the only shape worth serving.
|
|
106
|
+
*
|
|
107
|
+
* Wide is not a preference between two options. A square surface — WhatsApp's
|
|
108
|
+
* thumbnail — takes the *centre* of a wide image, which is where the subject
|
|
109
|
+
* is; a wide surface takes a band out of the middle of a square one and cuts the
|
|
110
|
+
* top and bottom off. Wide degrades, square destroys, and there is one
|
|
111
|
+
* `og:image` to satisfy both.
|
|
112
|
+
*/
|
|
113
|
+
const SHARE_BOX = { width: 1200, height: 630 } as const;
|
|
114
|
+
|
|
115
|
+
/** Below this, a preview carries no image at all — not a small one, none. */
|
|
116
|
+
const NO_PREVIEW_BELOW = 200;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where each surface starts cutting text off.
|
|
120
|
+
*
|
|
121
|
+
* One string feeds `description`, `og:description` and `twitter:description`, so
|
|
122
|
+
* the tightest is the one that counts. Facebook's mobile preview is that: a line
|
|
123
|
+
* under it survives everywhere, while a line written to Google's 160 is already
|
|
124
|
+
* clipped in a share. A title is cut far sooner, by the surface people search on.
|
|
125
|
+
*/
|
|
126
|
+
const LENGTH_LIMITS = {
|
|
127
|
+
title: {
|
|
128
|
+
limit: 60,
|
|
129
|
+
why: "a search result cuts a title around there, and the end of it is usually the part that identifies the page",
|
|
130
|
+
},
|
|
131
|
+
description: {
|
|
132
|
+
limit: 125,
|
|
133
|
+
why: "search truncates nearer 160 and X caps at 200, but a share preview on mobile is the tightest of the three",
|
|
134
|
+
},
|
|
135
|
+
} as const;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Warns rather than throws: a long description is not broken, it is edited by
|
|
139
|
+
* someone else's ellipsis. The page still renders, the preview still appears,
|
|
140
|
+
* and the last clause is silently gone — which is precisely the kind of thing
|
|
141
|
+
* nobody notices without being told.
|
|
142
|
+
*/
|
|
143
|
+
export function warnIfClipped(
|
|
144
|
+
field: keyof typeof LENGTH_LIMITS,
|
|
145
|
+
value: string,
|
|
146
|
+
at: string
|
|
147
|
+
): void {
|
|
148
|
+
const { limit, why } = LENGTH_LIMITS[field];
|
|
149
|
+
if (value.length <= limit) return;
|
|
150
|
+
|
|
151
|
+
warn(at, `${field} is ${value.length} characters, past ~${limit}: ${why}.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Warns about a share image rather than rejecting it.
|
|
156
|
+
*
|
|
157
|
+
* A wrong-sized image still produces a page, still produces a preview, and is
|
|
158
|
+
* still worth shipping while someone finds a better one — so this is a warning
|
|
159
|
+
* and not a throw. It fires wherever `metaFor` runs, which is every page in a
|
|
160
|
+
* build and every page in dev.
|
|
161
|
+
*
|
|
162
|
+
* Checked here rather than in `shareImage`, because this is the path every page
|
|
163
|
+
* takes: that helper is optional, and an imported asset can be passed straight
|
|
164
|
+
* through. The size of one is a fact about a file, so no type can see it.
|
|
165
|
+
*/
|
|
166
|
+
export function warnAboutShareImage(image: OgImage, at: string): void {
|
|
167
|
+
const { width, height } = image;
|
|
168
|
+
// The page is named by `at`, and it has one share image — repeating the
|
|
169
|
+
// asset URL only pastes a `/_image?href=…` endpoint into every dev warning.
|
|
170
|
+
const say = (problem: string) =>
|
|
171
|
+
warn(
|
|
172
|
+
at,
|
|
173
|
+
`share image is ${width}x${height}: ${problem}. ${SHARE_BOX.width}x${SHARE_BOX.height} is what every surface crops to.`
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (width < NO_PREVIEW_BELOW || height < NO_PREVIEW_BELOW) {
|
|
177
|
+
say(
|
|
178
|
+
`below ${NO_PREVIEW_BELOW}x${NO_PREVIEW_BELOW}, so the preview will carry no image at all`
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (width < SHARE_BOX.width || height < SHARE_BOX.height) {
|
|
183
|
+
say(
|
|
184
|
+
"smaller than the box, so platforms will scale it up or fall back to a small preview"
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// 1.5 rather than the box's own 1.91: a 16:9 photograph crops cleanly, and
|
|
189
|
+
// warning about it would be noise. A squarer image loses its top and bottom.
|
|
190
|
+
if (width / height < 1.5) {
|
|
191
|
+
say("not wide, so wide surfaces will crop the top and bottom off");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolves the page's share image against the site origin. */
|
|
196
|
+
export function resolveShareImage(
|
|
197
|
+
siteUrl: HttpsUrl,
|
|
198
|
+
image: ShareImage
|
|
199
|
+
): OgImage {
|
|
200
|
+
return {
|
|
201
|
+
url: absoluteUrl(siteUrl, image.asset.src, "share image"),
|
|
202
|
+
width: image.asset.width,
|
|
203
|
+
height: image.asset.height,
|
|
204
|
+
alt: image.alt,
|
|
205
|
+
mime: imageMime(image.asset.format),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The icon links: one asset, two `rel`s.
|
|
211
|
+
*
|
|
212
|
+
* Browsers read `icon`, Apple reads `apple-touch-icon` and scales it, and both
|
|
213
|
+
* point at the same file — so they are built together rather than left to a
|
|
214
|
+
* layout to remember the second one. `sizes` is stated so a browser need not
|
|
215
|
+
* fetch the file to find out what it is choosing between.
|
|
216
|
+
*
|
|
217
|
+
* The href stays as written. Unlike `og:image` these are read by a browser that
|
|
218
|
+
* already has the page, so root-relative is correct and making it absolute would
|
|
219
|
+
* only hard-code the origin.
|
|
220
|
+
*/
|
|
221
|
+
export function iconLinks(icon: SiteIcon): MetaTag[] {
|
|
222
|
+
const mime = imageMime(icon.format);
|
|
223
|
+
const attrs = {
|
|
224
|
+
href: icon.src,
|
|
225
|
+
sizes: `${icon.width}x${icon.height}`,
|
|
226
|
+
...(mime !== undefined ? { type: mime } : {}),
|
|
227
|
+
};
|
|
228
|
+
return [
|
|
229
|
+
link({ rel: "icon", ...attrs }),
|
|
230
|
+
link({ rel: "apple-touch-icon", ...attrs }),
|
|
231
|
+
];
|
|
232
|
+
}
|
package/src/meta/tag.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { AnalyticsTag } from "../analytics/index.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A head tag as data, not markup.
|
|
5
|
+
*
|
|
6
|
+
* lib does not render HTML — that would tie it to one framework and to one
|
|
7
|
+
* templating syntax. It describes the tags; the consumer spreads them.
|
|
8
|
+
*/
|
|
9
|
+
export type MetaTag =
|
|
10
|
+
| { readonly kind: "title"; readonly text: string }
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: "meta";
|
|
13
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "link";
|
|
17
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A block of text in a `<script>` — structured data, and nothing else.
|
|
21
|
+
*
|
|
22
|
+
* `content` is already escaped for the element it goes in, so a renderer
|
|
23
|
+
* must write it *raw*: escaping it again as HTML would turn every quote in
|
|
24
|
+
* the JSON into an entity and the block into gibberish.
|
|
25
|
+
*/
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: "script";
|
|
28
|
+
/** Omitted for JavaScript, which needs none. */
|
|
29
|
+
readonly type?: string;
|
|
30
|
+
readonly content: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A script fetched from somewhere else — analytics, and so far only that.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately *not* a slot a consumer can put anything in. Nothing takes
|
|
36
|
+
* one of these as input: the only way to get one is to declare typed
|
|
37
|
+
* settings that lib knows how to build a script from, which is the
|
|
38
|
+
* difference between machinery and the passthrough rule 1 forbids.
|
|
39
|
+
*
|
|
40
|
+
* Always deferred. A third-party script that blocks the parser is a
|
|
41
|
+
* measurement that costs the thing it measures.
|
|
42
|
+
*/
|
|
43
|
+
| {
|
|
44
|
+
readonly kind: "externalScript";
|
|
45
|
+
readonly src: string;
|
|
46
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A hidden iframe inside `<noscript>`, for the body rather than the head.
|
|
50
|
+
*
|
|
51
|
+
* Tag Manager's documented fallback, and the only thing lib emits that does
|
|
52
|
+
* not belong in `<head>` — which is why `buildMeta` returns two lists
|
|
53
|
+
* rather than one. Structured rather than a string of markup, for the
|
|
54
|
+
* reason `externalScript` is.
|
|
55
|
+
*/
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "noscriptFrame";
|
|
58
|
+
readonly src: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const meta = (attrs: Readonly<Record<string, string>>): MetaTag => ({
|
|
62
|
+
kind: "meta",
|
|
63
|
+
attrs,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const link = (attrs: Readonly<Record<string, string>>): MetaTag => ({
|
|
67
|
+
kind: "link",
|
|
68
|
+
attrs,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An analytics tag as a head tag. The two vocabularies overlap but are not the
|
|
73
|
+
* same type: `analytics/` knows nothing of `MetaTag`, which is what keeps this
|
|
74
|
+
* folder free to import it without a cycle.
|
|
75
|
+
*
|
|
76
|
+
* Every kind is named rather than left to a trailing `else`, so that adding one
|
|
77
|
+
* to `AnalyticsTag` and forgetting it here is a *compile* error and not a tag
|
|
78
|
+
* that quietly disappears: `script` narrows to `never` only while the list is
|
|
79
|
+
* complete, and assigning anything else to `never` does not type-check.
|
|
80
|
+
*/
|
|
81
|
+
export function asMetaTag(script: AnalyticsTag): MetaTag {
|
|
82
|
+
if (script.kind === "inline") {
|
|
83
|
+
return { kind: "script", content: script.content };
|
|
84
|
+
}
|
|
85
|
+
if (script.kind === "external") {
|
|
86
|
+
return {
|
|
87
|
+
kind: "externalScript",
|
|
88
|
+
src: script.src,
|
|
89
|
+
attrs: script.attributes,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (script.kind === "noscriptFrame") {
|
|
93
|
+
return { kind: "noscriptFrame", src: script.src };
|
|
94
|
+
}
|
|
95
|
+
const unhandled: never = script;
|
|
96
|
+
throw new Error(`Unhandled analytics tag: ${JSON.stringify(unhandled)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_VIEWPORT = "width=device-width, initial-scale=1";
|
|
100
|
+
|
|
101
|
+
interface Preamble {
|
|
102
|
+
readonly title: string;
|
|
103
|
+
/**
|
|
104
|
+
* The real URL of what this document represents.
|
|
105
|
+
*
|
|
106
|
+
* Optional for the one document that represents nothing: a 404 stands for
|
|
107
|
+
* whatever was requested and is not there, so naming a canonical would tell
|
|
108
|
+
* a crawler the missing page and that URL are the same thing.
|
|
109
|
+
*/
|
|
110
|
+
readonly canonical?: string;
|
|
111
|
+
readonly viewport?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What every document in the site opens with: what encoding it is in, how to
|
|
116
|
+
* scale it, what it is called, and — for anything that stands for a URL — which
|
|
117
|
+
* URL that is.
|
|
118
|
+
*
|
|
119
|
+
* A function returning the *head* of the list rather than something appended
|
|
120
|
+
* later: the spec requires `charset` within the first 1024 bytes, so its
|
|
121
|
+
* position is part of its meaning.
|
|
122
|
+
*/
|
|
123
|
+
export function preamble(input: Preamble): MetaTag[] {
|
|
124
|
+
const tags: MetaTag[] = [
|
|
125
|
+
meta({ charset: "utf-8" }),
|
|
126
|
+
meta({ name: "viewport", content: input.viewport ?? DEFAULT_VIEWPORT }),
|
|
127
|
+
{ kind: "title", text: input.title },
|
|
128
|
+
];
|
|
129
|
+
if (input.canonical !== undefined) {
|
|
130
|
+
tags.push(link({ rel: "canonical", href: input.canonical }));
|
|
131
|
+
}
|
|
132
|
+
return tags;
|
|
133
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type MetaTag, meta } from "./tag.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ownership tokens from webmaster tools, as `<meta name="…" content="token">`.
|
|
5
|
+
*
|
|
6
|
+
* An object with one key rather than a bare token, so the day a site needs Bing,
|
|
7
|
+
* Yandex or Meta the field grows a sibling instead of changing shape. Each of
|
|
8
|
+
* those is a fixed, unmemorable tag name — Bing's is `msvalidate.01` — and a
|
|
9
|
+
* misspelled one fails silently, so they get named keys if they are ever needed
|
|
10
|
+
* rather than a free `name`/`content` pair now.
|
|
11
|
+
*/
|
|
12
|
+
export interface SiteVerification {
|
|
13
|
+
/**
|
|
14
|
+
* Google Search Console.
|
|
15
|
+
*
|
|
16
|
+
* One token or several. Several is normal: a token identifies an *owner*, so
|
|
17
|
+
* a site verified by two people, or claimed as both a domain and a URL
|
|
18
|
+
* prefix, legitimately carries more than one, and each is checked
|
|
19
|
+
* independently.
|
|
20
|
+
*/
|
|
21
|
+
readonly google?: string | readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The tag name each key is written as.
|
|
26
|
+
*
|
|
27
|
+
* Adding a service is this table plus its key on `SiteVerification`, and the
|
|
28
|
+
* `satisfies` is what keeps that a pair: a key with no tag name here, or a name
|
|
29
|
+
* here for a key that does not exist, fails to compile. Bing is
|
|
30
|
+
* `msvalidate.01`, Yandex `yandex-verification`, Meta
|
|
31
|
+
* `facebook-domain-verification`, Pinterest `p:domain_verify` — none of them
|
|
32
|
+
* guessable, and all of them silently inert when wrong.
|
|
33
|
+
*/
|
|
34
|
+
const VERIFICATION_TAG_NAME = {
|
|
35
|
+
google: "google-site-verification",
|
|
36
|
+
} as const satisfies Readonly<Record<keyof SiteVerification, string>>;
|
|
37
|
+
|
|
38
|
+
export function verificationTags(
|
|
39
|
+
verification: SiteVerification | undefined
|
|
40
|
+
): readonly MetaTag[] {
|
|
41
|
+
if (verification === undefined) return [];
|
|
42
|
+
|
|
43
|
+
const tags: MetaTag[] = [];
|
|
44
|
+
for (const [key, name] of Object.entries(VERIFICATION_TAG_NAME)) {
|
|
45
|
+
const tokens = verification[key as keyof SiteVerification];
|
|
46
|
+
if (tokens === undefined) continue;
|
|
47
|
+
|
|
48
|
+
for (const token of typeof tokens === "string" ? [tokens] : tokens) {
|
|
49
|
+
tags.push(meta({ name, content: token }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return tags;
|
|
53
|
+
}
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Letter } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An ISO 4217 currency code: `"EUR"`, `"RON"`, `"GBP"`.
|
|
5
|
+
*
|
|
6
|
+
* Three uppercase letters, which is what `priceCurrency` is read as. A symbol —
|
|
7
|
+
* `€`, `lei` — is how a price is *printed* in one place for one audience, and
|
|
8
|
+
* two currencies can share one symbol, so it identifies nothing.
|
|
9
|
+
*/
|
|
10
|
+
export type CurrencyCode = `${Letter}${Letter}${Letter}`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An amount and what it is denominated in, kept together.
|
|
14
|
+
*
|
|
15
|
+
* A bare number is the mistake this exists to stop. Two deployments of one
|
|
16
|
+
* template charge 28 and 120 for the same thing because one is in euros and the
|
|
17
|
+
* other is not, and nothing about `28` says which — least of all to a search
|
|
18
|
+
* engine reading it as a price.
|
|
19
|
+
*
|
|
20
|
+
* `amount` is a number rather than a string: it is arithmetic to whoever sets
|
|
21
|
+
* it, and formatting it for a reader is a separate job needing a locale this
|
|
22
|
+
* type has no business holding.
|
|
23
|
+
*/
|
|
24
|
+
export interface Price {
|
|
25
|
+
readonly amount: number;
|
|
26
|
+
readonly currency: CurrencyCode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What one quantity band costs.
|
|
31
|
+
*
|
|
32
|
+
* `quantity` takes a count or a range, because both occur and the range alone
|
|
33
|
+
* would make the ordinary case noisy: `{ min: 2, max: 2 }` five times over says
|
|
34
|
+
* nothing `2, 3, 4, 5, 6` does not. Same shape as `ThemeColor` — a scalar for
|
|
35
|
+
* the common case, an object when it genuinely varies.
|
|
36
|
+
*/
|
|
37
|
+
export interface PriceTier {
|
|
38
|
+
readonly quantity: number | { readonly min: number; readonly max: number };
|
|
39
|
+
/**
|
|
40
|
+
* What one unit costs at this quantity — not the total for the band.
|
|
41
|
+
*
|
|
42
|
+
* Named for what it is, because the two readings differ by a factor of the
|
|
43
|
+
* group size and both are plausible numbers. A room at 22 for four people
|
|
44
|
+
* is 88 or it is 22, and a field called `amount` does not say which.
|
|
45
|
+
*/
|
|
46
|
+
readonly amountPerUnit: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A price that depends on how many are bought.
|
|
51
|
+
*
|
|
52
|
+
* One currency for the whole table rather than one per tier: a product priced
|
|
53
|
+
* in two currencies at once is not a discount, it is a mistake, and stating it
|
|
54
|
+
* once removes the chance of writing it.
|
|
55
|
+
*/
|
|
56
|
+
export interface TieredPrice {
|
|
57
|
+
readonly currency: CurrencyCode;
|
|
58
|
+
/**
|
|
59
|
+
* The bands, cheapest quantity first. Never empty — a product with no price
|
|
60
|
+
* at all is an offer of nothing.
|
|
61
|
+
*/
|
|
62
|
+
readonly tiers: readonly [PriceTier, ...PriceTier[]];
|
|
63
|
+
/**
|
|
64
|
+
* What the quantity counts: `"players"`, `"seats"`, `"nights"`.
|
|
65
|
+
*
|
|
66
|
+
* lib has no idea what is being sold, and a bare number in the output reads
|
|
67
|
+
* as "how many items" — which for a room booked by the group is wrong.
|
|
68
|
+
*/
|
|
69
|
+
readonly unit?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The band a tier covers, with a bare count widened to a range of one. */
|
|
73
|
+
export function tierRange(tier: PriceTier): {
|
|
74
|
+
readonly min: number;
|
|
75
|
+
readonly max: number;
|
|
76
|
+
} {
|
|
77
|
+
return typeof tier.quantity === "number"
|
|
78
|
+
? { min: tier.quantity, max: tier.quantity }
|
|
79
|
+
: tier.quantity;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The whole span a price table covers, floor of the first band to ceiling of
|
|
84
|
+
* the last.
|
|
85
|
+
*
|
|
86
|
+
* Worth having at all because the alternative is storing the span beside the
|
|
87
|
+
* table, where the two can disagree and only one of them can be right.
|
|
88
|
+
*
|
|
89
|
+
* Internal, and not exported from the package: on its own it is only half an
|
|
90
|
+
* answer, true of a run and an overstatement of a table with holes in it. What
|
|
91
|
+
* a caller wants is `formatQuantities` or `quantitiesFor`, which pick between
|
|
92
|
+
* this and `quantitiesOf` by reading the tiers.
|
|
93
|
+
*/
|
|
94
|
+
export function quantityRange(tiers: readonly [PriceTier, ...PriceTier[]]): {
|
|
95
|
+
readonly min: number;
|
|
96
|
+
readonly max: number;
|
|
97
|
+
} {
|
|
98
|
+
const first = tierRange(tiers[0]);
|
|
99
|
+
const last = tiers.at(-1);
|
|
100
|
+
return {
|
|
101
|
+
min: first.min,
|
|
102
|
+
// Non-empty by type, so `at(-1)` cannot really miss; answering with the
|
|
103
|
+
// first band's ceiling beats inventing a number for a case that is
|
|
104
|
+
// unreachable.
|
|
105
|
+
max: last === undefined ? first.max : tierRange(last).max,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every quantity a table actually sells, ascending.
|
|
111
|
+
*
|
|
112
|
+
* The honest form of `quantityRange` for a table with holes in it. A room sold
|
|
113
|
+
* to twos, fours and sixes spans 2 to 6 and is not sellable at 3 — so the span
|
|
114
|
+
* is a claim about two sizes that cannot be booked, while this is a list of the
|
|
115
|
+
* ones that can.
|
|
116
|
+
*
|
|
117
|
+
* Both exist because they answer different questions and structured data wants
|
|
118
|
+
* different things: a contiguous table is one `QuantitativeValue` with a min and
|
|
119
|
+
* a max, and a table with holes has to enumerate.
|
|
120
|
+
*/
|
|
121
|
+
export function quantitiesOf(
|
|
122
|
+
tiers: readonly [PriceTier, ...PriceTier[]]
|
|
123
|
+
): readonly number[] {
|
|
124
|
+
const sizes = new Set<number>();
|
|
125
|
+
for (const tier of tiers) {
|
|
126
|
+
const band = tierRange(tier);
|
|
127
|
+
for (let size = band.min; size <= band.max; size++) {
|
|
128
|
+
sizes.add(size);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...sizes].sort((a, b) => a - b);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether a table's quantities run without a break.
|
|
136
|
+
*
|
|
137
|
+
* What decides how the quantities are published: a run gets a min and a max, a
|
|
138
|
+
* broken set has to be listed. Derived rather than declared, so it cannot
|
|
139
|
+
* disagree with the tiers it describes.
|
|
140
|
+
*/
|
|
141
|
+
export function isContiguous(
|
|
142
|
+
tiers: readonly [PriceTier, ...PriceTier[]]
|
|
143
|
+
): boolean {
|
|
144
|
+
const sizes = quantitiesOf(tiers);
|
|
145
|
+
const first = sizes[0];
|
|
146
|
+
const last = sizes.at(-1);
|
|
147
|
+
|
|
148
|
+
// Both ends exist — `tiers` is non-empty and every band contributes at
|
|
149
|
+
// least one size — but the type cannot see it, so the unreachable case
|
|
150
|
+
// answers "yes" rather than being asserted away.
|
|
151
|
+
if (first === undefined || last === undefined) return true;
|
|
152
|
+
|
|
153
|
+
// A run of n sizes spans exactly n - 1 steps. Counting beats comparing
|
|
154
|
+
// neighbours: the sizes are already deduplicated and sorted.
|
|
155
|
+
return sizes.length === last - first + 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The quantities a table sells, written out for a reader.
|
|
160
|
+
*
|
|
161
|
+
* The prose half of `quantitiesFor`, and it exists for the same reason: a run
|
|
162
|
+
* reads as `2–6`, and a table that skips sizes has to say `2, 4, 6`, because
|
|
163
|
+
* a range would tell someone they can book a three when they cannot.
|
|
164
|
+
*
|
|
165
|
+
* Which form to use is decided here rather than by the caller — it follows from
|
|
166
|
+
* the tiers, and there is one right answer per table. What the caller supplies
|
|
167
|
+
* is punctuation, exactly as `formatHours` takes its separators: lib has no
|
|
168
|
+
* language and no opinion about dashes.
|
|
169
|
+
*/
|
|
170
|
+
export function formatQuantities(
|
|
171
|
+
tiers: readonly [PriceTier, ...PriceTier[]],
|
|
172
|
+
format: {
|
|
173
|
+
/** Between the ends of a run: `2–6`. */
|
|
174
|
+
readonly between?: string;
|
|
175
|
+
/** Between sizes that are listed one by one: `2, 4, 6`. */
|
|
176
|
+
readonly and?: string;
|
|
177
|
+
} = {}
|
|
178
|
+
): string {
|
|
179
|
+
const sizes = quantitiesOf(tiers);
|
|
180
|
+
const first = sizes[0];
|
|
181
|
+
const last = sizes.at(-1);
|
|
182
|
+
if (first === undefined || last === undefined) return "";
|
|
183
|
+
|
|
184
|
+
if (!isContiguous(tiers)) return sizes.join(format.and ?? ", ");
|
|
185
|
+
return first === last
|
|
186
|
+
? `${first}`
|
|
187
|
+
: `${first}${format.between ?? "–"}${last}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Throws on a price table that cannot be read as one.
|
|
192
|
+
*
|
|
193
|
+
* Two failures, and both are contradictions rather than omissions. An overlap
|
|
194
|
+
* gives one quantity two prices, and whichever a reader sees is the one they
|
|
195
|
+
* will expect to pay. A backwards band names no quantity at all.
|
|
196
|
+
*
|
|
197
|
+
* Throws rather than warns, for the reason a wrong opening time does: this is a
|
|
198
|
+
* number a customer acts on.
|
|
199
|
+
*
|
|
200
|
+
* **A gap is not an error.** The tiers *are* the list of what is sold, so a
|
|
201
|
+
* table running 2, 4, 6 describes a thing sold to twos, fours and sixes — not
|
|
202
|
+
* one missing its odd sizes. Rejecting that would need the table to carry a
|
|
203
|
+
* second field saying which reading applies, and a field that can contradict
|
|
204
|
+
* the data beside it is the thing worth avoiding: it goes stale the moment a
|
|
205
|
+
* tier is added. What a gap changes is how the quantities are *published*,
|
|
206
|
+
* which `quantitiesFor` derives rather than being told.
|
|
207
|
+
*/
|
|
208
|
+
export function assertPriceTiers(price: TieredPrice, at: string): void {
|
|
209
|
+
const problems: string[] = [];
|
|
210
|
+
let previous: { min: number; max: number } | undefined;
|
|
211
|
+
|
|
212
|
+
for (const tier of price.tiers) {
|
|
213
|
+
const range = tierRange(tier);
|
|
214
|
+
|
|
215
|
+
if (range.max < range.min) {
|
|
216
|
+
problems.push(
|
|
217
|
+
`a tier covers ${range.min} to ${range.max}, which is backwards.`
|
|
218
|
+
);
|
|
219
|
+
} else if (previous !== undefined) {
|
|
220
|
+
if (range.min <= previous.max) {
|
|
221
|
+
problems.push(
|
|
222
|
+
`${range.min} to ${range.max} overlaps ${previous.min} to ${previous.max}, so one quantity has two prices.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
previous = range;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (problems.length > 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${at}: price tiers do not describe a single table.\n${problems
|
|
233
|
+
.map((problem) => ` - ${problem}`)
|
|
234
|
+
.join("\n")}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|