@stack-mode/site-manager-kit 1.44.0 → 1.45.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/README.md +618 -606
- package/audit/ts-hooks.mjs +68 -68
- package/audit/wiring-audit.d.mts +37 -37
- package/audit/wiring-audit.mjs +604 -604
- package/bin/site-manager-kit.mjs +10 -10
- package/package.json +73 -72
- package/src/announcements.ts +13 -13
- package/src/attribution-fields.ts +52 -52
- package/src/attribution.tsx +47 -47
- package/src/blog.ts +122 -122
- package/src/chat-route.ts +136 -136
- package/src/chat-types.ts +76 -76
- package/src/client.ts +116 -116
- package/src/config.ts +78 -78
- package/src/copy-keys-route.ts +21 -21
- package/src/copy-keys.ts +77 -77
- package/src/data.ts +189 -189
- package/src/downloads.ts +7 -7
- package/src/faq.ts +21 -21
- package/src/floor-plans.ts +45 -45
- package/src/form-guard.tsx +37 -37
- package/src/form-types.ts +13 -13
- package/src/forms.ts +76 -76
- package/src/glossary.ts +19 -19
- package/src/health-route.ts +34 -34
- package/src/index.ts +36 -36
- package/src/indexnow.ts +40 -40
- package/src/listings.ts +38 -38
- package/src/llms-route.ts +45 -0
- package/src/metadata.ts +55 -55
- package/src/not-found-reporter.tsx +16 -16
- package/src/not-found.ts +53 -53
- package/src/payment.ts +70 -70
- package/src/phone-input.tsx +26 -26
- package/src/phone.ts +37 -37
- package/src/preview-route.ts +48 -48
- package/src/preview.ts +94 -94
- package/src/residents.ts +23 -23
- package/src/revalidate-route.ts +34 -34
- package/src/robots.ts +5 -1
- package/src/rss-route.ts +26 -26
- package/src/services.ts +52 -52
- package/src/site-chat-panel.tsx +537 -537
- package/src/site-chat.tsx +26 -26
- package/src/sitemap.ts +26 -26
- package/src/structured-data.tsx +207 -207
- package/src/types.ts +581 -580
- package/src/version.ts +1 -1
- package/src/visual-accents.ts +126 -126
- package/src/visual-bridge-client.tsx +1070 -1070
- package/src/visual-bridge.tsx +30 -30
- package/src/visual-clicks.ts +13 -13
- package/src/visual-lists.ts +37 -37
- package/src/visual-match.ts +77 -77
- package/src/visual-messages.ts +160 -160
- package/src/visual-mode.ts +18 -18
- package/src/visual-preview.ts +69 -69
- package/src/visual-tags.ts +90 -90
- package/src/visual.ts +59 -59
- package/src/waitlist-form.ts +39 -39
- package/src/waitlist.ts +48 -48
package/src/site-chat.tsx
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { bp, tagsFor } from "./client";
|
|
2
|
-
import type { ChatConfig } from "./chat-types";
|
|
3
|
-
import { SiteChatPanel, type SiteChatProps } from "./site-chat-panel";
|
|
4
|
-
|
|
5
|
-
/** The chat's settings, cached like other reads and refreshed when they change in Site Manager. Null when off. */
|
|
6
|
-
export async function getChatConfig(): Promise<ChatConfig | null> {
|
|
7
|
-
try {
|
|
8
|
-
return await bp<ChatConfig>("/chat", { tags: tagsFor("site_chat") });
|
|
9
|
-
} catch {
|
|
10
|
-
// Website Chat is off for the site (403), or Site Manager can't be reached: no chat rather than a broken page.
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Put once inside <body> in app/layout.tsx, with the chat route at app/api/blueprint/chat/route.ts. Shows a chat
|
|
17
|
-
* button on every page while Website Chat is live in Site Manager, and nothing otherwise.
|
|
18
|
-
*
|
|
19
|
-
* <SiteChat /> the site's accent from Site Manager, light or dark from the visitor's device
|
|
20
|
-
* <SiteChat accent="#0e7c66" theme="light" position="left" />
|
|
21
|
-
*/
|
|
22
|
-
export async function SiteChat(props: Omit<SiteChatProps, "config">) {
|
|
23
|
-
const config = await getChatConfig();
|
|
24
|
-
if (!config?.live) return null;
|
|
25
|
-
return <SiteChatPanel config={config} {...props} />;
|
|
26
|
-
}
|
|
1
|
+
import { bp, tagsFor } from "./client";
|
|
2
|
+
import type { ChatConfig } from "./chat-types";
|
|
3
|
+
import { SiteChatPanel, type SiteChatProps } from "./site-chat-panel";
|
|
4
|
+
|
|
5
|
+
/** The chat's settings, cached like other reads and refreshed when they change in Site Manager. Null when off. */
|
|
6
|
+
export async function getChatConfig(): Promise<ChatConfig | null> {
|
|
7
|
+
try {
|
|
8
|
+
return await bp<ChatConfig>("/chat", { tags: tagsFor("site_chat") });
|
|
9
|
+
} catch {
|
|
10
|
+
// Website Chat is off for the site (403), or Site Manager can't be reached: no chat rather than a broken page.
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Put once inside <body> in app/layout.tsx, with the chat route at app/api/blueprint/chat/route.ts. Shows a chat
|
|
17
|
+
* button on every page while Website Chat is live in Site Manager, and nothing otherwise.
|
|
18
|
+
*
|
|
19
|
+
* <SiteChat /> the site's accent from Site Manager, light or dark from the visitor's device
|
|
20
|
+
* <SiteChat accent="#0e7c66" theme="light" position="left" />
|
|
21
|
+
*/
|
|
22
|
+
export async function SiteChat(props: Omit<SiteChatProps, "config">) {
|
|
23
|
+
const config = await getChatConfig();
|
|
24
|
+
if (!config?.live) return null;
|
|
25
|
+
return <SiteChatPanel config={config} {...props} />;
|
|
26
|
+
}
|
package/src/sitemap.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import type { MetadataRoute } from "next";
|
|
2
|
-
import { bp, tagsFor } from "./client";
|
|
3
|
-
import type { SitemapEntry } from "./types";
|
|
4
|
-
|
|
5
|
-
/** Pure: Site Manager's entries to Next's sitemap format, plus any extra entries the site adds. */
|
|
6
|
-
export function buildSitemap(entries: SitemapEntry[], extra: MetadataRoute.Sitemap = []): MetadataRoute.Sitemap {
|
|
7
|
-
const seen = new Set<string>();
|
|
8
|
-
const out: MetadataRoute.Sitemap = [];
|
|
9
|
-
for (const e of [...entries.map((x) => ({ url: x.url, lastModified: new Date(x.lastModified) })), ...extra]) {
|
|
10
|
-
// Absolute and without a trailing slash, the home page included.
|
|
11
|
-
const url = e.url.replace(/\/+$/, "");
|
|
12
|
-
if (seen.has(url)) continue;
|
|
13
|
-
seen.add(url);
|
|
14
|
-
out.push({ ...e, url });
|
|
15
|
-
}
|
|
16
|
-
return out;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* For `app/sitemap.ts`: every page Site Manager lists (no noindex, no redirected paths, no pages of sections that
|
|
21
|
-
* are off), with real last-modified dates.
|
|
22
|
-
*/
|
|
23
|
-
export async function sitemap(extra: MetadataRoute.Sitemap = []): Promise<MetadataRoute.Sitemap> {
|
|
24
|
-
const entries = (await bp<SitemapEntry[]>("/sitemap", { tags: tagsFor("seo", "redirects") })) ?? [];
|
|
25
|
-
return buildSitemap(entries, extra);
|
|
26
|
-
}
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { bp, tagsFor } from "./client";
|
|
3
|
+
import type { SitemapEntry } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Pure: Site Manager's entries to Next's sitemap format, plus any extra entries the site adds. */
|
|
6
|
+
export function buildSitemap(entries: SitemapEntry[], extra: MetadataRoute.Sitemap = []): MetadataRoute.Sitemap {
|
|
7
|
+
const seen = new Set<string>();
|
|
8
|
+
const out: MetadataRoute.Sitemap = [];
|
|
9
|
+
for (const e of [...entries.map((x) => ({ url: x.url, lastModified: new Date(x.lastModified) })), ...extra]) {
|
|
10
|
+
// Absolute and without a trailing slash, the home page included.
|
|
11
|
+
const url = e.url.replace(/\/+$/, "");
|
|
12
|
+
if (seen.has(url)) continue;
|
|
13
|
+
seen.add(url);
|
|
14
|
+
out.push({ ...e, url });
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* For `app/sitemap.ts`: every page Site Manager lists (no noindex, no redirected paths, no pages of sections that
|
|
21
|
+
* are off), with real last-modified dates.
|
|
22
|
+
*/
|
|
23
|
+
export async function sitemap(extra: MetadataRoute.Sitemap = []): Promise<MetadataRoute.Sitemap> {
|
|
24
|
+
const entries = (await bp<SitemapEntry[]>("/sitemap", { tags: tagsFor("seo", "redirects") })) ?? [];
|
|
25
|
+
return buildSitemap(entries, extra);
|
|
26
|
+
}
|
package/src/structured-data.tsx
CHANGED
|
@@ -1,207 +1,207 @@
|
|
|
1
|
-
import { jsonLdScript } from "./faq";
|
|
2
|
-
import type { Event, FloorPlan, Hours, Listing, Site } from "./types";
|
|
3
|
-
|
|
4
|
-
// Structured data (schema.org JSON-LD) built from what the kit already read from Site Manager. Pure helpers, so a page
|
|
5
|
-
// builds its blocks from data it fetched and writes them with <StructuredData />. Empty values are left out, never
|
|
6
|
-
// guessed. The business never gets a rating or reviews: search engines ignore self-served reviews for a business.
|
|
7
|
-
|
|
8
|
-
type Json = Record<string, unknown>;
|
|
9
|
-
|
|
10
|
-
const CONTEXT = "https://schema.org";
|
|
11
|
-
const DAYS = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday" } as const;
|
|
12
|
-
|
|
13
|
-
/** Drops keys whose value is empty: undefined, null, "", or an empty list. */
|
|
14
|
-
function compact(o: Json): Json {
|
|
15
|
-
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** An absolute address on the website, from a path or a full URL. Null without a website address. */
|
|
19
|
-
export function absoluteUrl(websiteUrl: string | null, pathOrUrl: string): string | null {
|
|
20
|
-
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
21
|
-
if (!websiteUrl) return null;
|
|
22
|
-
const base = websiteUrl.replace(/\/+$/, "");
|
|
23
|
-
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
|
24
|
-
return path === "/" ? base : `${base}${path.replace(/\/+$/, "")}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Text from HTML for descriptions: tags out, common entities decoded, spaces collapsed, cut to a length. */
|
|
28
|
-
export function plainText(html: string, max = 500): string {
|
|
29
|
-
const text = html
|
|
30
|
-
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
31
|
-
// Block-level tags and line breaks separate words; inline ones (<b>, <a>, <em>) sit inside them.
|
|
32
|
-
.replace(/<\/?(p|div|br|li|ul|ol|h[1-6]|tr|td|th|blockquote|section|article|figure|figcaption|hr)\b[^>]*>/gi, " ")
|
|
33
|
-
.replace(/<[^>]+>/g, "")
|
|
34
|
-
.replace(/ /g, " ")
|
|
35
|
-
.replace(/&/g, "&")
|
|
36
|
-
.replace(/"/g, '"')
|
|
37
|
-
.replace(/'|'/g, "'")
|
|
38
|
-
.replace(/</g, "<")
|
|
39
|
-
.replace(/>/g, ">")
|
|
40
|
-
.replace(/\s+/g, " ")
|
|
41
|
-
.trim();
|
|
42
|
-
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function postalAddress(a: { line1: string; line2?: string | null; city?: string | null; region?: string | null; postalCode?: string | null; country?: string | null }): Json | null {
|
|
46
|
-
const street = [a.line1, a.line2].filter(Boolean).join(", ");
|
|
47
|
-
if (!street && !a.city) return null;
|
|
48
|
-
return compact({ "@type": "PostalAddress", streetAddress: street, addressLocality: a.city, addressRegion: a.region, postalCode: a.postalCode, addressCountry: a.country });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Opening hours as schema.org specifications: one per set of days sharing the same open and close times. Closed days
|
|
53
|
-
* and days by appointment are left out (they have no times to publish).
|
|
54
|
-
*/
|
|
55
|
-
export function openingHours(hours: Hours | null): Json[] {
|
|
56
|
-
if (!hours) return [];
|
|
57
|
-
const groups = new Map<string, string[]>();
|
|
58
|
-
for (const [key, day] of Object.entries(DAYS) as [keyof Hours, string][]) {
|
|
59
|
-
const h = hours[key];
|
|
60
|
-
if (!h || h.closed || !("open" in h)) continue;
|
|
61
|
-
const k = `${h.open}-${h.close}`;
|
|
62
|
-
groups.set(k, [...(groups.get(k) ?? []), day]);
|
|
63
|
-
}
|
|
64
|
-
return [...groups].map(([k, days]) => {
|
|
65
|
-
const [opens, closes] = k.split("-");
|
|
66
|
-
return { "@type": "OpeningHoursSpecification", dayOfWeek: days.map((d) => `${CONTEXT}/${d}`), opens, closes };
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** A Google Maps link for the Google Business Profile place. */
|
|
71
|
-
export const placeMapUrl = (placeId: string) => `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`;
|
|
72
|
-
|
|
73
|
-
/** The business as the site's main entity: its type, name, contact details, address, hours, profiles and areas. */
|
|
74
|
-
export function businessJsonLd(site: Pick<Site, "name" | "websiteUrl" | "business">, opts: { logo?: string | null; image?: string | null } = {}): Json {
|
|
75
|
-
const b = site.business;
|
|
76
|
-
return compact({
|
|
77
|
-
"@context": CONTEXT,
|
|
78
|
-
"@type": b.schemaType || "LocalBusiness",
|
|
79
|
-
"@id": site.websiteUrl ? `${absoluteUrl(site.websiteUrl, "/")}#business` : undefined,
|
|
80
|
-
name: b.displayName || site.name,
|
|
81
|
-
legalName: b.legalName && b.legalName !== b.displayName ? b.legalName : undefined,
|
|
82
|
-
url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined,
|
|
83
|
-
logo: opts.logo,
|
|
84
|
-
image: opts.image ?? opts.logo,
|
|
85
|
-
telephone: b.phone,
|
|
86
|
-
email: b.email,
|
|
87
|
-
address: postalAddress(b.address),
|
|
88
|
-
geo: b.geo ? { "@type": "GeoCoordinates", latitude: b.geo.lat, longitude: b.geo.lng } : undefined,
|
|
89
|
-
openingHoursSpecification: openingHours(b.hours),
|
|
90
|
-
sameAs: b.social.map((s) => s.url).filter((u) => /^https?:\/\//.test(u)),
|
|
91
|
-
areaServed: (b.serviceAreas ?? []).map((name) => ({ "@type": "Place", name })),
|
|
92
|
-
priceRange: b.priceRange,
|
|
93
|
-
foundingDate: b.foundedYear ? String(b.foundedYear) : undefined,
|
|
94
|
-
hasMap: b.googlePlaceId ? placeMapUrl(b.googlePlaceId) : undefined,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** A breadcrumb trail from the home page down to this page. */
|
|
99
|
-
export function breadcrumbJsonLd(items: { name: string; url: string }[]): Json {
|
|
100
|
-
return {
|
|
101
|
-
"@context": CONTEXT,
|
|
102
|
-
"@type": "BreadcrumbList",
|
|
103
|
-
itemListElement: items.map((it, i) => ({ "@type": "ListItem", position: i + 1, name: it.name, item: it.url })),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Breadcrumb items for a path: Home, then each folder, then the page. `labels` names segments by their path
|
|
109
|
-
* ("/floor-plans": "Floor Plans"); a segment without one is made readable from its slug.
|
|
110
|
-
*/
|
|
111
|
-
export function breadcrumbsFor(path: string, websiteUrl: string, labels: Record<string, string> = {}, home = "Home"): { name: string; url: string }[] {
|
|
112
|
-
const parts = path.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
113
|
-
const items = [{ name: home, url: absoluteUrl(websiteUrl, "/")! }];
|
|
114
|
-
parts.forEach((_, i) => {
|
|
115
|
-
const p = `/${parts.slice(0, i + 1).join("/")}`;
|
|
116
|
-
const fallback = decodeURIComponent(parts[i]).replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
|
117
|
-
items.push({ name: labels[p] ?? fallback, url: absoluteUrl(websiteUrl, p)! });
|
|
118
|
-
});
|
|
119
|
-
return items;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const organizer = (site: Pick<Site, "name" | "websiteUrl" | "business">) => compact({ "@type": "Organization", name: site.business.displayName || site.name, url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined });
|
|
123
|
-
|
|
124
|
-
/** An event: in person at its own location text, else at the business's address. */
|
|
125
|
-
export function eventJsonLd(event: Event, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
126
|
-
const address = postalAddress(site.business.address);
|
|
127
|
-
return compact({
|
|
128
|
-
"@context": CONTEXT,
|
|
129
|
-
"@type": "Event",
|
|
130
|
-
name: event.title,
|
|
131
|
-
startDate: event.allDay ? event.startsAtLocal.slice(0, 10) : event.startsAt,
|
|
132
|
-
// An all-day end is a local date; the UTC time can fall on another day, so without the local one it's left out.
|
|
133
|
-
endDate: event.endsAt ? (event.allDay ? event.endsAtLocal?.slice(0, 10) : event.endsAt) : undefined,
|
|
134
|
-
eventAttendanceMode: `${CONTEXT}/OfflineEventAttendanceMode`,
|
|
135
|
-
eventStatus: `${CONTEXT}/EventScheduled`,
|
|
136
|
-
location: compact({ "@type": "Place", name: event.locationText || site.business.displayName || site.name, address }),
|
|
137
|
-
description: plainText(event.descriptionHtml),
|
|
138
|
-
image: event.image?.url,
|
|
139
|
-
url,
|
|
140
|
-
organizer: organizer(site),
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const sqftValue = (sqft: number | null | undefined) => (sqft ? { "@type": "QuantitativeValue", value: sqft, unitCode: "FTK" } : undefined);
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* A home for sale or sold, as a RealEstateListing about the home itself. Search engines have no rich result for
|
|
148
|
-
* listings, so this is plain description: the offer only while it's for sale and the price is known.
|
|
149
|
-
*/
|
|
150
|
-
export function listingJsonLd(listing: Listing, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
151
|
-
const forSale = listing.status === "active" || listing.status === "coming_soon" || listing.status === "pending";
|
|
152
|
-
const home = compact({
|
|
153
|
-
"@type": listing.propertyType === "single_family" ? "SingleFamilyResidence" : "Accommodation",
|
|
154
|
-
address: postalAddress(listing.address),
|
|
155
|
-
geo: listing.geo ? { "@type": "GeoCoordinates", latitude: listing.geo.lat, longitude: listing.geo.lng } : undefined,
|
|
156
|
-
numberOfBedrooms: listing.beds,
|
|
157
|
-
numberOfBathroomsTotal: listing.baths ? Number(listing.baths) : undefined,
|
|
158
|
-
floorSize: sqftValue(listing.sqft),
|
|
159
|
-
yearBuilt: listing.yearBuilt,
|
|
160
|
-
});
|
|
161
|
-
return compact({
|
|
162
|
-
"@context": CONTEXT,
|
|
163
|
-
"@type": "RealEstateListing",
|
|
164
|
-
name: listing.headline || [listing.address.line1, listing.address.city].filter(Boolean).join(", "),
|
|
165
|
-
url,
|
|
166
|
-
datePosted: listing.listedOn,
|
|
167
|
-
image: listing.featuredImage?.url,
|
|
168
|
-
about: home,
|
|
169
|
-
offers:
|
|
170
|
-
forSale && listing.price
|
|
171
|
-
? { "@type": "Offer", price: listing.price, priceCurrency: "USD", availability: `${CONTEXT}/${listing.status === "pending" ? "LimitedAvailability" : "InStock"}`, seller: organizer(site) }
|
|
172
|
-
: undefined,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** A floor plan inside its apartment community, with beds, baths, size and the starting rent when it's known. */
|
|
177
|
-
export function floorPlanJsonLd(plan: FloorPlan, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
178
|
-
const b = site.business;
|
|
179
|
-
return compact({
|
|
180
|
-
"@context": CONTEXT,
|
|
181
|
-
"@type": b.schemaType || "ApartmentComplex",
|
|
182
|
-
name: b.displayName || site.name,
|
|
183
|
-
url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined,
|
|
184
|
-
address: postalAddress(b.address),
|
|
185
|
-
accommodationFloorPlan: compact({
|
|
186
|
-
"@type": "FloorPlan",
|
|
187
|
-
name: plan.name,
|
|
188
|
-
url,
|
|
189
|
-
numberOfBedrooms: plan.beds,
|
|
190
|
-
numberOfBathroomsTotal: Number(plan.baths),
|
|
191
|
-
floorSize: sqftValue(plan.sqftMin),
|
|
192
|
-
image: plan.images.find((i) => i.kind === "plan")?.url ?? plan.images[0]?.url,
|
|
193
|
-
offers: plan.startingRent ? { "@type": "Offer", price: plan.startingRent, priceCurrency: "USD", description: "Starting rent per month" } : undefined,
|
|
194
|
-
}),
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** One `<script type="application/ld+json">` per block. A server component: put it anywhere in a page or layout. */
|
|
199
|
-
export function StructuredData({ blocks }: { blocks: (Json | null | undefined | false)[] }) {
|
|
200
|
-
return (
|
|
201
|
-
<>
|
|
202
|
-
{blocks.filter((b): b is Json => !!b).map((b, i) => (
|
|
203
|
-
<script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(b) }} />
|
|
204
|
-
))}
|
|
205
|
-
</>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
1
|
+
import { jsonLdScript } from "./faq";
|
|
2
|
+
import type { Event, FloorPlan, Hours, Listing, Site } from "./types";
|
|
3
|
+
|
|
4
|
+
// Structured data (schema.org JSON-LD) built from what the kit already read from Site Manager. Pure helpers, so a page
|
|
5
|
+
// builds its blocks from data it fetched and writes them with <StructuredData />. Empty values are left out, never
|
|
6
|
+
// guessed. The business never gets a rating or reviews: search engines ignore self-served reviews for a business.
|
|
7
|
+
|
|
8
|
+
type Json = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
const CONTEXT = "https://schema.org";
|
|
11
|
+
const DAYS = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday" } as const;
|
|
12
|
+
|
|
13
|
+
/** Drops keys whose value is empty: undefined, null, "", or an empty list. */
|
|
14
|
+
function compact(o: Json): Json {
|
|
15
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An absolute address on the website, from a path or a full URL. Null without a website address. */
|
|
19
|
+
export function absoluteUrl(websiteUrl: string | null, pathOrUrl: string): string | null {
|
|
20
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
21
|
+
if (!websiteUrl) return null;
|
|
22
|
+
const base = websiteUrl.replace(/\/+$/, "");
|
|
23
|
+
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
|
24
|
+
return path === "/" ? base : `${base}${path.replace(/\/+$/, "")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Text from HTML for descriptions: tags out, common entities decoded, spaces collapsed, cut to a length. */
|
|
28
|
+
export function plainText(html: string, max = 500): string {
|
|
29
|
+
const text = html
|
|
30
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
|
|
31
|
+
// Block-level tags and line breaks separate words; inline ones (<b>, <a>, <em>) sit inside them.
|
|
32
|
+
.replace(/<\/?(p|div|br|li|ul|ol|h[1-6]|tr|td|th|blockquote|section|article|figure|figcaption|hr)\b[^>]*>/gi, " ")
|
|
33
|
+
.replace(/<[^>]+>/g, "")
|
|
34
|
+
.replace(/ /g, " ")
|
|
35
|
+
.replace(/&/g, "&")
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'|'/g, "'")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/\s+/g, " ")
|
|
41
|
+
.trim();
|
|
42
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function postalAddress(a: { line1: string; line2?: string | null; city?: string | null; region?: string | null; postalCode?: string | null; country?: string | null }): Json | null {
|
|
46
|
+
const street = [a.line1, a.line2].filter(Boolean).join(", ");
|
|
47
|
+
if (!street && !a.city) return null;
|
|
48
|
+
return compact({ "@type": "PostalAddress", streetAddress: street, addressLocality: a.city, addressRegion: a.region, postalCode: a.postalCode, addressCountry: a.country });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Opening hours as schema.org specifications: one per set of days sharing the same open and close times. Closed days
|
|
53
|
+
* and days by appointment are left out (they have no times to publish).
|
|
54
|
+
*/
|
|
55
|
+
export function openingHours(hours: Hours | null): Json[] {
|
|
56
|
+
if (!hours) return [];
|
|
57
|
+
const groups = new Map<string, string[]>();
|
|
58
|
+
for (const [key, day] of Object.entries(DAYS) as [keyof Hours, string][]) {
|
|
59
|
+
const h = hours[key];
|
|
60
|
+
if (!h || h.closed || !("open" in h)) continue;
|
|
61
|
+
const k = `${h.open}-${h.close}`;
|
|
62
|
+
groups.set(k, [...(groups.get(k) ?? []), day]);
|
|
63
|
+
}
|
|
64
|
+
return [...groups].map(([k, days]) => {
|
|
65
|
+
const [opens, closes] = k.split("-");
|
|
66
|
+
return { "@type": "OpeningHoursSpecification", dayOfWeek: days.map((d) => `${CONTEXT}/${d}`), opens, closes };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A Google Maps link for the Google Business Profile place. */
|
|
71
|
+
export const placeMapUrl = (placeId: string) => `https://www.google.com/maps/place/?q=place_id:${encodeURIComponent(placeId)}`;
|
|
72
|
+
|
|
73
|
+
/** The business as the site's main entity: its type, name, contact details, address, hours, profiles and areas. */
|
|
74
|
+
export function businessJsonLd(site: Pick<Site, "name" | "websiteUrl" | "business">, opts: { logo?: string | null; image?: string | null } = {}): Json {
|
|
75
|
+
const b = site.business;
|
|
76
|
+
return compact({
|
|
77
|
+
"@context": CONTEXT,
|
|
78
|
+
"@type": b.schemaType || "LocalBusiness",
|
|
79
|
+
"@id": site.websiteUrl ? `${absoluteUrl(site.websiteUrl, "/")}#business` : undefined,
|
|
80
|
+
name: b.displayName || site.name,
|
|
81
|
+
legalName: b.legalName && b.legalName !== b.displayName ? b.legalName : undefined,
|
|
82
|
+
url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined,
|
|
83
|
+
logo: opts.logo,
|
|
84
|
+
image: opts.image ?? opts.logo,
|
|
85
|
+
telephone: b.phone,
|
|
86
|
+
email: b.email,
|
|
87
|
+
address: postalAddress(b.address),
|
|
88
|
+
geo: b.geo ? { "@type": "GeoCoordinates", latitude: b.geo.lat, longitude: b.geo.lng } : undefined,
|
|
89
|
+
openingHoursSpecification: openingHours(b.hours),
|
|
90
|
+
sameAs: b.social.map((s) => s.url).filter((u) => /^https?:\/\//.test(u)),
|
|
91
|
+
areaServed: (b.serviceAreas ?? []).map((name) => ({ "@type": "Place", name })),
|
|
92
|
+
priceRange: b.priceRange,
|
|
93
|
+
foundingDate: b.foundedYear ? String(b.foundedYear) : undefined,
|
|
94
|
+
hasMap: b.googlePlaceId ? placeMapUrl(b.googlePlaceId) : undefined,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A breadcrumb trail from the home page down to this page. */
|
|
99
|
+
export function breadcrumbJsonLd(items: { name: string; url: string }[]): Json {
|
|
100
|
+
return {
|
|
101
|
+
"@context": CONTEXT,
|
|
102
|
+
"@type": "BreadcrumbList",
|
|
103
|
+
itemListElement: items.map((it, i) => ({ "@type": "ListItem", position: i + 1, name: it.name, item: it.url })),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Breadcrumb items for a path: Home, then each folder, then the page. `labels` names segments by their path
|
|
109
|
+
* ("/floor-plans": "Floor Plans"); a segment without one is made readable from its slug.
|
|
110
|
+
*/
|
|
111
|
+
export function breadcrumbsFor(path: string, websiteUrl: string, labels: Record<string, string> = {}, home = "Home"): { name: string; url: string }[] {
|
|
112
|
+
const parts = path.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
113
|
+
const items = [{ name: home, url: absoluteUrl(websiteUrl, "/")! }];
|
|
114
|
+
parts.forEach((_, i) => {
|
|
115
|
+
const p = `/${parts.slice(0, i + 1).join("/")}`;
|
|
116
|
+
const fallback = decodeURIComponent(parts[i]).replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
|
117
|
+
items.push({ name: labels[p] ?? fallback, url: absoluteUrl(websiteUrl, p)! });
|
|
118
|
+
});
|
|
119
|
+
return items;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const organizer = (site: Pick<Site, "name" | "websiteUrl" | "business">) => compact({ "@type": "Organization", name: site.business.displayName || site.name, url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined });
|
|
123
|
+
|
|
124
|
+
/** An event: in person at its own location text, else at the business's address. */
|
|
125
|
+
export function eventJsonLd(event: Event, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
126
|
+
const address = postalAddress(site.business.address);
|
|
127
|
+
return compact({
|
|
128
|
+
"@context": CONTEXT,
|
|
129
|
+
"@type": "Event",
|
|
130
|
+
name: event.title,
|
|
131
|
+
startDate: event.allDay ? event.startsAtLocal.slice(0, 10) : event.startsAt,
|
|
132
|
+
// An all-day end is a local date; the UTC time can fall on another day, so without the local one it's left out.
|
|
133
|
+
endDate: event.endsAt ? (event.allDay ? event.endsAtLocal?.slice(0, 10) : event.endsAt) : undefined,
|
|
134
|
+
eventAttendanceMode: `${CONTEXT}/OfflineEventAttendanceMode`,
|
|
135
|
+
eventStatus: `${CONTEXT}/EventScheduled`,
|
|
136
|
+
location: compact({ "@type": "Place", name: event.locationText || site.business.displayName || site.name, address }),
|
|
137
|
+
description: plainText(event.descriptionHtml),
|
|
138
|
+
image: event.image?.url,
|
|
139
|
+
url,
|
|
140
|
+
organizer: organizer(site),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sqftValue = (sqft: number | null | undefined) => (sqft ? { "@type": "QuantitativeValue", value: sqft, unitCode: "FTK" } : undefined);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A home for sale or sold, as a RealEstateListing about the home itself. Search engines have no rich result for
|
|
148
|
+
* listings, so this is plain description: the offer only while it's for sale and the price is known.
|
|
149
|
+
*/
|
|
150
|
+
export function listingJsonLd(listing: Listing, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
151
|
+
const forSale = listing.status === "active" || listing.status === "coming_soon" || listing.status === "pending";
|
|
152
|
+
const home = compact({
|
|
153
|
+
"@type": listing.propertyType === "single_family" ? "SingleFamilyResidence" : "Accommodation",
|
|
154
|
+
address: postalAddress(listing.address),
|
|
155
|
+
geo: listing.geo ? { "@type": "GeoCoordinates", latitude: listing.geo.lat, longitude: listing.geo.lng } : undefined,
|
|
156
|
+
numberOfBedrooms: listing.beds,
|
|
157
|
+
numberOfBathroomsTotal: listing.baths ? Number(listing.baths) : undefined,
|
|
158
|
+
floorSize: sqftValue(listing.sqft),
|
|
159
|
+
yearBuilt: listing.yearBuilt,
|
|
160
|
+
});
|
|
161
|
+
return compact({
|
|
162
|
+
"@context": CONTEXT,
|
|
163
|
+
"@type": "RealEstateListing",
|
|
164
|
+
name: listing.headline || [listing.address.line1, listing.address.city].filter(Boolean).join(", "),
|
|
165
|
+
url,
|
|
166
|
+
datePosted: listing.listedOn,
|
|
167
|
+
image: listing.featuredImage?.url,
|
|
168
|
+
about: home,
|
|
169
|
+
offers:
|
|
170
|
+
forSale && listing.price
|
|
171
|
+
? { "@type": "Offer", price: listing.price, priceCurrency: "USD", availability: `${CONTEXT}/${listing.status === "pending" ? "LimitedAvailability" : "InStock"}`, seller: organizer(site) }
|
|
172
|
+
: undefined,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A floor plan inside its apartment community, with beds, baths, size and the starting rent when it's known. */
|
|
177
|
+
export function floorPlanJsonLd(plan: FloorPlan, site: Pick<Site, "name" | "websiteUrl" | "business">, url: string): Json {
|
|
178
|
+
const b = site.business;
|
|
179
|
+
return compact({
|
|
180
|
+
"@context": CONTEXT,
|
|
181
|
+
"@type": b.schemaType || "ApartmentComplex",
|
|
182
|
+
name: b.displayName || site.name,
|
|
183
|
+
url: site.websiteUrl ? absoluteUrl(site.websiteUrl, "/") : undefined,
|
|
184
|
+
address: postalAddress(b.address),
|
|
185
|
+
accommodationFloorPlan: compact({
|
|
186
|
+
"@type": "FloorPlan",
|
|
187
|
+
name: plan.name,
|
|
188
|
+
url,
|
|
189
|
+
numberOfBedrooms: plan.beds,
|
|
190
|
+
numberOfBathroomsTotal: Number(plan.baths),
|
|
191
|
+
floorSize: sqftValue(plan.sqftMin),
|
|
192
|
+
image: plan.images.find((i) => i.kind === "plan")?.url ?? plan.images[0]?.url,
|
|
193
|
+
offers: plan.startingRent ? { "@type": "Offer", price: plan.startingRent, priceCurrency: "USD", description: "Starting rent per month" } : undefined,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One `<script type="application/ld+json">` per block. A server component: put it anywhere in a page or layout. */
|
|
199
|
+
export function StructuredData({ blocks }: { blocks: (Json | null | undefined | false)[] }) {
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
{blocks.filter((b): b is Json => !!b).map((b, i) => (
|
|
203
|
+
<script key={i} type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(b) }} />
|
|
204
|
+
))}
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|