@escape-game-over/atlas 0.1.67 → 0.1.69

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/docs/checks.md CHANGED
@@ -124,7 +124,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
124
124
  | `breadcrumbs.test.ts` | the trail, a dropped ancestor, `orphanSegments`, and how a gap is reported |
125
125
  | `jsonld/*.test.ts` | that a `</script>` in any value cannot close the block, each node's shape and `@id`, and how a price table becomes offers |
126
126
  | `analytics.test.ts` | Umami's attributes, its three-state booleans, the `domains` list that would record nothing, and that `consentRequired` answers exactly when a tag was emitted |
127
- | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` — and where `googleEvent` sends, per setting |
127
+ | `google-analytics.test.ts` | that consent is denied first and precedes every tag, one loader for many ids, dead `UA-` properties, and the preconnect — once, ahead of the block that writes the loader's URL, never `crossorigin` |
128
128
  | `contact.test.ts` | the E.164 a `tel:` needs — trunk zero dropped, spacing stripped — and the displayed form kept |
129
129
  | `hours.test.ts` | collapsing a week into runs, week start changing the answer, and every impossible week that throws |
130
130
  | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -34,7 +34,7 @@
34
34
  "README.md"
35
35
  ],
36
36
  "engines": {
37
- "node": ">=22.12.0"
37
+ "node": ">=24.0.0"
38
38
  },
39
39
  "scripts": {
40
40
  "test": "npm run test:types && npm run test:unit && npm run test:examples",
@@ -54,14 +54,14 @@
54
54
  "@types/node": ">=22",
55
55
  "astro": ">=7",
56
56
  "typescript": ">=5",
57
- "vite": ">=8"
57
+ "vite": ">= 8"
58
58
  },
59
59
  "devDependencies": {
60
- "@biomejs/biome": "2.5.14",
61
- "@types/node": "26.6.2",
62
- "@vitest/coverage-istanbul": "5.0.1",
60
+ "@biomejs/biome": "2.5.15",
61
+ "@types/node": "26.6.3",
62
+ "@vitest/coverage-istanbul": "5.0.3",
63
63
  "astro": "7.3.5",
64
64
  "typescript": "6.0.3",
65
- "vitest": "5.0.1"
65
+ "vitest": "5.0.3"
66
66
  }
67
67
  }
@@ -5,9 +5,12 @@
5
5
  * (`closedby="any"`). Close it with `<form method="dialog"><button>…</button></form>`.
6
6
  *
7
7
  * ```astro
8
- * <AutoDialog id="promo" aria-label="…" after={2000} once="session">…</AutoDialog>
8
+ * <AutoDialog id="promo" aria-label="…" when={{ on: "delay", ms: 2000 }} once="session">…</AutoDialog>
9
9
  * ```
10
10
  *
11
+ * With `when={{ on: "visible" }}` it opens when the place it is written
12
+ * scrolls into view.
13
+ *
11
14
  * A dialog opened only by a button or a script needs none of this: write
12
15
  * `<dialog closedby="any">`.
13
16
  */
@@ -20,8 +23,8 @@ type Props = AutoOpen &
20
23
  readonly id: string;
21
24
  };
22
25
 
23
- const { after, once, waitForConsent, modal, ...attrs } = Astro.props;
24
- const auto: AutoOpen = { after, once, waitForConsent, modal };
26
+ const { when, once, waitForConsent, modal, ...attrs } = Astro.props;
27
+ const auto: AutoOpen = { when, once, waitForConsent, modal };
25
28
  ---
26
29
 
27
30
  <script src="./auto-dialog.ts" />
@@ -31,6 +34,11 @@ const auto: AutoOpen = { after, once, waitForConsent, modal };
31
34
  data-auto={JSON.stringify(auto)}
32
35
  style="display:contents"
33
36
  >
37
+ <!-- Out of the flow, so it adds no gap to a flex or grid parent. -->
38
+ <span
39
+ data-spot
40
+ style="position:absolute"
41
+ ></span>
34
42
  <dialog
35
43
  closedby="any"
36
44
  {...attrs}
@@ -78,11 +78,11 @@ if (meta.consentVendors.length > 0 && !Astro.slots.has("consent")) {
78
78
  <MetaTags tags={meta.tags} />
79
79
  {rememberLocale && <RememberLocale />}
80
80
  {Object.entries(fonts).map(([key, font]) => (
81
- <Font
82
- cssVariable={`--${key}`}
83
- preload={font.preload === true}
84
- />
85
- ))}
81
+ <Font
82
+ cssVariable={`--${key}`}
83
+ preload={font.preload === true}
84
+ />
85
+ ))}
86
86
  <slot name="head" />
87
87
  </head>
88
88
  <body {...body}>
@@ -1,17 +1,29 @@
1
1
  import { CONSENT_EVENT, CONSENT_TAG, readConsent } from "./consent.ts";
2
2
  import { element } from "./element.ts";
3
+ import { type LoadTrigger, triggered } from "./load-script.ts";
3
4
  import { data, ref } from "./ref.ts";
4
5
 
6
+ /**
7
+ * `loadScript`'s triggers, as a dialog uses them. `visible` watches where the
8
+ * `<AutoDialog>` sits in the page, so placing it in a section opens it as the
9
+ * visitor scrolls there. A dialog opened by a click is a plain `<dialog>`.
10
+ */
11
+ export type OpenTrigger =
12
+ | { readonly on: "idle" }
13
+ | { readonly on: "delay"; readonly ms: number }
14
+ | { readonly on: "visible"; readonly margin?: string }
15
+ | { readonly on: "interaction" };
16
+
5
17
  /** When an `<AutoDialog>` opens by itself. */
6
18
  export interface AutoOpen {
7
- /** Milliseconds after load, or after the consent answer with `waitForConsent`. */
8
- readonly after: number;
19
+ /** Counted from `load`, or from the consent answer with `waitForConsent`. */
20
+ readonly when: OpenTrigger;
9
21
  /**
10
22
  * Once closed, stays closed for the tab (`session`) or for good (`visitor`).
11
23
  * Omitted, it opens on every page.
12
24
  */
13
25
  readonly once?: "session" | "visitor";
14
- /** Holds the timer until the consent banner is answered, where it asks. */
26
+ /** Holds the trigger until the consent banner is answered, where it asks. */
15
27
  readonly waitForConsent?: boolean;
16
28
  /** Defaults to `true`; `false` leaves the page usable behind it. */
17
29
  readonly modal?: boolean;
@@ -20,10 +32,14 @@ export interface AutoOpen {
20
32
  const storage = (once: "session" | "visitor"): Storage =>
21
33
  once === "session" ? sessionStorage : localStorage;
22
34
 
23
- /** Opens `dialog` as `options` say, until `signal` aborts. */
35
+ /**
36
+ * Opens `dialog` as `options` say, until `signal` aborts. `spot` is where the
37
+ * dialog sits in the page, which a `visible` trigger watches.
38
+ */
24
39
  export function autoOpen(
25
40
  dialog: HTMLDialogElement,
26
- { after, once, waitForConsent = false, modal = true }: AutoOpen,
41
+ spot: Element,
42
+ { when, once, waitForConsent = false, modal = true }: AutoOpen,
27
43
  signal: AbortSignal
28
44
  ): void {
29
45
  const key = `atlas-dialog:${dialog.id}`;
@@ -44,13 +60,14 @@ export function autoOpen(
44
60
  );
45
61
  }
46
62
 
63
+ const trigger: LoadTrigger =
64
+ when.on === "visible" ? { ...when, element: spot } : when;
47
65
  const start = () => {
48
- const timer = setTimeout(() => {
66
+ void triggered(trigger, signal).then(() => {
49
67
  if (dialog.open) return;
50
68
  if (modal) dialog.showModal();
51
69
  else dialog.show();
52
- }, after);
53
- signal.addEventListener("abort", () => clearTimeout(timer));
70
+ });
54
71
  };
55
72
 
56
73
  const asking =
@@ -66,6 +83,7 @@ export function autoOpen(
66
83
  export const autoDialog = element("atlas-dialog", ({ root, signal }) => {
67
84
  autoOpen(
68
85
  ref<HTMLDialogElement>(root, "dialog"),
86
+ ref(root, "[data-spot]"),
69
87
  JSON.parse(data(root, "auto")),
70
88
  signal
71
89
  );
package/src/config.ts CHANGED
@@ -2,20 +2,21 @@ import type { Fonts } from "./fonts.ts";
2
2
  import type { LlmsConfig } from "./llms.ts";
3
3
  import type { RobotsConfig } from "./robots.ts";
4
4
  import type { SitemapConfig } from "./sitemap.ts";
5
- import type { StringKeys } from "./types.ts";
5
+ import type { NoExcessKeys, StringKeys } from "./types.ts";
6
6
 
7
7
  export interface LocaleMeta {
8
8
  /** Name of the language, written in that language. */
9
9
  readonly label: string;
10
10
  // No language tag here: the locale's own key is the tag. One identifier,
11
11
  // used for the URL segment, `lang`, `hreflang` and `og:locale` alike.
12
- readonly dir: "ltr" | "rtl";
12
+ // No `dir` either: writing direction follows from the tag too.
13
13
  }
14
14
 
15
15
  /** `LocaleMeta` after defaults are filled in. */
16
16
  export interface ResolvedLocaleMeta {
17
17
  readonly label: string;
18
18
  readonly htmlLang: string;
19
+ /** Derived: the writing direction of the tag's script, from `Intl`. */
19
20
  readonly dir: "ltr" | "rtl";
20
21
  /** Derived: `htmlLang` with `-` swapped for `_`, which is the OG form. */
21
22
  readonly ogLocale: string;
@@ -55,7 +56,7 @@ interface MalformedLanguageTag<T extends string> {
55
56
  type ValidateLocales<T> = {
56
57
  [K in StringKeys<T>]: [LanguageTag<K>] extends [never]
57
58
  ? MalformedLanguageTag<K>
58
- : LocaleMeta;
59
+ : LocaleMeta & NoExcessKeys<T[K], keyof LocaleMeta>;
59
60
  };
60
61
 
61
62
  export interface RoutingConfig<L extends string> {
@@ -156,10 +157,9 @@ export function defineSiteConfig<
156
157
  /**
157
158
  * What a project may restate about a locale.
158
159
  *
159
- * `dir` is deliberately not here: writing direction is a fact about the script a
160
- * language is written in, not a choice a deployment makes. Greek is
161
- * left-to-right for everybody, and a project that could say otherwise would only
162
- * ever be wrong. The label is the one part that is a matter of taste.
160
+ * Only the label: writing direction is a fact about the script a language is
161
+ * written in, not a choice a deployment makes, so it is derived from the tag.
162
+ * The label is the one part that is a matter of taste.
163
163
  */
164
164
  export type LocaleMetaOverride = Pick<LocaleMeta, "label">;
165
165
 
@@ -184,6 +184,8 @@ export interface LocaleNotEnabled<L extends string> {
184
184
  *
185
185
  * The locale key is the language tag, and `ogLocale` is that tag with `-`
186
186
  * swapped for `_` — Open Graph writes `en_US` where BCP 47 writes `en-US`.
187
+ * `dir` comes from the tag's script, so `ar-EG` and `az-Arab-IR` are
188
+ * right-to-left and `az-AZ` is not.
187
189
  */
188
190
  export function mergeLocaleMeta<L extends string>(
189
191
  base: Readonly<Record<L, LocaleMeta>>,
@@ -199,10 +201,20 @@ export function mergeLocaleMeta<L extends string>(
199
201
  const meta = { ...defaults, ...overrides?.[locale] };
200
202
  merged[locale] = {
201
203
  label: meta.label,
202
- dir: meta.dir,
204
+ dir: writingDirection(locale),
203
205
  htmlLang: locale,
204
206
  ogLocale: locale.replace(/-/g, "_"),
205
207
  };
206
208
  }
207
209
  return merged as Readonly<Record<L, ResolvedLocaleMeta>>;
208
210
  }
211
+
212
+ function writingDirection(tag: string): "ltr" | "rtl" {
213
+ const direction = new Intl.Locale(tag).getTextInfo().direction;
214
+ if (direction === undefined) {
215
+ throw new Error(
216
+ `This runtime cannot tell the writing direction of "${tag}".`
217
+ );
218
+ }
219
+ return direction;
220
+ }