@escape-game-over/atlas 0.1.1 → 0.1.3

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.
@@ -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) => ({
@@ -285,7 +322,18 @@ export function createSite<
285
322
  // Umami only, tagged so the misses read on their own — the point
286
323
  // of measuring this page is finding a redirect somebody forgot,
287
324
  // not counting the people who mistype. See `notFoundAnalytics`.
288
- tags: buildNotFoundMeta(title, project.analytics),
325
+ //
326
+ // `checkedIcon()` rather than `project.icon`: the 404 is held to the
327
+ // same square-and-PNG rules as every other page. It is also the one
328
+ // page that could plausibly render before any other, so letting it
329
+ // read the icon unchecked would move where a bad one is caught.
330
+ tags: buildNotFoundMeta({
331
+ title,
332
+ analytics: project.analytics,
333
+ icon: checkedIcon(),
334
+ themeColor: project.themeColor,
335
+ colorScheme: project.colorScheme,
336
+ }),
289
337
  // Nothing. The body channel exists for Tag Manager's `<noscript>`,
290
338
  // and no Google tag reaches this page.
291
339
  bodyTags: [],
@@ -450,55 +498,108 @@ export function createSite<
450
498
  return joinUrl(siteUrl, path);
451
499
  }
452
500
 
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.
501
+ /**
502
+ * The rules lib writes for itself, from the route table rather than from
503
+ * anything a project states.
504
+ *
505
+ * Both answer one question: which URL that this build publishes nothing at
506
+ * will be asked for anyway? Each is a path the design deliberately does not
507
+ * serve — the root the routing mode leaves unowned, and the page one that
508
+ * is not numbered — and a path nobody serves on purpose is still a path
509
+ * somebody types. Both are read off `entries`, so a retranslated slug or a
510
+ * switched-off page moves the rule with it instead of leaving one pointing
511
+ * at a 404.
512
+ *
513
+ * Two, and deliberately not a third per *page*: mirroring every URL a
514
+ * project builds is a rule per page, and with slugs translated per locale
515
+ * it is a rule per page per language — a file of hundreds against the 2000
516
+ * a host reads, for URLs nothing ever linked. See `NOT-BUILT.md`.
517
+ *
518
+ * Both permanent. Neither is a state that changes while the config stays as
519
+ * it is: page one will never live at `/page/1`, and the root will not move
520
+ * while `prefixDefaultLocale` is what it is.
521
+ */
522
+ function inferredRedirects(): readonly ResolvedRedirect[] {
523
+ const status = statusFor("permanent");
524
+
525
+ // One root is served and the other is not, and which is which is what
526
+ // `prefixDefaultLocale` decides: prefixed, the site answers on `/en-US`
527
+ // and nothing owns `/`; unprefixed, it answers on `/` and `/en-US` is a
528
+ // URL nobody built — the one people still type, having seen every other
529
+ // language wear its prefix. Whichever of the two this build does not
530
+ // serve is pointed at the one it does.
458
531
  //
459
532
  // A redirect rather than a page: a meta-refresh stub at `/` is a soft
460
533
  // redirect, which search engines follow slowly and weigh less, and it
461
534
  // costs a render before the reader goes anywhere. The two cannot both
462
535
  // 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>[] =
536
+ // which is what `builtPaths` rejects.
537
+ const root: readonly ResolvedRedirect[] =
467
538
  rootEntry === undefined
468
539
  ? []
469
540
  : [
470
541
  {
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",
542
+ from: prefixDefaultLocale ? "/" : `/${defaultLocale}`,
543
+ to: rootEntry.path,
544
+ status,
479
545
  },
480
546
  ];
481
547
 
548
+ // `/news/page/1` for every list that runs past one page, in every
549
+ // language it is published in — see `pageOneAlias` for who asks for it.
550
+ //
551
+ // Only a route that *has* a second page: `1` or omitted is an ordinary
552
+ // route, and `/about/page/1` is a URL nobody has had a reason to type.
553
+ // The alias for a list that once ran longer and no longer does is the
554
+ // one this misses, and it is not worth a rule per page of the site.
555
+ const pageOne = entries
556
+ .filter(
557
+ (entry) =>
558
+ entry.page === 1 &&
559
+ (resolved[entry.routeId]?.pages ?? 1) > 1
560
+ )
561
+ .map((entry) => ({
562
+ from: pageOneAlias(entry.path, entry.locale, pathContext),
563
+ to: entry.path,
564
+ status,
565
+ }));
566
+
567
+ return [...root, ...pageOne];
568
+ }
569
+
570
+ function redirects(
571
+ rules: readonly RedirectRule<RouteId, L>[]
572
+ ): readonly ResolvedRedirect[] {
573
+ const stated: readonly ResolvedRedirect[] = rules.map((rule) => ({
574
+ from: rule.from,
575
+ // Three kinds of target, told apart by shape: a string is external
576
+ // and passes through, `file` is served verbatim from `public/`, and
577
+ // a route is resolved to whatever URL it has in the locale asked
578
+ // for.
579
+ to: ((): string => {
580
+ if (typeof rule.to === "string") return rule.to;
581
+ if ("file" in rule.to) return rule.to.file;
582
+ return pathFor(rule.to.route, rule.to.locale ?? defaultLocale);
583
+ })(),
584
+ status: statusFor(rule.kind),
585
+ }));
586
+
587
+ const claimed = new Set(stated.map((rule) => rule.from));
588
+ const inferred = inferredRedirects().filter(
589
+ // Dropped rather than reported, both times, because neither is a
590
+ // mistake anyone made: a rule the project wrote for one of these
591
+ // paths is a decision and this is a default, and a page built at
592
+ // one of them is a page, which outranks a mirror of another page.
593
+ // The same clash among *stated* rules still throws below — there,
594
+ // both sides were written on purpose and only one can fire.
595
+ (rule) => !claimed.has(rule.from) && !builtPaths.has(rule.from)
596
+ );
597
+
482
598
  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
- })),
599
+ // So a rule that shadows a real page is rejected rather than
600
+ // sitting dead in the file.
601
+ builtPaths,
602
+ rules: [...inferred, ...stated],
502
603
  });
503
604
  }
504
605