@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,106 @@
1
+ import { type HttpsUrl, normalizeOrigin } from "../url.ts";
2
+
3
+ /**
4
+ * The `@id` each addressable node takes, and the types that keep them apart.
5
+ *
6
+ * Their own file because both ends need them: the node that *is* the thing, and
7
+ * every node that points at it. A fragment invented separately at each end is a
8
+ * dangling reference that no validator reports and no crawler resolves.
9
+ *
10
+ * ## Which nodes get one
11
+ *
12
+ * **An `@id` is for being pointed at.** That is the whole test, and it splits
13
+ * what lib emits cleanly in two:
14
+ *
15
+ * - *With* — `LocalBusiness`, `Organization`, `WebSite`, `Service`. Each is
16
+ * referred to by something else: a product names its seller, an article its
17
+ * publisher, a venue its parent brand, a catalogue the services it lists.
18
+ * - *Without* — `Product`, `Article`, `VideoObject`, `BreadcrumbList`,
19
+ * `OfferCatalog`. Nothing in the graph refers to these, so a fragment on each
20
+ * would be an identity kept in step with nothing. They are ordinary
21
+ * unidentified nodes, as a `PostalAddress` inside a business is.
22
+ *
23
+ * Two things follow, and both have come up:
24
+ *
25
+ * 1. **An unused id is not free.** An `@id` is a claim that two nodes carrying
26
+ * it are the same thing, so a crawler *merges* them. That is the mechanism
27
+ * behind `organizationId` — and the reason not to mint fragments for nodes
28
+ * with no natural key. A service page is one page and one service, so
29
+ * `serviceId` cannot collide; a catalogue has no such key, and two on one
30
+ * page deriving `#offer-catalog` alike would silently become one list.
31
+ * 2. **Adding one later is additive, not a migration.** The input gains an
32
+ * optional field and every existing call still compiles, so nothing here is
33
+ * being deferred at the cost of a sweep through the consuming projects.
34
+ * Mint an id the day something points at the node, and not before.
35
+ */
36
+
37
+ /**
38
+ * The identity of a business node: an origin with the `#business` fragment.
39
+ *
40
+ * A shape rather than an opaque brand, like `EmailAddress`. What makes an id
41
+ * valid is what it looks like, so the type says that and stays honest: a
42
+ * hand-written `"https://x.example#business"` is a perfectly good id and type
43
+ * -checks, while the mistake this exists to stop — passing a bare URL where a
44
+ * reference belongs — does not.
45
+ */
46
+ export type BusinessId = `${HttpsUrl}#business`;
47
+
48
+ /** The identity of a brand node. See `BusinessId`. */
49
+ export type OrganizationId = `${HttpsUrl}#organization`;
50
+
51
+ /** The identity of a site node. See `BusinessId`. */
52
+ export type WebsiteId = `${HttpsUrl}#website`;
53
+
54
+ /**
55
+ * The identity of a service node — a *page's* URL with `#service`, not an
56
+ * origin's.
57
+ *
58
+ * The one id here that is not site-level, and the reason it is not: a site has
59
+ * one business, one brand and one site node, so those three are fragments on an
60
+ * origin. It has as many services as it has service pages, and they need
61
+ * telling apart.
62
+ *
63
+ * It exists because two pages describe the same service: the page that *is* it,
64
+ * and the catalogue elsewhere that lists it among the others. Without a shared
65
+ * id those are two services that happen to be spelled alike — which is the same
66
+ * failure `organizationId` exists to stop, one level down.
67
+ */
68
+ export type ServiceId = `${HttpsUrl}#service`;
69
+
70
+ /**
71
+ * The `@id` a business node takes, from the site's origin.
72
+ *
73
+ * Exported because a reference has to be built by whoever points at it, and the
74
+ * fragment must match exactly — a node linking to `#local-business` when the
75
+ * business calls itself `#business` is a dangling reference that no validator
76
+ * reports and no crawler resolves. One definition, used by both ends.
77
+ */
78
+ export function businessId(url: HttpsUrl): BusinessId {
79
+ return `${normalizeOrigin(url)}#business`;
80
+ }
81
+
82
+ /** The `@id` a brand node takes. See `businessId`. */
83
+ export function organizationId(url: HttpsUrl): OrganizationId {
84
+ return `${normalizeOrigin(url)}#organization`;
85
+ }
86
+
87
+ /** The `@id` a site node takes. See `businessId`. */
88
+ export function websiteId(url: HttpsUrl): WebsiteId {
89
+ return `${normalizeOrigin(url)}#website`;
90
+ }
91
+
92
+ /**
93
+ * The `@id` a service node takes, from **the service's own page**.
94
+ *
95
+ * The origin is deliberately not normalised away, unlike the three above: those
96
+ * identify things a site has one of, this identifies one of many. Pass the same
97
+ * URL the service page is canonical at, and the catalogue listing it will point
98
+ * at the node that page emits.
99
+ *
100
+ * The only per-page id here, and the only one lib mints beyond the three
101
+ * site-level ones — see the note at the top of this file for the test that
102
+ * decides which nodes get one.
103
+ */
104
+ export function serviceId(url: HttpsUrl): ServiceId {
105
+ return `${url}#service`;
106
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Structured data, as one `@graph` per page.
3
+ *
4
+ * One script rather than several: the nodes describe one page, and a node that
5
+ * names another by `@id` — a review pointing at the business it reviews — is
6
+ * resolved within the graph it shares. Separate blocks are read separately, so
7
+ * that reference has nothing to land on.
8
+ *
9
+ * lib emits only what it can *derive* — a breadcrumb is the route table read
10
+ * back — because everything else is a claim about a business, and a claim lib
11
+ * invented would be a claim nobody checked.
12
+ *
13
+ * **Who this is written for: Google.** Every other reader is welcome and none
14
+ * of them breaks a tie. It comes up more often than it looks, because the
15
+ * markup that suits a search engine and the markup that suits a naive scraper
16
+ * are not always the same — a scraper that string-matches `"LocalBusiness"`
17
+ * wants the general type stated, while Google asks for the most specific
18
+ * subtype and resolves the hierarchy itself. Where the two disagree, follow the
19
+ * documented guidance and let the scraper miss it.
20
+ *
21
+ * Which is why the doc on a field tends to quote Google verbatim and date the
22
+ * check. A rule you can trace to a sentence survives the next person who reads
23
+ * the schema.org page and concludes something reasonable and different.
24
+ *
25
+ * **One file per node.** Each is a self-contained answer to "what does Google
26
+ * read here, and why this shape" — several hundred lines of that reasoning,
27
+ * which is worth keeping beside its own node rather than a thousand lines from
28
+ * somewhere else. What they share lives in `node.ts` and `ids.ts`, and only
29
+ * `place.ts` and `quantity.ts` are used by more than one.
30
+ */
31
+
32
+ export { type ArticleInput, article } from "./article.ts";
33
+ export { type BreadcrumbStep, breadcrumbList } from "./breadcrumb.ts";
34
+ export { type LocalBusinessInput, localBusiness } from "./business.ts";
35
+ export {
36
+ type BusinessId,
37
+ businessId,
38
+ type OrganizationId,
39
+ organizationId,
40
+ type ServiceId,
41
+ serviceId,
42
+ type WebsiteId,
43
+ websiteId,
44
+ } from "./ids.ts";
45
+ export { type JsonLdNode, serializeJsonLd } from "./node.ts";
46
+ export { type OrganizationInput, organization } from "./organization.ts";
47
+ export { geoCoordinates, openingHours, postalAddress } from "./place.ts";
48
+ export { type ProductInput, product } from "./product.ts";
49
+ export { quantitativeValue, quantitiesFor } from "./quantity.ts";
50
+ export {
51
+ type CatalogEntry,
52
+ type OfferCatalogInput,
53
+ offerCatalog,
54
+ type ServiceArea,
55
+ type ServiceInput,
56
+ service,
57
+ } from "./service.ts";
58
+ export { type VideoInput, type VideoSource, videoObject } from "./video.ts";
59
+ export { type WebsiteInput, website } from "./website.ts";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What a node is, and how a graph of them is written out.
3
+ *
4
+ * The mechanics rather than the vocabulary: every other file here builds one
5
+ * kind of node and imports these to do it.
6
+ */
7
+
8
+ /** A node in the graph. Deliberately loose: `@type` is what a consumer varies. */
9
+ export interface JsonLdNode {
10
+ readonly "@type": string | readonly string[];
11
+ readonly [key: string]: unknown;
12
+ }
13
+
14
+ /**
15
+ * A value as JSON-LD is normally written: bare for one, a list for several.
16
+ *
17
+ * Input and output are separate concerns here. A caller may write either shape,
18
+ * because always-an-array would mean `["Restaurant"]` at every call site to
19
+ * serve the rare case — but a list of one in the *output* reads as a bug to
20
+ * anyone who has seen ordinary JSON-LD, which is the same smell as the
21
+ * `["LocalBusiness"]` this file used to emit. So the shape is decided once,
22
+ * here, rather than passed through from whatever a caller happened to type.
23
+ *
24
+ * Generic because two fields want it and the rule is the same on both: `@type`,
25
+ * where several means a thing that is genuinely several things, and `image`,
26
+ * where several means one picture at several ratios.
27
+ */
28
+ export function oneOrMany<T>(value: T | readonly T[]): T | readonly T[] {
29
+ if (!Array.isArray(value)) return value;
30
+ const [only, ...rest] = value as readonly T[];
31
+ return only !== undefined && rest.length === 0 ? only : value;
32
+ }
33
+
34
+ /** `@type`, in the shape JSON-LD is normally written. See `oneOrMany`. */
35
+ export function schemaType(
36
+ type: string | readonly string[]
37
+ ): string | readonly string[] {
38
+ return oneOrMany(type);
39
+ }
40
+
41
+ /**
42
+ * `alternateName`, if there is a second name worth offering.
43
+ *
44
+ * Dropped when it matches the name it is an alternative to, because a search
45
+ * engine reads it as *another* name and a repeat of the first one is not that:
46
+ * it widens nothing and is one more string to reconcile. Decided here rather
47
+ * than by the caller — three nodes take the field, the rule is the same on all
48
+ * of them, and a project asked to write `name === alt ? {} : { alt }` at each
49
+ * one is being handed lib's rule to enforce by hand.
50
+ *
51
+ * Which makes `alternateName: brand.name` safe to pass unconditionally: a venue
52
+ * whose name already *is* the brand's simply emits nothing.
53
+ */
54
+ export function alternateName(
55
+ name: string,
56
+ alternate: string | undefined
57
+ ): { readonly alternateName?: string } {
58
+ return alternate === undefined || alternate === name
59
+ ? {}
60
+ : { alternateName: alternate };
61
+ }
62
+
63
+ /**
64
+ * The graph, serialised for a `<script type="application/ld+json">`.
65
+ *
66
+ * Every `<` is replaced by its unicode escape, and that is the whole reason
67
+ * this is a function rather than a `JSON.stringify` at the call site. A script element ends at the first
68
+ * `</script` in its text, so a business name, a room description or an alt text
69
+ * containing one would close the block early and spill the rest of the graph
70
+ * into the page as markup. The escape is invisible to a JSON parser and to
71
+ * anything reading the data.
72
+ */
73
+ export function serializeJsonLd(nodes: readonly JsonLdNode[]): string {
74
+ return JSON.stringify({
75
+ "@context": "https://schema.org",
76
+ "@graph": nodes,
77
+ }).replaceAll("<", "\\u003c");
78
+ }
@@ -0,0 +1,154 @@
1
+ import {
2
+ type EmailAddress,
3
+ e164,
4
+ type PhoneNumber,
5
+ type PostalAddress,
6
+ } from "../contact.ts";
7
+ import { type HttpsUrl, joinUrl, type UrlPath } from "../url.ts";
8
+ import { organizationId } from "./ids.ts";
9
+ import { alternateName, type JsonLdNode } from "./node.ts";
10
+ import { postalAddress } from "./place.ts";
11
+
12
+ export interface OrganizationInput {
13
+ /** The brand's name, which is not necessarily any one venue's name. */
14
+ readonly name: string;
15
+ /** The brand's own home. Also its identity, see `@id` below. */
16
+ readonly url: HttpsUrl;
17
+ /**
18
+ * Another name the brand actually goes by — an acronym, a short form.
19
+ *
20
+ * Google's test is the word *common*: "another common name that your
21
+ * organization goes by". A name people use, not a convention invented for
22
+ * the markup — a form nobody types is worse than none, because it is a name
23
+ * offered to match queries that will never contain it.
24
+ *
25
+ * Google also says to keep this in step with the site name. That holds when
26
+ * one organization publishes one site; where a brand publishes a site per
27
+ * venue the two names differ on purpose, and it is the *brand's* aliases
28
+ * that belong here.
29
+ */
30
+ readonly alternateName?: string;
31
+ /**
32
+ * The brand's logo, as a path on `url` — `"/logo.png"`.
33
+ *
34
+ * The reason most sites want this node at all: it is what a knowledge panel
35
+ * shows. Google asks for at least 112×112 and prefers a plain mark on a
36
+ * clear background over a photograph.
37
+ *
38
+ * A path rather than a URL so it cannot be hosted anywhere but the brand's
39
+ * own origin. A node claiming the brand lives at one address while keeping
40
+ * its logo at another is the mistake this shape makes unwritable, and the
41
+ * origin stops being repeated in a second place that can disagree.
42
+ *
43
+ * Prefer a stable path over a bundled asset: `/_astro/logo.<hash>.png`
44
+ * moves whenever the file is touched, and this is entity data a search
45
+ * engine keeps, not a page resource it re-fetches.
46
+ */
47
+ readonly logo?: UrlPath;
48
+ /**
49
+ * The registered name, where it differs from the trading one.
50
+ *
51
+ * "Acme Entertainment SRL" against "Acme". Worth stating when they differ,
52
+ * because it is what ties the brand to public records.
53
+ */
54
+ readonly legalName?: string;
55
+ /**
56
+ * How to reach the *company*, as opposed to any one of its venues.
57
+ *
58
+ * Flat rather than a `ContactPoint`, which is the shape you would expect
59
+ * from reading schema.org. Google's Organization page documents `telephone`
60
+ * and `email` directly, and documents exactly two sub-properties under
61
+ * `contactPoint` — `telephone` and `email`. So the nested form carries the
62
+ * same two facts one level deeper.
63
+ *
64
+ * What would have justified the nesting is `contactType`, which separates a
65
+ * sales line from a support line. That belonged to the Corporate Contact
66
+ * feature, and Google retired it: its documentation now redirects to a
67
+ * knowledge-panel help page that does not mention structured data. Checked
68
+ * August 2026.
69
+ *
70
+ * Only worth filling in where the company really is reachable separately.
71
+ * A brand whose only number belongs to one of its venues should leave both
72
+ * out: putting that number here says that venue answers for the company,
73
+ * and a contact that misdirects is worse than one that is absent.
74
+ */
75
+ readonly telephone?: PhoneNumber;
76
+ readonly email?: EmailAddress;
77
+ /**
78
+ * Pages elsewhere that are unmistakably this brand.
79
+ *
80
+ * The same field as on a venue, one level up — social accounts, Wikipedia,
81
+ * Wikidata, a trade listing. It is what lets a search engine decide two
82
+ * mentions are the same company, which is the whole business of a knowledge
83
+ * panel. Pages *about* the brand that it does not control — a press
84
+ * write-up — are not it.
85
+ */
86
+ readonly sameAs?: readonly HttpsUrl[];
87
+ /**
88
+ * Where the company is, when there is a single answer.
89
+ *
90
+ * Optional, unlike `LocalBusinessInput.address`, and the difference is the
91
+ * whole distinction between the two nodes. A venue *is* a place and cannot
92
+ * be described without one. A company need not be: a brand with venues in
93
+ * three cities has three addresses, and each belongs to the venue that
94
+ * keeps it — stating one here would pick a favourite, and stating the head
95
+ * office would invite a search engine to put the wrong city beside every
96
+ * room.
97
+ *
98
+ * So it is for the other shape, which the second example is: one company,
99
+ * one workshop, no venue node at all. Without this the address reaches the
100
+ * contact page and nothing in the graph.
101
+ *
102
+ * Google lists it as recommended — *"The address (physical or mailing) of
103
+ * your organization, if applicable"* — and "if applicable" is doing the same
104
+ * work as the `?` here. Checked August 2026.
105
+ */
106
+ readonly address?: PostalAddress;
107
+ }
108
+
109
+ /**
110
+ * The brand behind the site.
111
+ *
112
+ * **Only worth emitting when the brand is bigger than the venue.** A
113
+ * `LocalBusiness` already *is* an `Organization` — the type extends it — so a
114
+ * single independent venue should put `logo` and `sameAs` straight on its
115
+ * business node and stop there. Two nodes for one entity is not more
116
+ * information, it is the same claim made twice for a crawler to reconcile.
117
+ *
118
+ * It earns its place when several venues share a name: each site declares its
119
+ * own `LocalBusiness` and points `parentOrganization` at the same brand `@id`,
120
+ * which is what says the Rome and Bucharest rooms are one company rather than
121
+ * two that happen to be spelled alike.
122
+ *
123
+ * The `@id` is the brand's URL with a fragment, and is therefore identical on
124
+ * every deployment — which is the entire mechanism. Point two sites at
125
+ * different ids and they are two brands.
126
+ *
127
+ * Reference:
128
+ * <https://developers.google.com/search/docs/appearance/structured-data/organization>
129
+ */
130
+ export function organization(brand: OrganizationInput): JsonLdNode {
131
+ return {
132
+ "@type": "Organization",
133
+ "@id": organizationId(brand.url),
134
+ name: brand.name,
135
+ url: brand.url,
136
+ ...alternateName(brand.name, brand.alternateName),
137
+ ...(brand.logo === undefined
138
+ ? {}
139
+ : { logo: joinUrl(brand.url, brand.logo) }),
140
+ ...(brand.legalName === undefined
141
+ ? {}
142
+ : { legalName: brand.legalName }),
143
+ // E.164, as on the business: the only form that identifies a number
144
+ // unambiguously enough to match a company against a listing.
145
+ ...(brand.telephone === undefined
146
+ ? {}
147
+ : { telephone: e164(brand.telephone) }),
148
+ ...(brand.email === undefined ? {} : { email: brand.email }),
149
+ ...(brand.sameAs === undefined ? {} : { sameAs: brand.sameAs }),
150
+ ...(brand.address === undefined
151
+ ? {}
152
+ : { address: postalAddress(brand.address) }),
153
+ };
154
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ assertCoordinates,
3
+ type Coordinates,
4
+ type PostalAddress,
5
+ } from "../contact.ts";
6
+ import {
7
+ assertHours,
8
+ groupHours,
9
+ schemaDays,
10
+ type WeeklyHours,
11
+ } from "../hours.ts";
12
+ import type { JsonLdNode } from "./node.ts";
13
+
14
+ /**
15
+ * The sub-nodes that say where a place is and when it is open.
16
+ *
17
+ * Together because they are the same kind of thing — facts about a location,
18
+ * each checked at the point it becomes a claim — and because a venue node is
19
+ * the only thing that assembles all three.
20
+ */
21
+
22
+ /**
23
+ * A `PostalAddress`, from the parts a project stores.
24
+ *
25
+ * A node rather than a string, which `address` also accepts: the components are
26
+ * what a search engine matches a business against a place with, and handing over
27
+ * one joined line makes it guess where the town ends. `region` is omitted rather
28
+ * than sent empty — a country that does not use one should not appear to have
29
+ * left it blank.
30
+ *
31
+ * Reference: <https://schema.org/PostalAddress>, read by Google inside a
32
+ * business's `address`:
33
+ * <https://developers.google.com/search/docs/appearance/structured-data/local-business>
34
+ */
35
+ export function postalAddress(address: PostalAddress): JsonLdNode {
36
+ return {
37
+ "@type": "PostalAddress",
38
+ streetAddress: address.street,
39
+ addressLocality: address.locality,
40
+ postalCode: address.postalCode,
41
+ addressCountry: address.country,
42
+ ...(address.region === undefined
43
+ ? {}
44
+ : { addressRegion: address.region }),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * A week as `openingHoursSpecification`.
50
+ *
51
+ * One node per set of hours rather than per day: days that open at the same
52
+ * time are listed together, which is how the vocabulary is meant to be used and
53
+ * turns seven nodes into two. A day with a lunch break yields one node per span,
54
+ * sharing the day list.
55
+ *
56
+ * Closed days are left out. The specification says when a venue *is* open, so a
57
+ * day that appears nowhere in it is shut — which is unambiguous, where a span
58
+ * from 00:00 to 00:00 could be read as either closed or around the clock.
59
+ *
60
+ * Reference:
61
+ * <https://developers.google.com/search/docs/appearance/structured-data/local-business#business-hours>
62
+ */
63
+ export function openingHours(hours: WeeklyHours): readonly JsonLdNode[] {
64
+ assertHours(hours, "openingHours");
65
+
66
+ return groupHours(hours).flatMap((group) =>
67
+ group.hours === "closed"
68
+ ? []
69
+ : group.hours.map((range) => ({
70
+ "@type": "OpeningHoursSpecification",
71
+ dayOfWeek: schemaDays(group),
72
+ opens: range.opens,
73
+ closes: range.closes,
74
+ }))
75
+ );
76
+ }
77
+
78
+ /**
79
+ * A point on the map, checked before it is published.
80
+ *
81
+ * The precision rule is enforced here rather than left to the caller because
82
+ * this is the only place that knows the coordinate is about to become a claim
83
+ * about where a building is.
84
+ *
85
+ * Reference: <https://schema.org/GeoCoordinates>, and the `geo` property in
86
+ * <https://developers.google.com/search/docs/appearance/structured-data/local-business>
87
+ */
88
+ export function geoCoordinates(where: Coordinates): JsonLdNode {
89
+ assertCoordinates(where, "geoCoordinates");
90
+
91
+ return {
92
+ "@type": "GeoCoordinates",
93
+ latitude: where.latitude,
94
+ longitude: where.longitude,
95
+ };
96
+ }
@@ -0,0 +1,172 @@
1
+ import {
2
+ assertPriceTiers,
3
+ type Price,
4
+ type PriceTier,
5
+ type TieredPrice,
6
+ } from "../money.ts";
7
+ import type { HttpsUrl } from "../url.ts";
8
+ import type { BusinessId, OrganizationId } from "./ids.ts";
9
+ import { type JsonLdNode, schemaType } from "./node.ts";
10
+ import { quantitativeValue } from "./quantity.ts";
11
+
12
+ export interface ProductInput {
13
+ readonly name: string;
14
+ readonly description: string;
15
+ /** This product's own page. */
16
+ readonly url: HttpsUrl;
17
+ /** A picture of it, absolute. */
18
+ readonly image?: HttpsUrl;
19
+ /**
20
+ * What it costs: one price, or a price per quantity.
21
+ *
22
+ * The reason to emit this node at all — an offer with a price and a
23
+ * currency is what a search result can show a number for. A product with no
24
+ * offer is a name and a picture, which the page already said.
25
+ *
26
+ * A table becomes an `AggregateOffer` carrying the range, with each band as
27
+ * an `Offer` inside it. Two bare prices on one product would otherwise read
28
+ * as a contradiction rather than as a discount.
29
+ */
30
+ readonly price: Price | TieredPrice;
31
+ /**
32
+ * The `@id` of whoever sells it — a venue, or the company itself.
33
+ *
34
+ * A reference rather than a repeat: the seller is described once, in its own
35
+ * node, with the address and the rest that make it a real entity.
36
+ *
37
+ * Both id types, matching `ServiceInput.provider`. schema.org's `seller`
38
+ * expects an `Organization`, and a `LocalBusiness` *is* one — the narrower
39
+ * type here was an accident of the first site that needed it, where the
40
+ * seller happened to be a venue. A company that sells from a workshop
41
+ * rather than a shopfront publishes one `Organization` and has no business
42
+ * node to point at, and could not name its seller at all.
43
+ */
44
+ readonly seller?: BusinessId | OrganizationId;
45
+ /**
46
+ * Further types alongside `Product`, e.g. `"Game"`.
47
+ *
48
+ * *Alongside*, and deliberately unlike `LocalBusinessInput.type`, which
49
+ * replaces. The difference is whether one type implies the other: an
50
+ * `EntertainmentBusiness` already is a `LocalBusiness`, so naming both says
51
+ * one thing twice — while a `Game` is not a `Product` and a `Product` is
52
+ * not a `Game`. Two unrelated branches of the hierarchy, and the node is
53
+ * genuinely both: `Product` is what makes the offer legible to a search
54
+ * result, `Game` is what the thing actually is.
55
+ *
56
+ * Not checked against schema.org's vocabulary — see
57
+ * `LocalBusinessInput.type` for why.
58
+ */
59
+ readonly alsoA?: readonly string[];
60
+ }
61
+
62
+ /**
63
+ * Something the site sells, with its price.
64
+ *
65
+ * Vocabulary only — no notion of what this site's products *are*. A consumer
66
+ * adds whatever its own type calls for by spreading the result: the node is a
67
+ * plain object, and `numberOfPlayers` on a `Game` or `timeRequired` on a course
68
+ * is a fact about that domain, which lib has no way to check and no business
69
+ * inventing.
70
+ *
71
+ * `availability` is stated as in stock. A page that exists for a thing you can
72
+ * book is saying exactly that, and an offer with no availability is read as
73
+ * unknown rather than as available.
74
+ *
75
+ * Reference:
76
+ * <https://developers.google.com/search/docs/appearance/structured-data/product>
77
+ */
78
+ export function product(item: ProductInput): JsonLdNode {
79
+ return {
80
+ // Through `schemaType` like every other node, so the ordinary case — a
81
+ // product that is only a product — emits `"Product"` rather than a
82
+ // one-element list. The rule was centralised and this call site was
83
+ // left building its own array.
84
+ "@type": schemaType(["Product", ...(item.alsoA ?? [])]),
85
+ name: item.name,
86
+ description: item.description,
87
+ url: item.url,
88
+ ...(item.image === undefined ? {} : { image: item.image }),
89
+ offers: offersFor(item),
90
+ };
91
+ }
92
+
93
+ /** The common parts of every `Offer` this file writes. */
94
+ function offerFor(
95
+ item: ProductInput,
96
+ amount: number,
97
+ band?: PriceTier
98
+ ): JsonLdNode {
99
+ // `currency` is on both members of the union, so no narrowing is needed;
100
+ // `unit` describes what a quantity counts and only a table has one.
101
+ const unit = "tiers" in item.price ? item.price.unit : undefined;
102
+
103
+ return {
104
+ "@type": "Offer",
105
+ price: amount,
106
+ priceCurrency: item.price.currency,
107
+ availability: "https://schema.org/InStock",
108
+ url: item.url,
109
+ ...(band === undefined
110
+ ? {}
111
+ : {
112
+ // What makes two prices a table rather than a contradiction:
113
+ // each one says the quantity it applies at.
114
+ //
115
+ // The quantity is passed as written, not through `tierRange`:
116
+ // the two take the same `number | { min, max }`, and widening
117
+ // first would publish a band of one as `minValue: 2,
118
+ // maxValue: 2` — a range across a single size, where `value:
119
+ // 2` is the same claim said plainly.
120
+ eligibleQuantity: {
121
+ ...quantitativeValue(band.quantity),
122
+ ...(unit === undefined ? {} : { unitText: unit }),
123
+ },
124
+ }),
125
+ ...(item.seller === undefined
126
+ ? {}
127
+ : { seller: { "@id": item.seller } }),
128
+ };
129
+ }
130
+
131
+ /** One `Offer`, or an `AggregateOffer` wrapping the bands. */
132
+ function offersFor(item: ProductInput): JsonLdNode {
133
+ if ("amount" in item.price) {
134
+ return offerFor(item, item.price.amount);
135
+ }
136
+
137
+ assertPriceTiers(item.price, "product");
138
+
139
+ // A table of one is one price, whatever it was written as. Wrapping it
140
+ // would emit an `AggregateOffer` whose low and high are the same number
141
+ // and whose `offerCount` is 1 — a range across nothing, and a shape that
142
+ // says "prices vary" of a product whose price does not. The band is still
143
+ // passed through, so a single row covering 2 to 6 keeps its
144
+ // `eligibleQuantity`: what collapses is the wrapper, not the quantity.
145
+ const [only, ...rest] = item.price.tiers;
146
+ if (rest.length === 0) return offerFor(item, only.amountPerUnit, only);
147
+
148
+ const amounts = item.price.tiers.map((tier) => tier.amountPerUnit);
149
+
150
+ // The wrapper carries the *aggregate* facts and nothing else. `seller`,
151
+ // `url` and `availability` are per-offer and sit on the bands, so a reader
152
+ // that descends finds each one complete and a reader that does not gets the
153
+ // range, which is what a result renders.
154
+ //
155
+ // Worth stating because the asymmetry looks like an oversight from one
156
+ // angle: an untiered product carries `seller` at the top level and a tiered
157
+ // one does not. Adding it here alone would be the arbitrary choice — it is
158
+ // one of three omissions, not one — and repeating an `@id` once per band
159
+ // plus once more says nothing the bands have not already said.
160
+ return {
161
+ "@type": "AggregateOffer",
162
+ priceCurrency: item.price.currency,
163
+ // The range is what a result renders; the bands below are the detail
164
+ // behind it. Both, because either alone is a worse answer.
165
+ lowPrice: Math.min(...amounts),
166
+ highPrice: Math.max(...amounts),
167
+ offerCount: item.price.tiers.length,
168
+ offers: item.price.tiers.map((tier) =>
169
+ offerFor(item, tier.amountPerUnit, tier)
170
+ ),
171
+ };
172
+ }