@escape-game-over/atlas 0.1.2 → 0.1.4

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.
@@ -0,0 +1,105 @@
1
+ import { warn } from "../warn.ts";
2
+ import type { JsonLdNode } from "./node.ts";
3
+
4
+ /** One question and the answer the page gives for it. */
5
+ export interface FaqEntry {
6
+ readonly question: string;
7
+ /**
8
+ * The answer, as plain text.
9
+ *
10
+ * Google accepts a small amount of HTML here — `<p>`, `<br>`, `<ol>`,
11
+ * `<ul>`, `<li>`, `<a>`, `<b>`, `<strong>`, `<i>`, `<em>` — and drops
12
+ * everything else. Plain text is what a caller passing a translated string
13
+ * has anyway, and it cannot be silently half-rendered, so nothing here
14
+ * builds markup for you. Pass the markup yourself if the answer needs it.
15
+ *
16
+ * Checked August 2026.
17
+ */
18
+ readonly answer: string;
19
+ }
20
+
21
+ /**
22
+ * The questions a page answers, as a `FAQPage`.
23
+ *
24
+ * **Read this before calling it: it renders nowhere, and it is not free.** This
25
+ * node was in `docs/NOT-BUILT.md` until it was asked for, and the reasoning
26
+ * there is the reason it stays opt-in rather than something `metaFor()` emits.
27
+ *
28
+ * Google restricted FAQ rich results to *"well-known, authoritative government
29
+ * and health websites"* in 2023, then removed them from Search entirely on
30
+ * 7 May 2026 and archived the documentation. There is no expandable Q&A under a
31
+ * commercial result any more, and no flag to earn one back.
32
+ *
33
+ * **A model does not read it either** — the argument for emitting it anyway,
34
+ * and it does not hold. JSON-LD sits in a `<script>` block, and the
35
+ * HTML-to-text pipelines feeding a model routinely drop `<script>` and often
36
+ * the whole `<head>`. Independent testing through 2025–26 repeatedly found that
37
+ * content present *only* in structured data goes unextracted, while the same
38
+ * content in visible headings and paragraphs is read reliably. A FAQ page's
39
+ * questions are already visible text; that is what gets read.
40
+ *
41
+ * **What it can still buy.** Schema is parsed at *indexing* time, so it reaches
42
+ * the surfaces built on a search index rather than only those fetching the page.
43
+ * Bing has said outright that its LLMs read schema, which is the live
44
+ * third-party payoff and the reason this exists at all.
45
+ *
46
+ * **The cost, which is the part that decides it.** Every other node here is
47
+ * metadata *about* a page — an address, a price, a date. This one is a verbatim
48
+ * *copy of* it: every question and every full answer a second time, in a script
49
+ * block on the page already showing them. A ten-question FAQ is several KB
50
+ * duplicated on every load, and page weight is paid per visitor, forever. Call
51
+ * this where that trade has been made deliberately, not by default.
52
+ *
53
+ * **Only questions the page actually shows.** The node is a description of the
54
+ * page, not a second copy of a FAQ that lives elsewhere: Google's first quality
55
+ * rule is that the content "must be visible to the user on the source page",
56
+ * and a node listing answers a reader cannot find is the kind of mismatch that
57
+ * earns a manual action rather than a warning. A page rendering a subset — the
58
+ * questions it has answers for, say — passes that subset.
59
+ *
60
+ * No `@id`: nothing in a graph refers to a `FAQPage`, and an id is for being
61
+ * pointed at. See the note at the top of `ids.ts`.
62
+ *
63
+ * Throws on an empty list rather than emitting an empty node. `mainEntity` is
64
+ * required, so a `FAQPage` holding no questions is not a thin node — it is an
65
+ * invalid one, saying the page is a FAQ and then naming nothing it answers.
66
+ * Whether a page has questions to show is the caller's to know, and a page with
67
+ * none should leave the node out of its graph rather than pass an empty list
68
+ * and hope.
69
+ *
70
+ * Reference, now archived rather than current guidance:
71
+ * <https://developers.google.com/search/docs/appearance/structured-data/faqpage>
72
+ *
73
+ * @param at What to name when this is wrong — the page's canonical URL.
74
+ */
75
+ export function faqPage(entries: readonly FaqEntry[], at: string): JsonLdNode {
76
+ if (entries.length === 0) {
77
+ throw new Error(
78
+ `${at}: a FAQPage needs at least one question. Leave the node out of the graph on a page that shows none, rather than emitting one that answers nothing.`
79
+ );
80
+ }
81
+
82
+ const seen = new Set<string>();
83
+ for (const entry of entries) {
84
+ const key = entry.question.trim().toLowerCase();
85
+ if (seen.has(key)) {
86
+ warn(
87
+ at,
88
+ `two questions read "${entry.question}". Google merges a repeated question rather than listing it twice, so the second answer is dropped.`
89
+ );
90
+ }
91
+ seen.add(key);
92
+ }
93
+
94
+ return {
95
+ "@type": "FAQPage",
96
+ mainEntity: entries.map((entry) => ({
97
+ "@type": "Question",
98
+ name: entry.question,
99
+ acceptedAnswer: {
100
+ "@type": "Answer",
101
+ text: entry.answer,
102
+ },
103
+ })),
104
+ };
105
+ }
@@ -32,6 +32,7 @@
32
32
  export { type ArticleInput, article } from "./article.ts";
33
33
  export { type BreadcrumbStep, breadcrumbList } from "./breadcrumb.ts";
34
34
  export { type LocalBusinessInput, localBusiness } from "./business.ts";
35
+ export { type FaqEntry, faqPage } from "./faq.ts";
35
36
  export {
36
37
  type BusinessId,
37
38
  businessId,
@@ -11,8 +11,8 @@ interface VideoDetails {
11
11
  * The site's own origin, which the thumbnails are made absolute against.
12
12
  *
13
13
  * An origin rather than the page's URL: `joinUrl` puts a path on the end of
14
- * what it is given, so handing it `…/challenges/cardio` would produce
15
- * `…/challenges/cardio/thumb.png`. The same bargain `localBusiness` strikes,
14
+ * what it is given, so handing it `…/rooms/blue-room` would produce
15
+ * `…/rooms/blue-room/thumb.png`. The same bargain `localBusiness` strikes,
16
16
  * named for what it actually needs.
17
17
  */
18
18
  readonly origin: HttpsUrl;
package/src/meta/index.ts CHANGED
@@ -78,7 +78,7 @@ export type MetaInput<L extends string> = Omit<MetaContentBase, "image"> &
78
78
  PageKind &
79
79
  MetaInputDerived<L>;
80
80
 
81
- interface MetaInputDerived<L extends string> {
81
+ interface MetaInputDerived<L extends string> extends ChromeInput {
82
82
  /** The locale of *this* page, not the site default. */
83
83
  readonly locale: L;
84
84
  readonly canonical: HttpsUrl;
@@ -119,8 +119,6 @@ interface MetaInputDerived<L extends string> {
119
119
  * Graph equivalent to fall back to. Site-level, like `siteName`.
120
120
  */
121
121
  readonly twitterSite?: string;
122
- /** The site's square icon, used for every icon link. */
123
- readonly icon: SiteIcon;
124
122
  /**
125
123
  * Webmaster-tool ownership tokens. Site-level, like `siteName`.
126
124
  *
@@ -144,6 +142,20 @@ interface MetaInputDerived<L extends string> {
144
142
  * generated — a link to a missing one is worse than no link.
145
143
  */
146
144
  readonly llmsUrl?: HttpsUrl;
145
+ }
146
+
147
+ /**
148
+ * The site's own furniture, as opposed to anything about a page.
149
+ *
150
+ * Its own interface because it is what the 404 shares with every real page and
151
+ * nearly all it shares: that document has no canonical, no description, no
152
+ * alternates and no share image, but it is still served under the site's name
153
+ * in the site's tab, and a reader who lands on it should not be able to tell
154
+ * from the chrome that they left.
155
+ */
156
+ export interface ChromeInput {
157
+ /** The site's square icon, used for every icon link. */
158
+ readonly icon: SiteIcon;
147
159
  /**
148
160
  * Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
149
161
  * Two values emit one tag per `prefers-color-scheme`.
@@ -172,6 +184,45 @@ export interface DocumentTags {
172
184
  readonly body: MetaTag[];
173
185
  }
174
186
 
187
+ /**
188
+ * What the browser dresses its own furniture with: the tab's icon, the tint of
189
+ * the address bar, and which schemes the page renders form controls for.
190
+ *
191
+ * Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
192
+ * around it. Sharing the code is the point rather than a convenience: these are
193
+ * the tags a reader sees *as* the site — a 404 with a different tab icon looks
194
+ * like it came from somewhere else, which is the opposite of what a 404 is for —
195
+ * and two copies of a four-branch block is how one of them quietly stops
196
+ * matching the other.
197
+ */
198
+ function chromeTags(input: ChromeInput): MetaTag[] {
199
+ const tags: MetaTag[] = [...iconLinks(input.icon)];
200
+
201
+ if (input.colorScheme !== undefined) {
202
+ tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
203
+ }
204
+ if (typeof input.themeColor === "string") {
205
+ tags.push(meta({ name: "theme-color", content: input.themeColor }));
206
+ } else if (input.themeColor !== undefined) {
207
+ tags.push(
208
+ meta({
209
+ name: "theme-color",
210
+ media: "(prefers-color-scheme: light)",
211
+ content: input.themeColor.light,
212
+ })
213
+ );
214
+ tags.push(
215
+ meta({
216
+ name: "theme-color",
217
+ media: "(prefers-color-scheme: dark)",
218
+ content: input.themeColor.dark,
219
+ })
220
+ );
221
+ }
222
+
223
+ return tags;
224
+ }
225
+
175
226
  /** Builds the head tags every page needs. */
176
227
  export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
177
228
  const tags: MetaTag[] = [
@@ -209,29 +260,7 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
209
260
  tags.push(link({ rel: "describedby", href: input.llmsUrl }));
210
261
  }
211
262
 
212
- for (const iconLink of iconLinks(input.icon)) tags.push(iconLink);
213
-
214
- if (input.colorScheme !== undefined) {
215
- tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
216
- }
217
- if (typeof input.themeColor === "string") {
218
- tags.push(meta({ name: "theme-color", content: input.themeColor }));
219
- } else if (input.themeColor !== undefined) {
220
- tags.push(
221
- meta({
222
- name: "theme-color",
223
- media: "(prefers-color-scheme: light)",
224
- content: input.themeColor.light,
225
- })
226
- );
227
- tags.push(
228
- meta({
229
- name: "theme-color",
230
- media: "(prefers-color-scheme: dark)",
231
- content: input.themeColor.dark,
232
- })
233
- );
234
- }
263
+ for (const tag of chromeTags(input)) tags.push(tag);
235
264
 
236
265
  for (const alternate of input.alternates) {
237
266
  tags.push(
@@ -400,6 +429,20 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
400
429
  return { head: tags, body: bodyTags };
401
430
  }
402
431
 
432
+ /**
433
+ * What a 404 needs, which is the site's furniture and a title and nothing else.
434
+ *
435
+ * An object rather than the positional arguments this took before: the two it
436
+ * gained are both site-level and both optional-looking at a call site, and
437
+ * `buildNotFoundMeta(title, icon, themeColor, analytics)` is four positions
438
+ * where three of them are interchangeable to a reader.
439
+ */
440
+ export interface NotFoundInput extends ChromeInput {
441
+ readonly title: string;
442
+ /** Site-level, and only Umami reaches this page — see `notFoundAnalytics`. */
443
+ readonly analytics?: AnalyticsSettings;
444
+ }
445
+
403
446
  /**
404
447
  * Builds the head of a 404 page.
405
448
  *
@@ -412,13 +455,16 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
412
455
  * host serves this file at the address that was requested, so what gets
413
456
  * recorded is the missing path itself, which is the difference between knowing
414
457
  * there are 404s and knowing which redirect to write.
458
+ *
459
+ * It also carries the site's chrome, through the same `chromeTags` every real
460
+ * page goes through. A 404 is the one page a visitor reaches by accident, and
461
+ * the tab it opens in is how they judge whether they are still on the site they
462
+ * meant to be on — an unstyled tab showing the browser's default globe reads as
463
+ * a different origin, or as nothing at all.
415
464
  */
416
- export function buildNotFoundMeta(
417
- title: string,
418
- analytics?: AnalyticsSettings
419
- ): MetaTag[] {
465
+ export function buildNotFoundMeta(input: NotFoundInput): MetaTag[] {
420
466
  return [
421
- ...preamble({ title }),
467
+ ...preamble({ title: input.title }),
422
468
  // Through the same builder as every other page, so the one document lib
423
469
  // writes for itself cannot spell the tag differently from the ones it
424
470
  // writes for a project. Links are still followed: a 404 often carries
@@ -427,6 +473,7 @@ export function buildNotFoundMeta(
427
473
  name: "robots",
428
474
  content: robotsContent({ index: false, follow: true }),
429
475
  }),
430
- ...notFoundAnalytics(analytics).map(asMetaTag),
476
+ ...notFoundAnalytics(input.analytics).map(asMetaTag),
477
+ ...chromeTags(input),
431
478
  ];
432
479
  }
@@ -322,7 +322,18 @@ export function createSite<
322
322
  // Umami only, tagged so the misses read on their own — the point
323
323
  // of measuring this page is finding a redirect somebody forgot,
324
324
  // not counting the people who mistype. See `notFoundAnalytics`.
325
- 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
+ }),
326
337
  // Nothing. The body channel exists for Tag Manager's `<noscript>`,
327
338
  // and no Google tag reaches this page.
328
339
  bodyTags: [],