@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.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. 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
+ }