@sarimarcus/content-sites-tourism 0.13.0 → 0.14.0
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/CHANGELOG.md +4 -0
- package/README.md +3 -1
- package/package.json +4 -3
- package/src/schema/builders.ts +234 -0
- package/src/schema/city.ts +42 -0
- package/src/schema/index.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.14.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- New `./schema`: monument, museum, restaurant, bar, dish, neighborhood and guide item-list builders and the `TourismCity` address policy (SLA-2737).
|
|
8
|
+
|
|
5
9
|
## 0.13.0 (2026-10-01)
|
|
6
10
|
|
|
7
11
|
- No changes; lockstep release with ui (SLA-2819).
|
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
The city-tourism vertical: city-entity components (monuments, restaurants, hotels, events…), trip-planner
|
|
4
4
|
pieces, tourism page templates and their utilities. Used by the Córdoba and Granada sites.
|
|
5
5
|
|
|
6
|
-
Named entry points: `cards`, `sections`, `templates` and `types` (`src/internal` is never exported).
|
|
6
|
+
Named entry points: `cards`, `sections`, `templates`, `schema` and `types` (`src/internal` is never exported).
|
|
7
|
+
`schema` holds the city-entity JSON-LD builders (monument, museum, restaurant, bar, dish, neighborhood, guide item
|
|
8
|
+
list) on core `schema`, and the `TourismCity` address policy they share.
|
|
7
9
|
Page templates are rendered by thin route files in each site.
|
|
8
10
|
|
|
9
11
|
Depends on `@sarimarcus/content-sites-core` and `@sarimarcus/content-sites-ui` (exact lockstep peers).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-tourism",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "City-tourism vertical: city-entity components, pages templates and utilities for the tourism sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
"./cards": "./src/cards/index.ts",
|
|
30
30
|
"./sections": "./src/sections/index.ts",
|
|
31
31
|
"./templates": "./src/templates/index.ts",
|
|
32
|
+
"./schema": "./src/schema/index.ts",
|
|
32
33
|
"./types": "./src/types/index.ts",
|
|
33
34
|
"./tailwind.css": "./src/tailwind.css"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"astro": "^7.0.6",
|
|
37
38
|
"tailwindcss": "^4.1.18",
|
|
38
|
-
"@sarimarcus/content-sites-core": "0.
|
|
39
|
-
"@sarimarcus/content-sites-ui": "0.
|
|
39
|
+
"@sarimarcus/content-sites-core": "0.14.0",
|
|
40
|
+
"@sarimarcus/content-sites-ui": "0.14.0"
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aggregateRating, createPersonSchema, generateOpeningHours, geoCoordinates,
|
|
3
|
+
normalizeHotelPriceRange, normalizePriceRange, parseHoursString, postalAddress, postalCodeOf, withAuthorPersonSchema,
|
|
4
|
+
} from '@sarimarcus/content-sites-core/schema';
|
|
5
|
+
import type { JsonLdNode, SchemaBuilder } from '@sarimarcus/content-sites-core/schema';
|
|
6
|
+
import { cityAddressPolicy, type TourismCity } from './city.ts';
|
|
7
|
+
|
|
8
|
+
const isFree = (data: Record<string, any>) => data.tags?.includes('free') || data.fees?.toLowerCase().includes('free');
|
|
9
|
+
|
|
10
|
+
export interface MonumentOptions {
|
|
11
|
+
city: TourismCity;
|
|
12
|
+
touristType?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** TouristAttraction at route `monument`; hours from the free-text `hours` line. */
|
|
16
|
+
export function monumentBuilder(options: MonumentOptions): SchemaBuilder {
|
|
17
|
+
const address = cityAddressPolicy(options.city);
|
|
18
|
+
const touristType = options.touristType ?? 'Cultural';
|
|
19
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
20
|
+
'@type': 'TouristAttraction',
|
|
21
|
+
name: data.name,
|
|
22
|
+
description: data.description,
|
|
23
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
24
|
+
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
25
|
+
geo: geoCoordinates(data.coordinates),
|
|
26
|
+
openingHoursSpecification: parseHoursString(data.hours),
|
|
27
|
+
isAccessibleForFree: isFree(data),
|
|
28
|
+
touristType,
|
|
29
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
30
|
+
url: ctx.route('monument', data.slug),
|
|
31
|
+
}, data.author);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Museum at route `museum`; hours from the free-text `hours` line. */
|
|
35
|
+
export function museumBuilder(options: { city: TourismCity }): SchemaBuilder {
|
|
36
|
+
const address = cityAddressPolicy(options.city);
|
|
37
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
38
|
+
'@type': 'Museum',
|
|
39
|
+
name: data.name,
|
|
40
|
+
description: data.description,
|
|
41
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
42
|
+
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
43
|
+
geo: geoCoordinates(data.coordinates),
|
|
44
|
+
openingHoursSpecification: parseHoursString(data.hours),
|
|
45
|
+
isAccessibleForFree: isFree(data),
|
|
46
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
47
|
+
url: ctx.route('museum', data.slug),
|
|
48
|
+
}, data.author);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const menu = (sectionName: string, items: string[] | undefined): JsonLdNode | undefined =>
|
|
52
|
+
((items?.length ?? 0) > 0 ? {
|
|
53
|
+
'@type': 'Menu',
|
|
54
|
+
hasMenuSection: {
|
|
55
|
+
'@type': 'MenuSection',
|
|
56
|
+
name: sectionName,
|
|
57
|
+
hasMenuItem: items!.map((name) => ({ '@type': 'MenuItem', name })),
|
|
58
|
+
},
|
|
59
|
+
} : undefined);
|
|
60
|
+
|
|
61
|
+
export interface RestaurantOptions {
|
|
62
|
+
city: TourismCity;
|
|
63
|
+
menuSectionName?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Restaurant at route `restaurant`; `specialties` become its menu. */
|
|
67
|
+
export function restaurantBuilder(options: RestaurantOptions): SchemaBuilder {
|
|
68
|
+
const address = cityAddressPolicy(options.city);
|
|
69
|
+
const sectionName = options.menuSectionName ?? 'Specialties';
|
|
70
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
71
|
+
'@type': 'Restaurant',
|
|
72
|
+
name: data.name,
|
|
73
|
+
description: data.description,
|
|
74
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
75
|
+
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
76
|
+
servesCuisine: data.cuisine,
|
|
77
|
+
priceRange: normalizePriceRange(data.averagePrice, data.priceFrom),
|
|
78
|
+
openingHoursSpecification: generateOpeningHours(data.openingHours),
|
|
79
|
+
telephone: data.phone,
|
|
80
|
+
aggregateRating: aggregateRating(data),
|
|
81
|
+
geo: geoCoordinates(data.coordinates),
|
|
82
|
+
sameAs: data.website ? [data.website] : undefined,
|
|
83
|
+
hasMenu: menu(sectionName, data.specialties),
|
|
84
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
85
|
+
url: ctx.route('restaurant', data.slug),
|
|
86
|
+
}, data.author);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BarOptions {
|
|
90
|
+
city: TourismCity;
|
|
91
|
+
menuSectionName?: string;
|
|
92
|
+
/** `servesCuisine` for a bar whose `category` is `tapas`. */
|
|
93
|
+
tapasCuisine?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** BarOrPub at route `bar`; `specialties` become its drinks menu. */
|
|
97
|
+
export function barBuilder(options: BarOptions): SchemaBuilder {
|
|
98
|
+
const address = cityAddressPolicy(options.city);
|
|
99
|
+
const sectionName = options.menuSectionName ?? 'Signature Drinks';
|
|
100
|
+
const tapas = options.tapasCuisine ?? 'Tapas';
|
|
101
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
102
|
+
'@type': 'BarOrPub',
|
|
103
|
+
name: data.name,
|
|
104
|
+
description: data.description,
|
|
105
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
106
|
+
address: postalAddress(address, data, { street: data.address, postalCode: postalCodeOf(data) }),
|
|
107
|
+
geo: geoCoordinates(data.coordinates),
|
|
108
|
+
priceRange: normalizePriceRange(data.averagePrice, data.priceFrom),
|
|
109
|
+
aggregateRating: aggregateRating(data),
|
|
110
|
+
sameAs: data.website ? [data.website] : undefined,
|
|
111
|
+
servesCuisine: data.category === 'tapas' ? tapas : undefined,
|
|
112
|
+
hasMenu: menu(sectionName, data.specialties),
|
|
113
|
+
openingHoursSpecification: generateOpeningHours(data.openingHours),
|
|
114
|
+
telephone: data.phone,
|
|
115
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
116
|
+
url: ctx.route('bar', data.slug),
|
|
117
|
+
}, data.author);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface DishOptions {
|
|
121
|
+
articleSection?: string;
|
|
122
|
+
inLanguage?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A dish page as an Article at route `dish`, dated by `dateModified` (now when absent). */
|
|
126
|
+
export function dishBuilder(options: DishOptions = {}): SchemaBuilder {
|
|
127
|
+
const { articleSection = 'Gastronomy', inLanguage = 'en' } = options;
|
|
128
|
+
return (data, ctx) => {
|
|
129
|
+
const author = ctx.resolveAuthor(data.author);
|
|
130
|
+
const node: JsonLdNode = {
|
|
131
|
+
'@type': 'Article',
|
|
132
|
+
headline: data.name,
|
|
133
|
+
description: data.description,
|
|
134
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
135
|
+
author: author ? { '@id': author.id } : undefined,
|
|
136
|
+
publisher: ctx.publisherFor(data.author),
|
|
137
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
138
|
+
datePublished: ctx.formatDate(data.dateModified, true),
|
|
139
|
+
dateModified: ctx.formatDate(data.dateModified, true),
|
|
140
|
+
keywords: data.tags?.join(', ') || '',
|
|
141
|
+
articleSection,
|
|
142
|
+
inLanguage,
|
|
143
|
+
url: ctx.route('dish', data.slug),
|
|
144
|
+
};
|
|
145
|
+
return author ? [createPersonSchema(author), node] : node;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Place at route `neighborhood`, inside the city; pinned to the city when it has no coordinates. */
|
|
150
|
+
export function neighborhoodBuilder(options: { city: TourismCity }): SchemaBuilder {
|
|
151
|
+
const { city } = options;
|
|
152
|
+
const address = cityAddressPolicy(city);
|
|
153
|
+
return (data, ctx) => withAuthorPersonSchema(ctx, {
|
|
154
|
+
'@type': 'Place',
|
|
155
|
+
name: data.name,
|
|
156
|
+
description: data.description,
|
|
157
|
+
image: ctx.resolveImageUrl(data.image, data.slug),
|
|
158
|
+
address: postalAddress(address, data),
|
|
159
|
+
geo: geoCoordinates(data.coordinates || city.geo),
|
|
160
|
+
containedInPlace: {
|
|
161
|
+
'@type': 'City',
|
|
162
|
+
name: city.locality,
|
|
163
|
+
address: { '@type': 'PostalAddress', addressRegion: city.region, addressCountry: city.country },
|
|
164
|
+
},
|
|
165
|
+
mainEntityOfPage: { '@id': ctx.pageUrl },
|
|
166
|
+
url: ctx.route('neighborhood', data.slug),
|
|
167
|
+
}, data.author);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Guide item `type` (a plural collection name) → schema type (SLA-2468). */
|
|
171
|
+
export const GUIDE_ITEM_TYPES: Record<string, string> = {
|
|
172
|
+
restaurants: 'Restaurant',
|
|
173
|
+
hotels: 'LodgingBusiness',
|
|
174
|
+
monuments: 'TouristAttraction',
|
|
175
|
+
museums: 'Museum',
|
|
176
|
+
activities: 'TouristAttraction',
|
|
177
|
+
bars: 'BarOrPub',
|
|
178
|
+
dishes: 'MenuItem',
|
|
179
|
+
events: 'Event',
|
|
180
|
+
neighborhoods: 'Place',
|
|
181
|
+
walks: 'TouristTrip',
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A ranked guide as an ItemList of mixed-type nested entities. Event nests address and geo under `location`;
|
|
186
|
+
* TouristTrip carries no `geo`; hotels carry a price range and no hours.
|
|
187
|
+
*/
|
|
188
|
+
export function guideItemListBuilder(options: { city: TourismCity; itemTypes?: Record<string, string> }): SchemaBuilder {
|
|
189
|
+
const policy = cityAddressPolicy(options.city);
|
|
190
|
+
const itemTypes = options.itemTypes ?? GUIDE_ITEM_TYPES;
|
|
191
|
+
return (data, ctx) => {
|
|
192
|
+
const nested = (item: any): JsonLdNode => {
|
|
193
|
+
const schemaType = itemTypes[item.type] ?? 'WebPage';
|
|
194
|
+
const entity: JsonLdNode = {
|
|
195
|
+
'@type': schemaType,
|
|
196
|
+
name: item.name,
|
|
197
|
+
url: ctx.absOrHttp(item.url),
|
|
198
|
+
image: ctx.resolveImageUrl(item.image, item.slug),
|
|
199
|
+
};
|
|
200
|
+
const addressNode = (item.type !== 'dishes' && item.type !== 'neighborhoods' && item.address)
|
|
201
|
+
? postalAddress(policy, item, { street: item.address, postalCode: item.address?.match(/(\d{5})/)?.[1] })
|
|
202
|
+
: undefined;
|
|
203
|
+
const geoNode = geoCoordinates(item.coordinates);
|
|
204
|
+
if (schemaType === 'Event') {
|
|
205
|
+
if (addressNode || geoNode) {
|
|
206
|
+
entity.location = { '@type': 'Place', name: item.name, ...(addressNode && { address: addressNode }), ...(geoNode && { geo: geoNode }) };
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
if (addressNode) entity.address = addressNode;
|
|
210
|
+
if (geoNode && schemaType !== 'TouristTrip') entity.geo = geoNode;
|
|
211
|
+
}
|
|
212
|
+
if (item.type === 'restaurants' || item.type === 'bars') {
|
|
213
|
+
entity.priceRange = normalizePriceRange(item.averagePrice, item.priceFrom);
|
|
214
|
+
if (item.type === 'restaurants' && item.cuisine) entity.servesCuisine = item.cuisine;
|
|
215
|
+
const hours = generateOpeningHours(item.openingHours);
|
|
216
|
+
if (hours) entity.openingHoursSpecification = hours;
|
|
217
|
+
} else if (item.type === 'monuments' || item.type === 'museums') {
|
|
218
|
+
const hours = parseHoursString(item.hours);
|
|
219
|
+
if (hours) entity.openingHoursSpecification = hours;
|
|
220
|
+
}
|
|
221
|
+
if (item.type === 'hotels' && item.priceRange) entity.priceRange = normalizeHotelPriceRange(item.priceRange);
|
|
222
|
+
return entity;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
'@type': 'ItemList',
|
|
226
|
+
name: data.name,
|
|
227
|
+
description: data.description,
|
|
228
|
+
url: data.url,
|
|
229
|
+
numberOfItems: data.items?.length || 0,
|
|
230
|
+
itemListElement: (data.items || []).map((item: any) => ({ '@type': 'ListItem', position: item.rank, item: nested(item) })),
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { geoCoordinates } from '@sarimarcus/content-sites-core/schema';
|
|
2
|
+
import type { AddressPolicy, TouristDestinationFields } from '@sarimarcus/content-sites-core/schema';
|
|
3
|
+
|
|
4
|
+
/** The city a tourism site covers: every entity's PostalAddress and the default pin come from here. */
|
|
5
|
+
export interface TourismCity {
|
|
6
|
+
locality: string;
|
|
7
|
+
region: string;
|
|
8
|
+
/** ISO 3166-1 alpha-2. */
|
|
9
|
+
country: string;
|
|
10
|
+
/** Postal code for an item with none of its own. Leave unset rather than invent one. */
|
|
11
|
+
postalFallback?: string;
|
|
12
|
+
geo: { lat: number; lng: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fixed locality/region/country from the city; an empty street is kept as `""`. */
|
|
16
|
+
export function cityAddressPolicy(city: TourismCity): AddressPolicy {
|
|
17
|
+
return {
|
|
18
|
+
locality: () => city.locality,
|
|
19
|
+
region: () => city.region,
|
|
20
|
+
country: () => city.country,
|
|
21
|
+
postalFallback: city.postalFallback,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The site-level TouristDestination for a city guide: the city, its region and its pin. */
|
|
26
|
+
export function cityDestinationFields(
|
|
27
|
+
city: TourismCity,
|
|
28
|
+
copy: { name: string; description: string; touristType?: string },
|
|
29
|
+
): TouristDestinationFields {
|
|
30
|
+
return {
|
|
31
|
+
name: copy.name,
|
|
32
|
+
description: copy.description,
|
|
33
|
+
address: { '@type': 'PostalAddress', addressLocality: city.locality, addressRegion: city.region, addressCountry: city.country },
|
|
34
|
+
containedInPlace: {
|
|
35
|
+
'@type': 'AdministrativeArea',
|
|
36
|
+
name: city.region,
|
|
37
|
+
address: { '@type': 'PostalAddress', addressCountry: city.country },
|
|
38
|
+
},
|
|
39
|
+
geo: geoCoordinates(city.geo),
|
|
40
|
+
touristType: copy.touristType ?? 'Cultural',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-tourism/schema: city-entity JSON-LD builders on core `schema`.
|
|
2
|
+
export { cityAddressPolicy, cityDestinationFields } from './city.ts';
|
|
3
|
+
export type { TourismCity } from './city.ts';
|
|
4
|
+
export {
|
|
5
|
+
monumentBuilder, museumBuilder, restaurantBuilder, barBuilder, dishBuilder, neighborhoodBuilder,
|
|
6
|
+
guideItemListBuilder, GUIDE_ITEM_TYPES,
|
|
7
|
+
} from './builders.ts';
|
|
8
|
+
export type { MonumentOptions, RestaurantOptions, BarOptions, DishOptions } from './builders.ts';
|