@escape-game-over/atlas 0.1.1 → 0.1.2

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.
@@ -129,6 +129,16 @@ export function pageSegmentFor<L extends string>(
129
129
  return ctx.pageSegmentByLocale?.[locale] ?? ctx.pageSegment;
130
130
  }
131
131
 
132
+ /** A list's base path with a page number hung under the page segment. */
133
+ function underPageSegment(
134
+ base: UrlPath,
135
+ segment: string,
136
+ page: number
137
+ ): UrlPath {
138
+ // The locale root is `/`, and joining onto it would double the slash.
139
+ return base === "/" ? `/${segment}/${page}` : `${base}/${segment}/${page}`;
140
+ }
141
+
132
142
  /**
133
143
  * The path of one page of a route's list.
134
144
  *
@@ -144,9 +154,24 @@ export function pagePath<L extends string>(
144
154
  ): UrlPath {
145
155
  if (page <= 1) return base;
146
156
 
147
- const segment = pageSegmentFor(locale, ctx);
148
- // The locale root is `/`, and joining onto it would double the slash.
149
- return base === "/" ? `/${segment}/${page}` : `${base}/${segment}/${page}`;
157
+ return underPageSegment(base, pageSegmentFor(locale, ctx), page);
158
+ }
159
+
160
+ /**
161
+ * The `.../page/1` a list is deliberately *not* published at.
162
+ *
163
+ * The counterpart of the rule above: giving page one the bare path leaves the
164
+ * numbered form unclaimed, and unclaimed is not unasked-for — a reader edits
165
+ * `/news/page/2` down to `1`, or a crawler assumes a sequence starts where its
166
+ * segment says. So this is the one path here that exists to be redirected away
167
+ * from rather than linked, and nothing but `redirects()` should name it.
168
+ */
169
+ export function pageOneAlias<L extends string>(
170
+ base: UrlPath,
171
+ locale: L,
172
+ ctx: PathContext<L>
173
+ ): UrlPath {
174
+ return underPageSegment(base, pageSegmentFor(locale, ctx), 1);
150
175
  }
151
176
 
152
177
  export function localePrefix<L extends string>(
package/src/site/api.ts CHANGED
@@ -292,6 +292,18 @@ export interface Site<
292
292
  * a retranslated slug moves it too. External targets are `https://` URLs and
293
293
  * pass through untouched.
294
294
  *
295
+ * **What comes back is more than what went in.** lib adds the rules the
296
+ * route table implies, for the URLs its own design leaves unpublished but
297
+ * reachable: whichever site root the routing mode does not serve — `/` when
298
+ * every locale is prefixed, `/en-US` when the default locale is not — and
299
+ * `/news/page/1` for a list whose page one is the bare path. A rule you
300
+ * state for one of those paths replaces the inferred one, so pass `[]` and
301
+ * you still get a file worth writing.
302
+ *
303
+ * Pages under an unused locale prefix are *not* claimed: that is a rule per
304
+ * page per language, and it still misses the reader who edits a translated
305
+ * slug's prefix. See `NOT-BUILT.md`.
306
+ *
295
307
  * Returns the rules as data. Rendering is a separate step —
296
308
  * `buildCloudflareRedirects` writes the `_redirects` that Cloudflare and
297
309
  * Netlify read, and a host with its own syntax takes these and writes its own.
@@ -34,6 +34,7 @@ import {
34
34
  listRouteEntries,
35
35
  mergeRoutes,
36
36
  type PathContext,
37
+ pageOneAlias,
37
38
  pagePath,
38
39
  type RouteEntry,
39
40
  slugFor,
@@ -218,8 +219,31 @@ export function createSite<
218
219
  });
219
220
  }
220
221
 
222
+ /**
223
+ * The sitemap, built once and handed to everyone who asks.
224
+ *
225
+ * Three callers, and two of them want one string out of it: `robots()` and
226
+ * `llms()` each advertise `entry.url`, so an unmemoised call derived every
227
+ * URL on the site — each with its alternates, each joined to the origin —
228
+ * three times over to answer a question about a filename. `siteRoutes`
229
+ * then does the whole thing again on every dev-server request that hits a
230
+ * generated file.
231
+ *
232
+ * Safe to hold because everything it reads is settled before this closure
233
+ * exists: `entries`, `localeMeta` and the origin are fixed for the life of
234
+ * a `Site`, and `alternatesFor` reads nothing else. A dev server picks up
235
+ * an edited route by importing the module again and getting a *new* site,
236
+ * not by this one answering differently — so there is nothing here for a
237
+ * cache to go stale against.
238
+ *
239
+ * Sharing the `Sitemap` itself is safe for the same reason, with one part
240
+ * that would not be: `staticPaths` maps a fresh array per call, which is
241
+ * what an SSG router demands of it.
242
+ */
243
+ let built: Sitemap | undefined;
221
244
  function sitemap(): Sitemap {
222
- return buildSitemap<L, RouteId>({
245
+ if (built !== undefined) return built;
246
+ built = buildSitemap<L, RouteId>({
223
247
  siteUrl,
224
248
  name: config_.sitemap?.name ?? "sitemap.xml",
225
249
  entryLimit: config_.sitemap?.entryLimit,
@@ -227,6 +251,7 @@ export function createSite<
227
251
  localeMeta,
228
252
  alternatesFor,
229
253
  });
254
+ return built;
230
255
  }
231
256
 
232
257
  // Registry declaration order, filtered to what this project builds.
@@ -242,12 +267,24 @@ export function createSite<
242
267
  pathContext
243
268
  ) as readonly RouteEntry<L, RouteId>[];
244
269
 
245
- // In prefix-everything mode no route owns `/`; the locale root stands in for
246
- // it, provided some route actually claims that path.
270
+ // The page the site's root resolves to, wherever routing puts it: `/` when
271
+ // the default locale is unprefixed, `/en-US` when it is not. Undefined
272
+ // unless a route actually claims that path — nothing has to own the root.
247
273
  const localeRootPath = buildPath("", defaultLocale, pathContext);
248
- const rootEntry = prefixDefaultLocale
249
- ? entries.find((entry) => entry.path === localeRootPath)
250
- : undefined;
274
+ const rootEntry = entries.find(
275
+ (entry) =>
276
+ entry.locale === defaultLocale && entry.path === localeRootPath
277
+ );
278
+
279
+ /**
280
+ * Every path this build serves.
281
+ *
282
+ * Read by `redirects()` twice over: to reject a stated rule that shadows a
283
+ * real page, and to keep an inferred one from doing the same.
284
+ */
285
+ const builtPaths: ReadonlySet<string> = new Set(
286
+ entries.map((entry) => entry.path)
287
+ );
251
288
 
252
289
  function staticPaths(param: string): StaticPath<L, RouteId>[] {
253
290
  const paths: StaticPath<L, RouteId>[] = entries.map((entry) => ({
@@ -450,55 +487,108 @@ export function createSite<
450
487
  return joinUrl(siteUrl, path);
451
488
  }
452
489
 
453
- function redirects(
454
- rules: readonly RedirectRule<RouteId, L>[]
455
- ): readonly ResolvedRedirect[] {
456
- // When every locale is prefixed, nothing owns `/` — so lib claims it,
457
- // with a real 301 to the default locale's root.
490
+ /**
491
+ * The rules lib writes for itself, from the route table rather than from
492
+ * anything a project states.
493
+ *
494
+ * Both answer one question: which URL that this build publishes nothing at
495
+ * will be asked for anyway? Each is a path the design deliberately does not
496
+ * serve — the root the routing mode leaves unowned, and the page one that
497
+ * is not numbered — and a path nobody serves on purpose is still a path
498
+ * somebody types. Both are read off `entries`, so a retranslated slug or a
499
+ * switched-off page moves the rule with it instead of leaving one pointing
500
+ * at a 404.
501
+ *
502
+ * Two, and deliberately not a third per *page*: mirroring every URL a
503
+ * project builds is a rule per page, and with slugs translated per locale
504
+ * it is a rule per page per language — a file of hundreds against the 2000
505
+ * a host reads, for URLs nothing ever linked. See `NOT-BUILT.md`.
506
+ *
507
+ * Both permanent. Neither is a state that changes while the config stays as
508
+ * it is: page one will never live at `/page/1`, and the root will not move
509
+ * while `prefixDefaultLocale` is what it is.
510
+ */
511
+ function inferredRedirects(): readonly ResolvedRedirect[] {
512
+ const status = statusFor("permanent");
513
+
514
+ // One root is served and the other is not, and which is which is what
515
+ // `prefixDefaultLocale` decides: prefixed, the site answers on `/en-US`
516
+ // and nothing owns `/`; unprefixed, it answers on `/` and `/en-US` is a
517
+ // URL nobody built — the one people still type, having seen every other
518
+ // language wear its prefix. Whichever of the two this build does not
519
+ // serve is pointed at the one it does.
458
520
  //
459
521
  // A redirect rather than a page: a meta-refresh stub at `/` is a soft
460
522
  // redirect, which search engines follow slowly and weigh less, and it
461
523
  // costs a render before the reader goes anywhere. The two cannot both
462
524
  // exist, since a static host serves the file and the rule never fires —
463
- // which is what `builtPaths` below rejects.
464
- // Named by route id like any other rule, so the target is derived from
465
- // the route table and follows a retranslated slug.
466
- const root: readonly RedirectRule<RouteId, L>[] =
525
+ // which is what `builtPaths` rejects.
526
+ const root: readonly ResolvedRedirect[] =
467
527
  rootEntry === undefined
468
528
  ? []
469
529
  : [
470
530
  {
471
- from: "/",
472
- to: {
473
- route: rootEntry.routeId,
474
- locale: rootEntry.locale,
475
- },
476
- // The root will never own a page again while every
477
- // locale is prefixed, which is what permanent means.
478
- kind: "permanent",
531
+ from: prefixDefaultLocale ? "/" : `/${defaultLocale}`,
532
+ to: rootEntry.path,
533
+ status,
479
534
  },
480
535
  ];
481
536
 
537
+ // `/news/page/1` for every list that runs past one page, in every
538
+ // language it is published in — see `pageOneAlias` for who asks for it.
539
+ //
540
+ // Only a route that *has* a second page: `1` or omitted is an ordinary
541
+ // route, and `/about/page/1` is a URL nobody has had a reason to type.
542
+ // The alias for a list that once ran longer and no longer does is the
543
+ // one this misses, and it is not worth a rule per page of the site.
544
+ const pageOne = entries
545
+ .filter(
546
+ (entry) =>
547
+ entry.page === 1 &&
548
+ (resolved[entry.routeId]?.pages ?? 1) > 1
549
+ )
550
+ .map((entry) => ({
551
+ from: pageOneAlias(entry.path, entry.locale, pathContext),
552
+ to: entry.path,
553
+ status,
554
+ }));
555
+
556
+ return [...root, ...pageOne];
557
+ }
558
+
559
+ function redirects(
560
+ rules: readonly RedirectRule<RouteId, L>[]
561
+ ): readonly ResolvedRedirect[] {
562
+ const stated: readonly ResolvedRedirect[] = rules.map((rule) => ({
563
+ from: rule.from,
564
+ // Three kinds of target, told apart by shape: a string is external
565
+ // and passes through, `file` is served verbatim from `public/`, and
566
+ // a route is resolved to whatever URL it has in the locale asked
567
+ // for.
568
+ to: ((): string => {
569
+ if (typeof rule.to === "string") return rule.to;
570
+ if ("file" in rule.to) return rule.to.file;
571
+ return pathFor(rule.to.route, rule.to.locale ?? defaultLocale);
572
+ })(),
573
+ status: statusFor(rule.kind),
574
+ }));
575
+
576
+ const claimed = new Set(stated.map((rule) => rule.from));
577
+ const inferred = inferredRedirects().filter(
578
+ // Dropped rather than reported, both times, because neither is a
579
+ // mistake anyone made: a rule the project wrote for one of these
580
+ // paths is a decision and this is a default, and a page built at
581
+ // one of them is a page, which outranks a mirror of another page.
582
+ // The same clash among *stated* rules still throws below — there,
583
+ // both sides were written on purpose and only one can fire.
584
+ (rule) => !claimed.has(rule.from) && !builtPaths.has(rule.from)
585
+ );
586
+
482
587
  return buildRedirects({
483
- // Every path this build serves, so a rule that shadows a real page
484
- // is rejected rather than sitting dead in the file.
485
- builtPaths: new Set(entries.map((entry) => entry.path)),
486
- rules: [...root, ...rules].map((rule) => ({
487
- from: rule.from,
488
- // Three kinds of target, told apart by shape: a string is
489
- // external and passes through, `file` is served verbatim from
490
- // `public/`, and a route is resolved to whatever URL it has in
491
- // the locale asked for.
492
- to: ((): string => {
493
- if (typeof rule.to === "string") return rule.to;
494
- if ("file" in rule.to) return rule.to.file;
495
- return pathFor(
496
- rule.to.route,
497
- rule.to.locale ?? defaultLocale
498
- );
499
- })(),
500
- status: statusFor(rule.kind),
501
- })),
588
+ // So a rule that shadows a real page is rejected rather than
589
+ // sitting dead in the file.
590
+ builtPaths,
591
+ rules: [...inferred, ...stated],
502
592
  });
503
593
  }
504
594