@escape-game-over/atlas 0.1.29 → 0.1.31

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/NOT-BUILT.md CHANGED
@@ -500,27 +500,22 @@ looking at" better than any link can.
500
500
 
501
501
  ### Should `/en-US/about-us` redirect when the default locale is unprefixed?
502
502
 
503
- **No. Only `/en-US` itself does.**
503
+ **Yes, by one wildcard — not by a rule per page.**
504
504
 
505
505
  With `prefixDefaultLocale: false` the default locale is served at `/about-us`
506
- and nothing is built under `/en-US/`. Claiming the prefixed root is one rule and
507
- answers the URL people actually type, having seen every other language wear a
508
- prefix. Claiming the pages under it is a different proposition: a rule per page,
509
- and slugs are translated per locale, so the URL a reader lands on by editing
510
- `/el-GR/sxetika-me-emas` down to English keeps the *Greek* slug —
506
+ and nothing is built under `/en-US/`. Claiming the pages under it one rule at a
507
+ time does not work: slugs are translated per locale, so the URL a reader lands
508
+ on by editing `/el-GR/sxetika-me-emas` down to English keeps the *Greek* slug —
511
509
  `/en-US/sxetika-me-emas`. Covering that means the cross product of the prefix
512
- with every locale's spelling of every slug, which is a `_redirects` of hundreds
513
- against the 2000 a host reads, all of it for URLs nothing has ever linked.
514
-
515
- This was built first, in the one-rule-per-default-locale-page form, which is
516
- worth naming because it looks complete and is not: it answers `/en-US/about-us`
517
- and misses `/en-US/sxetika-me-emas`, so the case that motivated it is the case
518
- it does not cover.
519
-
520
- A wildcard would sidestep the count — `/en-US/* /:splat 301` is one line — but
521
- not the translated slug, and it is Cloudflare's syntax rather than something
522
- `ResolvedRedirect` can carry to another host. The pages get the 404 page, which
523
- is what it is for.
510
+ with every locale's spelling of every slug, a `_redirects` of hundreds against
511
+ the 2000 a host reads.
512
+
513
+ `/en-US/* /:splat 301` is one line instead. It still does not cover the
514
+ translated slug — `/en-US/sxetika-me-emas` goes to `/sxetika-me-emas`, which
515
+ 404s — but that URL 404ed without the rule too, so it is never worse and it
516
+ answers every English slug. It is Cloudflare's syntax, which is what every site
517
+ deploys to. It is written last, so a stated `/en-US/old` rule still fires, and
518
+ left out if anything is built under the prefix.
524
519
 
525
520
  ---
526
521
 
package/docs/toolchain.md CHANGED
@@ -93,14 +93,16 @@ slug or a switched-off page moves the rule with the page:
93
93
  | -------------- | ------------- | ---------------------------------------------- |
94
94
  | `/` | `/en-US` | `prefixDefaultLocale: true` leaves `/` unowned |
95
95
  | `/en-US` | `/` | `prefixDefaultLocale: false` leaves it unbuilt |
96
+ | `/en-US/*` | `/:splat` | the same, for every page under the prefix |
96
97
  | `/news/page/1` | `/news` | page one is the bare slug, never `page/1` |
97
98
 
98
99
  The first two are the same rule in its two directions: whichever root the
99
100
  routing mode does not serve points at the one it does. Both together cost one
100
- rule; the third costs one per paginated list per language. Nothing here scales
101
- with the number of pages a site has — see [`NOT-BUILT.md`](NOT-BUILT.md) for why
102
- the prefixed form of every *page* is left to the 404 instead. A project that
103
- states its own rule for one of these paths keeps it: the inferred one steps
101
+ rule. The wildcard costs one more, and it is written last in the file, because
102
+ Cloudflare fires the first rule that matches and a stated `/en-US/old` rule has
103
+ to be reached before it. The page-one rule costs one per paginated list per
104
+ language. Nothing here scales with the number of pages a site has. A project
105
+ that states its own rule for one of these paths keeps it: the inferred one steps
104
106
  aside.
105
107
 
106
108
  The root points at whichever route has an empty slug, so nothing has to name a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -62,6 +62,17 @@ export interface CarouselOptions {
62
62
  * renders, and calling would make a project write the first position twice.
63
63
  */
64
64
  readonly onChange: (index: number) => void;
65
+ /**
66
+ * Called with `autoplayMs` whenever a countdown to the next automatic
67
+ * advance begins, and with `null` when autoplay stops.
68
+ *
69
+ * For a progress bar. A countdown begins on attach, after every tick and
70
+ * after every manual move, and each one starts from zero — so the bar
71
+ * restarts on every number and stops on `null`, and never has to know why.
72
+ * A hold (hover, focus, a hidden tab, `pause`) and a detach report `null`
73
+ * once, not once per reason.
74
+ */
75
+ readonly onCountdown?: (ms: number | null) => void;
65
76
  }
66
77
 
67
78
  export interface Carousel {
@@ -107,6 +118,7 @@ export function carousel(options: CarouselOptions): Carousel {
107
118
  swipeThreshold = 10,
108
119
  lockMs = 500,
109
120
  onChange,
121
+ onCountdown,
110
122
  } = options;
111
123
 
112
124
  /**
@@ -143,9 +155,19 @@ export function carousel(options: CarouselOptions): Carousel {
143
155
  */
144
156
  const holds = new Set<string>();
145
157
 
158
+ /** Whether a countdown is running, so `null` is reported once per stop. */
159
+ let counting = false;
160
+
161
+ const stopAutoplay = (): void => {
162
+ clearInterval(autoplayTimer);
163
+ if (!counting) return;
164
+ counting = false;
165
+ onCountdown?.(null);
166
+ };
167
+
146
168
  const hold = (reason: string): void => {
147
169
  holds.add(reason);
148
- clearInterval(autoplayTimer);
170
+ stopAutoplay();
149
171
  };
150
172
 
151
173
  const release = (reason: string): void => {
@@ -171,16 +193,28 @@ export function carousel(options: CarouselOptions): Carousel {
171
193
  };
172
194
 
173
195
  const restartAutoplay = (): void => {
174
- clearInterval(autoplayTimer);
175
196
  // Nothing to rotate through, nobody watching, or no autoplay asked
176
197
  // for — in each case, nothing to schedule.
177
- if (autoplayMs === undefined || !canMove || !attached) return;
178
- if (holds.size > 0) return;
198
+ if (
199
+ autoplayMs === undefined ||
200
+ !canMove ||
201
+ !attached ||
202
+ holds.size > 0
203
+ ) {
204
+ stopAutoplay();
205
+ return;
206
+ }
207
+ clearInterval(autoplayTimer);
179
208
  autoplayTimer = setInterval(() => {
180
209
  // No `restartAutoplay` here: the interval already paces itself, and
181
210
  // resetting it from inside its own tick would only churn timers.
182
211
  if (claim()) move(index + 1);
212
+ // Reported even when the lock refused the move: the next tick is
213
+ // still `autoplayMs` away either way.
214
+ onCountdown?.(autoplayMs);
183
215
  }, autoplayMs);
216
+ counting = true;
217
+ onCountdown?.(autoplayMs);
184
218
  };
185
219
 
186
220
  /** The only place `index` changes. `at` may be out of range or negative. */
@@ -326,7 +360,7 @@ export function carousel(options: CarouselOptions): Carousel {
326
360
  return () => {
327
361
  listeners.abort();
328
362
  attached = false;
329
- clearInterval(autoplayTimer);
363
+ stopAutoplay();
330
364
 
331
365
  // The automatic holds belong to this attachment: an element
332
366
  // detached while hovered would otherwise keep "hover" forever,
package/src/site/api.ts CHANGED
@@ -309,14 +309,11 @@ export interface Site<
309
309
  * **What comes back is more than what went in.** lib adds the rules the
310
310
  * route table implies, for the URLs its own design leaves unpublished but
311
311
  * reachable: whichever site root the routing mode does not serve — `/` when
312
- * every locale is prefixed, `/en-US` when the default locale is not — and
313
- * `/news/page/1` for a list whose page one is the bare path. A rule you
314
- * state for one of those paths replaces the inferred one, so pass `[]` and
315
- * you still get a file worth writing.
316
- *
317
- * Pages under an unused locale prefix are *not* claimed: that is a rule per
318
- * page per language, and it still misses the reader who edits a translated
319
- * slug's prefix. See `NOT-BUILT.md`.
312
+ * every locale is prefixed, `/en-US` when the default locale is not — the
313
+ * pages under that unused `/en-US/`, as one `/en-US/* /:splat` wildcard
314
+ * written last, and `/news/page/1` for a list whose page one is the bare
315
+ * path. A rule you state for one of those paths replaces the inferred one,
316
+ * so pass `[]` and you still get a file worth writing.
320
317
  *
321
318
  * Returns the rules as data. Rendering is a separate step —
322
319
  * `buildCloudflareRedirects` writes the `_redirects` that Cloudflare and
@@ -513,10 +513,8 @@ export function createSite<
513
513
  * switched-off page moves the rule with it instead of leaving one pointing
514
514
  * at a 404.
515
515
  *
516
- * Two, and deliberately not a third per *page*: mirroring every URL a
517
- * project builds is a rule per page, and with slugs translated per locale
518
- * it is a rule per page per language — a file of hundreds against the 2000
519
- * a host reads, for URLs nothing ever linked. See `NOT-BUILT.md`.
516
+ * The pages under an unused prefix are `prefixSplat`'s, as one wildcard
517
+ * rather than a rule per page per language. See `NOT-BUILT.md`.
520
518
  *
521
519
  * Both permanent. Neither is a state that changes while the config stays as
522
520
  * it is: page one will never live at `/page/1`, and the root will not move
@@ -570,6 +568,32 @@ export function createSite<
570
568
  return [...root, ...pageOne];
571
569
  }
572
570
 
571
+ /**
572
+ * `/en-US/about` → `/about` when the default locale is unprefixed: the
573
+ * pages under the prefix `inferredRedirects` points at the root, in one
574
+ * Cloudflare wildcard. A translated slug under the wrong prefix still ends
575
+ * on the 404, one hop later — no worse than without it.
576
+ *
577
+ * Kept apart because it has to be written last: Cloudflare fires the first
578
+ * rule that matches, and a wildcard ahead of a stated `/en-US/old` rule
579
+ * would swallow it. Skipped when anything is built under the prefix, which
580
+ * the wildcard would make unreachable.
581
+ */
582
+ function prefixSplat(): readonly ResolvedRedirect[] {
583
+ if (prefixDefaultLocale) return [];
584
+ const prefix = `/${defaultLocale}/`;
585
+ for (const path of builtPaths) {
586
+ if (path.startsWith(prefix)) return [];
587
+ }
588
+ return [
589
+ {
590
+ from: `${prefix}*`,
591
+ to: "/:splat",
592
+ status: statusFor("permanent"),
593
+ },
594
+ ];
595
+ }
596
+
573
597
  function redirects(
574
598
  rules: readonly RedirectRule<RouteId, L>[]
575
599
  ): readonly ResolvedRedirect[] {
@@ -590,6 +614,7 @@ export function createSite<
590
614
  }));
591
615
 
592
616
  const claimed = new Set(stated.map((rule) => rule.from));
617
+ const splat = prefixSplat().filter((rule) => !claimed.has(rule.from));
593
618
  const inferred = inferredRedirects().filter(
594
619
  // Dropped rather than reported, both times, because neither is a
595
620
  // mistake anyone made: a rule the project wrote for one of these
@@ -604,7 +629,7 @@ export function createSite<
604
629
  // So a rule that shadows a real page is rejected rather than
605
630
  // sitting dead in the file.
606
631
  builtPaths,
607
- rules: [...inferred, ...stated],
632
+ rules: [...inferred, ...stated, ...splat],
608
633
  });
609
634
  }
610
635