@escape-game-over/atlas 0.1.115 → 0.1.117

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 (62) hide show
  1. package/README.md +1 -1
  2. package/docs/NOT-BUILT.md +2 -2
  3. package/package.json +2 -2
  4. package/src/analytics/ids.ts +1 -13
  5. package/src/analytics/index.ts +18 -18
  6. package/src/analytics/storage.ts +34 -39
  7. package/src/analytics/vendor-settings.ts +162 -0
  8. package/src/astro/axon-event.ts +2 -4
  9. package/src/astro/axon-pixel.ts +3 -6
  10. package/src/astro/clarity-event.ts +2 -4
  11. package/src/astro/clarity.ts +3 -6
  12. package/src/astro/consent.ts +3 -6
  13. package/src/astro/drip-event.ts +2 -4
  14. package/src/astro/drip.ts +2 -7
  15. package/src/astro/escape-navigator-consent.ts +2 -6
  16. package/src/astro/escapeall-calendar.ts +2 -5
  17. package/src/astro/google-event.ts +3 -7
  18. package/src/astro/google-map.ts +3 -5
  19. package/src/astro/images.ts +12 -26
  20. package/src/astro/index.ts +2 -9
  21. package/src/astro/meta-event.ts +2 -4
  22. package/src/astro/meta-pixel.ts +4 -8
  23. package/src/astro/public-files.ts +11 -52
  24. package/src/astro/site-routes.ts +11 -55
  25. package/src/astro/snap-event.ts +2 -4
  26. package/src/astro/snap-pixel.ts +3 -6
  27. package/src/astro/tiktok-event.ts +2 -4
  28. package/src/astro/tiktok-pixel.ts +70 -39
  29. package/src/astro/umami-event.ts +2 -6
  30. package/src/astro/vendors.ts +64 -0
  31. package/src/config.ts +8 -17
  32. package/src/content/rich.ts +15 -18
  33. package/src/i18n/catalog.ts +20 -1
  34. package/src/i18n/define.ts +0 -5
  35. package/src/i18n/translate.ts +9 -48
  36. package/src/index.ts +24 -166
  37. package/src/jsonld/article.ts +7 -17
  38. package/src/jsonld/business.ts +27 -28
  39. package/src/jsonld/creative-work.ts +8 -20
  40. package/src/jsonld/node.ts +37 -7
  41. package/src/jsonld/organization.ts +21 -30
  42. package/src/jsonld/place.ts +2 -6
  43. package/src/jsonld/product.ts +16 -22
  44. package/src/jsonld/service.ts +21 -37
  45. package/src/jsonld/video.ts +4 -8
  46. package/src/jsonld/website.ts +3 -5
  47. package/src/meta/content.ts +14 -66
  48. package/src/meta/index.ts +10 -32
  49. package/src/project.ts +4 -18
  50. package/src/routes/define.ts +0 -13
  51. package/src/routes/family.ts +135 -3
  52. package/src/site/api.ts +1 -21
  53. package/src/site/create.ts +17 -26
  54. package/src/site/define.ts +13 -9
  55. package/src/site/page.ts +1 -10
  56. package/src/warn.ts +6 -6
  57. package/src/analytics/axon-pixel.ts +0 -24
  58. package/src/analytics/clarity.ts +0 -29
  59. package/src/analytics/drip.ts +0 -20
  60. package/src/analytics/meta-pixel.ts +0 -29
  61. package/src/analytics/snap-pixel.ts +0 -28
  62. package/src/analytics/tiktok-pixel.ts +0 -29
@@ -2,7 +2,7 @@ import type { IsoDate } from "../types.ts";
2
2
  import type { HttpsUrl } from "../url.ts";
3
3
  import { warn } from "../warn.ts";
4
4
  import type { BusinessId, OrganizationId } from "./ids.ts";
5
- import { type JsonLdNode, oneOrMany } from "./node.ts";
5
+ import { type JsonLdNode, listed, oneOrMany, reference } from "./node.ts";
6
6
 
7
7
  export interface ArticleInput {
8
8
  /**
@@ -137,29 +137,19 @@ export function article(item: ArticleInput): JsonLdNode {
137
137
  return {
138
138
  "@type": item.kind ?? "Article",
139
139
  headline: item.headline,
140
- ...(item.description === undefined
141
- ? {}
142
- : { description: item.description }),
140
+ description: item.description,
143
141
  // Not `url`: an article is a thing, and this is the page it is *on*.
144
142
  mainEntityOfPage: item.url,
145
143
  datePublished: item.datePublished,
146
- ...(item.dateModified === undefined
147
- ? {}
148
- : { dateModified: item.dateModified }),
144
+ dateModified: item.dateModified,
149
145
  // Passed through in the shape it arrived in, bar a list of one — which
150
146
  // is `schemaType`'s rule applied to a second field: writing `[x]` is
151
147
  // fine, emitting it reads as a bug.
152
- ...(item.image === undefined ? {} : { image: oneOrMany(item.image) }),
153
- ...(item.author === undefined
154
- ? {}
155
- : { author: { "@id": item.author } }),
156
- ...(item.publisher === undefined
157
- ? {}
158
- : { publisher: { "@id": item.publisher } }),
148
+ image: item.image === undefined ? undefined : oneOrMany(item.image),
149
+ author: reference(item.author),
150
+ publisher: reference(item.publisher),
159
151
  // An empty list is not a set of no keywords, it is a field saying
160
152
  // nothing — so it is left out, as every optional here is.
161
- ...(item.tags === undefined || item.tags.length === 0
162
- ? {}
163
- : { keywords: item.tags }),
153
+ keywords: listed(item.tags),
164
154
  };
165
155
  }
@@ -10,7 +10,13 @@ import type { ImageAsset } from "../image.ts";
10
10
  import type { NonEmpty } from "../types.ts";
11
11
  import { absoluteUrl, type HttpsUrl } from "../url.ts";
12
12
  import { businessId, type OrganizationId } from "./ids.ts";
13
- import { alternateName, type JsonLdNode, schemaType } from "./node.ts";
13
+ import {
14
+ alternateName,
15
+ type JsonLdNode,
16
+ listed,
17
+ reference,
18
+ schemaType,
19
+ } from "./node.ts";
14
20
  import { geoCoordinates, openingHours, postalAddress } from "./place.ts";
15
21
 
16
22
  export interface LocalBusinessInput {
@@ -173,34 +179,27 @@ export function localBusiness(business: LocalBusinessInput): JsonLdNode {
173
179
  "@type": schemaType(business.type ?? "LocalBusiness"),
174
180
  "@id": businessId(business.url, business.location),
175
181
  name: business.name,
176
- ...alternateName(business.name, business.alternateName),
182
+ alternateName: alternateName(business.name, business.alternateName),
177
183
  url: business.url,
178
184
  address: postalAddress(business.address),
179
- ...(business.geo === undefined
180
- ? {}
181
- : { geo: geoCoordinates(business.geo) }),
182
- ...(business.priceRange === undefined
183
- ? {}
184
- : { priceRange: business.priceRange }),
185
- ...(business.image === undefined || business.image.length === 0
186
- ? {}
187
- : {
188
- image: business.image.map((photo) =>
189
- absoluteUrl(business.url, photo.src, "venue photo")
190
- ),
191
- }),
192
- ...(business.telephone === undefined
193
- ? {}
194
- : { telephone: e164(business.telephone) }),
195
- ...(business.email === undefined ? {} : { email: business.email }),
196
- ...(business.hours === undefined
197
- ? {}
198
- : { openingHoursSpecification: openingHours(business.hours) }),
199
- ...(business.sameAs === undefined ? {} : { sameAs: business.sameAs }),
200
- ...(business.parentOrganization === undefined
201
- ? {}
202
- : {
203
- parentOrganization: { "@id": business.parentOrganization },
204
- }),
185
+ geo:
186
+ business.geo === undefined
187
+ ? undefined
188
+ : geoCoordinates(business.geo),
189
+ priceRange: business.priceRange,
190
+ image: listed(business.image)?.map((photo) =>
191
+ absoluteUrl(business.url, photo.src, "venue photo")
192
+ ),
193
+ telephone:
194
+ business.telephone === undefined
195
+ ? undefined
196
+ : e164(business.telephone),
197
+ email: business.email,
198
+ openingHoursSpecification:
199
+ business.hours === undefined
200
+ ? undefined
201
+ : openingHours(business.hours),
202
+ sameAs: business.sameAs,
203
+ parentOrganization: reference(business.parentOrganization),
205
204
  };
206
205
  }
@@ -1,7 +1,7 @@
1
1
  import type { Quantity } from "../money.ts";
2
2
  import type { HttpsUrl } from "../url.ts";
3
3
  import type { BusinessId, OrganizationId } from "./ids.ts";
4
- import { type JsonLdNode, schemaType } from "./node.ts";
4
+ import { type JsonLdNode, listed, reference, schemaType } from "./node.ts";
5
5
  import { quantitativeValue } from "./quantity.ts";
6
6
 
7
7
  /**
@@ -92,19 +92,11 @@ export function creativeWork(work: CreativeWorkInput): JsonLdNode {
92
92
  "@type": schemaType(["CreativeWork", ...(work.alsoA ?? [])]),
93
93
  name: work.name,
94
94
  url: work.url,
95
- ...(work.description === undefined
96
- ? {}
97
- : { description: work.description }),
98
- ...(work.image === undefined ? {} : { image: work.image }),
99
- ...(work.genre === undefined || work.genre.length === 0
100
- ? {}
101
- : { genre: work.genre }),
102
- ...(work.creator === undefined
103
- ? {}
104
- : { creator: { "@id": work.creator } }),
105
- ...(work.properties === undefined || work.properties.length === 0
106
- ? {}
107
- : { additionalProperty: work.properties.map(propertyValue) }),
95
+ description: work.description,
96
+ image: work.image,
97
+ genre: listed(work.genre),
98
+ creator: reference(work.creator),
99
+ additionalProperty: listed(work.properties)?.map(propertyValue),
108
100
  };
109
101
  }
110
102
 
@@ -114,12 +106,8 @@ function propertyValue(property: CreativeWorkProperty): JsonLdNode {
114
106
  name: property.name,
115
107
  value: {
116
108
  ...quantitativeValue(property.value),
117
- ...(property.unitCode === undefined
118
- ? {}
119
- : { unitCode: property.unitCode }),
120
- ...(property.unitText === undefined
121
- ? {}
122
- : { unitText: property.unitText }),
109
+ unitCode: property.unitCode,
110
+ unitText: property.unitText,
123
111
  },
124
112
  };
125
113
  }
@@ -7,7 +7,13 @@
7
7
 
8
8
  import { literal } from "../analytics/tags.ts";
9
9
 
10
- /** A node in the graph. Deliberately loose: `@type` is what a consumer varies. */
10
+ /**
11
+ * A node in the graph. Deliberately loose: `@type` is what a consumer varies.
12
+ *
13
+ * An optional field it lacks is written as `undefined`, never branched around
14
+ * with a conditional spread: `serializeJsonLd` goes through JSON, which drops
15
+ * it, so the published node has no such key either way.
16
+ */
11
17
  export interface JsonLdNode {
12
18
  readonly "@type": string | readonly string[];
13
19
  readonly [key: string]: unknown;
@@ -47,8 +53,8 @@ export function schemaType(
47
53
  * engine reads it as *another* name and a repeat of the first one is not that:
48
54
  * it widens nothing and is one more string to reconcile. Decided here rather
49
55
  * than by the caller — three nodes take the field, the rule is the same on all
50
- * of them, and a project asked to write `name === alt ? {} : { alt }` at each
51
- * one is being handed lib's rule to enforce by hand.
56
+ * of them, and a project asked to write `alt === name ? undefined : alt` at
57
+ * each one is being handed lib's rule to enforce by hand.
52
58
  *
53
59
  * Which makes `alternateName: brand.name` safe to pass unconditionally: a venue
54
60
  * whose name already *is* the brand's simply emits nothing.
@@ -56,10 +62,34 @@ export function schemaType(
56
62
  export function alternateName(
57
63
  name: string,
58
64
  alternate: string | undefined
59
- ): { readonly alternateName?: string } {
60
- return alternate === undefined || alternate === name
61
- ? {}
62
- : { alternateName: alternate };
65
+ ): string | undefined {
66
+ return alternate === name ? undefined : alternate;
67
+ }
68
+
69
+ /**
70
+ * A pointer to a node published elsewhere in the graph, or nothing.
71
+ *
72
+ * Every field built from it is optional, and an `undefined` field is dropped
73
+ * when the graph is serialised: so a node can write `author:
74
+ * reference(item.author)` without a branch.
75
+ */
76
+ export function reference<Id extends string>(
77
+ id: Id | undefined
78
+ ): { readonly "@id": Id } | undefined {
79
+ return id === undefined ? undefined : { "@id": id };
80
+ }
81
+
82
+ /**
83
+ * A list, or nothing when it is empty.
84
+ *
85
+ * An empty list is not a set of nothing, it is a field saying nothing — a
86
+ * claim to have been asked and had no answer — so it is left out like any
87
+ * other absent field.
88
+ */
89
+ export function listed<T>(
90
+ list: readonly T[] | undefined
91
+ ): readonly T[] | undefined {
92
+ return list === undefined || list.length === 0 ? undefined : list;
63
93
  }
64
94
 
65
95
  /** The graph, serialised for a `<script type="application/ld+json">`. */
@@ -8,7 +8,7 @@ import type { ImageAsset } from "../image.ts";
8
8
  import type { IsoDate } from "../types.ts";
9
9
  import { absoluteUrl, type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
10
10
  import { organizationId } from "./ids.ts";
11
- import { alternateName, type JsonLdNode } from "./node.ts";
11
+ import { alternateName, type JsonLdNode, listed } from "./node.ts";
12
12
  import { postalAddress } from "./place.ts";
13
13
 
14
14
  export interface OrganizationInput {
@@ -172,36 +172,27 @@ export function organization(brand: OrganizationInput): JsonLdNode {
172
172
  "@id": organizationId(brand.url),
173
173
  name: brand.name,
174
174
  url: brand.url,
175
- ...alternateName(brand.name, brand.alternateName),
176
- ...(brand.description === undefined
177
- ? {}
178
- : { description: brand.description }),
179
- ...(brand.foundingDate === undefined
180
- ? {}
181
- : { foundingDate: brand.foundingDate }),
182
- ...(brand.logo === undefined
183
- ? {}
184
- : { logo: joinUrl(brand.url, brand.logo) }),
185
- ...(brand.image === undefined || brand.image.length === 0
186
- ? {}
187
- : {
188
- image: brand.image.map((photo) =>
189
- absoluteUrl(brand.url, photo.src, "organization photo")
190
- ),
191
- }),
192
- ...(brand.legalName === undefined
193
- ? {}
194
- : { legalName: brand.legalName }),
195
- ...(brand.vatID === undefined ? {} : { vatID: brand.vatID }),
175
+ alternateName: alternateName(brand.name, brand.alternateName),
176
+ description: brand.description,
177
+ foundingDate: brand.foundingDate,
178
+ logo:
179
+ brand.logo === undefined
180
+ ? undefined
181
+ : joinUrl(brand.url, brand.logo),
182
+ image: listed(brand.image)?.map((photo) =>
183
+ absoluteUrl(brand.url, photo.src, "organization photo")
184
+ ),
185
+ legalName: brand.legalName,
186
+ vatID: brand.vatID,
196
187
  // E.164, as on the business: the only form that identifies a number
197
188
  // unambiguously enough to match a company against a listing.
198
- ...(brand.telephone === undefined
199
- ? {}
200
- : { telephone: e164(brand.telephone) }),
201
- ...(brand.email === undefined ? {} : { email: brand.email }),
202
- ...(brand.sameAs === undefined ? {} : { sameAs: brand.sameAs }),
203
- ...(brand.address === undefined
204
- ? {}
205
- : { address: postalAddress(brand.address) }),
189
+ telephone:
190
+ brand.telephone === undefined ? undefined : e164(brand.telephone),
191
+ email: brand.email,
192
+ sameAs: brand.sameAs,
193
+ address:
194
+ brand.address === undefined
195
+ ? undefined
196
+ : postalAddress(brand.address),
206
197
  };
207
198
  }
@@ -37,13 +37,9 @@ export function postalAddress(address: PostalAddress): JsonLdNode {
37
37
  "@type": "PostalAddress",
38
38
  streetAddress: address.street,
39
39
  addressLocality: address.locality,
40
- ...(address.postalCode === undefined
41
- ? {}
42
- : { postalCode: address.postalCode }),
40
+ postalCode: address.postalCode,
43
41
  addressCountry: address.country,
44
- ...(address.region === undefined
45
- ? {}
46
- : { addressRegion: address.region }),
42
+ addressRegion: address.region,
47
43
  };
48
44
  }
49
45
 
@@ -11,7 +11,7 @@ import {
11
11
  import type { NonEmpty } from "../types.ts";
12
12
  import type { HttpsUrl } from "../url.ts";
13
13
  import type { BusinessId, OrganizationId } from "./ids.ts";
14
- import { type JsonLdNode, schemaType } from "./node.ts";
14
+ import { type JsonLdNode, reference, schemaType } from "./node.ts";
15
15
  import { quantitativeValue } from "./quantity.ts";
16
16
 
17
17
  export interface ProductInput {
@@ -91,7 +91,7 @@ export function product(item: ProductInput): JsonLdNode {
91
91
  name: item.name,
92
92
  description: item.description,
93
93
  url: item.url,
94
- ...(item.image === undefined ? {} : { image: item.image }),
94
+ image: item.image,
95
95
  offers: offersFor(item),
96
96
  };
97
97
  }
@@ -116,30 +116,24 @@ function offerFor(
116
116
  "@type": "Offer",
117
117
  // An `Offer` is a `Thing`, so it can carry a `name`; it is what tells
118
118
  // the adults' price from the children's.
119
- ...(name === undefined ? {} : { name }),
119
+ name,
120
120
  price: amount,
121
121
  priceCurrency: item.price.currency,
122
122
  availability: "https://schema.org/InStock",
123
123
  url: item.url,
124
- ...(band === undefined
125
- ? {}
126
- : {
127
- // What makes two prices a table rather than a contradiction:
128
- // each one says the quantity it applies at.
129
- //
130
- // The quantity is passed as written, not through `tierRange`:
131
- // the two take the same `number | { min, max }`, and widening
132
- // first would publish a band of one as `minValue: 2,
133
- // maxValue: 2` — a range across a single size, where `value:
134
- // 2` is the same claim said plainly.
135
- eligibleQuantity: {
136
- ...quantitativeValue(band.quantity),
137
- ...(unit === undefined ? {} : { unitText: unit }),
138
- },
139
- }),
140
- ...(item.seller === undefined
141
- ? {}
142
- : { seller: { "@id": item.seller } }),
124
+ // What makes two prices a table rather than a contradiction:
125
+ // each one says the quantity it applies at.
126
+ //
127
+ // The quantity is passed as written, not through `tierRange`:
128
+ // the two take the same `number | { min, max }`, and widening
129
+ // first would publish a band of one as `minValue: 2,
130
+ // maxValue: 2` — a range across a single size, where `value:
131
+ // 2` is the same claim said plainly.
132
+ eligibleQuantity:
133
+ band === undefined
134
+ ? undefined
135
+ : { ...quantitativeValue(band.quantity), unitText: unit },
136
+ seller: reference(item.seller),
143
137
  };
144
138
  }
145
139
 
@@ -1,7 +1,7 @@
1
1
  import type { HttpsUrl } from "../url.ts";
2
2
  import type { BusinessId, OrganizationId } from "./ids.ts";
3
3
  import { serviceId } from "./ids.ts";
4
- import type { JsonLdNode } from "./node.ts";
4
+ import { type JsonLdNode, listed, reference } from "./node.ts";
5
5
 
6
6
  /**
7
7
  * What a business *does*, for the sites that sell work rather than tickets.
@@ -82,29 +82,23 @@ export interface ServiceInput {
82
82
  }
83
83
 
84
84
  /** An `Audience` node, or nothing. See `ServiceInput.audience`. */
85
- function audienceFor(audienceType: string | undefined): {
86
- readonly audience?: JsonLdNode;
87
- } {
85
+ function audienceFor(audienceType: string | undefined): JsonLdNode | undefined {
88
86
  return audienceType === undefined
89
- ? {}
90
- : { audience: { "@type": "Audience", audienceType } };
87
+ ? undefined
88
+ : { "@type": "Audience", audienceType };
91
89
  }
92
90
 
93
91
  /** `areaServed`, as `Place` nodes. See `ServiceArea`. */
94
- function areasFor(areas: readonly ServiceArea[] | undefined): {
95
- readonly areaServed?: readonly JsonLdNode[];
96
- } {
92
+ function areasFor(
93
+ areas: readonly ServiceArea[] | undefined
94
+ ): readonly JsonLdNode[] | undefined {
97
95
  // Length rather than presence: a service that serves nowhere in particular
98
96
  // says nothing, and an empty list is a claim to have been asked and had no
99
97
  // 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
- };
98
+ return listed(areas)?.map((area) => ({
99
+ "@type": area.kind ?? "AdministrativeArea",
100
+ name: area.name,
101
+ }));
108
102
  }
109
103
 
110
104
  /**
@@ -118,17 +112,11 @@ export function service(input: ServiceInput): JsonLdNode {
118
112
  "@id": serviceId(input.url),
119
113
  name: input.name,
120
114
  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),
115
+ description: input.description,
116
+ serviceType: input.serviceType,
117
+ provider: reference(input.provider),
118
+ audience: audienceFor(input.audience),
119
+ areaServed: areasFor(input.areaServed),
132
120
  };
133
121
  }
134
122
 
@@ -212,9 +200,7 @@ export function offerCatalog(input: OfferCatalogInput): JsonLdNode {
212
200
  return {
213
201
  "@type": "OfferCatalog",
214
202
  name: input.name,
215
- ...(input.description === undefined
216
- ? {}
217
- : { description: input.description }),
203
+ description: input.description,
218
204
  // Unconditional: this is the node's only link to anything.
219
205
  provider: { "@id": input.provider },
220
206
  itemListElement: input.entries.map((entry) => ({
@@ -224,13 +210,11 @@ export function offerCatalog(input: OfferCatalogInput): JsonLdNode {
224
210
  // The id only where there is a page to hang it on. A fragment
225
211
  // invented for an entry with no page would be an identity for
226
212
  // something nothing else can ever refer to.
227
- ...(entry.url === undefined
228
- ? {}
229
- : { "@id": serviceId(entry.url), url: entry.url }),
213
+ "@id":
214
+ entry.url === undefined ? undefined : serviceId(entry.url),
215
+ url: entry.url,
230
216
  name: entry.name,
231
- ...(entry.description === undefined
232
- ? {}
233
- : { description: entry.description }),
217
+ description: entry.description,
234
218
  },
235
219
  })),
236
220
  };
@@ -227,13 +227,9 @@ export function videoObject(video: VideoInput): JsonLdNode {
227
227
  )
228
228
  ),
229
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 }),
230
+ description: video.description,
231
+ duration,
232
+ contentUrl: video.contentUrl,
233
+ embedUrl: video.embedUrl,
238
234
  };
239
235
  }
@@ -1,6 +1,6 @@
1
1
  import type { HttpsUrl } from "../url.ts";
2
2
  import { type OrganizationId, websiteId } from "./ids.ts";
3
- import { alternateName, type JsonLdNode } from "./node.ts";
3
+ import { alternateName, type JsonLdNode, reference } from "./node.ts";
4
4
 
5
5
  export interface WebsiteInput {
6
6
  /**
@@ -50,9 +50,7 @@ export function website(site: WebsiteInput): JsonLdNode {
50
50
  "@id": websiteId(site.url),
51
51
  name: site.name,
52
52
  url: site.url,
53
- ...alternateName(site.name, site.alternateName),
54
- ...(site.publisher === undefined
55
- ? {}
56
- : { publisher: { "@id": site.publisher } }),
53
+ alternateName: alternateName(site.name, site.alternateName),
54
+ publisher: reference(site.publisher),
57
55
  };
58
56
  }
@@ -9,32 +9,9 @@ import type { RobotsPolicy } from "./robots.ts";
9
9
  import type { ShareImage } from "./share-image.ts";
10
10
 
11
11
  /**
12
- * What only a consumer can supply: a page's words, and what kind of thing it is.
12
+ * What only a consumer can supply: a page's words, and whether it is an article.
13
13
  */
14
14
 
15
- /**
16
- * What kind of thing a page represents, for `og:type`.
17
- *
18
- * The whole vocabulary Open Graph defines, and a union for the reason
19
- * `RobotsDirective` is one: it is small and closed, and a value outside it is
20
- * not an error anywhere — a scraper that does not recognise `og:type` falls
21
- * back to `website` and renders a perfectly ordinary card. So `"artcile"` costs
22
- * you the article treatment and looks exactly like success.
23
- *
24
- * `website` is the default and the right answer for most pages. `article` is
25
- * the one that earns anything extra: it is what tells a scraper this is a dated
26
- * piece of writing rather than a section of the site.
27
- *
28
- * Reference: <https://ogp.me/#types>
29
- */
30
- export type OpenGraphType =
31
- | "website"
32
- | "article"
33
- | "book"
34
- | "profile"
35
- | `music.${"song" | "album" | "playlist" | "radio_station"}`
36
- | `video.${"movie" | "episode" | "tv_show" | "other"}`;
37
-
38
15
  /**
39
16
  * A page that is a dated piece of writing, said once.
40
17
  *
@@ -48,10 +25,11 @@ export type OpenGraphType =
48
25
  * Supplying this is what emits the `Article` node, `og:type: article` and the
49
26
  * `article:*` properties together. They are three statements of one fact, and a
50
27
  * page that could make them separately is a page that could make two of them.
51
- * That is why `MetaContent.type` cannot be set to `"article"`: saying a page is
52
- * an article without saying when it was published is the half-statement this
53
- * exists to prevent, and it is also the half that carries no benefit — the date
54
- * is the whole reason the type is worth claiming.
28
+ * That is why there is no `type` to set: saying a page is an article without
29
+ * saying when it was published is the half-statement this exists to prevent.
30
+ * Open Graph's other kinds (`book`, `profile`, `video.*`) each come with fields
31
+ * of their own; one would be added the same way, as a field that is the
32
+ * description, with the two made exclusive then.
55
33
  */
56
34
  export interface ArticleContent {
57
35
  /**
@@ -99,34 +77,8 @@ export interface ArticleContent {
99
77
  readonly images?: readonly ImageAsset[];
100
78
  }
101
79
 
102
- /**
103
- * What kind of page this is: an ordinary one, or an article.
104
- *
105
- * A union rather than two optional fields, so the pair cannot both be set. They
106
- * answer the same question — `article` decides `og:type` — and a page that
107
- * supplied both would have one of them silently ignored, which is the shape of
108
- * mistake this folder keeps removing rather than reporting.
109
- *
110
- * Both branches allow the fields to be absent, so an ordinary page writes
111
- * neither and gets `website`.
112
- */
113
- export type PageKind =
114
- | {
115
- readonly type?: Exclude<OpenGraphType, "article">;
116
- readonly article?: never;
117
- }
118
- | {
119
- readonly type?: never;
120
- /**
121
- * This page's publication details, if it is a dated piece of writing.
122
- *
123
- * One field, three outputs — see `ArticleContent`.
124
- */
125
- readonly article: ArticleContent;
126
- };
127
-
128
- /** The fields every page has, whatever kind it is. */
129
- export interface MetaContentBase {
80
+ /** What a page passes to `metaFor`: its words, its picture, and whether it is an article. */
81
+ export interface MetaContent {
130
82
  readonly title: string;
131
83
  /**
132
84
  * Required: a page without a description leaves search engines and every
@@ -172,18 +124,14 @@ export interface MetaContentBase {
172
124
  * and never sees the page they end up on.
173
125
  */
174
126
  readonly jsonLd?: readonly JsonLdNode[];
127
+ /**
128
+ * This page's publication details, if it is a dated piece of writing:
129
+ * `og:type` becomes `article`, with its `article:*` properties and an
130
+ * `Article` node. Without it a page is a `website`. See `ArticleContent`.
131
+ */
132
+ readonly article?: ArticleContent;
175
133
  }
176
134
 
177
- /**
178
- * What a page passes to `metaFor`: its words, its picture, and what kind of
179
- * thing it is.
180
- *
181
- * An intersection rather than one interface because `PageKind` is a union, and
182
- * that union is the whole point — see it for why `type` and `article` cannot
183
- * both be written.
184
- */
185
- export type MetaContent = MetaContentBase & PageKind;
186
-
187
135
  /** Browser-UI tint. A single colour, or one per `prefers-color-scheme`. */
188
136
  export type ThemeColor =
189
137
  | string