@evinvest/kitstart 0.1.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 +245 -0
- package/dist/cli/kitstart-size.d.ts +2 -0
- package/dist/cli/kitstart-size.js +11 -0
- package/dist/cli/size.d.ts +31 -0
- package/dist/cli/size.js +99 -0
- package/dist/core/accept.d.ts +49 -0
- package/dist/core/accept.js +78 -0
- package/dist/core/analytics.d.ts +43 -0
- package/dist/core/analytics.js +42 -0
- package/dist/core/antispam.d.ts +60 -0
- package/dist/core/antispam.js +90 -0
- package/dist/core/content.d.ts +82 -0
- package/dist/core/content.js +12 -0
- package/dist/core/lead.d.ts +88 -0
- package/dist/core/lead.js +42 -0
- package/dist/core/memo.d.ts +8 -0
- package/dist/core/memo.js +19 -0
- package/dist/core/place/index.d.ts +5 -0
- package/dist/core/place/index.js +5 -0
- package/dist/core/place/live.d.ts +30 -0
- package/dist/core/place/live.js +132 -0
- package/dist/core/place/publication.d.ts +29 -0
- package/dist/core/place/publication.js +42 -0
- package/dist/core/place/rating.d.ts +16 -0
- package/dist/core/place/rating.js +33 -0
- package/dist/core/place/types.d.ts +91 -0
- package/dist/core/place/types.js +27 -0
- package/dist/core/place/view.d.ts +56 -0
- package/dist/core/place/view.js +43 -0
- package/dist/core/routing.d.ts +121 -0
- package/dist/core/routing.js +198 -0
- package/dist/core/seo/ld.d.ts +58 -0
- package/dist/core/seo/ld.js +161 -0
- package/dist/core/seo/sitemap.d.ts +52 -0
- package/dist/core/seo/sitemap.js +60 -0
- package/dist/core/site.d.ts +118 -0
- package/dist/core/site.js +84 -0
- package/dist/core/status.d.ts +47 -0
- package/dist/core/status.js +62 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/next/config/build-env.d.ts +32 -0
- package/dist/next/config/build-env.js +101 -0
- package/dist/next/config/index.d.ts +30 -0
- package/dist/next/config/index.js +38 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +12 -0
- package/dist/next/load.d.ts +24 -0
- package/dist/next/load.js +36 -0
- package/dist/next/metadata.d.ts +31 -0
- package/dist/next/metadata.js +66 -0
- package/dist/next/og-palette.d.ts +14 -0
- package/dist/next/og-palette.js +14 -0
- package/dist/next/og.d.ts +31 -0
- package/dist/next/og.js +42 -0
- package/dist/next/quote-route.d.ts +36 -0
- package/dist/next/quote-route.js +124 -0
- package/dist/next/routes.d.ts +15 -0
- package/dist/next/routes.js +50 -0
- package/dist/proxy/index.d.ts +31 -0
- package/dist/proxy/index.js +108 -0
- package/dist/react/AnalyticsBoundary.d.ts +14 -0
- package/dist/react/AnalyticsBoundary.js +55 -0
- package/dist/react/AreaChips.d.ts +8 -0
- package/dist/react/AreaChips.js +11 -0
- package/dist/react/CallBar.d.ts +29 -0
- package/dist/react/CallBar.js +7 -0
- package/dist/react/Coverage.d.ts +24 -0
- package/dist/react/Coverage.js +10 -0
- package/dist/react/Faq.d.ts +17 -0
- package/dist/react/Faq.js +5 -0
- package/dist/react/LangSwitch.d.ts +24 -0
- package/dist/react/LangSwitch.js +17 -0
- package/dist/react/MapFacade.d.ts +18 -0
- package/dist/react/MapFacade.js +8 -0
- package/dist/react/PlaceDirectory.d.ts +25 -0
- package/dist/react/PlaceDirectory.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +34 -0
- package/dist/react/QuoteFormShell.js +10 -0
- package/dist/react/StatusScreen.d.ts +36 -0
- package/dist/react/StatusScreen.js +22 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.js +29 -0
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +1 -0
- package/dist/server/boot.d.ts +11 -0
- package/dist/server/boot.js +20 -0
- package/dist/server/client-key.d.ts +22 -0
- package/dist/server/client-key.js +20 -0
- package/dist/server/env.d.ts +44 -0
- package/dist/server/env.js +73 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/lead-store-postgres.d.ts +14 -0
- package/dist/server/lead-store-postgres.js +23 -0
- package/dist/server/lead-store-sqlite.d.ts +21 -0
- package/dist/server/lead-store-sqlite.js +158 -0
- package/dist/server/lead-store.d.ts +20 -0
- package/dist/server/lead-store.js +43 -0
- package/dist/server/notify.d.ts +37 -0
- package/dist/server/notify.js +79 -0
- package/dist/server/place-source.d.ts +48 -0
- package/dist/server/place-source.js +107 -0
- package/dist/server/smtp.d.ts +38 -0
- package/dist/server/smtp.js +197 -0
- package/dist/testing/e2e.d.ts +52 -0
- package/dist/testing/e2e.js +57 -0
- package/dist/testing/fixtures.d.ts +11 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/landing-contract.d.ts +33 -0
- package/dist/testing/landing-contract.js +95 -0
- package/dist/testing/lead-store-contract.d.ts +13 -0
- package/dist/testing/lead-store-contract.js +55 -0
- package/dist/testing/served-files.d.ts +7 -0
- package/dist/testing/served-files.js +41 -0
- package/package.json +129 -0
- package/template/README.md +55 -0
- package/template/app/[locale]/[location]/layout.tsx +29 -0
- package/template/app/[locale]/[location]/page.tsx +18 -0
- package/template/app/[locale]/[location]/prices/page.tsx +26 -0
- package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
- package/template/app/[locale]/error.tsx +14 -0
- package/template/app/[locale]/layout.tsx +28 -0
- package/template/app/[locale]/not-found.tsx +18 -0
- package/template/app/[locale]/page.tsx +40 -0
- package/template/app/[locale]/thanks/page.tsx +21 -0
- package/template/app/global-not-found.tsx +28 -0
- package/template/app/globals.css +18 -0
- package/template/app/health/route.ts +6 -0
- package/template/app/og/route.tsx +27 -0
- package/template/app/quote/route.ts +17 -0
- package/template/app/robots.ts +6 -0
- package/template/app/sitemap.ts +8 -0
- package/template/assets/brand.toml +68 -0
- package/template/assets/card.toml +8 -0
- package/template/assets/fonts/Inter-OFL.txt +93 -0
- package/template/assets/fonts/Inter-Regular.ttf +0 -0
- package/template/assets/fonts/Inter-Regular.woff2 +0 -0
- package/template/assets/mark.svg +1 -0
- package/template/deploy/config.nix +24 -0
- package/template/eslint.config.mjs +28 -0
- package/template/flake.nix +46 -0
- package/template/instrumentation.ts +13 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +44 -0
- package/template/postcss.config.mjs +3 -0
- package/template/proxy.ts +11 -0
- package/template/src/entities/content/index.ts +12 -0
- package/template/src/entities/content/model/en.ts +47 -0
- package/template/src/entities/content/model/fr.ts +47 -0
- package/template/src/entities/content/model/types.ts +37 -0
- package/template/src/entities/place/api/load.ts +12 -0
- package/template/src/entities/place/index.ts +3 -0
- package/template/src/entities/place/server.ts +1 -0
- package/template/src/shared/config/env.ts +17 -0
- package/template/src/shared/config/i18n.ts +15 -0
- package/template/src/shared/config/lead.ts +15 -0
- package/template/src/shared/config/places.ts +20 -0
- package/template/src/shared/config/public.ts +11 -0
- package/template/src/shared/config/site.ts +39 -0
- package/template/src/shared/ui/fonts.ts +15 -0
- package/template/src/views/home/index.ts +1 -0
- package/template/src/views/home/ui/PlaceHome.tsx +76 -0
- package/template/src/views/not-found/index.ts +1 -0
- package/template/src/views/not-found/server.ts +1 -0
- package/template/src/views/not-found/ui/Gone.tsx +29 -0
- package/template/src/views/not-found/ui/NotFound.tsx +27 -0
- package/template/src/views/place/server.ts +13 -0
- package/template/src/views/server-error/index.ts +1 -0
- package/template/src/views/server-error/ui/ServerError.tsx +24 -0
- package/template/steiger.config.ts +26 -0
- package/template/tests/bundle_budget.txt +8 -0
- package/template/tests/contract.test.ts +14 -0
- package/template/tests/e2e/playwright.config.ts +26 -0
- package/template/tests/e2e/screenshot-section.css +5 -0
- package/template/tests/e2e/sections.spec.ts +12 -0
- package/template/tests/e2e/tsconfig.json +11 -0
- package/template/tests/empty.ts +1 -0
- package/template/tsconfig.json +23 -0
- package/template/vitest.config.ts +20 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ldCompact, localBusiness } from "@evinvest/marketing";
|
|
2
|
+
import { freshRating } from "../place/rating.js";
|
|
3
|
+
import { servedLocalities, storefrontOf } from "../place/types.js";
|
|
4
|
+
import { placeOrigin, siteOrigin } from "../place/view.js";
|
|
5
|
+
import { contactOf } from "../site.js";
|
|
6
|
+
const origin = (o) => o ?? "";
|
|
7
|
+
export const organizationId = (site) => `${origin(siteOrigin(site))}/#organization`;
|
|
8
|
+
// Language-free: the French and the English page describe one business.
|
|
9
|
+
export const businessId = (site, slug) => `${origin(placeOrigin(site, slug))}/#business`;
|
|
10
|
+
export const websiteId = (site, slug) => `${origin(placeOrigin(site, slug))}/#website`;
|
|
11
|
+
export function organizationNode(site) {
|
|
12
|
+
return ldCompact({
|
|
13
|
+
"@type": "Organization",
|
|
14
|
+
"@id": organizationId(site),
|
|
15
|
+
name: site.brand.name,
|
|
16
|
+
legalName: site.brand.legalName,
|
|
17
|
+
url: siteOrigin(site),
|
|
18
|
+
email: site.brand.email,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function days(place) {
|
|
22
|
+
return place.hours?.map(h => ({
|
|
23
|
+
"@type": "OpeningHoursSpecification",
|
|
24
|
+
dayOfWeek: h.days,
|
|
25
|
+
opens: h.opens,
|
|
26
|
+
closes: h.closes,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A radius as a `GeoCircle`; named communes as `City` for a service-area
|
|
31
|
+
* business (the design's shape, §3.4) and as `Place` for a storefront, which
|
|
32
|
+
* is what aquafix's indexed pages already say — its goldens do not move.
|
|
33
|
+
*/
|
|
34
|
+
export function areaServedNodes(place) {
|
|
35
|
+
const type = place.presence.kind === "service-area" ? "City" : "Place";
|
|
36
|
+
const named = servedLocalities(place).map(name => ({ "@type": type, name }));
|
|
37
|
+
const circles = (place.serviceArea ?? []).flatMap(area => area.kind === "radius"
|
|
38
|
+
? [
|
|
39
|
+
{
|
|
40
|
+
"@type": "GeoCircle",
|
|
41
|
+
geoMidpoint: { "@type": "GeoCoordinates", latitude: area.center.lat, longitude: area.center.lng },
|
|
42
|
+
geoRadius: area.km * 1000,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
: []);
|
|
46
|
+
return [...named, ...circles];
|
|
47
|
+
}
|
|
48
|
+
/** The business behind one place. */
|
|
49
|
+
export function businessNode(site, view, now) {
|
|
50
|
+
const { place } = view;
|
|
51
|
+
const front = storefrontOf(place);
|
|
52
|
+
const rating = freshRating(place, now);
|
|
53
|
+
return localBusiness({
|
|
54
|
+
id: businessId(site, place.slug),
|
|
55
|
+
type: site.brand.businessType,
|
|
56
|
+
name: place.gbpName,
|
|
57
|
+
url: view.url(site.pages.home),
|
|
58
|
+
telephone: contactOf(site, place).phone ?? undefined,
|
|
59
|
+
email: site.brand.email ?? undefined,
|
|
60
|
+
image: front?.storefrontPhoto ?? undefined,
|
|
61
|
+
address: front
|
|
62
|
+
? {
|
|
63
|
+
streetAddress: front.address.street,
|
|
64
|
+
postalCode: front.address.postalCode,
|
|
65
|
+
addressLocality: front.address.locality,
|
|
66
|
+
addressRegion: front.address.region,
|
|
67
|
+
addressCountry: front.address.country,
|
|
68
|
+
}
|
|
69
|
+
: undefined,
|
|
70
|
+
geo: front?.geo ?? undefined,
|
|
71
|
+
parentOrganization: { "@id": organizationId(site) },
|
|
72
|
+
}, ldCompact({
|
|
73
|
+
priceRange: site.brand.priceRange,
|
|
74
|
+
areaServed: areaServedNodes(place),
|
|
75
|
+
openingHoursSpecification: days(place),
|
|
76
|
+
aggregateRating: rating
|
|
77
|
+
? { "@type": "AggregateRating", ratingValue: rating.value, reviewCount: rating.count, bestRating: 5 }
|
|
78
|
+
: undefined,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/** The town an offer is priced for: a storefront's own, or the first commune served. */
|
|
82
|
+
function cityOf(place) {
|
|
83
|
+
const name = storefrontOf(place)?.address.locality ?? servedLocalities(place)[0];
|
|
84
|
+
return name === undefined ? undefined : { "@type": "City", name };
|
|
85
|
+
}
|
|
86
|
+
/** One `Service` + `Offer` per price row: the same integer as the table cell, tax included. */
|
|
87
|
+
export function offerNodes(site, place, offers) {
|
|
88
|
+
return offers.map(row => {
|
|
89
|
+
const currency = row.currency ?? "EUR";
|
|
90
|
+
return ldCompact({
|
|
91
|
+
"@type": "Service",
|
|
92
|
+
name: row.name,
|
|
93
|
+
serviceType: row.name,
|
|
94
|
+
provider: { "@id": businessId(site, place.slug) },
|
|
95
|
+
areaServed: cityOf(place),
|
|
96
|
+
offers: {
|
|
97
|
+
"@type": "Offer",
|
|
98
|
+
price: row.price,
|
|
99
|
+
priceCurrency: currency,
|
|
100
|
+
availability: "https://schema.org/InStock",
|
|
101
|
+
priceSpecification: {
|
|
102
|
+
"@type": "PriceSpecification",
|
|
103
|
+
minPrice: row.price,
|
|
104
|
+
priceCurrency: currency,
|
|
105
|
+
valueAddedTaxIncluded: true,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function faqPageNode(items) {
|
|
112
|
+
return {
|
|
113
|
+
"@type": "FAQPage",
|
|
114
|
+
mainEntity: items.map(item => ({
|
|
115
|
+
"@type": "Question",
|
|
116
|
+
name: item.q,
|
|
117
|
+
acceptedAnswer: { "@type": "Answer", text: item.a },
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Home → page. The chain is the route, so a new page cannot forget it. */
|
|
122
|
+
export function breadcrumbNode(site, view, page, copy) {
|
|
123
|
+
const items = [
|
|
124
|
+
{ "@type": "ListItem", position: 1, name: `${site.brand.name} ${copy.placeName}`, item: view.url(site.pages.home) },
|
|
125
|
+
];
|
|
126
|
+
if (page !== "home") {
|
|
127
|
+
items.push({ "@type": "ListItem", position: 2, name: copy.title, item: view.url(site.pages[page]) });
|
|
128
|
+
}
|
|
129
|
+
return { "@type": "BreadcrumbList", itemListElement: items };
|
|
130
|
+
}
|
|
131
|
+
/** The `@graph` for one of a place's pages in one language. */
|
|
132
|
+
export function placeGraph(site, view, page, copy, now) {
|
|
133
|
+
const { place } = view;
|
|
134
|
+
const url = view.url(site.pages[page]);
|
|
135
|
+
const nodes = [
|
|
136
|
+
organizationNode(site),
|
|
137
|
+
businessNode(site, view, now),
|
|
138
|
+
{
|
|
139
|
+
"@type": "WebSite",
|
|
140
|
+
"@id": websiteId(site, place.slug),
|
|
141
|
+
url: origin(placeOrigin(site, place.slug)),
|
|
142
|
+
name: `${site.brand.name} ${copy.placeName}`,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"@type": "WebPage",
|
|
146
|
+
"@id": `${url}#page`,
|
|
147
|
+
url,
|
|
148
|
+
name: copy.title,
|
|
149
|
+
description: copy.description,
|
|
150
|
+
inLanguage: site.i18n.hreflangOf(view.locale),
|
|
151
|
+
isPartOf: { "@id": websiteId(site, place.slug) },
|
|
152
|
+
about: { "@id": businessId(site, place.slug) },
|
|
153
|
+
},
|
|
154
|
+
breadcrumbNode(site, view, page, copy),
|
|
155
|
+
];
|
|
156
|
+
if (copy.offers && copy.offers.length > 0)
|
|
157
|
+
nodes.push(...offerNodes(site, place, copy.offers));
|
|
158
|
+
if (copy.faq && copy.faq.length > 0)
|
|
159
|
+
nodes.push(faqPageNode(copy.faq));
|
|
160
|
+
return { "@context": "https://schema.org", "@graph": nodes };
|
|
161
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Place } from "../place/types.js";
|
|
2
|
+
import type { Site } from "../site.js";
|
|
3
|
+
/**
|
|
4
|
+
* One sitemap per host: a sitemap may only list URLs on its own host, and a
|
|
5
|
+
* place's canonical host is its subdomain (the apex on a `single` site). The
|
|
6
|
+
* apex of a `subdomains` site lists the brand page.
|
|
7
|
+
*
|
|
8
|
+
* Each language version is its own entry, annotated with the whole set
|
|
9
|
+
* *including itself*. No `lastmod`: a deploy timestamp that moves without the
|
|
10
|
+
* content moving is a lie. An unpublished place is left out entirely, and a
|
|
11
|
+
* site with no domain lists nothing.
|
|
12
|
+
*
|
|
13
|
+
* Shapes match Next's `MetadataRoute.Sitemap` / `MetadataRoute.Robots`, so
|
|
14
|
+
* `./next` returns them as they are.
|
|
15
|
+
*/
|
|
16
|
+
export interface SitemapEntry {
|
|
17
|
+
url: string;
|
|
18
|
+
priority: number;
|
|
19
|
+
alternates: {
|
|
20
|
+
languages: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface RobotsRule {
|
|
24
|
+
userAgent: string | string[];
|
|
25
|
+
allow?: string;
|
|
26
|
+
disallow?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RobotsFile {
|
|
29
|
+
rules: RobotsRule[];
|
|
30
|
+
sitemap?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The sitemap for a request's host. `places` is the list as the live source
|
|
34
|
+
* sees it now — the caller fetches it strictly: an unreachable source must
|
|
35
|
+
* fail the sitemap (a 5xx a crawler retries), never shrink it (a list of
|
|
36
|
+
* places a crawler drops).
|
|
37
|
+
*/
|
|
38
|
+
export declare function sitemapFor<L extends string, P extends string>(site: Site<L, P>, host: string, places: readonly Place<L>[]): SitemapEntry[];
|
|
39
|
+
/** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
|
|
40
|
+
export declare const AI_CRAWLERS: string[];
|
|
41
|
+
/**
|
|
42
|
+
* Allow everything, and point at the host's own sitemap. Unpublished places
|
|
43
|
+
* are held back by `noindex`, not here, so a crawler can still read the
|
|
44
|
+
* `noindex`. A site with no domain is not ready to be crawled at all.
|
|
45
|
+
*/
|
|
46
|
+
export declare function robotsFor<L extends string, P extends string>(site: Site<L, P>, host: string): RobotsFile;
|
|
47
|
+
/** The OG card URL: on the apex, so it never needs a rewrite. */
|
|
48
|
+
export declare function ogImageUrl<L extends string, P extends string>(site: Site<L, P>, query: {
|
|
49
|
+
slug?: string;
|
|
50
|
+
locale: L;
|
|
51
|
+
page?: string;
|
|
52
|
+
}): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isPublished } from "../place/publication.js";
|
|
2
|
+
import { placeOrigin, siteOrigin } from "../place/view.js";
|
|
3
|
+
import { createRouting } from "../routing.js";
|
|
4
|
+
function cluster(site, origin, suffix, priority) {
|
|
5
|
+
const languages = site.i18n.languageAlternates(suffix || "/", origin);
|
|
6
|
+
return site.i18n.locales.map(locale => ({
|
|
7
|
+
url: `${origin}${site.i18n.localePath(locale, suffix || "/")}`,
|
|
8
|
+
priority,
|
|
9
|
+
alternates: { languages },
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The sitemap for a request's host. `places` is the list as the live source
|
|
14
|
+
* sees it now — the caller fetches it strictly: an unreachable source must
|
|
15
|
+
* fail the sitemap (a 5xx a crawler retries), never shrink it (a list of
|
|
16
|
+
* places a crawler drops).
|
|
17
|
+
*/
|
|
18
|
+
export function sitemapFor(site, host, places) {
|
|
19
|
+
const apex = siteOrigin(site);
|
|
20
|
+
if (apex === null)
|
|
21
|
+
return [];
|
|
22
|
+
const slug = createRouting(site).hostSlug(host);
|
|
23
|
+
if (slug === null)
|
|
24
|
+
return cluster(site, apex, "", 1);
|
|
25
|
+
const place = places.find(p => p.slug === slug);
|
|
26
|
+
const origin = placeOrigin(site, slug);
|
|
27
|
+
if (!place || origin === null || !isPublished(place, site.publication, site.brand))
|
|
28
|
+
return [];
|
|
29
|
+
return site.pageKeys.flatMap(page => cluster(site, origin, site.pages[page], page === "home" ? 1 : 0.8));
|
|
30
|
+
}
|
|
31
|
+
/** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
|
|
32
|
+
export const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
|
|
33
|
+
/**
|
|
34
|
+
* Allow everything, and point at the host's own sitemap. Unpublished places
|
|
35
|
+
* are held back by `noindex`, not here, so a crawler can still read the
|
|
36
|
+
* `noindex`. A site with no domain is not ready to be crawled at all.
|
|
37
|
+
*/
|
|
38
|
+
export function robotsFor(site, host) {
|
|
39
|
+
const apex = siteOrigin(site);
|
|
40
|
+
if (apex === null)
|
|
41
|
+
return { rules: [{ userAgent: "*", disallow: "/" }] };
|
|
42
|
+
const slug = createRouting(site).hostSlug(host);
|
|
43
|
+
const origin = (slug === null ? null : placeOrigin(site, slug)) ?? apex;
|
|
44
|
+
return {
|
|
45
|
+
rules: [
|
|
46
|
+
{ userAgent: "*", allow: "/" },
|
|
47
|
+
{ userAgent: [...AI_CRAWLERS], allow: "/" },
|
|
48
|
+
],
|
|
49
|
+
sitemap: `${origin}/sitemap.xml`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** The OG card URL: on the apex, so it never needs a rewrite. */
|
|
53
|
+
export function ogImageUrl(site, query) {
|
|
54
|
+
const params = new URLSearchParams({ lang: query.locale });
|
|
55
|
+
if (query.slug)
|
|
56
|
+
params.set("l", query.slug);
|
|
57
|
+
if (query.page)
|
|
58
|
+
params.set("p", query.page);
|
|
59
|
+
return `${siteOrigin(site) ?? ""}/og?${params.toString()}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { LocaleRegistry } from "@evinvest/i18n";
|
|
2
|
+
import type { LeadSchema } from "./lead.js";
|
|
3
|
+
import type { PublicationPolicy } from "./place/publication.js";
|
|
4
|
+
import type { Place } from "./place/types.js";
|
|
5
|
+
/**
|
|
6
|
+
* The composition root of a landing site: every brand fact the machinery reads
|
|
7
|
+
* — routing, the lead funnel, schema.org, analytics, mail — arrives through one
|
|
8
|
+
* object built by `defineSite`, instead of each slice importing the brand's
|
|
9
|
+
* constants. Pure: no React, no Next, no `node:*`.
|
|
10
|
+
*/
|
|
11
|
+
export interface BrandFacts {
|
|
12
|
+
/** `brand_id` in analytics, the `data-brand` palette scope. */
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
legalName: string;
|
|
16
|
+
email: string | null;
|
|
17
|
+
/** As printed, international; `null` → no `tel:` channel. */
|
|
18
|
+
phone: string | null;
|
|
19
|
+
/** The apex; `null` → nothing is indexable and robots disallow everything. */
|
|
20
|
+
domain: string | null;
|
|
21
|
+
/** schema.org type of each place's business node: `"Plumber"`, `"LocalBusiness"`… */
|
|
22
|
+
businessType: string;
|
|
23
|
+
priceRange?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* How places map onto hosts. `subdomains`: one place per `<slug>.<domain>`,
|
|
27
|
+
* the apex a directory of them. `single`: the whole site is one place, and
|
|
28
|
+
* its canonical origin is the apex.
|
|
29
|
+
*/
|
|
30
|
+
export type Topology = {
|
|
31
|
+
kind: "subdomains";
|
|
32
|
+
apex: "directory";
|
|
33
|
+
} | {
|
|
34
|
+
kind: "single";
|
|
35
|
+
place: string;
|
|
36
|
+
};
|
|
37
|
+
/** A locale- and place-free page suffix: `""` is home, `"/prices"` a subpage. */
|
|
38
|
+
export type PageSuffix = `/${string}` | "";
|
|
39
|
+
/**
|
|
40
|
+
* A path an earlier site served. `to` gets the path's locale prefix (`null`
|
|
41
|
+
* when unprefixed) and answers where it moved, or `null` when that twin is
|
|
42
|
+
* still a live page.
|
|
43
|
+
*/
|
|
44
|
+
export interface LegacyRedirect<L extends string> {
|
|
45
|
+
from: string;
|
|
46
|
+
to: (locale: L | null) => string | null;
|
|
47
|
+
}
|
|
48
|
+
export interface SiteConfig<L extends string, P extends string> {
|
|
49
|
+
brand: BrandFacts;
|
|
50
|
+
i18n: LocaleRegistry<L>;
|
|
51
|
+
/**
|
|
52
|
+
* `og:locale` per language — a region is required there, unlike `hreflang`.
|
|
53
|
+
* A locale left out is derived from its `hreflang` (`fr-FR` → `fr_FR`).
|
|
54
|
+
*/
|
|
55
|
+
ogLocale?: Readonly<Partial<Record<L, string>>>;
|
|
56
|
+
topology: Topology;
|
|
57
|
+
/** Every indexable page of a place; `home` is required. */
|
|
58
|
+
pages: Readonly<Record<P | "home", PageSuffix>>;
|
|
59
|
+
/** Every place the site has, baked; a live source may overlay them. */
|
|
60
|
+
places: readonly Place<L>[];
|
|
61
|
+
/** Which fields a place must fill before it may be indexed. */
|
|
62
|
+
publication: PublicationPolicy;
|
|
63
|
+
/** What the quote form asks and what a lead must have. */
|
|
64
|
+
lead: LeadSchema<string>;
|
|
65
|
+
legacyRedirects?: readonly LegacyRedirect<L>[];
|
|
66
|
+
/**
|
|
67
|
+
* Paths the brand serves as files besides the routes every landing has
|
|
68
|
+
* (`NON_PAGE_ROUTES`): what is in `public/` and the app-root metadata files
|
|
69
|
+
* (`/icon.svg`, `/favicon.ico`). Exact paths; any other path is a page or
|
|
70
|
+
* the 404, with or without an extension. `describeLandingContract` with
|
|
71
|
+
* `root` checks the list against the tree.
|
|
72
|
+
*/
|
|
73
|
+
publicFiles?: readonly string[];
|
|
74
|
+
}
|
|
75
|
+
export interface Site<L extends string, P extends string = string> extends SiteConfig<L, P> {
|
|
76
|
+
/** Page keys in declaration order, `home` first as declared. */
|
|
77
|
+
readonly pageKeys: readonly (P | "home")[];
|
|
78
|
+
readonly placeSlugs: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
export declare function defineSite<const L extends string, const P extends string>(config: SiteConfig<L, P>): Site<L, P>;
|
|
81
|
+
/** One value per locale of the site, in its order. */
|
|
82
|
+
export declare function perLocale<L extends string, T>(site: {
|
|
83
|
+
i18n: LocaleRegistry<L>;
|
|
84
|
+
}, fn: (locale: L) => T): Record<L, T>;
|
|
85
|
+
/** `og:locale` for a language: the configured one, else its `hreflang` with `_`. */
|
|
86
|
+
export declare function ogLocaleOf<L extends string>(site: Pick<SiteConfig<L, string>, "i18n" | "ogLocale">, locale: L): string;
|
|
87
|
+
/**
|
|
88
|
+
* A card fact `withLanding` inlined at build (`process.env.SITE_CARD_PHONE`…):
|
|
89
|
+
* `""` is a fact the card leaves out, `undefined` a build that skipped the
|
|
90
|
+
* card — and a site that silently lost its phone number is worse than one that
|
|
91
|
+
* does not build.
|
|
92
|
+
*/
|
|
93
|
+
export declare function cardFact(name: string, value: string | undefined): string | null;
|
|
94
|
+
/**
|
|
95
|
+
* A fact the owner has not supplied yet, rendered as a placeholder until then.
|
|
96
|
+
* Listed once in the brand's `site.ts`, instead of discovered on the page.
|
|
97
|
+
*/
|
|
98
|
+
export interface OwnerTodo {
|
|
99
|
+
field: string;
|
|
100
|
+
why: string;
|
|
101
|
+
/** A domain (launch) is refused while any of these is open. */
|
|
102
|
+
blocksLaunch: boolean;
|
|
103
|
+
}
|
|
104
|
+
/** The owner facts that stand between the site and a domain. */
|
|
105
|
+
export declare function openLaunchBlockers(todos: readonly OwnerTodo[]): OwnerTodo[];
|
|
106
|
+
/** Throws while a blocking owner fact is open and the site has a domain. */
|
|
107
|
+
export declare function assertLaunchable(site: {
|
|
108
|
+
brand: {
|
|
109
|
+
domain: string | null;
|
|
110
|
+
};
|
|
111
|
+
}, todos: readonly OwnerTodo[]): void;
|
|
112
|
+
/** The baked place for a slug, if the site has it. */
|
|
113
|
+
export declare function bakedPlace<L extends string, P extends string>(site: Site<L, P>, slug: string): Place<L> | undefined;
|
|
114
|
+
/** The numbers a place answers on: its own, or the brand's until it has one. */
|
|
115
|
+
export declare function contactOf(site: Pick<SiteConfig<string, string>, "brand">, place: Place<string>): {
|
|
116
|
+
phone: string | null;
|
|
117
|
+
whatsapp: string | null;
|
|
118
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function defineSite(config) {
|
|
2
|
+
const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
|
|
3
|
+
if (!pageKeys.includes("home"))
|
|
4
|
+
throw new Error("defineSite: pages.home is required");
|
|
5
|
+
for (const key of pageKeys) {
|
|
6
|
+
// One spelling per page: `/prices/` would be a second URL for `/prices`.
|
|
7
|
+
const suffix = config.pages[key];
|
|
8
|
+
if (suffix.endsWith("/") || suffix.includes("//"))
|
|
9
|
+
throw new Error(`defineSite: page "${key}" suffix "${suffix}" must not end in "/"`);
|
|
10
|
+
}
|
|
11
|
+
const placeSlugs = config.places.map(p => p.slug);
|
|
12
|
+
const dup = placeSlugs.find((s, i) => placeSlugs.indexOf(s) !== i);
|
|
13
|
+
if (dup !== undefined)
|
|
14
|
+
throw new Error(`defineSite: place slug "${dup}" is listed twice`);
|
|
15
|
+
for (const slug of placeSlugs) {
|
|
16
|
+
// The host-mode route param is `_<slug>`; a slug starting with the mark
|
|
17
|
+
// would read back as a different place.
|
|
18
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug))
|
|
19
|
+
throw new Error(`defineSite: place slug "${slug}" must be [a-z0-9-], not starting with "-"`);
|
|
20
|
+
// `/<locale>/404/404` is the proxy's route to the 404 (see `GONE`).
|
|
21
|
+
if (slug === "404")
|
|
22
|
+
throw new Error(`defineSite: place slug "404" is reserved for the 404 route`);
|
|
23
|
+
}
|
|
24
|
+
for (const file of config.publicFiles ?? []) {
|
|
25
|
+
if (!file.startsWith("/") || file.endsWith("/") || file.startsWith("/_next/"))
|
|
26
|
+
throw new Error(`defineSite: public file "${file}" must be a path like "/icon.svg"`);
|
|
27
|
+
// `decide` passes a file only without a language: under one it is a page path.
|
|
28
|
+
if (config.i18n.isLocale(file.split("/")[1] ?? ""))
|
|
29
|
+
throw new Error(`defineSite: public file "${file}" must not sit under a language`);
|
|
30
|
+
}
|
|
31
|
+
if (config.topology.kind === "single" && !placeSlugs.includes(config.topology.place)) {
|
|
32
|
+
throw new Error(`defineSite: topology.place "${config.topology.place}" is not one of the places`);
|
|
33
|
+
}
|
|
34
|
+
return { ...config, pageKeys, placeSlugs };
|
|
35
|
+
}
|
|
36
|
+
/** One value per locale of the site, in its order. */
|
|
37
|
+
export function perLocale(site, fn) {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const locale of site.i18n.locales)
|
|
40
|
+
out[locale] = fn(locale);
|
|
41
|
+
if (!isComplete(out, site.i18n.locales))
|
|
42
|
+
throw new Error("perLocale: a locale produced no value");
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function isComplete(record, locales) {
|
|
46
|
+
return locales.every(locale => Object.hasOwn(record, locale));
|
|
47
|
+
}
|
|
48
|
+
/** `og:locale` for a language: the configured one, else its `hreflang` with `_`. */
|
|
49
|
+
export function ogLocaleOf(site, locale) {
|
|
50
|
+
return site.ogLocale?.[locale] ?? site.i18n.hreflangOf(locale).replace("-", "_");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A card fact `withLanding` inlined at build (`process.env.SITE_CARD_PHONE`…):
|
|
54
|
+
* `""` is a fact the card leaves out, `undefined` a build that skipped the
|
|
55
|
+
* card — and a site that silently lost its phone number is worse than one that
|
|
56
|
+
* does not build.
|
|
57
|
+
*/
|
|
58
|
+
export function cardFact(name, value) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
throw new Error(`${name} was not inlined — build through withLanding (next.config.ts)`);
|
|
61
|
+
return value === "" ? null : value;
|
|
62
|
+
}
|
|
63
|
+
/** The owner facts that stand between the site and a domain. */
|
|
64
|
+
export function openLaunchBlockers(todos) {
|
|
65
|
+
return todos.filter(t => t.blocksLaunch);
|
|
66
|
+
}
|
|
67
|
+
/** Throws while a blocking owner fact is open and the site has a domain. */
|
|
68
|
+
export function assertLaunchable(site, todos) {
|
|
69
|
+
const open = openLaunchBlockers(todos);
|
|
70
|
+
if (site.brand.domain !== null && open.length > 0) {
|
|
71
|
+
throw new Error(`not launchable: ${open.map(t => `${t.field} (${t.why})`).join("; ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The baked place for a slug, if the site has it. */
|
|
75
|
+
export function bakedPlace(site, slug) {
|
|
76
|
+
return site.places.find(p => p.slug === slug);
|
|
77
|
+
}
|
|
78
|
+
/** The numbers a place answers on: its own, or the brand's until it has one. */
|
|
79
|
+
export function contactOf(site, place) {
|
|
80
|
+
return {
|
|
81
|
+
phone: place.channels.phone ?? site.brand.phone,
|
|
82
|
+
whatsapp: place.channels.whatsapp ?? site.brand.phone,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Place } from "./place/types.js";
|
|
2
|
+
import { type Site } from "./site.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where a status screen's buttons go and which phone it prints. Every
|
|
5
|
+
* status page — the 404, the 500, the thank-you page — answers in the page's
|
|
6
|
+
* language with the right place's phone, and its language switch goes home
|
|
7
|
+
* (a dead URL has no twin in the other language).
|
|
8
|
+
*/
|
|
9
|
+
export interface StatusTarget<L extends string> {
|
|
10
|
+
locale: L;
|
|
11
|
+
/** The place the URL named, baked, or `null` for the brand's own pages. */
|
|
12
|
+
place: Place<L> | null;
|
|
13
|
+
phone: string | null;
|
|
14
|
+
home: string;
|
|
15
|
+
/** Where "try again" goes. */
|
|
16
|
+
retry: string;
|
|
17
|
+
langHrefs: Record<L, string>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The brand's own status target from two facts — for a client boundary
|
|
21
|
+
* (`error.tsx`, the `notFound()` boundary), which must not import the site
|
|
22
|
+
* config: it would carry every place into every page's bundle.
|
|
23
|
+
*/
|
|
24
|
+
export declare function brandStatusTarget<L extends string>(facts: {
|
|
25
|
+
locales: readonly L[];
|
|
26
|
+
phone: string | null;
|
|
27
|
+
}, locale: L, options?: {
|
|
28
|
+
retry?: string;
|
|
29
|
+
thanks?: boolean;
|
|
30
|
+
}): StatusTarget<L>;
|
|
31
|
+
/**
|
|
32
|
+
* Server side — the global not-found page, a thank-you page: the target for
|
|
33
|
+
* the place the params name. Not for a client module (see
|
|
34
|
+
* {@link brandStatusTarget}).
|
|
35
|
+
*
|
|
36
|
+
* Read from the route params alone — `useParams()` in a client boundary, or a
|
|
37
|
+
* page's `params` — never from the request: a not-found boundary is rendered
|
|
38
|
+
* into every cached page, and a `headers()` there would make each one
|
|
39
|
+
* per-request again. The link mode rides in the `location` param (`_royat`).
|
|
40
|
+
*/
|
|
41
|
+
export declare function statusTarget<L extends string, P extends string>(site: Site<L, P>, params: {
|
|
42
|
+
locale?: string | string[] | undefined;
|
|
43
|
+
location?: string | string[] | undefined;
|
|
44
|
+
}, options?: {
|
|
45
|
+
retry?: string;
|
|
46
|
+
thanks?: boolean;
|
|
47
|
+
}): StatusTarget<L>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createPlaceView } from "./place/view.js";
|
|
2
|
+
import { parsePlaceParam, THANKS } from "./routing.js";
|
|
3
|
+
import { bakedPlace, contactOf, perLocale } from "./site.js";
|
|
4
|
+
function complete(r, locales) {
|
|
5
|
+
return locales.every(l => typeof r[l] === "string");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The brand's own status target from two facts — for a client boundary
|
|
9
|
+
* (`error.tsx`, the `notFound()` boundary), which must not import the site
|
|
10
|
+
* config: it would carry every place into every page's bundle.
|
|
11
|
+
*/
|
|
12
|
+
export function brandStatusTarget(facts, locale, options = {}) {
|
|
13
|
+
const suffix = options.thanks ? THANKS : "";
|
|
14
|
+
const langHrefs = {};
|
|
15
|
+
for (const l of facts.locales)
|
|
16
|
+
langHrefs[l] = `/${l}${suffix}`;
|
|
17
|
+
if (!complete(langHrefs, facts.locales))
|
|
18
|
+
throw new Error("brandStatusTarget: a locale was skipped");
|
|
19
|
+
return {
|
|
20
|
+
locale,
|
|
21
|
+
place: null,
|
|
22
|
+
phone: facts.phone,
|
|
23
|
+
home: `/${locale}`,
|
|
24
|
+
retry: options.retry ?? `/${locale}`,
|
|
25
|
+
langHrefs,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Server side — the global not-found page, a thank-you page: the target for
|
|
30
|
+
* the place the params name. Not for a client module (see
|
|
31
|
+
* {@link brandStatusTarget}).
|
|
32
|
+
*
|
|
33
|
+
* Read from the route params alone — `useParams()` in a client boundary, or a
|
|
34
|
+
* page's `params` — never from the request: a not-found boundary is rendered
|
|
35
|
+
* into every cached page, and a `headers()` there would make each one
|
|
36
|
+
* per-request again. The link mode rides in the `location` param (`_royat`).
|
|
37
|
+
*/
|
|
38
|
+
export function statusTarget(site, params, options = {}) {
|
|
39
|
+
const locale = typeof params.locale === "string" && site.i18n.isLocale(params.locale) ? params.locale : site.i18n.defaultLocale;
|
|
40
|
+
const suffix = options.thanks ? THANKS : "";
|
|
41
|
+
const param = typeof params.location === "string" ? parsePlaceParam(params.location) : null;
|
|
42
|
+
const place = param ? (bakedPlace(site, param.slug) ?? null) : null;
|
|
43
|
+
if (place && param) {
|
|
44
|
+
const view = createPlaceView(site, place, locale, param.mode);
|
|
45
|
+
return {
|
|
46
|
+
locale,
|
|
47
|
+
place,
|
|
48
|
+
phone: contactOf(site, place).phone,
|
|
49
|
+
home: view.href(""),
|
|
50
|
+
retry: options.retry ?? view.href(""),
|
|
51
|
+
langHrefs: perLocale(site, l => view.href(suffix, l)),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
locale,
|
|
56
|
+
place: null,
|
|
57
|
+
phone: site.brand.phone,
|
|
58
|
+
home: `/${locale}`,
|
|
59
|
+
retry: options.retry ?? `/${locale}`,
|
|
60
|
+
langHrefs: perLocale(site, l => `/${l}${suffix}`),
|
|
61
|
+
};
|
|
62
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart` — the core. Pure: no React, no Next, no `node:*`, so
|
|
3
|
+
* the proxy (edge), a client island and a server module can all import it.
|
|
4
|
+
* Runtime-bound halves live on their own subpaths.
|
|
5
|
+
*/
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
|
|
7
|
+
export { createPlaceView, freshRating, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
|
|
8
|
+
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
|
|
9
|
+
export { LEAD_SCHEMA_VERSION, MAX_FIELD, readCandidate, validateCandidate, type Lead, type LeadCandidate, type LeadExtra, type LeadSchema, type LeadStore, type LeadWire, type SpamVerdict, } from "./core/lead.js";
|
|
10
|
+
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
|
|
11
|
+
export { createAcceptLead, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, type AcceptDeps, type Outcome, } from "./core/accept.js";
|
|
12
|
+
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
|
|
13
|
+
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
|
|
14
|
+
export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, type AnalyticsTarget, type IntentChannel, } from "./core/analytics.js";
|
|
15
|
+
export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
|
|
16
|
+
export { brandStatusTarget, statusTarget, type StatusTarget } from "./core/status.js";
|
|
17
|
+
export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
|
|
18
|
+
export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
|
|
19
|
+
export { createBeaconSink, noopSink, type AnalyticsSink } from "@evinvest/analytics";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart` — the core. Pure: no React, no Next, no `node:*`, so
|
|
3
|
+
* the proxy (edge), a client island and a server module can all import it.
|
|
4
|
+
* Runtime-bound halves live on their own subpaths.
|
|
5
|
+
*/
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
|
|
7
|
+
export { createPlaceView, freshRating, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
|
|
8
|
+
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
|
|
9
|
+
export { LEAD_SCHEMA_VERSION, MAX_FIELD, readCandidate, validateCandidate, } from "./core/lead.js";
|
|
10
|
+
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
|
|
11
|
+
export { createAcceptLead, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, } from "./core/accept.js";
|
|
12
|
+
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
|
|
13
|
+
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
|
|
14
|
+
export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, } from "./core/analytics.js";
|
|
15
|
+
export { brandStatusTarget, statusTarget } from "./core/status.js";
|
|
16
|
+
// A landing needs these beside the machinery; re-exported by name so a brand
|
|
17
|
+
// imports one package and the list here is the whole surface.
|
|
18
|
+
export { createLocaleRegistry } from "@evinvest/i18n";
|
|
19
|
+
export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, } from "@evinvest/marketing";
|
|
20
|
+
export { createBeaconSink, noopSink } from "@evinvest/analytics";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface CardFacts {
|
|
2
|
+
/** As printed: international, spaced. `tel:` and `wa.me` derive from it; `null` → no phone channel. */
|
|
3
|
+
phone: string | null;
|
|
4
|
+
email: string | null;
|
|
5
|
+
/** The domain; `null` → a site before launch (noindex everywhere). */
|
|
6
|
+
site: string | null;
|
|
7
|
+
}
|
|
8
|
+
type Table = Record<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* The slice of TOML these two files use for the values read here: `[a.b]`
|
|
11
|
+
* headers and `key = "basic string"` lines; everything else (arrays, numbers,
|
|
12
|
+
* comments) is skipped — not the kit's `parseToml`, to keep `next.config.ts`
|
|
13
|
+
* free of the kit. `evinvest-palette` still validates brand.toml in full.
|
|
14
|
+
*/
|
|
15
|
+
export declare function readStringTables(text: string): Record<string, Table>;
|
|
16
|
+
export declare function readCard(root: string): CardFacts;
|
|
17
|
+
/**
|
|
18
|
+
* The inner markup of `assets/mark.svg` — the path, not the `<svg>` wrapper,
|
|
19
|
+
* which the component writes itself so it can size and colour it. The fill is
|
|
20
|
+
* `currentColor`, which an `<img>` could not inherit.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readMarkPath(root: string): string;
|
|
23
|
+
export type OgPalette = Record<"background" | "card" | "ink" | "inkSoft" | "primary", string>;
|
|
24
|
+
/**
|
|
25
|
+
* The dark scope's values for the OG card. `ImageResponse` draws with inline
|
|
26
|
+
* styles and cannot read a custom property, so the card takes the palette's
|
|
27
|
+
* values at build time instead of a second copy of them in code.
|
|
28
|
+
*/
|
|
29
|
+
export declare function readOgPalette(root: string): OgPalette;
|
|
30
|
+
/** Everything inlined as `process.env.*`, under the names app code reads. */
|
|
31
|
+
export declare function buildEnv(root: string): Record<string, string>;
|
|
32
|
+
export {};
|