@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,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
|
+
}
|