@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.
Files changed (63) hide show
  1. package/README.md +364 -0
  2. package/bin/use-project.mjs +131 -0
  3. package/docs/NOT-BUILT.md +329 -0
  4. package/docs/checks.md +139 -0
  5. package/docs/share-images.md +52 -0
  6. package/docs/toolchain.md +83 -0
  7. package/package.json +51 -0
  8. package/src/analytics/google.ts +351 -0
  9. package/src/analytics/index.ts +102 -0
  10. package/src/analytics/tags.ts +57 -0
  11. package/src/analytics/umami.ts +285 -0
  12. package/src/astro/MetaTags.astro +87 -0
  13. package/src/astro/consent.ts +165 -0
  14. package/src/astro/images.ts +315 -0
  15. package/src/astro/index.ts +44 -0
  16. package/src/astro/public-files.ts +129 -0
  17. package/src/astro/site-routes.ts +307 -0
  18. package/src/config.ts +218 -0
  19. package/src/contact.ts +233 -0
  20. package/src/file.ts +16 -0
  21. package/src/files.ts +39 -0
  22. package/src/hours.ts +312 -0
  23. package/src/i18n/define.ts +217 -0
  24. package/src/i18n/placeholders.ts +94 -0
  25. package/src/i18n/translate.ts +190 -0
  26. package/src/image.ts +29 -0
  27. package/src/index.ts +222 -0
  28. package/src/jsonld/article.ts +165 -0
  29. package/src/jsonld/breadcrumb.ts +34 -0
  30. package/src/jsonld/business.ts +196 -0
  31. package/src/jsonld/ids.ts +106 -0
  32. package/src/jsonld/index.ts +59 -0
  33. package/src/jsonld/node.ts +78 -0
  34. package/src/jsonld/organization.ts +154 -0
  35. package/src/jsonld/place.ts +96 -0
  36. package/src/jsonld/product.ts +172 -0
  37. package/src/jsonld/quantity.ts +55 -0
  38. package/src/jsonld/service.ts +237 -0
  39. package/src/jsonld/video.ts +239 -0
  40. package/src/jsonld/website.ts +58 -0
  41. package/src/llms.ts +160 -0
  42. package/src/meta/content.ts +190 -0
  43. package/src/meta/index.ts +432 -0
  44. package/src/meta/robots.ts +212 -0
  45. package/src/meta/share-image.ts +232 -0
  46. package/src/meta/tag.ts +133 -0
  47. package/src/meta/verification.ts +53 -0
  48. package/src/money.ts +237 -0
  49. package/src/project.ts +249 -0
  50. package/src/redirects.ts +266 -0
  51. package/src/robots.ts +80 -0
  52. package/src/routes/define.ts +412 -0
  53. package/src/routes/family.ts +251 -0
  54. package/src/routes/resolve.ts +266 -0
  55. package/src/site/api.ts +354 -0
  56. package/src/site/create.ts +660 -0
  57. package/src/site/index.ts +32 -0
  58. package/src/site/page.ts +148 -0
  59. package/src/sitemap.ts +257 -0
  60. package/src/types.ts +160 -0
  61. package/src/url.ts +144 -0
  62. package/src/warn.ts +88 -0
  63. package/src/xml.ts +103 -0
@@ -0,0 +1,55 @@
1
+ import {
2
+ isContiguous,
3
+ type PriceTier,
4
+ quantitiesOf,
5
+ quantityRange,
6
+ } from "../money.ts";
7
+ import type { JsonLdNode } from "./node.ts";
8
+
9
+ /**
10
+ * A quantity, as schema.org states one: an exact count, or a range.
11
+ *
12
+ * Its own helper because the shape recurs — how many people, how long, how far
13
+ * — and because the naive spelling of a range is a string like `"2–5"`, which
14
+ * reads correctly to a person and is one opaque value to everything else.
15
+ *
16
+ * The same `number | { min, max }` shape as `PriceTier.quantity`, so a caller
17
+ * moves between the two without translating, and a *set* of exact quantities is
18
+ * `sizes.map(quantitativeValue)` — which is how a thing sold at some sizes and
19
+ * not the ones between says so, where a min and a max would claim the gaps.
20
+ *
21
+ * Reference: <https://schema.org/QuantitativeValue>
22
+ */
23
+ export function quantitativeValue(
24
+ quantity: number | { readonly min: number; readonly max: number }
25
+ ): JsonLdNode {
26
+ return typeof quantity === "number"
27
+ ? { "@type": "QuantitativeValue", value: quantity }
28
+ : {
29
+ "@type": "QuantitativeValue",
30
+ minValue: quantity.min,
31
+ maxValue: quantity.max,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * The quantities a price table sells, in whichever form states them honestly.
37
+ *
38
+ * One node when the sizes run without a break — `minValue` 2, `maxValue` 6 —
39
+ * and one node per size when they do not. A thing sold to twos, fours and sixes
40
+ * published as a range from 2 to 6 advertises two bookings nobody can make, and
41
+ * a run published as three separate values is merely noisy.
42
+ *
43
+ * The choice is made here rather than by a caller because it is not a
44
+ * preference: it follows from the tiers, and there is exactly one right answer
45
+ * for any table. A consumer assigns the result and does not branch.
46
+ *
47
+ * Reference: <https://schema.org/QuantitativeValue>
48
+ */
49
+ export function quantitiesFor(
50
+ tiers: readonly [PriceTier, ...PriceTier[]]
51
+ ): JsonLdNode | readonly JsonLdNode[] {
52
+ return isContiguous(tiers)
53
+ ? quantitativeValue(quantityRange(tiers))
54
+ : quantitiesOf(tiers).map(quantitativeValue);
55
+ }
@@ -0,0 +1,237 @@
1
+ import type { HttpsUrl } from "../url.ts";
2
+ import type { BusinessId, OrganizationId } from "./ids.ts";
3
+ import { serviceId } from "./ids.ts";
4
+ import type { JsonLdNode } from "./node.ts";
5
+
6
+ /**
7
+ * What a business *does*, for the sites that sell work rather than tickets.
8
+ *
9
+ * The B2B half of `product.ts`. A venue sells a room at a price, and `Product`
10
+ * carries the number a result can render; a B2B site sells construction, a
11
+ * franchise, marketing — things quoted rather than priced, which is why nothing
12
+ * here takes a `Price`. An `Offer` with no amount says less than no offer, and
13
+ * a placeholder `PriceSpecification` carrying only a currency says nothing at
14
+ * all.
15
+ *
16
+ * **Google renders nothing for this.** There is no `Service` rich result, and
17
+ * that is worth knowing before writing much of it: the payoff is entity
18
+ * understanding — what this company does, for whom, where — rather than
19
+ * anything you will see in a SERP. It is here because without it the graph of a
20
+ * B2B site says "this is a business" and stops, while the same site's whole
21
+ * subject is the work it takes on.
22
+ */
23
+
24
+ /**
25
+ * Where a service is offered.
26
+ *
27
+ * `kind` is a schema.org `Place` subtype and is not checked against the
28
+ * vocabulary, for the reason `LocalBusinessInput.type` is not: the list is long,
29
+ * it moves, and a name lib rejected would be a name a crawler accepted. Use
30
+ * `"Country"` for a franchise territory, `"City"` for a local trade, and the
31
+ * default when it is neither.
32
+ */
33
+ export interface ServiceArea {
34
+ readonly name: string;
35
+ /** Defaults to `"AdministrativeArea"`, which is the honest general case. */
36
+ readonly kind?: string;
37
+ }
38
+
39
+ export interface ServiceInput {
40
+ readonly name: string;
41
+ /**
42
+ * This service's own page — and, through `serviceId`, its identity.
43
+ *
44
+ * Required rather than optional because the id is the point: a service
45
+ * described on its own page and listed in a catalogue elsewhere has to be
46
+ * one thing in the graph, and the URL is what makes it one.
47
+ */
48
+ readonly url: HttpsUrl;
49
+ readonly description?: string;
50
+ /**
51
+ * What kind of work it is, in your own words — "Escape room construction".
52
+ *
53
+ * Free text by design. schema.org offers no enumeration here, so this is
54
+ * the category as a human would say it, not a code from a list.
55
+ */
56
+ readonly serviceType?: string;
57
+ /**
58
+ * Who does the work, as an `@id` — the venue or the brand.
59
+ *
60
+ * A reference rather than a repeat, exactly as `ProductInput.seller` is:
61
+ * the provider is described once, in the node that carries its address and
62
+ * its hours.
63
+ *
64
+ * Optional here and required on `OfferCatalogInput`, because this node has
65
+ * an `@id` and that one does not: a service is reachable from any catalogue
66
+ * that lists it, so the graph joins up even when this is left out. Worth
67
+ * setting anyway on a service page that carries no catalogue, which is the
68
+ * case where nothing else says whose work it is.
69
+ */
70
+ readonly provider?: BusinessId | OrganizationId;
71
+ /**
72
+ * Who it is for — "Escape room operators", "Property developers".
73
+ *
74
+ * Taken as a string and wrapped in an `Audience` node here, rather than
75
+ * asking a project for the node: there is exactly one shape it can take,
76
+ * and a hand-written `{"@type": "Audiance"}` type-checks against every
77
+ * `JsonLdNode` lib accepts. The string is the part only a project knows.
78
+ */
79
+ readonly audience?: string;
80
+ /** Where the work is offered. Omitted rather than emitted empty. */
81
+ readonly areaServed?: readonly ServiceArea[];
82
+ }
83
+
84
+ /** An `Audience` node, or nothing. See `ServiceInput.audience`. */
85
+ function audienceFor(audienceType: string | undefined): {
86
+ readonly audience?: JsonLdNode;
87
+ } {
88
+ return audienceType === undefined
89
+ ? {}
90
+ : { audience: { "@type": "Audience", audienceType } };
91
+ }
92
+
93
+ /** `areaServed`, as `Place` nodes. See `ServiceArea`. */
94
+ function areasFor(areas: readonly ServiceArea[] | undefined): {
95
+ readonly areaServed?: readonly JsonLdNode[];
96
+ } {
97
+ // Length rather than presence: a service that serves nowhere in particular
98
+ // says nothing, and an empty list is a claim to have been asked and had no
99
+ // answer.
100
+ if (areas === undefined || areas.length === 0) return {};
101
+
102
+ return {
103
+ areaServed: areas.map((area) => ({
104
+ "@type": area.kind ?? "AdministrativeArea",
105
+ name: area.name,
106
+ })),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * One service, on the page that describes it.
112
+ *
113
+ * Reference: <https://schema.org/Service>
114
+ */
115
+ export function service(input: ServiceInput): JsonLdNode {
116
+ return {
117
+ "@type": "Service",
118
+ "@id": serviceId(input.url),
119
+ name: input.name,
120
+ url: input.url,
121
+ ...(input.description === undefined
122
+ ? {}
123
+ : { description: input.description }),
124
+ ...(input.serviceType === undefined
125
+ ? {}
126
+ : { serviceType: input.serviceType }),
127
+ ...(input.provider === undefined
128
+ ? {}
129
+ : { provider: { "@id": input.provider } }),
130
+ ...audienceFor(input.audience),
131
+ ...areasFor(input.areaServed),
132
+ };
133
+ }
134
+
135
+ /** One line of a catalogue: a service, named, and linked if it has a page. */
136
+ export interface CatalogEntry {
137
+ readonly name: string;
138
+ /**
139
+ * The page describing this service, where there is one.
140
+ *
141
+ * Supplying it is what ties this line to the full node that page emits,
142
+ * through the id both derive from `serviceId`. Leave it out for something
143
+ * offered but not written up, and the entry stands alone as a name.
144
+ */
145
+ readonly url?: HttpsUrl;
146
+ readonly description?: string;
147
+ }
148
+
149
+ export interface OfferCatalogInput {
150
+ /** What the list is called — "What we do", "Franchise packages". */
151
+ readonly name: string;
152
+ readonly description?: string;
153
+ /**
154
+ * Who offers them, as an `@id` — the venue or the brand.
155
+ *
156
+ * **Required**, unlike `ServiceInput.provider`, and the difference is not
157
+ * an oversight. A service node carries an `@id` of its own, so a catalogue
158
+ * elsewhere can point at it and the graph joins up either way. This node
159
+ * has no id and nothing refers to it, so `provider` is the *only* edge it
160
+ * has: without one it is a list of services belonging to nobody, sitting in
161
+ * a graph beside a business it never mentions.
162
+ *
163
+ * There is always an answer to hand. Every page that renders this also
164
+ * renders the site graph, so the venue's `businessId` and the brand's
165
+ * `organizationId` are both already built.
166
+ */
167
+ readonly provider: BusinessId | OrganizationId;
168
+ readonly entries: readonly CatalogEntry[];
169
+ }
170
+
171
+ /**
172
+ * Everything the business offers, listed in one place.
173
+ *
174
+ * ## Which page this goes on
175
+ *
176
+ * **The page that shows the offerings** — a "what we do" index, a services
177
+ * page. Failing that, the home page, exactly as `website()` does and for the
178
+ * same reason: it is a fact about the whole site, and one page has to carry it.
179
+ * Pass it through that view's own `jsonLd`, never through the site-wide graph.
180
+ *
181
+ * There is deliberately no way to hang a catalogue off `localBusiness()` or
182
+ * `organization()` as `hasOfferCatalog`. That says the same thing to a crawler
183
+ * as this node's `provider` does in reverse, and those two nodes are normally
184
+ * built once and emitted on every URL — so the nested form would drag the whole
185
+ * list onto every page of the site to state a fact already stated here. One
186
+ * shape, on one page.
187
+ *
188
+ * ## What it emits
189
+ *
190
+ * Each entry names a service and, where that service has a page, carries the
191
+ * same `@id` that page's own node does: one thing in the graph, described
192
+ * twice, rather than two things spelled alike.
193
+ *
194
+ * The catalogue itself has **no `@id`** — nothing points at one, which is the
195
+ * test `ids.ts` applies, and it is in the same position as `Product`, `Article`
196
+ * and `BreadcrumbList`. Two things make that comfortable rather than merely
197
+ * defensible: a catalogue has no natural unique key, so a minted fragment could
198
+ * collide with a second catalogue on the same page and silently merge the two
199
+ * lists; and if something ever does point at one — a `Service` gaining
200
+ * `hasOfferCatalog` — the id is an optional input field added then, which
201
+ * breaks no existing call. `provider` is required precisely because of this:
202
+ * with no id and nothing referring to it, that edge is the node's only
203
+ * attachment to the rest of the graph.
204
+ *
205
+ * Entries are wrapped in `Offer` because that is what an `OfferCatalog` holds —
206
+ * `itemListElement` takes offers, and `itemOffered` is where the service goes.
207
+ * The offers carry no price, deliberately: see the note at the top of this file.
208
+ *
209
+ * Reference: <https://schema.org/OfferCatalog>
210
+ */
211
+ export function offerCatalog(input: OfferCatalogInput): JsonLdNode {
212
+ return {
213
+ "@type": "OfferCatalog",
214
+ name: input.name,
215
+ ...(input.description === undefined
216
+ ? {}
217
+ : { description: input.description }),
218
+ // Unconditional: this is the node's only link to anything.
219
+ provider: { "@id": input.provider },
220
+ itemListElement: input.entries.map((entry) => ({
221
+ "@type": "Offer",
222
+ itemOffered: {
223
+ "@type": "Service",
224
+ // The id only where there is a page to hang it on. A fragment
225
+ // invented for an entry with no page would be an identity for
226
+ // something nothing else can ever refer to.
227
+ ...(entry.url === undefined
228
+ ? {}
229
+ : { "@id": serviceId(entry.url), url: entry.url }),
230
+ name: entry.name,
231
+ ...(entry.description === undefined
232
+ ? {}
233
+ : { description: entry.description }),
234
+ },
235
+ })),
236
+ };
237
+ }
@@ -0,0 +1,239 @@
1
+ import type { ImageAsset } from "../image.ts";
2
+ import type { IsoDate } from "../types.ts";
3
+ import { absoluteUrl, type HttpsUrl } from "../url.ts";
4
+ import { warn } from "../warn.ts";
5
+ import { type JsonLdNode, oneOrMany } from "./node.ts";
6
+
7
+ interface VideoDetails {
8
+ /** What the video is called — not the page's title unless they match. */
9
+ readonly name: string;
10
+ /**
11
+ * The site's own origin, which the thumbnails are made absolute against.
12
+ *
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,
16
+ * named for what it actually needs.
17
+ */
18
+ readonly origin: HttpsUrl;
19
+ /**
20
+ * Still frames. Required by Google, and worth giving several: their
21
+ * thumbnail guidance asks for one per aspect ratio a result may use, which
22
+ * is what `photoSet` in `astro/images.ts` cuts from one source.
23
+ *
24
+ * An asset *or* an absolute URL, because the two hosting stories differ. A
25
+ * self-hosted clip has a still in the repo; a YouTube one already has
26
+ * thumbnails on `i.ytimg.com` and copying them locally would be keeping a
27
+ * second copy of somebody else's file.
28
+ */
29
+ readonly thumbnail: readonly [
30
+ ImageAsset | HttpsUrl,
31
+ ...(ImageAsset | HttpsUrl)[],
32
+ ];
33
+ /**
34
+ * When it was first published.
35
+ *
36
+ * Google asks for ISO 8601 and recommends a timezone; a date is valid
37
+ * ISO 8601 at reduced precision, and is the precision anyone actually has
38
+ * for "when did we put the trailer up".
39
+ */
40
+ readonly uploadDate: IsoDate;
41
+ readonly description?: string;
42
+ /**
43
+ * How long it runs, **in seconds**.
44
+ *
45
+ * A number rather than the `PT1M30S` the spec wants, because that format is
46
+ * lib's problem and not a project's. The same trade `RobotsPolicy` makes
47
+ * with `-1` and `0`: state the fact, let the helper spell it.
48
+ */
49
+ readonly durationSeconds?: number;
50
+ }
51
+
52
+ /**
53
+ * How a crawler reaches the video. At least one way, and both are allowed.
54
+ *
55
+ * *Not* mutually exclusive, which is the tempting reading. Google states a
56
+ * preference rather than a choice: *"We recommend that you provide the
57
+ * `contentUrl` property, if possible… If `contentUrl` isn't available, provide
58
+ * `embedUrl` as an alternative."* A self-hosted clip that also has a player
59
+ * page has both, both are true, and neither is ignored — the file is used and
60
+ * the player is there to fall back to. Forbidding that would invent a rule.
61
+ *
62
+ * What the union does forbid is *neither*. Both are merely recommended by
63
+ * Google, so a `VideoObject` with no way to fetch the video is valid markup —
64
+ * and useless, which is the failure this file keeps making unwritable rather
65
+ * than detecting. A video nobody can reach is a claim that a video exists.
66
+ *
67
+ * Neither may be the **watch page**. Google is explicit about that, and
68
+ * `youtube.com/watch?v=<id>` is exactly what a watch page is — the URL anyone
69
+ * reaches for first, and the one that belongs in neither field.
70
+ */
71
+ export type VideoSource =
72
+ | {
73
+ /**
74
+ * The video file itself — the bytes. *"The most effective way"* for
75
+ * Google to fetch it, so prefer it where the file is yours.
76
+ */
77
+ readonly contentUrl: HttpsUrl;
78
+ /** A player for it, where one exists as well. */
79
+ readonly embedUrl?: HttpsUrl;
80
+ }
81
+ | {
82
+ readonly contentUrl?: undefined;
83
+ /**
84
+ * A player for this video, for when the file is not yours to expose.
85
+ *
86
+ * **The YouTube case.** The video need not be hosted here at all;
87
+ * what Google asks for is a way to reach it, and for YouTube that is
88
+ * the *embed* URL — `https://www.youtube.com/embed/<id>`.
89
+ */
90
+ readonly embedUrl: HttpsUrl;
91
+ };
92
+
93
+ /** Everything about the video, plus one of the two ways to reach it. */
94
+ export type VideoInput = VideoDetails & VideoSource;
95
+
96
+ /**
97
+ * The hosts that serve a YouTube watch page, spelled out.
98
+ *
99
+ * A set rather than a pattern, so `notyoutube.com` cannot match by containing
100
+ * the name, and a new subdomain is one line rather than a puzzle.
101
+ */
102
+ const YOUTUBE_WATCH_HOSTS = new Set([
103
+ "youtube.com",
104
+ "www.youtube.com",
105
+ "m.youtube.com",
106
+ "music.youtube.com",
107
+ ]);
108
+
109
+ /**
110
+ * Whether a URL is the page a video is *watched* on, rather than the video.
111
+ *
112
+ * Parsed rather than matched. A pattern over the whole string cannot tell a
113
+ * host from a path, so `https://x.example/?ref=youtube.com/watch` reads as a
114
+ * watch page to it, and so does `https://notyoutube.com/watch` — and a warning
115
+ * that fires on innocent URLs is one people learn to ignore. The `hostname` and
116
+ * the `pathname` are the two things actually being asked about, so those are
117
+ * the two things to look at.
118
+ *
119
+ * Anything unparseable is not a watch page as far as this is concerned: the
120
+ * argument is typed `https://…`, and a URL this cannot read is a problem for
121
+ * somewhere else to report.
122
+ */
123
+ function isWatchPage(url: string): boolean {
124
+ let parsed: URL;
125
+ try {
126
+ parsed = new URL(url);
127
+ } catch {
128
+ return false;
129
+ }
130
+
131
+ if (YOUTUBE_WATCH_HOSTS.has(parsed.hostname)) {
132
+ return parsed.pathname === "/watch";
133
+ }
134
+ // A youtu.be link is only ever a watch link: the id is the whole path.
135
+ if (parsed.hostname === "youtu.be") return parsed.pathname.length > 1;
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Seconds as ISO 8601, which is what `duration` is read as: `PT1M30S`.
141
+ *
142
+ * `undefined` for anything that is not a length — absent, fractional, negative,
143
+ * or zero. A video of no duration is not a video, so there is nothing to state,
144
+ * and the empty case that would have produced a bare `PT` cannot arise.
145
+ *
146
+ * The whole rule lives here rather than half here and half at the call site.
147
+ * Written twice, the two could disagree about what counts as a length, and the
148
+ * caller would emit something this function had already judged unusable.
149
+ */
150
+ function isoDuration(seconds: number | undefined): string | undefined {
151
+ if (seconds === undefined) return undefined;
152
+ if (!Number.isInteger(seconds) || seconds <= 0) return undefined;
153
+
154
+ const hours = Math.floor(seconds / 3600);
155
+ const minutes = Math.floor((seconds % 3600) / 60);
156
+ const rest = seconds % 60;
157
+ return `PT${hours > 0 ? `${hours}H` : ""}${minutes > 0 ? `${minutes}M` : ""}${rest > 0 ? `${rest}S` : ""}`;
158
+ }
159
+
160
+ /**
161
+ * A video on this page — a room trailer, a walkthrough.
162
+ *
163
+ * Its own node rather than a property of something else: Google reads a
164
+ * `VideoObject` from the page it plays on, and a video is a thing in its own
165
+ * right rather than a field of the room it depicts.
166
+ *
167
+ * `name`, `thumbnailUrl` and `uploadDate` are required by Google and are
168
+ * required here. The rest of its recommended list — `expires`, `hasPart`,
169
+ * `interactionStatistic`, `publication`, `regionsAllowed` — is deliberately
170
+ * absent: each answers a question this site does not have (a video that
171
+ * expires, chapters, view counts, a live broadcast, geo-restriction), and a
172
+ * field with nothing to put in it is a field that gets filled in wrongly.
173
+ *
174
+ * Reference:
175
+ * <https://developers.google.com/search/docs/appearance/structured-data/video>
176
+ */
177
+ export function videoObject(video: VideoInput): JsonLdNode {
178
+ // The one mistake Google names outright, and the one anyone makes: a watch
179
+ // page in a field that wants the file or the player. It is what the address
180
+ // bar holds while you are looking at the video, so it is what gets pasted.
181
+ //
182
+ // A warning rather than a type, because "watch page" is not a shape — it is
183
+ // a fact about one platform's URLs. Recognising YouTube's catches almost
184
+ // every instance without lib growing a list of video hosts, and without
185
+ // refusing a URL it has merely not heard of: anything unrecognised passes,
186
+ // which is the right way for a heuristic to fail.
187
+ for (const [field, url] of [
188
+ ["contentUrl", video.contentUrl],
189
+ ["embedUrl", video.embedUrl],
190
+ ] as const) {
191
+ if (url !== undefined && isWatchPage(url)) {
192
+ warn(
193
+ video.origin,
194
+ `video "${video.name}" gives a watch page as ${field}. Google asks for the file or the player and not the page it is watched on — for YouTube that is https://www.youtube.com/embed/<id> as embedUrl.`
195
+ );
196
+ }
197
+ }
198
+
199
+ // Asked for and not usable, which is the only case worth a line: absent is
200
+ // absent, and `isoDuration` has already decided what counts as a length.
201
+ const duration = isoDuration(video.durationSeconds);
202
+ if (video.durationSeconds !== undefined && duration === undefined) {
203
+ warn(
204
+ video.origin,
205
+ `video "${video.name}" has a duration of ${video.durationSeconds}, which is not a length: expected a whole number of seconds above zero. Leaving it out — a duration a crawler cannot parse is one it drops.`
206
+ );
207
+ }
208
+
209
+ return {
210
+ "@type": "VideoObject",
211
+ name: video.name,
212
+ // `absoluteUrl` already takes either an `https://` URL or a path from
213
+ // the root, so a remote thumbnail passes through and a local asset is
214
+ // joined — the same call serves a YouTube still and a file in `src/`.
215
+ //
216
+ // Through `oneOrMany` for the reason `@type` is: the input is a list
217
+ // because Google asks for several ratios, but a video with one still
218
+ // should say so as a string rather than as a list of one. Same rule,
219
+ // same file, no exemption to explain.
220
+ thumbnailUrl: oneOrMany(
221
+ video.thumbnail.map((frame) =>
222
+ absoluteUrl(
223
+ video.origin,
224
+ typeof frame === "string" ? frame : frame.src,
225
+ `video thumbnail for "${video.name}"`
226
+ )
227
+ )
228
+ ),
229
+ uploadDate: video.uploadDate,
230
+ ...(video.description === undefined
231
+ ? {}
232
+ : { description: video.description }),
233
+ ...(duration === undefined ? {} : { duration }),
234
+ ...(video.contentUrl === undefined
235
+ ? {}
236
+ : { contentUrl: video.contentUrl }),
237
+ ...(video.embedUrl === undefined ? {} : { embedUrl: video.embedUrl }),
238
+ };
239
+ }
@@ -0,0 +1,58 @@
1
+ import type { HttpsUrl } from "../url.ts";
2
+ import { type OrganizationId, websiteId } from "./ids.ts";
3
+ import { alternateName, type JsonLdNode } from "./node.ts";
4
+
5
+ export interface WebsiteInput {
6
+ /**
7
+ * What the site is called — the name a result should show above the URL.
8
+ *
9
+ * Google reads `og:site_name`, the `<title>` and the home page's headings
10
+ * too, and mostly gets it right from those. Stating it here is the only way
11
+ * to express a *preference* rather than leave it to be inferred, and the
12
+ * only way to offer the alternate below.
13
+ */
14
+ readonly name: string;
15
+ /** The site's own home page. Also its identity, see `@id`. */
16
+ readonly url: HttpsUrl;
17
+ /**
18
+ * A shorter name or an acronym, offered as a fallback.
19
+ *
20
+ * The one thing this node can say that nothing else can. A venue called
21
+ * "Acme Rome" whose brand is "Acme" has a second name worth naming, and
22
+ * there is no meta tag for it.
23
+ */
24
+ readonly alternateName?: string;
25
+ /** The `@id` of the organization that publishes it, from `organization()`. */
26
+ readonly publisher?: OrganizationId;
27
+ }
28
+
29
+ /**
30
+ * The site itself, as an entity.
31
+ *
32
+ * **Home page only.** Google: *"The `WebSite` structured data must be on the
33
+ * home page of the site."* On any other page it is ignored at best, so this is
34
+ * the one node here that a layout must not emit everywhere — pass it through a
35
+ * view's own `jsonLd`.
36
+ *
37
+ * Worth emitting despite Google saying it *"does not guarantee"* to use the
38
+ * name: the cost is one small node on one page, and an inferred name that comes
39
+ * out wrong is not otherwise correctable.
40
+ *
41
+ * No `potentialAction`. The `SearchAction` that once put a search box in a
42
+ * result was retired in November 2024 — see `NOT-BUILT.md`.
43
+ *
44
+ * Reference:
45
+ * <https://developers.google.com/search/docs/appearance/site-names>
46
+ */
47
+ export function website(site: WebsiteInput): JsonLdNode {
48
+ return {
49
+ "@type": "WebSite",
50
+ "@id": websiteId(site.url),
51
+ name: site.name,
52
+ url: site.url,
53
+ ...alternateName(site.name, site.alternateName),
54
+ ...(site.publisher === undefined
55
+ ? {}
56
+ : { publisher: { "@id": site.publisher } }),
57
+ };
58
+ }