@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.
- package/README.md +17 -1
- package/docs/NOT-BUILT.md +33 -18
- package/docs/checks.md +20 -5
- package/docs/client-scripts.md +307 -0
- package/package.json +11 -4
- package/src/astro/build-cache.ts +80 -0
- package/src/astro/filters-view.ts +253 -0
- package/src/astro/filters.ts +505 -0
- package/src/astro/images.ts +73 -0
- package/src/astro/site-routes.ts +15 -0
- package/src/contact-form.ts +1 -1
- package/src/index.ts +2 -0
- package/src/jsonld/faq.ts +105 -0
- package/src/jsonld/index.ts +1 -0
- package/src/jsonld/video.ts +2 -2
- package/src/meta/index.ts +79 -32
- package/src/site/create.ts +12 -1
|
@@ -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;
|
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
|
|
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
|
}
|
package/src/site/create.ts
CHANGED
|
@@ -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
|
-
|
|
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: [],
|