@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.
- package/README.md +17 -1
- package/docs/NOT-BUILT.md +49 -26
- package/docs/checks.md +20 -5
- package/docs/client-scripts.md +307 -0
- package/package.json +10 -4
- package/src/astro/filters-view.ts +253 -0
- package/src/astro/filters.ts +79 -31
- package/src/contact-form.ts +1 -1
- package/src/contact.ts +7 -6
- package/src/countries.ts +314 -0
- package/src/index.ts +7 -0
- package/src/jsonld/faq.ts +105 -0
- package/src/jsonld/index.ts +1 -0
- package/src/jsonld/video.ts +2 -2
package/src/countries.ts
ADDED
|
@@ -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
|
+
}
|
package/src/jsonld/index.ts
CHANGED
|
@@ -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,
|
package/src/jsonld/video.ts
CHANGED
|
@@ -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 `…/
|
|
15
|
-
* `…/
|
|
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;
|