@sarimarcus/content-sites-core 0.13.0 → 0.15.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.
@@ -0,0 +1,87 @@
1
+ /** A JSON-LD node as assembled. `undefined` values are dropped when the graph is serialised. */
2
+ export type JsonLdNode = Record<string, any>;
3
+
4
+ /** One `{ type, data }` entry a page hands to its StructuredData component. */
5
+ export interface SchemaEntry<T extends string = string> {
6
+ type: T;
7
+ data: Record<string, any>;
8
+ }
9
+
10
+ export interface OpeningHoursSpecification {
11
+ '@type': 'OpeningHoursSpecification';
12
+ dayOfWeek: string | string[];
13
+ opens: string;
14
+ closes: string;
15
+ }
16
+
17
+ export interface IdRef {
18
+ '@id': string;
19
+ }
20
+
21
+ /** An author record as the site stores it; only these fields reach JSON-LD. */
22
+ export interface SchemaAuthorProfile {
23
+ name: string;
24
+ slug?: string;
25
+ photo?: string;
26
+ socialLinks?: string[];
27
+ }
28
+
29
+ export interface ResolvedAuthor {
30
+ id: string;
31
+ name: string;
32
+ url: string;
33
+ image?: string;
34
+ sameAs?: string[];
35
+ }
36
+
37
+ /** Path segment per entity kind, e.g. `{ hotel: 'lodges' }`. A kind not listed uses its own name. */
38
+ export type RouteMap = Partial<Record<string, string>>;
39
+
40
+ export interface SchemaContextInput {
41
+ /** Site origin, with or without a trailing slash. */
42
+ siteUrl: string;
43
+ /** Base path without a trailing slash (`''` at the root). */
44
+ basePath: string;
45
+ /** The page's own pathname (`Astro.url.pathname`). */
46
+ pagePath: string;
47
+ /** Whether a file exists under the site's `public/` (path with a leading slash). Defaults to "no". */
48
+ fileExists?: (publicPath: string) => boolean;
49
+ /** An image path containing this marker is already an OG image. */
50
+ ogImageMarker?: string;
51
+ /** Author profile by display name. */
52
+ findAuthor?: (name: string) => SchemaAuthorProfile | undefined;
53
+ /** Markdown → plain text, for JSON-LD string values. */
54
+ plainText?: (markdown: string) => string;
55
+ /** `publisher` for a content item, given its author. Defaults to the Organization. */
56
+ publisher?: (authorName: string | undefined, ctx: SchemaContext) => IdRef;
57
+ /** Route segments per entity kind. */
58
+ routes?: RouteMap;
59
+ /** Clock for the future-date clamp and `defaultToNow`. */
60
+ now?: () => Date;
61
+ }
62
+
63
+ export interface SchemaContext {
64
+ siteUrl: string;
65
+ basePath: string;
66
+ siteBaseUrl: string;
67
+ pageUrl: string;
68
+ orgId: string;
69
+ websiteId: string;
70
+ /** `${siteUrl}${basePath}${path}` for a root-relative path. */
71
+ abs(path: string): string;
72
+ /** A root-relative path made absolute; an `http…` value passes through. */
73
+ absOrHttp(path: string): string;
74
+ /** Item URL for an entity kind: `${siteUrl}${basePath}/${segment}/${slug}`. */
75
+ route(kind: string, slug: string): string;
76
+ fileExists(publicPath: string): boolean;
77
+ resolveImageUrl(imagePath: string | undefined, slug?: string): string | undefined;
78
+ resolveAuthor(authorName: string | undefined): ResolvedAuthor | null;
79
+ formatDate(dateStr: string | undefined, defaultToNow?: boolean): string | undefined;
80
+ plainText(markdown: string): string;
81
+ publisherFor(authorName: string | undefined): IdRef;
82
+ }
83
+
84
+ /** Builds the node(s) for one schema entry; `null` emits nothing. */
85
+ export type SchemaBuilder = (data: Record<string, any>, ctx: SchemaContext) => JsonLdNode | JsonLdNode[] | null;
86
+
87
+ export type SchemaBuilders = Record<string, SchemaBuilder>;
@@ -0,0 +1,102 @@
1
+ import type { JsonLdNode } from './types.ts';
2
+
3
+ /** First integer in a price string; any "free" reads as 0; null when there is no number. */
4
+ export function parsePriceFromString(priceStr: string | undefined): number | null {
5
+ if (!priceStr) return null;
6
+ if (priceStr.toLowerCase().includes('free')) return 0;
7
+ const match = priceStr.match(/(\d+)/);
8
+ return match ? parseFloat(match[1]) : null;
9
+ }
10
+
11
+ /** Street and number only: drops postal code, city and country, and a second part that is a district or postcode. */
12
+ export function cleanStreetAddress(fullAddress: string | undefined): string {
13
+ if (!fullAddress) return '';
14
+ const parts = fullAddress.split(',').map((p) => p.trim());
15
+ const streetName = parts[0];
16
+ if (parts.length < 2) return streetName;
17
+ const cleanedNumber = parts[1].replace(/º\s*/g, '').trim();
18
+ const isNeighbourhoodOrPostal =
19
+ /^\d{5}/.test(cleanedNumber) || /^[A-ZÁÉÍÓÚÜÑ][a-záéíóúüñ]/.test(cleanedNumber);
20
+ return isNeighbourhoodOrPostal || !cleanedNumber ? streetName : `${streetName}, ${cleanedNumber}`;
21
+ }
22
+
23
+ /** A €-symbol range (`€`…`€€€€`) from a price string, inferred from `priceFrom` when given. */
24
+ export function normalizePriceRange(priceStr: string | undefined, priceFrom?: number): string {
25
+ if (!priceStr) return '€€';
26
+ if (/^€+$/.test(priceStr)) return priceStr;
27
+ const price = priceFrom || parsePriceFromString(priceStr) || 10;
28
+ if (price < 10) return '€';
29
+ if (price < 20) return '€€';
30
+ if (price < 40) return '€€€';
31
+ return '€€€€';
32
+ }
33
+
34
+ /** A hotel range as `€120-€200` (from `120-200€`), else `€€€`. */
35
+ export function normalizeHotelPriceRange(priceStr: string | undefined): string {
36
+ if (!priceStr) return '€€€';
37
+ if (/^€\d+-€\d+$/.test(priceStr)) return priceStr;
38
+ const match = priceStr.match(/(\d+)-(\d+)€?/);
39
+ if (match) return `€${match[1]}-€${match[2]}`;
40
+ return '€€€';
41
+ }
42
+
43
+ /** `postalCode`, else the first 5-digit run in `address`. */
44
+ export function postalCodeOf(data: { postalCode?: string; address?: string }): string | undefined {
45
+ return data.postalCode || data.address?.match(/(\d{5})/)?.[1];
46
+ }
47
+
48
+ /** How an entity's PostalAddress is filled: fixed city values or per-item fields. */
49
+ export interface AddressPolicy {
50
+ locality(data: Record<string, any>): string | undefined;
51
+ region(data: Record<string, any>): string | undefined;
52
+ country(data: Record<string, any>): string | undefined;
53
+ /** Postal code used when the item has none. */
54
+ postalFallback?: string;
55
+ /** Drop an empty street (`""`) instead of emitting it. */
56
+ omitEmptyStreet?: boolean;
57
+ }
58
+
59
+ /** Address read from each item's own `city`, `region` and `country`; empty values are dropped. */
60
+ export function dataAddressPolicy(): AddressPolicy {
61
+ return {
62
+ locality: (d) => d.city || undefined,
63
+ region: (d) => d.region || undefined,
64
+ country: (d) => d.country || undefined,
65
+ omitEmptyStreet: true,
66
+ };
67
+ }
68
+
69
+ export interface PostalAddressParts {
70
+ /** Raw address to clean into `streetAddress`. Absent: no street key. */
71
+ street?: string;
72
+ /** Postal code before the policy fallback. Absent: no postal key. */
73
+ postalCode?: string;
74
+ }
75
+
76
+ /** A PostalAddress in the fixed key order: street, locality, region, postal code, country. */
77
+ export function postalAddress(policy: AddressPolicy, data: Record<string, any>, parts: PostalAddressParts = {}): JsonLdNode {
78
+ const node: JsonLdNode = { '@type': 'PostalAddress' };
79
+ if ('street' in parts) {
80
+ const street = cleanStreetAddress(parts.street);
81
+ node.streetAddress = policy.omitEmptyStreet ? street || undefined : street;
82
+ }
83
+ node.addressLocality = policy.locality(data);
84
+ node.addressRegion = policy.region(data);
85
+ if ('postalCode' in parts) node.postalCode = parts.postalCode || policy.postalFallback;
86
+ node.addressCountry = policy.country(data);
87
+ return node;
88
+ }
89
+
90
+ export function geoCoordinates(coordinates: { lat: number; lng: number } | undefined): JsonLdNode | undefined {
91
+ return coordinates ? { '@type': 'GeoCoordinates', latitude: coordinates.lat, longitude: coordinates.lng } : undefined;
92
+ }
93
+
94
+ export function aggregateRating(data: { rating?: number; ratingCount?: number }): JsonLdNode | undefined {
95
+ return data.rating ? {
96
+ '@type': 'AggregateRating',
97
+ ratingValue: data.rating,
98
+ bestRating: 5,
99
+ worstRating: 1,
100
+ reviewCount: data.ratingCount ?? 1,
101
+ } : undefined;
102
+ }