@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
package/src/index.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The public API. A consuming project should need nothing outside this module.
3
+ *
4
+ * The flow is four steps, each inferring its types from the values of the last:
5
+ *
6
+ * ```ts
7
+ * // 1. site.config.ts — which languages exist, how URLs are shaped
8
+ * export default defineSiteConfig({ locales: {...}, defaultRouting: {...} })
9
+ *
10
+ * // 2. src/data — the base copy and routes
11
+ * export const baseMessages = defineMessages(config, {...})
12
+ * export const baseRoutes = defineRoutes(config, {...})
13
+ *
14
+ * // 3. projects/<name>/project.ts — one deployment's overlay
15
+ * export default defineProject(config, baseMessages, baseRoutes, {...})
16
+ *
17
+ * // 4. src/site.ts — the API the pages use
18
+ * export const site = createSite(config, baseMessages, baseRoutes, project)
19
+ * ```
20
+ */
21
+
22
+ export {
23
+ type AnalyticsSettings,
24
+ type AnalyticsTag,
25
+ type AnalyticsTags,
26
+ CONSENT_UPDATE_GLOBAL,
27
+ type ConsentDefaults,
28
+ type ConsentState,
29
+ type GoogleSettings,
30
+ type UmamiReplay,
31
+ type UmamiSettings,
32
+ type UmamiTracker,
33
+ } from "./analytics/index.ts";
34
+ export {
35
+ defineSiteConfig,
36
+ type LanguageTag,
37
+ type LocaleMeta,
38
+ type LocalesOf,
39
+ type ResolvedLocaleMeta,
40
+ type RoutingConfig,
41
+ type SiteConfigShape,
42
+ } from "./config.ts";
43
+ export {
44
+ assertCoordinates,
45
+ type CallingCode,
46
+ type Coordinates,
47
+ type CountryCode,
48
+ type EmailAddress,
49
+ e164,
50
+ formatPhone,
51
+ mailtoHref,
52
+ type PhoneNumber,
53
+ type PostalAddress,
54
+ telHref,
55
+ } from "./contact.ts";
56
+ export type { GeneratedFile } from "./file.ts";
57
+ export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
58
+ export {
59
+ assertHours,
60
+ type DayHours,
61
+ type FormattedHours,
62
+ formatHours,
63
+ groupHours,
64
+ type HoursFormat,
65
+ type HoursGroup,
66
+ type TimeOfDay,
67
+ type TimeRange,
68
+ type Weekday,
69
+ type WeeklyHours,
70
+ } from "./hours.ts";
71
+ export {
72
+ type BaseCatalog,
73
+ type CatalogCovers,
74
+ defineMessageOverrides,
75
+ defineMessages,
76
+ type MessageOverrides,
77
+ type OverrideCatalog,
78
+ } from "./i18n/define.ts";
79
+ export type {
80
+ EntryPlaceholders,
81
+ Placeholders,
82
+ } from "./i18n/placeholders.ts";
83
+ export type {
84
+ MessageParamsOf,
85
+ TranslateArgsOf,
86
+ TranslateFor,
87
+ } from "./i18n/translate.ts";
88
+ export type { ImageAsset } from "./image.ts";
89
+ export {
90
+ type ArticleInput,
91
+ article,
92
+ type BreadcrumbStep,
93
+ type BusinessId,
94
+ breadcrumbList,
95
+ businessId,
96
+ type CatalogEntry,
97
+ geoCoordinates,
98
+ type JsonLdNode,
99
+ type LocalBusinessInput,
100
+ localBusiness,
101
+ type OfferCatalogInput,
102
+ type OrganizationId,
103
+ type OrganizationInput,
104
+ offerCatalog,
105
+ openingHours,
106
+ organization,
107
+ organizationId,
108
+ type ProductInput,
109
+ postalAddress,
110
+ product,
111
+ quantitativeValue,
112
+ quantitiesFor,
113
+ type ServiceArea,
114
+ type ServiceId,
115
+ type ServiceInput,
116
+ serializeJsonLd,
117
+ service,
118
+ serviceId,
119
+ type VideoInput,
120
+ type VideoSource,
121
+ videoObject,
122
+ type WebsiteId,
123
+ type WebsiteInput,
124
+ website,
125
+ websiteId,
126
+ } from "./jsonld/index.ts";
127
+ export {
128
+ buildLlms,
129
+ type LlmsConfig,
130
+ type LlmsItem,
131
+ type LlmsSection,
132
+ } from "./llms.ts";
133
+ export {
134
+ type ArticleContent,
135
+ buildMeta,
136
+ buildNotFoundMeta,
137
+ type ImageFormat,
138
+ type MetaContent,
139
+ type MetaTag,
140
+ type OgImage,
141
+ type OpenGraphType,
142
+ type PageKind,
143
+ type RobotsPolicy,
144
+ type ShareImage,
145
+ type SiteVerification,
146
+ } from "./meta/index.ts";
147
+ // `quantitiesOf`, `quantityRange` and `isContiguous` are deliberately not here.
148
+ // They are how lib decides between "2–6" and "2, 4, 6", and a consumer holding
149
+ // them writes that decision a second time — which is how `llms.txt` came to
150
+ // advertise a range for a room that skips sizes. `formatQuantities` answers it
151
+ // for prose and `quantitiesFor` for structured data; both read the same tiers.
152
+ export {
153
+ assertPriceTiers,
154
+ type CurrencyCode,
155
+ formatQuantities,
156
+ type Price,
157
+ type PriceTier,
158
+ type TieredPrice,
159
+ tierRange,
160
+ } from "./money.ts";
161
+ export { defineProject, type ProjectInput } from "./project.ts";
162
+ export {
163
+ buildCloudflareRedirects,
164
+ buildRedirects,
165
+ type CloudflareRedirectsOptions,
166
+ type ExternalUrl,
167
+ type RedirectKind,
168
+ type RedirectRule,
169
+ type RedirectStatus,
170
+ type RedirectTarget,
171
+ type ResolvedRedirect,
172
+ type SitePath,
173
+ type ValidateRedirectTargets,
174
+ } from "./redirects.ts";
175
+ export type { RobotsConfig, RobotsGroup } from "./robots.ts";
176
+ export {
177
+ type ChangeFreq,
178
+ defineRouteOverrides,
179
+ defineRoutes,
180
+ type EnabledRouteIdFor,
181
+ type RouteData,
182
+ type RouteOverride,
183
+ type RouteRegistry,
184
+ type RouteSitemap,
185
+ } from "./routes/define.ts";
186
+ export {
187
+ enableRoutes,
188
+ type FamilyMember,
189
+ type FamilyMemberInput,
190
+ type FamilyOptions,
191
+ familyGuard,
192
+ type RouteIdsOf,
193
+ routeFamily,
194
+ } from "./routes/family.ts";
195
+ export type { RouteEntry } from "./routes/resolve.ts";
196
+ export {
197
+ type Alternate,
198
+ type Crumb,
199
+ createSite,
200
+ type LinkOptions,
201
+ type LlmsOptions,
202
+ type LocaleLink,
203
+ type PageContent,
204
+ type PageMeta,
205
+ type PageProps,
206
+ type PerRoute,
207
+ type RouteIdOf,
208
+ type Site,
209
+ type StaticPath,
210
+ type WhenEnabled,
211
+ } from "./site/index.ts";
212
+ export type { Sitemap, SitemapConfig } from "./sitemap.ts";
213
+ export type { IsoDate, Letter, StringKeys } from "./types.ts";
214
+ export {
215
+ absoluteUrl,
216
+ type HttpsUrl,
217
+ isHttpsUrl,
218
+ isUrlPath,
219
+ joinUrl,
220
+ normalizeOrigin,
221
+ type UrlPath,
222
+ } from "./url.ts";
@@ -0,0 +1,165 @@
1
+ import type { IsoDate } from "../types.ts";
2
+ import type { HttpsUrl } from "../url.ts";
3
+ import { warn } from "../warn.ts";
4
+ import type { BusinessId, OrganizationId } from "./ids.ts";
5
+ import { type JsonLdNode, oneOrMany } from "./node.ts";
6
+
7
+ export interface ArticleInput {
8
+ /**
9
+ * The title, as the page shows it.
10
+ *
11
+ * Google truncates a headline past about 110 characters, which this warns
12
+ * about rather than refuses: the article is still recognised, it is only
13
+ * the words after the cut that go.
14
+ */
15
+ readonly headline: string;
16
+ readonly description?: string;
17
+ /** The article's own page. */
18
+ readonly url: HttpsUrl;
19
+ /**
20
+ * A picture of it, absolute — or the same picture at several ratios.
21
+ *
22
+ * A list is what Google asks for: *"we recommend providing multiple
23
+ * high-resolution images with the following aspect ratios: 16x9, 4x3, and
24
+ * 1x1"*, since a result renders the photo in whichever shape that surface
25
+ * uses and supplying one ratio leaves the other two to be cropped by rules
26
+ * you do not control. `photoSet` in `astro/images.ts` cuts exactly those
27
+ * three from one source, which is where the list normally comes from.
28
+ *
29
+ * A single URL stays valid and is what a page with only its share image
30
+ * ends up with. Checked August 2026.
31
+ */
32
+ readonly image?: HttpsUrl | readonly HttpsUrl[];
33
+ /**
34
+ * When it was published.
35
+ *
36
+ * The field that makes this node worth emitting: a date is what turns a
37
+ * result into a dated one, and it is not derivable from anything else on
38
+ * the page. A date-only `IsoDate` rather than a timestamp, because a venue
39
+ * publishes on a day and not at an instant.
40
+ */
41
+ readonly datePublished: IsoDate;
42
+ /**
43
+ * When it last changed, if it has.
44
+ *
45
+ * Left out where nothing has been revised. Emitting it equal to
46
+ * `datePublished` is not more information — it is the same fact twice, and
47
+ * on a page that says it was updated the day it was written.
48
+ */
49
+ readonly dateModified?: IsoDate;
50
+ /**
51
+ * Who wrote it, as an `@id` — the venue, or the brand.
52
+ *
53
+ * A reference rather than a name: the node it points at already carries the
54
+ * name, the URL and the address that make an author identifiable, and a
55
+ * bare string here would be a second copy of the first of those.
56
+ *
57
+ * An organization rather than a person is normal for a venue's own news,
58
+ * and Google accepts either.
59
+ */
60
+ readonly author?: OrganizationId | BusinessId;
61
+ /**
62
+ * Who published it — the organization, as an `@id`.
63
+ *
64
+ * Not in Google's required-or-recommended property table, and not in any of
65
+ * its primary examples, so it feeds no rich result. It *is* documented, in
66
+ * the author best-practices: *"The publisher property should be used for
67
+ * the organization publishing the article, separate from the author."*
68
+ * Checked August 2026.
69
+ *
70
+ * Separate *roles*, which is not the same as separate parties. Google's
71
+ * example pairs a person with the paper they write for; a venue writing its
72
+ * own news fills both roles itself, and naming it twice is how you say so.
73
+ * A site that leaves this out has not said who stands behind the piece — it
74
+ * has only said who typed it.
75
+ *
76
+ * Takes a business id as readily as an organization one, because a
77
+ * `LocalBusiness` *is* an `Organization` and the venue publishing its own
78
+ * page is the ordinary case here.
79
+ */
80
+ readonly publisher?: OrganizationId | BusinessId;
81
+ /**
82
+ * What the article is about, as `keywords`.
83
+ *
84
+ * Subjects rather than a section: `articleSection` is the one part of a
85
+ * publication a piece lives in, which for a site with a single feed is a
86
+ * constant the URL already states. These vary per post, which is what makes
87
+ * them worth writing down.
88
+ *
89
+ * Emitted as a list rather than the comma-joined string `keywords` also
90
+ * accepts. Both are read; a list cannot be misparsed by a tag that contains
91
+ * a comma, and does not ask a caller to know that rule.
92
+ *
93
+ * Google's `Article` table lists no `keywords`, so this earns nothing from
94
+ * that rich result. Unlike `publisher` it is still worth carrying: it
95
+ * describes content the page really shows, rather than restating a
96
+ * relationship the graph already holds.
97
+ */
98
+ readonly tags?: readonly string[];
99
+ /**
100
+ * Which flavour of article. Defaults to `Article`.
101
+ *
102
+ * One type that replaces, like `LocalBusinessInput.type` and unlike
103
+ * `ProductInput.alsoA`: these three are alternatives rather than layers.
104
+ * `BlogPosting` *is* an `Article`, so `["Article", "BlogPosting"]` states
105
+ * one thing twice — where `Product` and `Game` are two claims, neither
106
+ * implying the other.
107
+ *
108
+ * All three earn the same rich result, so the choice is editorial: what a
109
+ * reader would call the thing.
110
+ */
111
+ readonly kind?: "Article" | "NewsArticle" | "BlogPosting";
112
+ }
113
+
114
+ /**
115
+ * A dated piece of writing — a post, an announcement, a news item.
116
+ *
117
+ * What it adds over the page being in the sitemap: a headline distinct from the
118
+ * `<title>`, a publication date, and an author that resolves to the business
119
+ * already described in this graph. That is what a dated search result is built
120
+ * from, and none of it is inferable from the markup around it.
121
+ *
122
+ * `mainEntityOfPage` says the article *is* this page rather than something the
123
+ * page mentions — the distinction that stops a crawler treating a list page and
124
+ * the post it links as two copies of one article.
125
+ *
126
+ * Reference:
127
+ * <https://developers.google.com/search/docs/appearance/structured-data/article>
128
+ */
129
+ export function article(item: ArticleInput): JsonLdNode {
130
+ if (item.headline.length > 110) {
131
+ warn(
132
+ item.url,
133
+ `headline is ${item.headline.length} characters, past the ~110 Google shows: the rest is dropped from the result. The page's own <h1> is unaffected.`
134
+ );
135
+ }
136
+
137
+ return {
138
+ "@type": item.kind ?? "Article",
139
+ headline: item.headline,
140
+ ...(item.description === undefined
141
+ ? {}
142
+ : { description: item.description }),
143
+ // Not `url`: an article is a thing, and this is the page it is *on*.
144
+ mainEntityOfPage: item.url,
145
+ datePublished: item.datePublished,
146
+ ...(item.dateModified === undefined
147
+ ? {}
148
+ : { dateModified: item.dateModified }),
149
+ // Passed through in the shape it arrived in, bar a list of one — which
150
+ // is `schemaType`'s rule applied to a second field: writing `[x]` is
151
+ // 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 } }),
159
+ // An empty list is not a set of no keywords, it is a field saying
160
+ // nothing — so it is left out, as every optional here is.
161
+ ...(item.tags === undefined || item.tags.length === 0
162
+ ? {}
163
+ : { keywords: item.tags }),
164
+ };
165
+ }
@@ -0,0 +1,34 @@
1
+ import type { HttpsUrl } from "../url.ts";
2
+ import type { JsonLdNode } from "./node.ts";
3
+
4
+ /** One step of a trail, ready for `BreadcrumbList`. */
5
+ export interface BreadcrumbStep {
6
+ readonly name: string;
7
+ readonly url: HttpsUrl;
8
+ }
9
+
10
+ /**
11
+ * The trail, as the one structured-data type that shows up in a search result.
12
+ *
13
+ * Every step keeps its `item`, including the last. Google permits omitting it
14
+ * there, and nothing is gained by doing so — while a *missing* intermediate
15
+ * `item` invalidates the list, which is why a step with no page is dropped
16
+ * before it reaches here rather than being emitted without a URL.
17
+ *
18
+ * `position` is 1-based, and is what orders the trail: the array order is not
19
+ * read.
20
+ *
21
+ * Reference:
22
+ * <https://developers.google.com/search/docs/appearance/structured-data/breadcrumb>
23
+ */
24
+ export function breadcrumbList(steps: readonly BreadcrumbStep[]): JsonLdNode {
25
+ return {
26
+ "@type": "BreadcrumbList",
27
+ itemListElement: steps.map((step, index) => ({
28
+ "@type": "ListItem",
29
+ position: index + 1,
30
+ name: step.name,
31
+ item: step.url,
32
+ })),
33
+ };
34
+ }
@@ -0,0 +1,196 @@
1
+ import {
2
+ type Coordinates,
3
+ type EmailAddress,
4
+ e164,
5
+ type PhoneNumber,
6
+ type PostalAddress,
7
+ } from "../contact.ts";
8
+ import type { WeeklyHours } from "../hours.ts";
9
+ import type { ImageAsset } from "../image.ts";
10
+ import { absoluteUrl, type HttpsUrl } from "../url.ts";
11
+ import { businessId, type OrganizationId } from "./ids.ts";
12
+ import { alternateName, type JsonLdNode, schemaType } from "./node.ts";
13
+ import { geoCoordinates, openingHours, postalAddress } from "./place.ts";
14
+
15
+ export interface LocalBusinessInput {
16
+ readonly name: string;
17
+ /** The site's own origin. Also the node's identity, see `@id` below. */
18
+ readonly url: HttpsUrl;
19
+ readonly address: PostalAddress;
20
+ /**
21
+ * Another name this venue actually goes by.
22
+ *
23
+ * Not in Google's `LocalBusiness` property table, and covered anyway: the
24
+ * page says *"since LocalBusiness is a subtype of Organization, we
25
+ * recommend following the fields for Organization in addition to the fields
26
+ * required and recommended below"*, and `alternateName` is documented
27
+ * there. Reading only the table is how you conclude it does nothing.
28
+ *
29
+ * Same test as on the brand: a name people use. A venue known locally as
30
+ * something shorter than its registered name has one; a naming scheme
31
+ * invented for the markup does not.
32
+ */
33
+ readonly alternateName?: string;
34
+ /**
35
+ * Where the door is.
36
+ *
37
+ * Worth supplying alongside the address rather than instead of it: the
38
+ * address is what matches a listing, and this is what a map pins. For a
39
+ * venue up a side street or inside a larger building the two differ by
40
+ * enough to send someone to the wrong entrance.
41
+ */
42
+ readonly geo?: Coordinates;
43
+ /**
44
+ * What a visit costs, roughly — `"20-38"`, or `"$$"`.
45
+ *
46
+ * Deliberately vague, and read as such: it places the venue against its
47
+ * neighbours in a local result rather than quoting anything. Google ignores
48
+ * the field entirely past 100 characters.
49
+ */
50
+ readonly priceRange?: string;
51
+ /**
52
+ * Photographs of the place.
53
+ *
54
+ * Taken as assets and made absolute here, against `url` above — the same
55
+ * bargain `resolveShareImage` strikes, and for the same reason: this
56
+ * function is holding the origin, so asking a caller to join the two would
57
+ * be asking it to repeat what it just handed over.
58
+ *
59
+ * Several rather than one, and the same photograph at each of the ratios a
60
+ * result may render it in — see `photoSet` in `astro/images.ts`, which cuts
61
+ * them from a single source.
62
+ *
63
+ * They must depict *this venue*. A logo is not a photograph of a building,
64
+ * and a stock interior is a picture of somewhere else: both are the kind of
65
+ * claim this file exists to avoid making on a project's behalf.
66
+ */
67
+ readonly image?: readonly ImageAsset[];
68
+ readonly telephone?: PhoneNumber;
69
+ readonly email?: EmailAddress;
70
+ /**
71
+ * When the venue is open.
72
+ *
73
+ * Worth supplying: this is what puts "Open now" or "Closes at 23:30" beside
74
+ * a result, and it is the one field a would-be visitor checks before
75
+ * setting out.
76
+ */
77
+ readonly hours?: WeeklyHours;
78
+ /**
79
+ * The `@id` of the brand this venue belongs to, from `organization()`.
80
+ *
81
+ * A reference, not a copy: the brand is described once, in its own node,
82
+ * and every venue points at it. Repeating the brand's details inside each
83
+ * business is how two venues end up disagreeing about their own company.
84
+ *
85
+ * Typed as the id rather than as a URL, so handing over the brand's plain
86
+ * address — the one plausible slip here — does not compile.
87
+ */
88
+ readonly parentOrganization?: OrganizationId;
89
+ /**
90
+ * What this place *is* — `"EntertainmentBusiness"`, `"Restaurant"`.
91
+ * Defaults to `LocalBusiness`.
92
+ *
93
+ * Named `type` because it becomes `@type`, and because that is what
94
+ * distinguishes it from `ProductInput.alsoA` at a glance: this one is the
95
+ * type, that one is a further one. Not `additionalTypes`, which reads as
96
+ * schema.org's `additionalType` property — a different thing, and one
97
+ * Google's local-business page explicitly does not support.
98
+ *
99
+ * It *replaces* the general type rather than joining it, which is what
100
+ * Google asks for: *"Use the most specific `LocalBusiness` sub-type
101
+ * possible; for example, `Restaurant`, `DaySpa`, `HealthClub`, and so
102
+ * on."* Every subtype is a `LocalBusiness` by schema.org's hierarchy, so a
103
+ * consumer that resolves types — Google does — loses nothing, and the
104
+ * generic one on its own is equally true of a dentist.
105
+ *
106
+ * Including *physicality*, which is the reason someone reaches for the
107
+ * parent again. `LocalBusiness` descends from both `Organization` and
108
+ * `Place`, and a subtype inherits both chains — `Thing > Place >
109
+ * LocalBusiness > EntertainmentBusiness`. So this is not the colloquial
110
+ * sense in which a streaming service is an entertainment business: that one
111
+ * could not use this type at all, because the type *is* a place. Naming the
112
+ * parent adds nothing the child did not already carry.
113
+ *
114
+ * An array is for a business that is genuinely several things at once, and
115
+ * Google's example is the shape: `["Electrician", "Plumber", "Locksmith"]`
116
+ * — siblings, none of them subsuming another. Pairing a type with its own
117
+ * parent is not that, and is the thing this field used to produce.
118
+ *
119
+ * Not checked against schema.org's vocabulary: a misspelling here is
120
+ * emitted and then ignored by every reader. Deliberately so: schema.org is
121
+ * open and still growing, and a partial enumeration would reject types that
122
+ * are perfectly valid — which gets cast past, at which point the check is
123
+ * theatre. Catching a typo belongs against the built output, where the
124
+ * whole vocabulary is available. See NOT-BUILT.md.
125
+ *
126
+ * Reference:
127
+ * <https://developers.google.com/search/docs/appearance/structured-data/local-business>
128
+ */
129
+ readonly type?: string | readonly [string, ...string[]];
130
+ /**
131
+ * Pages elsewhere that are unmistakably this same venue.
132
+ *
133
+ * Google: *"the URL of a page on another website with additional
134
+ * information about your organization… a profile page on a social media or
135
+ * review site."* It is how a search engine decides this site, that Facebook
136
+ * page and that directory listing are one entity rather than three — which
137
+ * is what a knowledge panel is assembled from.
138
+ *
139
+ * Profiles the business controls or that authoritatively identify it:
140
+ * social accounts, Wikipedia, Wikidata, a trade listing. Not partner links
141
+ * or press mentions, which are pages about *something* it did rather than
142
+ * pages that are it.
143
+ */
144
+ readonly sameAs?: readonly HttpsUrl[];
145
+ }
146
+
147
+ /**
148
+ * The business the site is for.
149
+ *
150
+ * The one node here that earns a visible result: address, telephone and opening
151
+ * hours are what a knowledge panel and a local pack are built from. Everything
152
+ * comes from the project — lib has no business details and invents none.
153
+ *
154
+ * Given an `@id` so later nodes can point at it rather than repeating it. It is
155
+ * the site's origin with a fragment, which is stable across every page the
156
+ * business appears on and unique to this deployment.
157
+ *
158
+ * Reference:
159
+ * <https://developers.google.com/search/docs/appearance/structured-data/local-business>
160
+ */
161
+ export function localBusiness(business: LocalBusinessInput): JsonLdNode {
162
+ return {
163
+ "@type": schemaType(business.type ?? "LocalBusiness"),
164
+ "@id": businessId(business.url),
165
+ name: business.name,
166
+ ...alternateName(business.name, business.alternateName),
167
+ url: business.url,
168
+ address: postalAddress(business.address),
169
+ ...(business.geo === undefined
170
+ ? {}
171
+ : { geo: geoCoordinates(business.geo) }),
172
+ ...(business.priceRange === undefined
173
+ ? {}
174
+ : { priceRange: business.priceRange }),
175
+ ...(business.image === undefined || business.image.length === 0
176
+ ? {}
177
+ : {
178
+ image: business.image.map((photo) =>
179
+ absoluteUrl(business.url, photo.src, "venue photo")
180
+ ),
181
+ }),
182
+ ...(business.telephone === undefined
183
+ ? {}
184
+ : { telephone: e164(business.telephone) }),
185
+ ...(business.email === undefined ? {} : { email: business.email }),
186
+ ...(business.hours === undefined
187
+ ? {}
188
+ : { openingHoursSpecification: openingHours(business.hours) }),
189
+ ...(business.sameAs === undefined ? {} : { sameAs: business.sameAs }),
190
+ ...(business.parentOrganization === undefined
191
+ ? {}
192
+ : {
193
+ parentOrganization: { "@id": business.parentOrganization },
194
+ }),
195
+ };
196
+ }