@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/contact.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { Digit, Letter } from "./types.ts";
|
|
2
|
+
import { warn } from "./warn.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The contact details every site puts in a footer, and the links they make.
|
|
6
|
+
*
|
|
7
|
+
* Here because the formatting is mechanical and every consuming repo would
|
|
8
|
+
* otherwise write the same `replace(/\s+/g, "")` in a component — where it looks
|
|
9
|
+
* like a detail of that component rather than a rule about telephone numbers.
|
|
10
|
+
*
|
|
11
|
+
* Plain data and free functions, not objects with methods: these are written in
|
|
12
|
+
* a project's settings file, and a factory call would turn a page of data into a
|
|
13
|
+
* page of code. They also have to survive being handed to structured data, which
|
|
14
|
+
* wants strings.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A country calling code: one to three digits, and nothing else.
|
|
19
|
+
*
|
|
20
|
+
* Spelled out from `Digit` rather than as `` `${number}` ``, which looks right
|
|
21
|
+
* and checks almost nothing — it accepts `"+39"` and `"3.9"` alike, so the very
|
|
22
|
+
* mistakes this is meant to reject would pass.
|
|
23
|
+
*/
|
|
24
|
+
export type CallingCode =
|
|
25
|
+
| `${Digit}`
|
|
26
|
+
| `${Digit}${Digit}`
|
|
27
|
+
| `${Digit}${Digit}${Digit}`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A telephone number, kept as the two facts it actually is.
|
|
31
|
+
*
|
|
32
|
+
* The calling code is separate because the national part is written however the
|
|
33
|
+
* country writes it — `06 0000 0000`, `(020) 7123 4567` — and stripping that
|
|
34
|
+
* formatting to build a dialable link is only safe once the code is known.
|
|
35
|
+
* Storing one joined string means every reader guessing where one ends.
|
|
36
|
+
*
|
|
37
|
+
* Not locale-varying, unlike a *displayed* address: spacing and the leading `+`
|
|
38
|
+
* are conventions of the number's country, not of the language it is read in. An
|
|
39
|
+
* Italian venue prints `+39 06 …` on its Greek pages too.
|
|
40
|
+
*/
|
|
41
|
+
export interface PhoneNumber {
|
|
42
|
+
/**
|
|
43
|
+
* Country calling code, digits only: `"39"`, `"30"`, `"1"`.
|
|
44
|
+
*
|
|
45
|
+
* Without the `+`, which is not part of the code — Italy's code *is* 39.
|
|
46
|
+
* The plus is notation for "dial internationally", and both formatters below
|
|
47
|
+
* add it, so storing it would only be a character every venue has to
|
|
48
|
+
* remember and every reader has to strip.
|
|
49
|
+
*/
|
|
50
|
+
readonly code: CallingCode;
|
|
51
|
+
/**
|
|
52
|
+
* The **national significant number** — what follows the country code when
|
|
53
|
+
* dialling from abroad, spaced however the country groups it.
|
|
54
|
+
*
|
|
55
|
+
* Not the domestic form, and the difference is one digit that matters. Most
|
|
56
|
+
* countries put a *trunk prefix* in front for local dialling — a leading `0`
|
|
57
|
+
* in the UK, Romania, Germany, France — which is not part of the number and
|
|
58
|
+
* does not belong here: London's `020 7123 4567` is written `20 7123 4567`.
|
|
59
|
+
* Italy is the standard exception and keeps its zero, because there the `0`
|
|
60
|
+
* *is* part of the number: Rome's `06 …` stays `06 …`. Greece has no trunk
|
|
61
|
+
* prefix at all, so its numbers are written as they are said.
|
|
62
|
+
*
|
|
63
|
+
* lib does not strip anything, which is what makes both readings possible:
|
|
64
|
+
* a rule that dropped the leading zero would be right for London and would
|
|
65
|
+
* leave Rome with no way to spell itself correctly. The cost is that this
|
|
66
|
+
* field has one right answer per country rather than "however it looks on
|
|
67
|
+
* the door", and getting it wrong is visible — `formatPhone` and `e164` read
|
|
68
|
+
* the same digits, so a stray trunk prefix shows up on the page as
|
|
69
|
+
* `+44 020 …` rather than hiding in a `tel:` nobody inspects.
|
|
70
|
+
*/
|
|
71
|
+
readonly number: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The number as a person reads it: `+39 06 0000 0000`.
|
|
76
|
+
*
|
|
77
|
+
* The stored formatting is kept rather than normalised — whoever wrote it knows
|
|
78
|
+
* how their country groups its digits, and no general rule does.
|
|
79
|
+
*/
|
|
80
|
+
export function formatPhone(phone: PhoneNumber): string {
|
|
81
|
+
return `+${phone.code} ${phone.number}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The number as a phone dials it: `tel:+390600000000`.
|
|
86
|
+
*
|
|
87
|
+
* E.164 — a plus, then digits, nothing else. Spaces, brackets and dashes are
|
|
88
|
+
* presentation, and a `tel:` containing them is at best ignored and at worst
|
|
89
|
+
* dialled wrongly.
|
|
90
|
+
*
|
|
91
|
+
* The digits themselves are whatever `number` holds — see it. Nothing is added
|
|
92
|
+
* and nothing is removed, so this and `formatPhone` are two renderings of one
|
|
93
|
+
* value rather than two opinions about it.
|
|
94
|
+
*/
|
|
95
|
+
export function telHref(phone: PhoneNumber): string {
|
|
96
|
+
return `tel:${e164(phone)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The number in E.164: `+390600000000`.
|
|
101
|
+
*
|
|
102
|
+
* The same digits `telHref` dials, without the scheme — which is what
|
|
103
|
+
* structured data wants, and the only form that identifies a number
|
|
104
|
+
* unambiguously enough to match a business against a listing.
|
|
105
|
+
*
|
|
106
|
+
* **Only punctuation is removed.** This used to strip a leading zero as well,
|
|
107
|
+
* on the rule that it is always a trunk prefix — true across most of Europe and
|
|
108
|
+
* false in Italy, where the zero is part of the number, so Rome shipped
|
|
109
|
+
* `+396…`, which reaches nothing. There was no way to write it correctly: `06`,
|
|
110
|
+
* `006` and `6` all came out the same.
|
|
111
|
+
*
|
|
112
|
+
* A rule here could only be right for one of the two, and lib has no business
|
|
113
|
+
* knowing which countries keep their zero — that is a list it would carry, get
|
|
114
|
+
* subtly wrong, and apply silently. So the digits are the project's to state,
|
|
115
|
+
* and `PhoneNumber.number` says what to state. Checked August 2026.
|
|
116
|
+
*
|
|
117
|
+
* `replace(/^0+/, "")` also took *every* leading zero rather than one trunk
|
|
118
|
+
* digit, so `007…` became `7…`.
|
|
119
|
+
*/
|
|
120
|
+
export function e164(phone: PhoneNumber): string {
|
|
121
|
+
return `+${phone.code}${phone.number.replace(/\D/g, "")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An email address, which is only ever used whole. */
|
|
125
|
+
export type EmailAddress = `${string}@${string}`;
|
|
126
|
+
|
|
127
|
+
export function mailtoHref(email: EmailAddress): string {
|
|
128
|
+
return `mailto:${email}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An ISO 3166-1 alpha-2 country code: `"IT"`, `"GR"`, `"RO"`.
|
|
133
|
+
*
|
|
134
|
+
* Two uppercase letters, which is what `addressCountry` is read as. A name —
|
|
135
|
+
* `"Italy"`, `"Ιταλία"` — is a translation of a country rather than an
|
|
136
|
+
* identifier for one, and belongs in the sentence a page prints, not here.
|
|
137
|
+
*/
|
|
138
|
+
export type CountryCode = `${Letter}${Letter}`;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where a place is, to the precision a map needs.
|
|
142
|
+
*
|
|
143
|
+
* Both halves required, and the pair optional as a whole — which is the point
|
|
144
|
+
* of it being an object. Half a coordinate is not half an answer: a latitude
|
|
145
|
+
* with no longitude describes a line around the earth, and a type that allowed
|
|
146
|
+
* it would be describing something no venue means.
|
|
147
|
+
*
|
|
148
|
+
* Not a substitute for the postal address, and not derived from one. This is
|
|
149
|
+
* the door; an address is how the post office finds the building, and for a
|
|
150
|
+
* venue down a side street the two are not the same point.
|
|
151
|
+
*/
|
|
152
|
+
export interface Coordinates {
|
|
153
|
+
/** Degrees north, negative for south. */
|
|
154
|
+
readonly latitude: number;
|
|
155
|
+
/** Degrees east, negative for west. */
|
|
156
|
+
readonly longitude: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Decimal places, for the precision check below. */
|
|
160
|
+
function decimals(value: number): number {
|
|
161
|
+
return String(value).split(".")[1]?.length ?? 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Rejects a coordinate that cannot be a venue, and warns about a coarse one.
|
|
166
|
+
*
|
|
167
|
+
* `0,0` throws. It is the Gulf of Guinea, and far more often it is what a
|
|
168
|
+
* failed lookup returned — a pair of zeroes reaching structured data is a bug
|
|
169
|
+
* upstream, never a location.
|
|
170
|
+
*
|
|
171
|
+
* Throws rather than warns even for a site still being built, because the whole
|
|
172
|
+
* field is optional: a venue that has not looked its coordinates up yet leaves
|
|
173
|
+
* them out and nothing is emitted. `0,0` is not the absence of a location, it
|
|
174
|
+
* is a claim about a specific patch of ocean, and a warning would let that
|
|
175
|
+
* claim ship.
|
|
176
|
+
*
|
|
177
|
+
* Precision only warns, and at four places rather than the five Google asks
|
|
178
|
+
* for, because a `number` cannot carry the difference. `41.89490` is written
|
|
179
|
+
* with five and stored as `41.8949`, so counting the decimals of the stored
|
|
180
|
+
* value would fail one correct coordinate in ten for having a zero at the end —
|
|
181
|
+
* a build broken by data that was right. What the warning still catches is the
|
|
182
|
+
* real mistake: a value pasted at two or three places, which is a hundred
|
|
183
|
+
* metres out and puts the pin on the wrong block.
|
|
184
|
+
*/
|
|
185
|
+
export function assertCoordinates(where: Coordinates, at: string): void {
|
|
186
|
+
if (where.latitude === 0 && where.longitude === 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`${at}: coordinates are 0,0 — the Gulf of Guinea, and what a failed lookup returns. Leave them out entirely until you have real ones: the field is optional, and no location beats a wrong one.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const coarse = (["latitude", "longitude"] as const).filter(
|
|
193
|
+
(axis) => decimals(where[axis]) < 4
|
|
194
|
+
);
|
|
195
|
+
if (coarse.length > 0) {
|
|
196
|
+
warn(
|
|
197
|
+
at,
|
|
198
|
+
`${coarse.join(" and ")} ${coarse.length === 1 ? "is" : "are"} rounded to fewer than four decimal places, which places the pin to within about a hundred metres rather than on the door. Take the value from a map rather than shortening it.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A postal address, in its parts.
|
|
205
|
+
*
|
|
206
|
+
* **Canonical, not displayed.** This is the address as the post office writes
|
|
207
|
+
* it — `Roma`, not `Rome` or `Ρώμη` — because that is what `PostalAddress` is
|
|
208
|
+
* read as, and what matches a business against a place. What a *page* shows is a
|
|
209
|
+
* different thing: translated, written for the reader, and living in the catalog
|
|
210
|
+
* beside every other line of copy.
|
|
211
|
+
*
|
|
212
|
+
* The two look like duplication and are not. One is a record, one is a sentence,
|
|
213
|
+
* and a Greek page that printed `Roma` would be as wrong as structured data that
|
|
214
|
+
* claimed the town was called `Ρώμη`.
|
|
215
|
+
*/
|
|
216
|
+
export interface PostalAddress {
|
|
217
|
+
/** Street and number: `"Via Nazionale 100"`. */
|
|
218
|
+
readonly street: string;
|
|
219
|
+
/** Town or city, in its own language: `"Roma"`. */
|
|
220
|
+
readonly locality: string;
|
|
221
|
+
/** Postal or ZIP code, as written locally. */
|
|
222
|
+
readonly postalCode: string;
|
|
223
|
+
/**
|
|
224
|
+
* The country, as its two-letter code: `"IT"`, not `"Italy"`.
|
|
225
|
+
*
|
|
226
|
+
* A code rather than a name because this field identifies a country to a
|
|
227
|
+
* machine, and every country has as many names as there are languages. The
|
|
228
|
+
* name a page prints is a translation, and lives with the rest of the copy.
|
|
229
|
+
*/
|
|
230
|
+
readonly country: CountryCode;
|
|
231
|
+
/** State, province or region, where the country uses one. */
|
|
232
|
+
readonly region?: string;
|
|
233
|
+
}
|
package/src/file.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file the build has to emit but no page renders — a sitemap, a `robots.txt`.
|
|
3
|
+
*
|
|
4
|
+
* It lives on its own because more than one feature produces one, and each of
|
|
5
|
+
* those features would otherwise have to reach into another for the shape.
|
|
6
|
+
*/
|
|
7
|
+
import type { HttpsUrl } from "./url.ts";
|
|
8
|
+
|
|
9
|
+
export interface GeneratedFile {
|
|
10
|
+
/** e.g. `"sitemap.xml"` — the filename the route must use. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** Absolute URL, for `robots.txt` and search-console submissions. */
|
|
13
|
+
readonly url: HttpsUrl;
|
|
14
|
+
readonly body: string;
|
|
15
|
+
readonly contentType: string;
|
|
16
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SitePath } from "./redirects.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The hook a project hangs its `public/` directory on.
|
|
5
|
+
*
|
|
6
|
+
* Empty here, and filled in by declaration merging — the same trick Vite uses
|
|
7
|
+
* for `ImportMetaEnv`. It exists because the union can only be known by reading
|
|
8
|
+
* a directory, which lib cannot do and must not import from a project, while the
|
|
9
|
+
* types that *use* it — `fileUrl`, a redirect target — live here.
|
|
10
|
+
*
|
|
11
|
+
* The `publicFiles()` integration writes the augmentation, so nothing needs
|
|
12
|
+
* writing by hand:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* declare module "@escape-game-over/atlas" {
|
|
16
|
+
* interface PublicFileRegistry {
|
|
17
|
+
* path: "/reports/annual-report-2026.pdf";
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
// Only an interface can be merged into, so this must not become a type alias —
|
|
23
|
+
// it is a hook for a project's augmentation, not a lazy annotation.
|
|
24
|
+
// biome-ignore lint/suspicious/noEmptyInterface: the emptiness is the point.
|
|
25
|
+
export interface PublicFileRegistry {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A path to a file in `public/`.
|
|
29
|
+
*
|
|
30
|
+
* The project's own union once `publicFiles()` has run, so a mistyped or deleted
|
|
31
|
+
* file is a compile error and an editor offers the real ones. Falls back to any
|
|
32
|
+
* site path when the integration is not in use — a check nobody has opted into
|
|
33
|
+
* should not turn every link into an error.
|
|
34
|
+
*/
|
|
35
|
+
export type PublicFilePath = PublicFileRegistry extends {
|
|
36
|
+
readonly path: infer Path extends string;
|
|
37
|
+
}
|
|
38
|
+
? Path
|
|
39
|
+
: SitePath;
|
package/src/hours.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { Digit } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a venue is open, as a week rather than a sentence.
|
|
5
|
+
*
|
|
6
|
+
* Two readers want this and want it differently: a footer prints "Mon–Thu & Sun
|
|
7
|
+
* 14:00–23:30", and `openingHoursSpecification` wants a day list with `opens`
|
|
8
|
+
* and `closes`. Both are views of the same fact, so the fact is stored once —
|
|
9
|
+
* per day, unambiguously — and each view is derived. A project that wrote the
|
|
10
|
+
* sentence by hand would eventually have a footer disagreeing with its own
|
|
11
|
+
* structured data, and only one of them would be visible.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** `00`–`23`. Spelled out so `24:00` and `9:00` are both rejected. */
|
|
15
|
+
type Hour = `0${Digit}` | `1${Digit}` | `2${"0" | "1" | "2" | "3"}`;
|
|
16
|
+
|
|
17
|
+
/** `00`–`59`. */
|
|
18
|
+
type Minute = `${"0" | "1" | "2" | "3" | "4" | "5"}${Digit}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A wall-clock time, 24-hour: `"14:00"`, `"00:45"`.
|
|
22
|
+
*
|
|
23
|
+
* The full union of valid times rather than `` `${number}:${number}` ``, which
|
|
24
|
+
* would accept `"25:99"` — the whole point is that a typo in the one field
|
|
25
|
+
* nobody proofreads is caught by the compiler rather than published.
|
|
26
|
+
*
|
|
27
|
+
* No seconds: no venue opens at 14:00:30, and schema.org reads `HH:MM` fine.
|
|
28
|
+
*/
|
|
29
|
+
export type TimeOfDay = `${Hour}:${Minute}`;
|
|
30
|
+
|
|
31
|
+
const WEEK = [
|
|
32
|
+
"monday",
|
|
33
|
+
"tuesday",
|
|
34
|
+
"wednesday",
|
|
35
|
+
"thursday",
|
|
36
|
+
"friday",
|
|
37
|
+
"saturday",
|
|
38
|
+
"sunday",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A day of the week, lowercase.
|
|
43
|
+
*
|
|
44
|
+
* Derived from `WEEK` so the type and the order cannot drift apart. Lowercase
|
|
45
|
+
* because these are identifiers — a project translates them for display, and
|
|
46
|
+
* schema.org's capitalised names are produced when serialising.
|
|
47
|
+
*/
|
|
48
|
+
export type Weekday = (typeof WEEK)[number];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A span of opening, which may run past midnight.
|
|
52
|
+
*
|
|
53
|
+
* `closes` earlier than `opens` means the next day: `14:00`–`00:45` is a venue
|
|
54
|
+
* that shuts at quarter to one in the morning. This is the convention
|
|
55
|
+
* `openingHoursSpecification` is read with, and it is why a range is stored as
|
|
56
|
+
* two times rather than a start and a duration.
|
|
57
|
+
*/
|
|
58
|
+
export interface TimeRange {
|
|
59
|
+
readonly opens: TimeOfDay;
|
|
60
|
+
readonly closes: TimeOfDay;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One day: shut, or one or more spans.
|
|
65
|
+
*
|
|
66
|
+
* `"closed"` rather than an empty array, and the array cannot *be* empty — one
|
|
67
|
+
* way to say each thing. An empty list reads as an oversight, and a day nobody
|
|
68
|
+
* filled in should not be indistinguishable from a day off.
|
|
69
|
+
*
|
|
70
|
+
* Several spans cover a venue that shuts for the afternoon.
|
|
71
|
+
*/
|
|
72
|
+
export type DayHours = "closed" | readonly [TimeRange, ...TimeRange[]];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The whole week, every day stated.
|
|
76
|
+
*
|
|
77
|
+
* A `Record` of all seven rather than a list of spans: forgetting Wednesday is
|
|
78
|
+
* then a compile error instead of a day quietly missing from a knowledge panel,
|
|
79
|
+
* and the same day cannot be listed twice with different hours.
|
|
80
|
+
*/
|
|
81
|
+
export type WeeklyHours = Readonly<Record<Weekday, DayHours>>;
|
|
82
|
+
|
|
83
|
+
/** Days that share the same hours, ready to be printed or serialised. */
|
|
84
|
+
export interface HoursGroup {
|
|
85
|
+
/** Every day in the group, in week order. */
|
|
86
|
+
readonly days: readonly [Weekday, ...Weekday[]];
|
|
87
|
+
/**
|
|
88
|
+
* The same days as consecutive spans: `[["monday", "thursday"],
|
|
89
|
+
* ["sunday", "sunday"]]` is what a footer renders as "Mon–Thu & Sun".
|
|
90
|
+
*
|
|
91
|
+
* A single-day span has the same day at both ends, so a caller never has to
|
|
92
|
+
* special-case one — it can compare the two and print one name.
|
|
93
|
+
*/
|
|
94
|
+
readonly runs: readonly (readonly [Weekday, Weekday])[];
|
|
95
|
+
readonly hours: DayHours;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The week rotated to start where a locale starts it. */
|
|
99
|
+
function weekFrom(start: Weekday): readonly Weekday[] {
|
|
100
|
+
const index = WEEK.indexOf(start);
|
|
101
|
+
return [...WEEK.slice(index), ...WEEK.slice(0, index)];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Identity of a day's hours, so two days can be compared for sameness. */
|
|
105
|
+
function keyOf(hours: DayHours): string {
|
|
106
|
+
return hours === "closed"
|
|
107
|
+
? "closed"
|
|
108
|
+
: hours.map((range) => `${range.opens}-${range.closes}`).join(",");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The week collapsed to its distinct sets of hours.
|
|
113
|
+
*
|
|
114
|
+
* Groups appear in the order their first day does, and closed days group like
|
|
115
|
+
* any other — a footer that wants to print "Sunday: closed" has it, and one
|
|
116
|
+
* that does not can filter.
|
|
117
|
+
*
|
|
118
|
+
* `weekStart` changes the answer rather than only the order: with Monday first,
|
|
119
|
+
* a venue open Sunday through Thursday reads "Mon–Thu & Sun"; with Sunday
|
|
120
|
+
* first, the same week is one run, "Sun–Thu". Runs do not wrap around the end
|
|
121
|
+
* of the week, which is what keeps those two different.
|
|
122
|
+
*/
|
|
123
|
+
export function groupHours(
|
|
124
|
+
hours: WeeklyHours,
|
|
125
|
+
weekStart: Weekday = "monday"
|
|
126
|
+
): readonly HoursGroup[] {
|
|
127
|
+
const groups = new Map<
|
|
128
|
+
string,
|
|
129
|
+
{
|
|
130
|
+
days: [Weekday, ...Weekday[]];
|
|
131
|
+
runs: [Weekday, Weekday][];
|
|
132
|
+
hours: DayHours;
|
|
133
|
+
}
|
|
134
|
+
>();
|
|
135
|
+
|
|
136
|
+
let previousKey: string | undefined;
|
|
137
|
+
for (const day of weekFrom(weekStart)) {
|
|
138
|
+
const dayHours = hours[day];
|
|
139
|
+
const key = keyOf(dayHours);
|
|
140
|
+
const group = groups.get(key);
|
|
141
|
+
|
|
142
|
+
// A group is created holding its first day rather than empty, which is
|
|
143
|
+
// what lets `days` be a non-empty tuple without a cast: there is no
|
|
144
|
+
// moment at which an empty group exists.
|
|
145
|
+
if (group === undefined) {
|
|
146
|
+
groups.set(key, {
|
|
147
|
+
days: [day],
|
|
148
|
+
runs: [[day, day]],
|
|
149
|
+
hours: dayHours,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
group.days.push(day);
|
|
153
|
+
const lastRun = group.runs.at(-1);
|
|
154
|
+
if (key === previousKey && lastRun !== undefined) {
|
|
155
|
+
lastRun[1] = day;
|
|
156
|
+
} else {
|
|
157
|
+
group.runs.push([day, day]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
previousKey = key;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [...groups.values()];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The words a printed week needs, which lib does not have.
|
|
168
|
+
*
|
|
169
|
+
* Day names and "closed" are copy: they are translated, and lib holds no copy.
|
|
170
|
+
* Everything else about the line — which days collapse into a span, what order
|
|
171
|
+
* they come in, where the dashes go — is mechanical and is done here, because
|
|
172
|
+
* otherwise every project rewrites the same joins in a component.
|
|
173
|
+
*/
|
|
174
|
+
export interface HoursFormat {
|
|
175
|
+
/** What to call a day. Usually a lookup in the project's catalog. */
|
|
176
|
+
day(day: Weekday): string;
|
|
177
|
+
/** The word for a day the venue is shut. */
|
|
178
|
+
closed: string;
|
|
179
|
+
/** Between the ends of a span: `Mon–Thu`, `14:00–23:30`. */
|
|
180
|
+
between?: string;
|
|
181
|
+
/** Between spans: `Mon–Thu, Sun`. */
|
|
182
|
+
and?: string;
|
|
183
|
+
/** Where the week starts, which changes how days collapse. */
|
|
184
|
+
weekStart?: Weekday;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One line of a printed week: which days, and when. */
|
|
188
|
+
export interface FormattedHours {
|
|
189
|
+
/** `"Mon–Thu, Sun"`. */
|
|
190
|
+
readonly days: string;
|
|
191
|
+
/** `"14:00–23:30"`, or the word for closed. */
|
|
192
|
+
readonly times: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The week as lines ready to print.
|
|
197
|
+
*
|
|
198
|
+
* Two strings rather than one so a footer can lay them out as a table and have
|
|
199
|
+
* the times line up; a caller wanting a sentence joins them itself.
|
|
200
|
+
*
|
|
201
|
+
* A run of one day prints as one name — `Sun`, not `Sun–Sun` — which is the
|
|
202
|
+
* special case every hand-written version of this forgets.
|
|
203
|
+
*/
|
|
204
|
+
export function formatHours(
|
|
205
|
+
hours: WeeklyHours,
|
|
206
|
+
format: HoursFormat
|
|
207
|
+
): readonly FormattedHours[] {
|
|
208
|
+
const between = format.between ?? "–";
|
|
209
|
+
const and = format.and ?? ", ";
|
|
210
|
+
|
|
211
|
+
return groupHours(hours, format.weekStart).map((group) => ({
|
|
212
|
+
days: group.runs
|
|
213
|
+
.map(([first, last]) =>
|
|
214
|
+
first === last
|
|
215
|
+
? format.day(first)
|
|
216
|
+
: `${format.day(first)}${between}${format.day(last)}`
|
|
217
|
+
)
|
|
218
|
+
.join(and),
|
|
219
|
+
times:
|
|
220
|
+
group.hours === "closed"
|
|
221
|
+
? format.closed
|
|
222
|
+
: group.hours
|
|
223
|
+
.map((range) => `${range.opens}${between}${range.closes}`)
|
|
224
|
+
.join(and),
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Minutes since midnight, for comparing two times. */
|
|
229
|
+
function minutes(time: TimeOfDay): number {
|
|
230
|
+
return Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Throws on a week that type-checks but cannot be true.
|
|
235
|
+
*
|
|
236
|
+
* The type stops a bad *time*; it cannot stop a bad *week*. Overlapping spans
|
|
237
|
+
* and reversed ones are typos, and hours are the one thing on a page nobody
|
|
238
|
+
* re-reads once written — a venue does not find out it has been claiming to
|
|
239
|
+
* open at 23:00 until somebody arrives at a locked door.
|
|
240
|
+
*
|
|
241
|
+
* Throws where the share-image checks only warn, because the failures are not
|
|
242
|
+
* the same kind of thing. An image below its box looks worse than intended; a
|
|
243
|
+
* wrong opening time is a false statement, published, that sends a real person
|
|
244
|
+
* to a closed building. There is no version of shipping it that is acceptable,
|
|
245
|
+
* so it stops the build.
|
|
246
|
+
*
|
|
247
|
+
* Every problem in the week is reported at once. Failing on the first would
|
|
248
|
+
* mean a build, a fix and another build for each typo in the same table.
|
|
249
|
+
*/
|
|
250
|
+
export function assertHours(hours: WeeklyHours, at: string): void {
|
|
251
|
+
const problems: string[] = [];
|
|
252
|
+
|
|
253
|
+
for (const day of WEEK) {
|
|
254
|
+
const ranges = hours[day];
|
|
255
|
+
if (ranges === "closed") {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let previousEnd: number | undefined;
|
|
260
|
+
ranges.forEach((range, index) => {
|
|
261
|
+
const opens = minutes(range.opens);
|
|
262
|
+
const closes = minutes(range.closes);
|
|
263
|
+
|
|
264
|
+
if (opens === closes) {
|
|
265
|
+
problems.push(
|
|
266
|
+
`${day} opens and closes at ${range.opens}, a span of no length. For a day off write "closed"; for around the clock, 00:00 to 23:59.`
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A span running past midnight ends the day: anything after it
|
|
272
|
+
// would belong to the following one.
|
|
273
|
+
if (closes < opens && index < ranges.length - 1) {
|
|
274
|
+
problems.push(
|
|
275
|
+
`${day} has a span after ${range.opens}–${range.closes}, which already runs into the next day.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (previousEnd !== undefined && opens < previousEnd) {
|
|
280
|
+
problems.push(
|
|
281
|
+
`${day} reopens at ${range.opens} before the previous span has closed.`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
previousEnd = closes < opens ? 24 * 60 : closes;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (problems.length > 0) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`${at}: opening hours are impossible as written.\n${problems
|
|
292
|
+
.map((problem) => ` - ${problem}`)
|
|
293
|
+
.join("\n")}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** schema.org spells its days capitalised; this file spells them as ids. */
|
|
299
|
+
const SCHEMA_DAY: Readonly<Record<Weekday, string>> = {
|
|
300
|
+
monday: "Monday",
|
|
301
|
+
tuesday: "Tuesday",
|
|
302
|
+
wednesday: "Wednesday",
|
|
303
|
+
thursday: "Thursday",
|
|
304
|
+
friday: "Friday",
|
|
305
|
+
saturday: "Saturday",
|
|
306
|
+
sunday: "Sunday",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** A group's days, as schema.org names them. */
|
|
310
|
+
export function schemaDays(group: HoursGroup): readonly string[] {
|
|
311
|
+
return group.days.map((day) => SCHEMA_DAY[day]);
|
|
312
|
+
}
|