@escape-game-over/atlas 0.1.3 → 0.1.5

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,314 @@
1
+ /**
2
+ * Every country a form can offer, as ISO 3166-1 alpha-2.
3
+ *
4
+ * Codes rather than names, which is the point on a multilingual site:
5
+ * `countriesFor` turns each one into the reader's own word for it and sorts
6
+ * them the way that language sorts. A list of English names would put "Czech
7
+ * Republic" in front of a Romanian reader and file it under C.
8
+ *
9
+ * The code is also what a form should submit, so an enquiry from Germany files
10
+ * the same whether it was picked as "Deutschland" or "Germany".
11
+ *
12
+ * Derived from ICU's region list rather than typed out: every two-letter code
13
+ * it answers to, less the withdrawn and transitional reservations it still
14
+ * knows (`SU`, `YU`, `DD`, `ZR`, `AN`, `UK`…) and the aggregates that are not
15
+ * countries (`EU`, `UN`, `QO`…). 250 entries: the 249 ISO assigns, plus `XK`
16
+ * for Kosovo, which is user-assigned and in common use.
17
+ *
18
+ * **This is a list, not a policy.** It says which codes are well-formed, not
19
+ * which countries a business will trade with — that is a per-project answer and
20
+ * belongs in the project, by filtering this.
21
+ */
22
+ export const COUNTRY_CODES = [
23
+ "AD",
24
+ "AE",
25
+ "AF",
26
+ "AG",
27
+ "AI",
28
+ "AL",
29
+ "AM",
30
+ "AO",
31
+ "AQ",
32
+ "AR",
33
+ "AS",
34
+ "AT",
35
+ "AU",
36
+ "AW",
37
+ "AX",
38
+ "AZ",
39
+ "BA",
40
+ "BB",
41
+ "BD",
42
+ "BE",
43
+ "BF",
44
+ "BG",
45
+ "BH",
46
+ "BI",
47
+ "BJ",
48
+ "BL",
49
+ "BM",
50
+ "BN",
51
+ "BO",
52
+ "BQ",
53
+ "BR",
54
+ "BS",
55
+ "BT",
56
+ "BV",
57
+ "BW",
58
+ "BY",
59
+ "BZ",
60
+ "CA",
61
+ "CC",
62
+ "CD",
63
+ "CF",
64
+ "CG",
65
+ "CH",
66
+ "CI",
67
+ "CK",
68
+ "CL",
69
+ "CM",
70
+ "CN",
71
+ "CO",
72
+ "CR",
73
+ "CU",
74
+ "CV",
75
+ "CW",
76
+ "CX",
77
+ "CY",
78
+ "CZ",
79
+ "DE",
80
+ "DJ",
81
+ "DK",
82
+ "DM",
83
+ "DO",
84
+ "DZ",
85
+ "EC",
86
+ "EE",
87
+ "EG",
88
+ "EH",
89
+ "ER",
90
+ "ES",
91
+ "ET",
92
+ "FI",
93
+ "FJ",
94
+ "FK",
95
+ "FM",
96
+ "FO",
97
+ "FR",
98
+ "GA",
99
+ "GB",
100
+ "GD",
101
+ "GE",
102
+ "GF",
103
+ "GG",
104
+ "GH",
105
+ "GI",
106
+ "GL",
107
+ "GM",
108
+ "GN",
109
+ "GP",
110
+ "GQ",
111
+ "GR",
112
+ "GS",
113
+ "GT",
114
+ "GU",
115
+ "GW",
116
+ "GY",
117
+ "HK",
118
+ "HM",
119
+ "HN",
120
+ "HR",
121
+ "HT",
122
+ "HU",
123
+ "ID",
124
+ "IE",
125
+ "IL",
126
+ "IM",
127
+ "IN",
128
+ "IO",
129
+ "IQ",
130
+ "IR",
131
+ "IS",
132
+ "IT",
133
+ "JE",
134
+ "JM",
135
+ "JO",
136
+ "JP",
137
+ "KE",
138
+ "KG",
139
+ "KH",
140
+ "KI",
141
+ "KM",
142
+ "KN",
143
+ "KP",
144
+ "KR",
145
+ "KW",
146
+ "KY",
147
+ "KZ",
148
+ "LA",
149
+ "LB",
150
+ "LC",
151
+ "LI",
152
+ "LK",
153
+ "LR",
154
+ "LS",
155
+ "LT",
156
+ "LU",
157
+ "LV",
158
+ "LY",
159
+ "MA",
160
+ "MC",
161
+ "MD",
162
+ "ME",
163
+ "MF",
164
+ "MG",
165
+ "MH",
166
+ "MK",
167
+ "ML",
168
+ "MM",
169
+ "MN",
170
+ "MO",
171
+ "MP",
172
+ "MQ",
173
+ "MR",
174
+ "MS",
175
+ "MT",
176
+ "MU",
177
+ "MV",
178
+ "MW",
179
+ "MX",
180
+ "MY",
181
+ "MZ",
182
+ "NA",
183
+ "NC",
184
+ "NE",
185
+ "NF",
186
+ "NG",
187
+ "NI",
188
+ "NL",
189
+ "NO",
190
+ "NP",
191
+ "NR",
192
+ "NU",
193
+ "NZ",
194
+ "OM",
195
+ "PA",
196
+ "PE",
197
+ "PF",
198
+ "PG",
199
+ "PH",
200
+ "PK",
201
+ "PL",
202
+ "PM",
203
+ "PN",
204
+ "PR",
205
+ "PS",
206
+ "PT",
207
+ "PW",
208
+ "PY",
209
+ "QA",
210
+ "RE",
211
+ "RO",
212
+ "RS",
213
+ "RU",
214
+ "RW",
215
+ "SA",
216
+ "SB",
217
+ "SC",
218
+ "SD",
219
+ "SE",
220
+ "SG",
221
+ "SH",
222
+ "SI",
223
+ "SJ",
224
+ "SK",
225
+ "SL",
226
+ "SM",
227
+ "SN",
228
+ "SO",
229
+ "SR",
230
+ "SS",
231
+ "ST",
232
+ "SV",
233
+ "SX",
234
+ "SY",
235
+ "SZ",
236
+ "TC",
237
+ "TD",
238
+ "TF",
239
+ "TG",
240
+ "TH",
241
+ "TJ",
242
+ "TK",
243
+ "TL",
244
+ "TM",
245
+ "TN",
246
+ "TO",
247
+ "TR",
248
+ "TT",
249
+ "TV",
250
+ "TW",
251
+ "TZ",
252
+ "UA",
253
+ "UG",
254
+ "UM",
255
+ "US",
256
+ "UY",
257
+ "UZ",
258
+ "VA",
259
+ "VC",
260
+ "VE",
261
+ "VG",
262
+ "VI",
263
+ "VN",
264
+ "VU",
265
+ "WF",
266
+ "WS",
267
+ "XK",
268
+ "YE",
269
+ "YT",
270
+ "ZA",
271
+ "ZM",
272
+ "ZW",
273
+ ] as const;
274
+
275
+ /**
276
+ * An ISO 3166-1 alpha-2 country code: `"IT"`, `"GR"`, `"RO"`.
277
+ *
278
+ * Read off the list above rather than described as a shape. `${Letter}${Letter}`
279
+ * admits all 676 two-letter strings, of which some 400 are not countries, so it
280
+ * accepted `"XX"` and every typo that happened to be two capitals. This accepts
281
+ * the 250 that exist and nothing else — including refusing the withdrawn codes
282
+ * (`SU`, `YU`, `AN`), which look plausible and are the ones worth catching.
283
+ *
284
+ * A name — `"Italy"`, `"Ιταλία"` — is a translation of a country rather than an
285
+ * identifier for one, and belongs in the sentence a page prints. `countriesFor`
286
+ * is what turns one of these into that.
287
+ */
288
+ export type CountryCode = (typeof COUNTRY_CODES)[number];
289
+
290
+ /** One country, in one language. */
291
+ export interface NamedCountry {
292
+ readonly code: CountryCode;
293
+ readonly name: string;
294
+ }
295
+
296
+ /**
297
+ * The list as one locale reads it: each code with its own name, A to Z in that
298
+ * language.
299
+ *
300
+ * Both halves are locale-dependent, so neither can be cached across locales —
301
+ * "Ägypten" sorts second in German and "Egypt" fifth in English, and a build
302
+ * that sorted once would ship one language's order to all of them.
303
+ *
304
+ * `Intl.DisplayNames` falls back to the code itself for anything it does not
305
+ * know, which is what keeps this total: an option always has a label.
306
+ */
307
+ export function countriesFor(locale: string): readonly NamedCountry[] {
308
+ const names = new Intl.DisplayNames([locale], { type: "region" });
309
+ const collator = new Intl.Collator(locale);
310
+ return COUNTRY_CODES.map((code) => ({
311
+ code,
312
+ name: names.of(code) ?? code,
313
+ })).sort((a, b) => collator.compare(a.name, b.name));
314
+ }
package/src/index.ts CHANGED
@@ -60,6 +60,11 @@ export {
60
60
  type MailEndpoint,
61
61
  sendContactMessage,
62
62
  } from "./contact-form.ts";
63
+ export {
64
+ COUNTRY_CODES,
65
+ countriesFor,
66
+ type NamedCountry,
67
+ } from "./countries.ts";
63
68
  export type { GeneratedFile } from "./file.ts";
64
69
  export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
65
70
  export {
@@ -101,6 +106,8 @@ export {
101
106
  breadcrumbList,
102
107
  businessId,
103
108
  type CatalogEntry,
109
+ type FaqEntry,
110
+ faqPage,
104
111
  geoCoordinates,
105
112
  type JsonLdNode,
106
113
  type LocalBusinessInput,
@@ -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;