@escape-game-over/atlas 0.1.108 → 0.1.110

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 CHANGED
@@ -72,6 +72,7 @@ export const { project, redirects } = atlas.project(
72
72
 
73
73
  enabledLocales: ["el-GR", "en-US"],
74
74
  overrideRouting: { defaultLocale: "el-GR", prefixDefaultLocale: true },
75
+ clock: { "el-GR": "h23", "en-US": "h12" }, // how each language prints times
75
76
 
76
77
  overrideMessages: {
77
78
  // Greek only — the English copy still falls through to the default catalog.
@@ -243,6 +244,8 @@ regression-tested by an `@ts-expect-error` suite.
243
244
  | A `robots` directive that is not a real one | error — the vocabulary is a union |
244
245
  | A project `url` that is not `https://`, or ends in a slash | error |
245
246
  | `defaultLocale` not among the project's `enabledLocales` | error |
247
+ | A `clock` leaving out a published locale, or naming an unpublished one | error |
248
+ | `site.clockFor` on a project that declares no clock | throws, naming the project |
246
249
  | An unknown field in a project declaration | error — a typo cannot sit there unread |
247
250
  | Two routes resolving to the same URL | build throws, naming both |
248
251
  | A site icon that is not square, a multiple of 48px, and PNG | build throws |
package/docs/checks.md CHANGED
@@ -42,6 +42,7 @@ sees.
42
42
  | unknown message key, or an override dropping a `{placeholder}` | two routes resolving to the same URL |
43
43
  | a message the project declared mandatory and never wrote (`CatalogCovers`) | |
44
44
  | copy or a locale label written for a locale outside `enabledLocales` | |
45
+ | a `clock` leaving out a published locale, or naming one outside `enabledLocales` | the same, for a project that was not type-checked first |
45
46
  | a slug whose translation sits at a different depth, in the registry or across the overlay | the same, when a slug is not a literal (computed, or from an env var) |
46
47
  | unknown route id, or a locale the site does not ship | a locale published twice |
47
48
  | a malformed language tag (`en_US`, `en-us`, `EN-US`, bare `en`) | an icon that is not square, a multiple of 48, and PNG |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.108",
3
+ "version": "0.1.110",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -22,6 +22,7 @@
22
22
  "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
23
23
  "./astro/document": "./src/astro/Document.astro",
24
24
  "./astro/element": "./src/astro/AtlasElement.astro",
25
+ "./astro/escapeall": "./src/astro/EscapeAll.astro",
25
26
  "./astro/escape-navigator": "./src/astro/EscapeNavigator.astro",
26
27
  "./astro/four-escape": "./src/astro/FourEscape.astro",
27
28
  "./astro/google-map": "./src/astro/GoogleMap.astro",
@@ -63,10 +64,10 @@
63
64
  "vite": ">= 8"
64
65
  },
65
66
  "devDependencies": {
66
- "@biomejs/biome": "2.6.6",
67
+ "@biomejs/biome": "2.6.7",
67
68
  "@types/node": "26.6.6",
68
69
  "@vitest/coverage-istanbul": "5.1.0",
69
- "astro": "7.4.10",
70
+ "astro": "7.4.11",
70
71
  "typescript": "6.0.3",
71
72
  "vitest": "5.1.0"
72
73
  }
@@ -53,6 +53,7 @@ export const STORAGE_VENDOR_NAMES = {
53
53
  clarity: "Microsoft Clarity",
54
54
  "4escape": "4escape",
55
55
  escapenavigator: "Escape Navigator",
56
+ escapeall: "EscapeAll",
56
57
  stripe: "Stripe",
57
58
  } as const satisfies Record<StorageVendor | BookingVendor | "stripe", string>;
58
59
 
@@ -314,6 +315,13 @@ const BOOKING = {
314
315
  stripe: true,
315
316
  loads: ["google-analytics", "google-ads", "meta"],
316
317
  },
318
+ // Read from its script 2026-11-12: nothing as it renders; the phone
319
+ // confirmation while a visitor books. Its queue cookie waits for its own
320
+ // consent cookie, which a site of ours never sets.
321
+ escapeall: {
322
+ kept: ["ea-cell-confirmation"],
323
+ tab: [],
324
+ },
317
325
  } as const satisfies Record<BookingVendor, BookingStorage>;
318
326
 
319
327
  /**
@@ -452,7 +460,7 @@ export function storedItems(
452
460
  let stripe = false;
453
461
  for (const widget of new Set(booking)) {
454
462
  const entry: BookingStorage = BOOKING[widget];
455
- widgetRows.push(
463
+ const own: StoredItem[] = [
456
464
  {
457
465
  id: widget,
458
466
  names: entry.kept,
@@ -466,8 +474,10 @@ export function storedItems(
466
474
  provider: widget,
467
475
  lasts: "tab",
468
476
  consent: "none",
469
- }
470
- );
477
+ },
478
+ ];
479
+ // A widget that keeps nothing for the tab has no row for it.
480
+ widgetRows.push(...own.filter((row) => row.names.length > 0));
471
481
  for (const vendor of entry.loads ?? []) vendors.add(vendor);
472
482
  if (entry.stripe) stripe = true;
473
483
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { HTMLAttributes } from "astro/types";
16
16
  import type { BookingWidget, BookingWidgetVars } from "../booking/widget.ts";
17
+ import EscapeAll from "./EscapeAll.astro";
17
18
  import EscapeNavigator from "./EscapeNavigator.astro";
18
19
  import FourEscape from "./FourEscape.astro";
19
20
 
@@ -39,6 +40,18 @@ const { widget, locale, vars, colorScheme, ...attrs } = Astro.props;
39
40
  vars={vars}
40
41
  />
41
42
  )}
43
+ {widget.vendor === "escapeall" && (
44
+ <EscapeAll
45
+ {...attrs}
46
+ host={widget.host}
47
+ companyId={widget.companyId}
48
+ serviceId={widget.serviceId}
49
+ template={widget.template}
50
+ skin={widget.skin}
51
+ players={widget.players}
52
+ locale={locale}
53
+ />
54
+ )}
42
55
  {widget.vendor === "escapenavigator" && (
43
56
  <EscapeNavigator
44
57
  {...attrs}
@@ -0,0 +1,62 @@
1
+ ---
2
+ /**
3
+ * An EscapeAll booking calendar: one room's, or every room's. See
4
+ * `escapeall.ts` for what it loads and keeps.
5
+ *
6
+ * `locale` is the page's. Attributes go on the calendar's element.
7
+ *
8
+ * ```astro
9
+ * <EscapeAll host="nl.escapeall.com" companyId="…" serviceId="…" template={9} locale={locale} />
10
+ * ```
11
+ */
12
+ import type { HTMLAttributes } from "astro/types";
13
+ import {
14
+ checkEscapeAll,
15
+ type EscapeAllWidget,
16
+ escapeAllAssets,
17
+ escapeAllLanguage,
18
+ } from "../booking/escapeall.ts";
19
+
20
+ type Props = HTMLAttributes<"div"> &
21
+ EscapeAllWidget & {
22
+ readonly locale: string;
23
+ };
24
+
25
+ const {
26
+ host,
27
+ companyId,
28
+ serviceId,
29
+ template,
30
+ skin,
31
+ players,
32
+ locale,
33
+ id,
34
+ ...attrs
35
+ } = Astro.props;
36
+ const widget = { host, companyId, serviceId, template, skin, players };
37
+
38
+ checkEscapeAll(widget, Astro.url.pathname);
39
+
40
+ const { stylesheets, script } = escapeAllAssets(widget);
41
+ // EscapeAll's script builds its selectors from the one it is given, so the
42
+ // calendar is found by id: the page's own, or one from the room or account.
43
+ const elementId = id ?? `escapeall-${serviceId ?? companyId}`;
44
+ ---
45
+
46
+ {stylesheets.map((href) => (
47
+ <link
48
+ rel='stylesheet'
49
+ href={href}
50
+ />
51
+ ))}
52
+ <script src="./escapeall-calendar.ts"></script>
53
+ <div
54
+ {...attrs}
55
+ id={elementId}
56
+ data-escapeall-company={companyId}
57
+ data-escapeall-service={serviceId}
58
+ data-escapeall-template={template}
59
+ data-escapeall-players={players}
60
+ data-escapeall-language={escapeAllLanguage(locale)}
61
+ data-escapeall-script={script}
62
+ ></div>
@@ -0,0 +1,32 @@
1
+ import { appendScript } from "./append-script.ts";
2
+
3
+ /**
4
+ * Draws each EscapeAll calendar on the page, once EscapeAll's script has
5
+ * loaded: the call its store scripts make, with the settings the component
6
+ * wrote on the element.
7
+ */
8
+ // Its script builds selectors by appending to the one it is given, so the
9
+ // calendar is named by its id, not handed over as an element.
10
+ type Calendar = (selector: string, settings: Record<string, unknown>) => void;
11
+
12
+ const host = window as unknown as { GetBookingCalendra?: Calendar };
13
+
14
+ const elements = [...document.querySelectorAll("[data-escapeall-company]")];
15
+ const [first] = elements;
16
+ if (first instanceof HTMLElement && first.dataset.escapeallScript) {
17
+ appendScript(first.dataset.escapeallScript).addEventListener("load", () => {
18
+ for (const element of elements) {
19
+ if (!(element instanceof HTMLElement)) continue;
20
+ const { escapeallCompany, escapeallService, escapeallLanguage } =
21
+ element.dataset;
22
+ host.GetBookingCalendra?.(`#${CSS.escape(element.id)}`, {
23
+ CompanyId: [escapeallCompany],
24
+ ...(escapeallService ? { ServiceId: [escapeallService] } : {}),
25
+ lang: escapeallLanguage,
26
+ Template: Number(element.dataset.escapeallTemplate),
27
+ InitialQuantity: Number(element.dataset.escapeallPlayers ?? 2),
28
+ FirstWeekDay: "Monday",
29
+ });
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,86 @@
1
+ import { invalidId, UUID, type Uuid } from "../analytics/ids.ts";
2
+
3
+ /**
4
+ * EscapeAll's booking calendar: a store's rooms and their free slots, booked
5
+ * on the page. One room's calendar when `serviceId` names it (EscapeAll calls
6
+ * a room a service), or every room's.
7
+ *
8
+ * EscapeAll's embed is a store script of its own that waits for EscapeAll's
9
+ * jQuery, then calls `GetBookingCalendra` from the shared `all.js` with the
10
+ * store's settings. The component makes that call itself, with the page's
11
+ * language, so it needs no store script. Read 2026-11-12 from Aruba's.
12
+ *
13
+ * It stores nothing as it renders, and loads nothing from anyone else; while
14
+ * a visitor books it keeps a phone confirmation in local storage. Its queue
15
+ * cookie is written only once the page sets EscapeAll's own consent cookie,
16
+ * which ours never does. All of it is in `storedItems`.
17
+ */
18
+ export interface EscapeAllWidget {
19
+ /** EscapeAll's regional site the account is on: `nl.escapeall.com`. */
20
+ readonly host: string;
21
+ /** The account's id, from EscapeAll: the store script's `CompanyId`. */
22
+ readonly companyId: Uuid;
23
+ /** One room's id, for its calendar alone; every room's without it. */
24
+ readonly serviceId?: Uuid;
25
+ /** The calendar's layout, by EscapeAll's number: the store's `Template`. */
26
+ readonly template: number;
27
+ /** The store's stylesheet, as EscapeAll hosts it: `CustomCss/<skin>.css`. */
28
+ readonly skin?: string;
29
+ /** The group size the calendar opens on: the store's `InitialQuantity`. */
30
+ readonly players?: number;
31
+ }
32
+
33
+ /** EscapeAll's own CSS variables: none it documents, so none taken. */
34
+ export type EscapeAllVars = Readonly<Partial<Record<string, never>>>;
35
+
36
+ /** The languages the calendar is translated into, as its paths take them. */
37
+ export const ESCAPEALL_LANGUAGES = ["en", "nl", "de"] as const;
38
+
39
+ export type EscapeAllLanguage = (typeof ESCAPEALL_LANGUAGES)[number];
40
+
41
+ /** EscapeAll's regional sites: `nl.escapeall.com`. */
42
+ const ESCAPEALL_HOST = /^[a-z]{2}\.escapeall\.com$/;
43
+
44
+ /** Fails the build on a calendar that would render nothing. */
45
+ export function checkEscapeAll(widget: EscapeAllWidget, at: string): void {
46
+ if (!ESCAPEALL_HOST.test(widget.host)) {
47
+ invalidId(
48
+ at,
49
+ `EscapeAll host "${widget.host}" is not one of EscapeAll's regional sites, like "nl.escapeall.com".`
50
+ );
51
+ }
52
+ for (const [name, id] of [
53
+ ["company", widget.companyId],
54
+ ["service", widget.serviceId],
55
+ ] as const) {
56
+ if (id !== undefined && !UUID.test(id)) {
57
+ invalidId(
58
+ at,
59
+ `EscapeAll ${name} id "${id}" is not a UUID, so the calendar would render nothing.`
60
+ );
61
+ }
62
+ }
63
+ }
64
+
65
+ /** The stylesheets and script a calendar on `host` needs. */
66
+ export function escapeAllAssets(widget: EscapeAllWidget) {
67
+ const base = `https://${widget.host}`;
68
+ return {
69
+ stylesheets: [
70
+ `${base}/Content/API/styles.css`,
71
+ ...(widget.skin === undefined
72
+ ? []
73
+ : [`${base}/CustomCss/${widget.skin}.css`]),
74
+ ],
75
+ script: `${base}/Scripts/API/all.js`,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * The calendar's language for a page's locale: English for one it is not
81
+ * translated into, as EscapeAll's own embeds fall back.
82
+ */
83
+ export function escapeAllLanguage(locale: string): EscapeAllLanguage {
84
+ const { language } = new Intl.Locale(locale);
85
+ return ESCAPEALL_LANGUAGES.find((known) => known === language) ?? "en";
86
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  EscapeNavigatorVars,
3
3
  EscapeNavigatorWidget,
4
4
  } from "./escape-navigator.ts";
5
+ import type { EscapeAllVars, EscapeAllWidget } from "./escapeall.ts";
5
6
  import type { FourEscapeVars, FourEscapeWidget } from "./four-escape.ts";
6
7
 
7
8
  /**
@@ -10,7 +11,8 @@ import type { FourEscapeVars, FourEscapeWidget } from "./four-escape.ts";
10
11
  */
11
12
  export type BookingWidget =
12
13
  | ({ readonly vendor: "4escape" } & FourEscapeWidget)
13
- | ({ readonly vendor: "escapenavigator" } & EscapeNavigatorWidget);
14
+ | ({ readonly vendor: "escapenavigator" } & EscapeNavigatorWidget)
15
+ | ({ readonly vendor: "escapeall" } & EscapeAllWidget);
14
16
 
15
17
  /** Who makes a booking widget: what `storedItems` takes for their rows. */
16
18
  export type BookingVendor = BookingWidget["vendor"];
@@ -19,6 +21,7 @@ export type BookingVendor = BookingWidget["vendor"];
19
21
  export interface BookingVendorVars {
20
22
  readonly "4escape": FourEscapeVars;
21
23
  readonly escapenavigator: EscapeNavigatorVars;
24
+ readonly escapeall: EscapeAllVars;
22
25
  }
23
26
 
24
27
  /** The CSS variables a widget takes: its own vendor's, and no other's. */
package/src/index.ts CHANGED
@@ -62,6 +62,15 @@ export {
62
62
  type EscapeNavigatorWidget,
63
63
  escapeNavigatorLanguage,
64
64
  } from "./booking/escape-navigator.ts";
65
+ export {
66
+ checkEscapeAll,
67
+ ESCAPEALL_LANGUAGES,
68
+ type EscapeAllLanguage,
69
+ type EscapeAllVars,
70
+ type EscapeAllWidget,
71
+ escapeAllAssets,
72
+ escapeAllLanguage,
73
+ } from "./booking/escapeall.ts";
65
74
  export {
66
75
  checkFourEscape,
67
76
  FOUR_ESCAPE_LANGUAGES,
package/src/project.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  SiteConfigShape,
8
8
  } from "./config.ts";
9
9
  import type { Fonts } from "./fonts.ts";
10
+ import type { HourCycle } from "./hours.ts";
10
11
  import type {
11
12
  BaseCatalog,
12
13
  OverrideCatalog,
@@ -105,6 +106,21 @@ export interface ProjectInput<L extends string, Routes> {
105
106
  * declare is a compile error.
106
107
  */
107
108
  readonly enabledLocales: readonly L[];
109
+ /**
110
+ * The clock each published language prints times on: `"h23"` for 18:00,
111
+ * `"h12"` for 6:00 PM. Read back with `site.clockFor(locale)`.
112
+ *
113
+ * One entry for every locale in `enabledLocales`, and none for any other,
114
+ * so what each page prints is written down beside the languages that print
115
+ * it: a bilingual Canadian site reads `{ "en-CA": "h12", "fr-CA": "h23" }`.
116
+ * Not taken from the locale's own default (`Intl`'s) on purpose: a site may
117
+ * print a 24-hour clock where its language's default is 12-hour, and that
118
+ * choice is the site's to state.
119
+ *
120
+ * Optional: a project that prints no times declares none, and
121
+ * `clockFor` refuses to answer for it.
122
+ */
123
+ readonly clock?: Readonly<Partial<Record<L, HourCycle>>>;
108
124
  /**
109
125
  * Replaces parts of the site's `defaultRouting` for this project.
110
126
  *
@@ -286,6 +302,17 @@ export type ProjectChecks<
286
302
  ? NoExcessKeys<T["overrideLocaleMeta"][K], keyof LocaleMetaOverride>
287
303
  : LocaleNotEnabled<K>;
288
304
  };
305
+ // The locales written must be ones this project publishes, as for
306
+ // `overrideLocaleMeta` above. That every one it publishes is there is
307
+ // checked in `ProjectOverrideChecks`: a conditional on `T` here would make
308
+ // the constraint circular.
309
+ readonly clock?: {
310
+ readonly [K in StringKeys<
311
+ T["clock"]
312
+ >]: K extends T["enabledLocales"][number]
313
+ ? HourCycle
314
+ : LocaleNotEnabled<K>;
315
+ };
289
316
  };
290
317
 
291
318
  /** The same checks that map the whole override objects, in parameter position. */
@@ -312,7 +339,16 @@ export type ProjectOverrideChecks<
312
339
  T["enabledLocales"][number]
313
340
  >;
314
341
  }
315
- : unknown);
342
+ : unknown) &
343
+ (T extends { readonly clock: object }
344
+ ? {
345
+ // A clock, once declared, is declared for every page: a locale
346
+ // left out would have no clock to print its times on.
347
+ readonly clock: {
348
+ readonly [K in T["enabledLocales"][number]]: HourCycle;
349
+ };
350
+ }
351
+ : unknown);
316
352
 
317
353
  /** A project's own messages, as a catalog: none when it adds none. */
318
354
  export type AddedCatalog<P> = P extends { readonly addMessages: infer A }
@@ -369,5 +405,25 @@ export function defineProject<
369
405
  );
370
406
  }
371
407
  }
408
+ // The clock's rule, for the same reason.
409
+ if (project.clock !== undefined) {
410
+ const published: readonly string[] = project.enabledLocales;
411
+ const missing = published.filter(
412
+ (locale) => !(locale in (project.clock ?? {}))
413
+ );
414
+ if (missing.length > 0) {
415
+ throw new Error(
416
+ `Project "${project.siteName}": clock has no ${missing.join(", ")}. A project that declares a clock gives one for every locale it publishes.`
417
+ );
418
+ }
419
+ const extra = Object.keys(project.clock).filter(
420
+ (locale) => !published.includes(locale)
421
+ );
422
+ if (extra.length > 0) {
423
+ throw new Error(
424
+ `Project "${project.siteName}": clock names ${extra.join(", ")}, which this project does not publish.`
425
+ );
426
+ }
427
+ }
372
428
  return project;
373
429
  }
package/src/site/api.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import type { GeneratedFile } from "../file.ts";
8
8
  import type { PublicFilePath } from "../files.ts";
9
9
  import type { Fonts } from "../fonts.ts";
10
+ import type { HourCycle } from "../hours.ts";
10
11
  import type { TranslateFor } from "../i18n/translate.ts";
11
12
  import type { LlmsSection } from "../llms.ts";
12
13
  import type { ThemeColor } from "../meta/index.ts";
@@ -219,6 +220,15 @@ export interface Site<
219
220
  bind?: { readonly values?: Readonly<Record<Values, string>> }
220
221
  ): PlainTextFor<Catalog, Values>;
221
222
 
223
+ /**
224
+ * The clock a locale's pages print times on, as the project's `clock`
225
+ * states it: hand it to `formatHours`, or to anything formatting a time.
226
+ *
227
+ * Throws for a project that declares no clock: one that prints times has
228
+ * to say how.
229
+ */
230
+ clockFor(locale: L): HourCycle;
231
+
222
232
  /** Absolute path of a route in one locale, e.g. `/el/sxetika-me-emas`. */
223
233
  pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath;
224
234
  /** The same, prefixed with the site origin — for canonicals and sitemaps. */
@@ -14,6 +14,7 @@ import {
14
14
  import type { GeneratedFile } from "../file.ts";
15
15
  import type { PublicFilePath } from "../files.ts";
16
16
  import { mergeFonts } from "../fonts.ts";
17
+ import type { HourCycle } from "../hours.ts";
17
18
  import { type BaseCatalog, mergeCatalog } from "../i18n/define.ts";
18
19
  import { createTranslateFactory } from "../i18n/translate.ts";
19
20
  import type { ImageAsset } from "../image.ts";
@@ -192,6 +193,18 @@ export function createSite<
192
193
 
193
194
  const resolved = mergeRoutes<L, Routes>(routes, project.overrideRoutes);
194
195
 
196
+ function clockFor(locale: L): HourCycle {
197
+ const cycle = project.clock?.[locale];
198
+ if (cycle === undefined) {
199
+ throw new Error(
200
+ project.clock === undefined
201
+ ? `Project "${project.siteName}" declares no clock, so it cannot print a time. Add \`clock\`, with an hour cycle for each locale it publishes.`
202
+ : `Project "${project.siteName}": clock has no ${locale}.`
203
+ );
204
+ }
205
+ return cycle;
206
+ }
207
+
195
208
  function pathFor(id: RouteId, locale: L, options?: LinkOptions): UrlPath {
196
209
  const route = resolved[id];
197
210
  if (route === undefined) {
@@ -867,6 +880,7 @@ export function createSite<
867
880
  ...bind,
868
881
  }),
869
882
 
883
+ clockFor,
870
884
  pathFor,
871
885
  urlFor,
872
886
  fileUrl,