@escape-game-over/atlas 0.1.1
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 +364 -0
- package/bin/use-project.mjs +131 -0
- package/docs/NOT-BUILT.md +329 -0
- package/docs/checks.md +139 -0
- package/docs/share-images.md +52 -0
- package/docs/toolchain.md +83 -0
- package/package.json +51 -0
- package/src/analytics/google.ts +351 -0
- package/src/analytics/index.ts +102 -0
- package/src/analytics/tags.ts +57 -0
- package/src/analytics/umami.ts +285 -0
- package/src/astro/MetaTags.astro +87 -0
- package/src/astro/consent.ts +165 -0
- package/src/astro/images.ts +315 -0
- package/src/astro/index.ts +44 -0
- package/src/astro/public-files.ts +129 -0
- package/src/astro/site-routes.ts +307 -0
- package/src/config.ts +218 -0
- package/src/contact.ts +233 -0
- package/src/file.ts +16 -0
- package/src/files.ts +39 -0
- package/src/hours.ts +312 -0
- package/src/i18n/define.ts +217 -0
- package/src/i18n/placeholders.ts +94 -0
- package/src/i18n/translate.ts +190 -0
- package/src/image.ts +29 -0
- package/src/index.ts +222 -0
- package/src/jsonld/article.ts +165 -0
- package/src/jsonld/breadcrumb.ts +34 -0
- package/src/jsonld/business.ts +196 -0
- package/src/jsonld/ids.ts +106 -0
- package/src/jsonld/index.ts +59 -0
- package/src/jsonld/node.ts +78 -0
- package/src/jsonld/organization.ts +154 -0
- package/src/jsonld/place.ts +96 -0
- package/src/jsonld/product.ts +172 -0
- package/src/jsonld/quantity.ts +55 -0
- package/src/jsonld/service.ts +237 -0
- package/src/jsonld/video.ts +239 -0
- package/src/jsonld/website.ts +58 -0
- package/src/llms.ts +160 -0
- package/src/meta/content.ts +190 -0
- package/src/meta/index.ts +432 -0
- package/src/meta/robots.ts +212 -0
- package/src/meta/share-image.ts +232 -0
- package/src/meta/tag.ts +133 -0
- package/src/meta/verification.ts +53 -0
- package/src/money.ts +237 -0
- package/src/project.ts +249 -0
- package/src/redirects.ts +266 -0
- package/src/robots.ts +80 -0
- package/src/routes/define.ts +412 -0
- package/src/routes/family.ts +251 -0
- package/src/routes/resolve.ts +266 -0
- package/src/site/api.ts +354 -0
- package/src/site/create.ts +660 -0
- package/src/site/index.ts +32 -0
- package/src/site/page.ts +148 -0
- package/src/sitemap.ts +257 -0
- package/src/types.ts +160 -0
- package/src/url.ts +144 -0
- package/src/warn.ts +88 -0
- package/src/xml.ts +103 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document head, built once from what a page and its project already know.
|
|
3
|
+
*
|
|
4
|
+
* Centralised because these tags are easy to get subtly wrong and invisible when
|
|
5
|
+
* they are: `og:url` must equal the canonical, `x-default` must point at the
|
|
6
|
+
* default locale, every alternate must use the locale's `hreflang` rather than
|
|
7
|
+
* its key, and a page calling itself an article must carry the date that makes
|
|
8
|
+
* the claim worth anything. Deriving them all in one pass from one input is what
|
|
9
|
+
* stops any two of them disagreeing.
|
|
10
|
+
*
|
|
11
|
+
* The pieces:
|
|
12
|
+
*
|
|
13
|
+
* - `tag.ts` — what a head tag is as data, and the opening tags every document
|
|
14
|
+
* shares. lib describes tags; the consumer renders them.
|
|
15
|
+
* - `content.ts` — what only a page can supply: its words, its picture, and
|
|
16
|
+
* whether it is an article.
|
|
17
|
+
* - `robots.ts` — the `robots` *meta tag*, which is not `lib/robots.ts`.
|
|
18
|
+
* - `share-image.ts` — the share image and the site icon, resolved and checked.
|
|
19
|
+
* - `verification.ts` — webmaster-tool ownership tokens.
|
|
20
|
+
*
|
|
21
|
+
* Anything lib does not model belongs in the consumer's own layout, rendered
|
|
22
|
+
* after these — there is no passthrough slot here for data lib cannot reason
|
|
23
|
+
* about.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
type AnalyticsSettings,
|
|
28
|
+
analyticsScripts,
|
|
29
|
+
notFoundAnalytics,
|
|
30
|
+
} from "../analytics/index.ts";
|
|
31
|
+
import type { ResolvedLocaleMeta } from "../config.ts";
|
|
32
|
+
import {
|
|
33
|
+
article,
|
|
34
|
+
type BreadcrumbStep,
|
|
35
|
+
breadcrumbList,
|
|
36
|
+
type JsonLdNode,
|
|
37
|
+
serializeJsonLd,
|
|
38
|
+
} from "../jsonld/index.ts";
|
|
39
|
+
import type { HttpsUrl } from "../url.ts";
|
|
40
|
+
import type { MetaContentBase, PageKind, ThemeColor } from "./content.ts";
|
|
41
|
+
import { robotsContent, warnAboutRobots } from "./robots.ts";
|
|
42
|
+
import {
|
|
43
|
+
iconLinks,
|
|
44
|
+
type OgImage,
|
|
45
|
+
type SiteIcon,
|
|
46
|
+
warnAboutShareImage,
|
|
47
|
+
warnIfClipped,
|
|
48
|
+
} from "./share-image.ts";
|
|
49
|
+
import { asMetaTag, link, type MetaTag, meta, preamble } from "./tag.ts";
|
|
50
|
+
import { type SiteVerification, verificationTags } from "./verification.ts";
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
ArticleContent,
|
|
54
|
+
MetaContent,
|
|
55
|
+
OpenGraphType,
|
|
56
|
+
PageKind,
|
|
57
|
+
ThemeColor,
|
|
58
|
+
} from "./content.ts";
|
|
59
|
+
export type { RobotsDirective, RobotsPolicy } from "./robots.ts";
|
|
60
|
+
export {
|
|
61
|
+
type ImageFormat,
|
|
62
|
+
type OgImage,
|
|
63
|
+
resolveShareImage,
|
|
64
|
+
type ShareImage,
|
|
65
|
+
type SiteIcon,
|
|
66
|
+
} from "./share-image.ts";
|
|
67
|
+
export type { MetaTag } from "./tag.ts";
|
|
68
|
+
export type { SiteVerification } from "./verification.ts";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Everything the head needs: what the page supplies, plus what lib derives.
|
|
72
|
+
*
|
|
73
|
+
* `PageKind` is intersected rather than inherited so that `Omit` applies to the
|
|
74
|
+
* base alone: omitting a key from a union would flatten it and lose the
|
|
75
|
+
* exclusivity that union exists for.
|
|
76
|
+
*/
|
|
77
|
+
export type MetaInput<L extends string> = Omit<MetaContentBase, "image"> &
|
|
78
|
+
PageKind &
|
|
79
|
+
MetaInputDerived<L>;
|
|
80
|
+
|
|
81
|
+
interface MetaInputDerived<L extends string> {
|
|
82
|
+
/** The locale of *this* page, not the site default. */
|
|
83
|
+
readonly locale: L;
|
|
84
|
+
readonly canonical: HttpsUrl;
|
|
85
|
+
readonly alternates: readonly {
|
|
86
|
+
readonly locale: L;
|
|
87
|
+
readonly url: HttpsUrl;
|
|
88
|
+
}[];
|
|
89
|
+
readonly localeMeta: Readonly<Record<L, ResolvedLocaleMeta>>;
|
|
90
|
+
readonly defaultLocale: L;
|
|
91
|
+
/** The share image, already resolved against the site origin. */
|
|
92
|
+
readonly image: OgImage;
|
|
93
|
+
/**
|
|
94
|
+
* `ArticleContent.images`, already resolved against the site origin.
|
|
95
|
+
*
|
|
96
|
+
* A sibling of `article` rather than a field inside it, because `PageKind`
|
|
97
|
+
* is a union and `Omit` on a union flattens it — which is the whole reason
|
|
98
|
+
* `MetaInput` intersects that union separately instead of omitting through
|
|
99
|
+
* it. So the resolved form arrives beside the page's own, exactly as
|
|
100
|
+
* `image` does: a page states `ShareImage`, lib hands `buildMeta` an
|
|
101
|
+
* `OgImage`.
|
|
102
|
+
*/
|
|
103
|
+
readonly articleImages?: readonly HttpsUrl[];
|
|
104
|
+
/**
|
|
105
|
+
* The page's breadcrumb trail, root first, or nothing.
|
|
106
|
+
*
|
|
107
|
+
* Empty and absent mean the same thing here — a page with no ancestors —
|
|
108
|
+
* and both emit no `BreadcrumbList`. Which pages those are is `siteFor`'s
|
|
109
|
+
* `breadcrumbFor` to decide; this only asks whether it was handed a trail.
|
|
110
|
+
*/
|
|
111
|
+
readonly breadcrumb?: readonly BreadcrumbStep[];
|
|
112
|
+
/**
|
|
113
|
+
* `og:site_name`: the publisher's human-readable name. Site-level, so it
|
|
114
|
+
* comes from the project rather than being restated on every page.
|
|
115
|
+
*/
|
|
116
|
+
readonly siteName: string;
|
|
117
|
+
/**
|
|
118
|
+
* `@handle` of the site. Attributes the card to your brand and has no Open
|
|
119
|
+
* Graph equivalent to fall back to. Site-level, like `siteName`.
|
|
120
|
+
*/
|
|
121
|
+
readonly twitterSite?: string;
|
|
122
|
+
/** The site's square icon, used for every icon link. */
|
|
123
|
+
readonly icon: SiteIcon;
|
|
124
|
+
/**
|
|
125
|
+
* Webmaster-tool ownership tokens. Site-level, like `siteName`.
|
|
126
|
+
*
|
|
127
|
+
* On every page rather than only the home page: a URL-prefix property is
|
|
128
|
+
* verified at whatever URL you point the tool at, and a site whose token
|
|
129
|
+
* lives on one page alone silently loses verification the day that page is
|
|
130
|
+
* renamed.
|
|
131
|
+
*/
|
|
132
|
+
readonly verification?: SiteVerification;
|
|
133
|
+
/**
|
|
134
|
+
* What this deployment records about its traffic. Site-level, like
|
|
135
|
+
* `verification` and for the same reason: it is one property in one
|
|
136
|
+
* dashboard, not a fact about a page.
|
|
137
|
+
*/
|
|
138
|
+
readonly analytics?: AnalyticsSettings;
|
|
139
|
+
/**
|
|
140
|
+
* Absolute URL of the site's `llms.txt`, if it publishes one.
|
|
141
|
+
*
|
|
142
|
+
* Emitted as `rel="describedby"`, the relation llmstxt.org proposes for
|
|
143
|
+
* "the file that describes this page". Undefined when no such file is
|
|
144
|
+
* generated — a link to a missing one is worse than no link.
|
|
145
|
+
*/
|
|
146
|
+
readonly llmsUrl?: HttpsUrl;
|
|
147
|
+
/**
|
|
148
|
+
* Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
|
|
149
|
+
* Two values emit one tag per `prefers-color-scheme`.
|
|
150
|
+
*/
|
|
151
|
+
readonly themeColor: ThemeColor;
|
|
152
|
+
/**
|
|
153
|
+
* Which schemes the page supports, e.g. `"light"` or `"dark light"`.
|
|
154
|
+
*
|
|
155
|
+
* Tells the browser how to render form controls and scrollbars, which is
|
|
156
|
+
* what stops the flash of a light control on a dark page.
|
|
157
|
+
*/
|
|
158
|
+
readonly colorScheme?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The tags a page needs, split by the element they belong in. */
|
|
162
|
+
export interface DocumentTags {
|
|
163
|
+
readonly head: MetaTag[];
|
|
164
|
+
/**
|
|
165
|
+
* For immediately after `<body>`, and usually empty.
|
|
166
|
+
*
|
|
167
|
+
* Its own list because one tag genuinely belongs there — Tag Manager's
|
|
168
|
+
* `<noscript>` iframe, which is ignored in the head. Returning it
|
|
169
|
+
* separately means a layout cannot put it in the wrong place, and cannot
|
|
170
|
+
* silently drop it by rendering only the head.
|
|
171
|
+
*/
|
|
172
|
+
readonly body: MetaTag[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Builds the head tags every page needs. */
|
|
176
|
+
export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
|
|
177
|
+
const tags: MetaTag[] = [
|
|
178
|
+
...preamble({
|
|
179
|
+
title: input.title,
|
|
180
|
+
canonical: input.canonical,
|
|
181
|
+
viewport: input.viewport,
|
|
182
|
+
}),
|
|
183
|
+
meta({ name: "description", content: input.description }),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
warnIfClipped("title", input.title, input.canonical);
|
|
187
|
+
warnIfClipped("description", input.description, input.canonical);
|
|
188
|
+
warnAboutShareImage(input.image, input.canonical);
|
|
189
|
+
|
|
190
|
+
warnAboutRobots(input.robots, input.canonical);
|
|
191
|
+
tags.push(meta({ name: "robots", content: robotsContent(input.robots) }));
|
|
192
|
+
|
|
193
|
+
for (const tag of verificationTags(input.verification)) {
|
|
194
|
+
tags.push(tag);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// In the order `analyticsScripts` returned them, which is the order they
|
|
198
|
+
// have to load in: Google's consent defaults are queued by an inline block
|
|
199
|
+
// that must precede every tag reading them, and a tag that got there first
|
|
200
|
+
// would silently apply the permissive implicit default instead.
|
|
201
|
+
const analytics = analyticsScripts(input.analytics, input.canonical);
|
|
202
|
+
for (const script of analytics.head) tags.push(asMetaTag(script));
|
|
203
|
+
const bodyTags: MetaTag[] = analytics.body.map(asMetaTag);
|
|
204
|
+
|
|
205
|
+
if (input.llmsUrl !== undefined) {
|
|
206
|
+
// Points a client at the file describing this page, per llmstxt.org.
|
|
207
|
+
// `describedby` rather than `alternate`: that one means "the same page
|
|
208
|
+
// in another format", which an index of the whole site is not.
|
|
209
|
+
tags.push(link({ rel: "describedby", href: input.llmsUrl }));
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const alternate of input.alternates) {
|
|
237
|
+
tags.push(
|
|
238
|
+
link({
|
|
239
|
+
rel: "alternate",
|
|
240
|
+
hreflang: input.localeMeta[alternate.locale].htmlLang,
|
|
241
|
+
href: alternate.url,
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const fallback = input.alternates.find(
|
|
247
|
+
(alternate) => alternate.locale === input.defaultLocale
|
|
248
|
+
);
|
|
249
|
+
if (fallback === undefined) {
|
|
250
|
+
// Unreachable through `createSite`, which validates that the default locale
|
|
251
|
+
// is published. Thrown rather than skipped because a silently missing
|
|
252
|
+
// `x-default` is exactly the kind of SEO gap nothing surfaces later.
|
|
253
|
+
throw new Error(
|
|
254
|
+
`No alternate for the default locale "${input.defaultLocale}", so x-default cannot be emitted.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
tags.push(
|
|
258
|
+
link({ rel: "alternate", hreflang: "x-default", href: fallback.url })
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const { url: imageUrl, width, height, mime } = input.image;
|
|
262
|
+
|
|
263
|
+
// Open Graph
|
|
264
|
+
//
|
|
265
|
+
// The type follows the content rather than being stated beside it: a page
|
|
266
|
+
// that described an article and called itself a website would be two claims
|
|
267
|
+
// about one thing, and only one of them could be right.
|
|
268
|
+
tags.push(
|
|
269
|
+
meta({
|
|
270
|
+
property: "og:type",
|
|
271
|
+
content:
|
|
272
|
+
input.article === undefined
|
|
273
|
+
? (input.type ?? "website")
|
|
274
|
+
: "article",
|
|
275
|
+
})
|
|
276
|
+
);
|
|
277
|
+
tags.push(meta({ property: "og:title", content: input.title }));
|
|
278
|
+
tags.push(meta({ property: "og:description", content: input.description }));
|
|
279
|
+
tags.push(meta({ property: "og:site_name", content: input.siteName }));
|
|
280
|
+
// Deliberately the canonical, never the request URL.
|
|
281
|
+
tags.push(meta({ property: "og:url", content: input.canonical }));
|
|
282
|
+
// This page's own locale, with every other published one advertised as an
|
|
283
|
+
// alternate. Emitting the site default here would label every translation
|
|
284
|
+
// with the same language.
|
|
285
|
+
const ogLocaleOf = (locale: L): string => input.localeMeta[locale].ogLocale;
|
|
286
|
+
|
|
287
|
+
tags.push(
|
|
288
|
+
meta({ property: "og:locale", content: ogLocaleOf(input.locale) })
|
|
289
|
+
);
|
|
290
|
+
for (const alternate of input.alternates) {
|
|
291
|
+
if (alternate.locale === input.locale) continue;
|
|
292
|
+
tags.push(
|
|
293
|
+
meta({
|
|
294
|
+
property: "og:locale:alternate",
|
|
295
|
+
content: ogLocaleOf(alternate.locale),
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
tags.push(meta({ property: "og:image", content: imageUrl }));
|
|
301
|
+
if (mime !== undefined) {
|
|
302
|
+
tags.push(meta({ property: "og:image:type", content: mime }));
|
|
303
|
+
}
|
|
304
|
+
tags.push(meta({ property: "og:image:width", content: String(width) }));
|
|
305
|
+
tags.push(meta({ property: "og:image:height", content: String(height) }));
|
|
306
|
+
tags.push(meta({ property: "og:image:alt", content: input.image.alt }));
|
|
307
|
+
|
|
308
|
+
// The dates, from the same object the `Article` node is built from below.
|
|
309
|
+
// A date-only value is a valid ISO 8601 datetime at reduced precision,
|
|
310
|
+
// which is the honest precision here: a venue publishes on a day.
|
|
311
|
+
//
|
|
312
|
+
// No `article:author`. Open Graph wants a URL to a profile page, and what
|
|
313
|
+
// this holds is a schema.org `@id` — a fragment on the site's own origin,
|
|
314
|
+
// which is a node in a graph rather than a page anyone can open. Emitting
|
|
315
|
+
// it would be answering a different question with the wrong value.
|
|
316
|
+
if (input.article !== undefined) {
|
|
317
|
+
tags.push(
|
|
318
|
+
meta({
|
|
319
|
+
property: "article:published_time",
|
|
320
|
+
content: input.article.datePublished,
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
if (input.article.dateModified !== undefined) {
|
|
324
|
+
tags.push(
|
|
325
|
+
meta({
|
|
326
|
+
property: "article:modified_time",
|
|
327
|
+
content: input.article.dateModified,
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
// One tag per property, which is how Open Graph spells an array — not
|
|
332
|
+
// one comma-joined value, which reads as a single very long subject.
|
|
333
|
+
for (const tag of input.article.tags ?? []) {
|
|
334
|
+
tags.push(meta({ property: "article:tag", content: tag }));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Twitter
|
|
339
|
+
if (input.twitterSite !== undefined) {
|
|
340
|
+
// Twitter @handle
|
|
341
|
+
tags.push(meta({ name: "twitter:site", content: input.twitterSite }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
tags.push(
|
|
345
|
+
meta({
|
|
346
|
+
name: "twitter:card",
|
|
347
|
+
content: "summary_large_image",
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
tags.push(meta({ name: "twitter:title", content: input.title }));
|
|
351
|
+
tags.push(
|
|
352
|
+
meta({ name: "twitter:description", content: input.description })
|
|
353
|
+
);
|
|
354
|
+
tags.push(meta({ name: "twitter:image", content: imageUrl }));
|
|
355
|
+
tags.push(meta({ name: "twitter:image:alt", content: input.image.alt }));
|
|
356
|
+
|
|
357
|
+
// Everything structured, collected into one `@graph` and emitted as a
|
|
358
|
+
// single script: the nodes describe one page, and a node that refers to
|
|
359
|
+
// another by `@id` resolves within a graph it shares.
|
|
360
|
+
//
|
|
361
|
+
// Emitted whenever there is a trail at all. What counts as one — a page
|
|
362
|
+
// with no ancestors has none — is decided where the trail is built, so
|
|
363
|
+
// nothing here restates it.
|
|
364
|
+
const nodes: JsonLdNode[] = [...(input.jsonLd ?? [])];
|
|
365
|
+
if (input.article !== undefined) {
|
|
366
|
+
// The URL, the description and the picture are the ones this function
|
|
367
|
+
// has already settled for the head — the canonical rather than the
|
|
368
|
+
// request URL, and the share image already made absolute. A view that
|
|
369
|
+
// supplied them again would be supplying a second answer to a question
|
|
370
|
+
// lib had answered, and the two would agree only until one changed.
|
|
371
|
+
nodes.push(
|
|
372
|
+
article({
|
|
373
|
+
...input.article,
|
|
374
|
+
headline: input.article.headline ?? input.title,
|
|
375
|
+
description: input.description,
|
|
376
|
+
url: input.canonical,
|
|
377
|
+
// The crops where the page cut them, the share card otherwise.
|
|
378
|
+
// Not both: the card is a 1.9:1 image with type often burnt
|
|
379
|
+
// into it, and mixing it into a set of photographs offers a
|
|
380
|
+
// search result a picture of the words rather than the thing.
|
|
381
|
+
image:
|
|
382
|
+
input.articleImages !== undefined &&
|
|
383
|
+
input.articleImages.length > 0
|
|
384
|
+
? input.articleImages
|
|
385
|
+
: imageUrl,
|
|
386
|
+
})
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (input.breadcrumb !== undefined && input.breadcrumb.length > 0) {
|
|
390
|
+
nodes.push(breadcrumbList(input.breadcrumb));
|
|
391
|
+
}
|
|
392
|
+
if (nodes.length > 0) {
|
|
393
|
+
tags.push({
|
|
394
|
+
kind: "script",
|
|
395
|
+
type: "application/ld+json",
|
|
396
|
+
content: serializeJsonLd(nodes),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { head: tags, body: bodyTags };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Builds the head of a 404 page.
|
|
405
|
+
*
|
|
406
|
+
* The one document with no canonical: it stands for whatever was asked for and
|
|
407
|
+
* is not there, so naming a canonical would assert that the missing URL and some
|
|
408
|
+
* real page are the same thing. `noindex` for the same reason — a 404 that gets
|
|
409
|
+
* indexed competes with the pages that do exist.
|
|
410
|
+
*
|
|
411
|
+
* It does carry analytics, and only Umami — see `notFoundAnalytics`. A static
|
|
412
|
+
* host serves this file at the address that was requested, so what gets
|
|
413
|
+
* recorded is the missing path itself, which is the difference between knowing
|
|
414
|
+
* there are 404s and knowing which redirect to write.
|
|
415
|
+
*/
|
|
416
|
+
export function buildNotFoundMeta(
|
|
417
|
+
title: string,
|
|
418
|
+
analytics?: AnalyticsSettings
|
|
419
|
+
): MetaTag[] {
|
|
420
|
+
return [
|
|
421
|
+
...preamble({ title }),
|
|
422
|
+
// Through the same builder as every other page, so the one document lib
|
|
423
|
+
// writes for itself cannot spell the tag differently from the ones it
|
|
424
|
+
// writes for a project. Links are still followed: a 404 often carries
|
|
425
|
+
// the nav, and those pages are real.
|
|
426
|
+
meta({
|
|
427
|
+
name: "robots",
|
|
428
|
+
content: robotsContent({ index: false, follow: true }),
|
|
429
|
+
}),
|
|
430
|
+
...notFoundAnalytics(analytics).map(asMetaTag),
|
|
431
|
+
];
|
|
432
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { warn } from "../warn.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `robots` *meta tag* — what a crawler may do with one page.
|
|
5
|
+
*
|
|
6
|
+
* Not `lib/robots.ts`, which writes `robots.txt` and is a different thing: that
|
|
7
|
+
* one is site-wide crawl rules served as a file, this one is a per-page
|
|
8
|
+
* decision emitted in the head. A page can be excluded by either and they do
|
|
9
|
+
* not mean the same thing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a crawler may do with this page.
|
|
14
|
+
*
|
|
15
|
+
* An object rather than a list of directives, and that is the whole point: a
|
|
16
|
+
* list can hold `["index", "noindex"]` — two real directives that cannot both
|
|
17
|
+
* be obeyed — and a crawler resolves it by taking the restrictive half, so the
|
|
18
|
+
* page quietly stops being indexed. Here `index` is one field with one value,
|
|
19
|
+
* so the contradiction cannot be written down at all. No validator, no build
|
|
20
|
+
* check, nothing to keep in step.
|
|
21
|
+
*
|
|
22
|
+
* That also retires `all` and `none`, which were shorthand for two decisions at
|
|
23
|
+
* once and the source of the subtler half of those pairs — `["all", "noindex"]`
|
|
24
|
+
* reads as fine and is not.
|
|
25
|
+
*
|
|
26
|
+
* The two required fields are the two that decide whether the page has a result
|
|
27
|
+
* at all. They are required rather than defaulted for the reason the field was
|
|
28
|
+
* always required: the wrong answer is invisible in the output, so inheriting
|
|
29
|
+
* one is how a mistake ships without anyone choosing it. The rest are
|
|
30
|
+
* presentation, and every default is "show as much as you like".
|
|
31
|
+
*/
|
|
32
|
+
export interface RobotsPolicy {
|
|
33
|
+
/** Whether this page may appear in results at all. */
|
|
34
|
+
readonly index: boolean;
|
|
35
|
+
/** Whether the links on it may be followed. */
|
|
36
|
+
readonly follow: boolean;
|
|
37
|
+
/** Whether a cached copy may be offered. Default: yes. */
|
|
38
|
+
readonly archive?: boolean;
|
|
39
|
+
/** Whether a translation may be offered. Default: yes. */
|
|
40
|
+
readonly translate?: boolean;
|
|
41
|
+
/** Whether its images may be indexed. Default: yes. */
|
|
42
|
+
readonly imageIndex?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* How large an image may appear beside a result. Default: `"large"`.
|
|
45
|
+
*
|
|
46
|
+
* Worth stating as a default rather than leaving out: omitted, Google
|
|
47
|
+
* chooses, and it chooses a thumbnail — which for an image-led site is the
|
|
48
|
+
* difference between a result people click and one they scroll past.
|
|
49
|
+
*/
|
|
50
|
+
readonly imagePreview?: "none" | "standard" | "large";
|
|
51
|
+
/**
|
|
52
|
+
* The text snippet: `false` for none, a whole number of characters to cap
|
|
53
|
+
* it at. Default: no limit.
|
|
54
|
+
*
|
|
55
|
+
* `-1` and `0` are what a crawler actually reads — no limit, and none — and
|
|
56
|
+
* are deliberately not the way to write them here: `true` and `false` say
|
|
57
|
+
* the same thing in words, and the two magic numbers stay inside lib.
|
|
58
|
+
*/
|
|
59
|
+
readonly snippet?: boolean | number;
|
|
60
|
+
/**
|
|
61
|
+
* Video preview: `false` for none, a whole number of seconds to cap it at.
|
|
62
|
+
* Default: no limit.
|
|
63
|
+
*/
|
|
64
|
+
readonly videoPreview?: boolean | number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A `robots` meta directive — the vocabulary of the tag this file writes.
|
|
69
|
+
*
|
|
70
|
+
* Internal to the builder: a project states a `RobotsPolicy` and never writes
|
|
71
|
+
* one of these. Kept as a type because it is what stops a typo in the builder
|
|
72
|
+
* itself, which is the only thing that produces them.
|
|
73
|
+
*/
|
|
74
|
+
export type RobotsDirective =
|
|
75
|
+
| "index"
|
|
76
|
+
| "noindex"
|
|
77
|
+
| "follow"
|
|
78
|
+
| "nofollow"
|
|
79
|
+
| "noarchive"
|
|
80
|
+
| "nosnippet"
|
|
81
|
+
| "noimageindex"
|
|
82
|
+
| "notranslate"
|
|
83
|
+
| `max-snippet:${number}`
|
|
84
|
+
| `max-image-preview:${"none" | "standard" | "large"}`
|
|
85
|
+
| `max-video-preview:${number}`;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a number is a cap a crawler can act on: whole, and not negative.
|
|
89
|
+
*
|
|
90
|
+
* One predicate rather than two, because two things need the answer — the
|
|
91
|
+
* builder, to decide what to emit, and the warning, to decide whether to say
|
|
92
|
+
* anything. Written twice they could disagree about what counts as a bad limit,
|
|
93
|
+
* and only one of them would be right.
|
|
94
|
+
*
|
|
95
|
+
* A negative is rejected even though `-1` means something to a crawler: on this
|
|
96
|
+
* field it means somebody reached for the magic number that `true` exists to
|
|
97
|
+
* replace, and `-2` means nothing at all.
|
|
98
|
+
*/
|
|
99
|
+
function isCount(value: number): boolean {
|
|
100
|
+
return Number.isInteger(value) && value >= 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A `max-…` limit as a crawler reads it: `-1` for no limit, `0` for none.
|
|
105
|
+
*
|
|
106
|
+
* The two magic numbers live here rather than in the policy, so nobody writing
|
|
107
|
+
* a page has to know them — `true` and `false` say the same thing in words.
|
|
108
|
+
*
|
|
109
|
+
* A number that is not a count falls back to no limit rather than being passed
|
|
110
|
+
* through. A crawler would drop `max-snippet:200.5` and apply its own default
|
|
111
|
+
* anyway, so this changes nothing about the outcome and quite a lot about the
|
|
112
|
+
* markup: the tag then says what is actually going to happen, and lib never
|
|
113
|
+
* writes a directive that cannot be read. `warnAboutRobots` says so out loud.
|
|
114
|
+
*/
|
|
115
|
+
function limit(value: boolean | number | undefined): number {
|
|
116
|
+
if (value === undefined || value === true) return -1;
|
|
117
|
+
if (value === false) return 0;
|
|
118
|
+
return isCount(value) ? value : -1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `robots` tag's content, from what the page decided.
|
|
123
|
+
*
|
|
124
|
+
* The preview limits are always stated for an indexable page rather than left
|
|
125
|
+
* out: omitted, Google picks its own, and `max-image-preview` in particular
|
|
126
|
+
* falls back to a thumbnail. "Show as much as you like" is what an image-led
|
|
127
|
+
* site wants and is not what saying nothing gives you.
|
|
128
|
+
*
|
|
129
|
+
* A page that is not indexed gets the two decisions and whatever else it
|
|
130
|
+
* switched off, and nothing about presentation — those describe how a result
|
|
131
|
+
* may look, and there is no result.
|
|
132
|
+
*
|
|
133
|
+
* Reference:
|
|
134
|
+
* <https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag>
|
|
135
|
+
*/
|
|
136
|
+
export function robotsContent(policy: RobotsPolicy): string {
|
|
137
|
+
const directives: RobotsDirective[] = [
|
|
138
|
+
policy.index ? "index" : "noindex",
|
|
139
|
+
policy.follow ? "follow" : "nofollow",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
if (policy.archive === false) directives.push("noarchive");
|
|
143
|
+
if (policy.imageIndex === false) directives.push("noimageindex");
|
|
144
|
+
if (policy.translate === false) directives.push("notranslate");
|
|
145
|
+
|
|
146
|
+
if (!policy.index) return directives.join(", ");
|
|
147
|
+
|
|
148
|
+
// `nosnippet` rather than `max-snippet:0`: the two are the same instruction
|
|
149
|
+
// and one of them says so in words.
|
|
150
|
+
if (policy.snippet === false) {
|
|
151
|
+
directives.push("nosnippet");
|
|
152
|
+
} else {
|
|
153
|
+
directives.push(`max-snippet:${limit(policy.snippet)}`);
|
|
154
|
+
}
|
|
155
|
+
directives.push(`max-image-preview:${policy.imagePreview ?? "large"}`);
|
|
156
|
+
directives.push(`max-video-preview:${limit(policy.videoPreview)}`);
|
|
157
|
+
|
|
158
|
+
return directives.join(", ");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Warns about a setting that will not do what it says.
|
|
163
|
+
*
|
|
164
|
+
* Both cases are warnings rather than errors, and for the same reason: neither
|
|
165
|
+
* is a contradiction. Every contradiction the old list could hold — `index`
|
|
166
|
+
* beside `noindex`, `nosnippet` beside a snippet length, no directives at all —
|
|
167
|
+
* is unwritable in this shape rather than detected, so there is nothing left to
|
|
168
|
+
* throw about and no rule kept in two places.
|
|
169
|
+
*
|
|
170
|
+
* A cap that is not a whole count is the one thing the object cannot rule out,
|
|
171
|
+
* since the field is a `number` and every number is one. It degrades rather
|
|
172
|
+
* than corrupts: `limit` falls back to no limit, which is what a crawler would
|
|
173
|
+
* have applied anyway after dropping the directive. Worth a line, not a build.
|
|
174
|
+
*
|
|
175
|
+
* A presentation setting on a page that is not indexed is coherent — `{ index:
|
|
176
|
+
* false, snippet: 200 }` asks for something real — it just asks for it of a
|
|
177
|
+
* result nobody will see.
|
|
178
|
+
*/
|
|
179
|
+
export function warnAboutRobots(policy: RobotsPolicy, at: string): void {
|
|
180
|
+
for (const [name, value] of [
|
|
181
|
+
["snippet", policy.snippet],
|
|
182
|
+
["videoPreview", policy.videoPreview],
|
|
183
|
+
] as const) {
|
|
184
|
+
if (typeof value === "number" && !isCount(value)) {
|
|
185
|
+
warn(
|
|
186
|
+
at,
|
|
187
|
+
`robots.${name} is ${value}, which is not a cap a crawler can act on: it wants a whole number of ${name === "snippet" ? "characters" : "seconds"}, from 0. Falling back to no limit — and for no limit, write \`true\`.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (policy.index) return;
|
|
193
|
+
|
|
194
|
+
const moot = (
|
|
195
|
+
[
|
|
196
|
+
["archive", policy.archive],
|
|
197
|
+
["snippet", policy.snippet],
|
|
198
|
+
["imageIndex", policy.imageIndex],
|
|
199
|
+
["imagePreview", policy.imagePreview],
|
|
200
|
+
["videoPreview", policy.videoPreview],
|
|
201
|
+
] as const
|
|
202
|
+
)
|
|
203
|
+
.filter(([, value]) => value !== undefined)
|
|
204
|
+
.map(([name]) => name);
|
|
205
|
+
|
|
206
|
+
if (moot.length > 0) {
|
|
207
|
+
warn(
|
|
208
|
+
at,
|
|
209
|
+
`robots sets ${moot.join(", ")}, which shapes how a result looks — and this page is not indexed, so there is no result to shape.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|