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