From cddb9d8b9c86269b23d269026fc7dd846d9c20e0 Mon Sep 17 00:00:00 2001 From: galela Date: Fri, 14 Aug 2026 20:14:56 +0200 Subject: [PATCH 1/4] feat(local-page-engine): own the local page contract, invert Shopify onto it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @woosmap/local-page-engine: a platform-neutral LocalPage document — store + enrichment + per-client config in, a structured page out. One document, several adapters: a new platform is an adapter, a new client is a config. Three things blocked any non-Shopify consumer: - The SEO title, the JSON-LD and the static-map URL lived in Liquid, so they could not be reused or unit-tested. Now TypeScript, behaviour-preserving: same endpoint, zoom and geometry for the map, LocalBusiness still omitting empty optionals, BreadcrumbList still gated on a region or county. - STORE_FIELD_DEFINITIONS — a Shopify metaobject schema — sat in the shared store-search-client, making it the workspace's de facto contract. It moves to apps/store-pages/app/metaobject-mapping.server.ts, downstream of the engine. The shared client no longer contains any Shopify. - The enrichment resolvers (Nearby + per-mode Distance Matrix, reverse-geocode, haversine neighbours, the TTL) move into the engine, still over an injected fetch. storeSlug is byte-for-byte the storeToMetaobjectHandle it replaces, so no published page changes URL. The sync now builds a LocalPage and maps it; SyncDeps.enrich returns a LocalPageEnrichment and the clock is injected. The conditional behaviour falls out of the model: a fresh TTL means no `nearby` key, so metaobjectUpsert leaves the stored value untouched. New, and the one part that is not a lift: the SEO copy. Shopify got the title and description from the renderable capability, so nothing generated them; a feed consumer has no such capability. examples/local-page.example.json is committed and guarded by a drift test — it is what a client's developer reads to judge whether they can consume the feed. 320 tests (was 235). Engine 98.9% statements, store-pages 98.2%, both over the 80% gate; the mapper is added to the store-pages gate. --- ONBOARDING.md | 4 +- README.md | 4 +- apps/store-pages/README.md | 30 ++- apps/store-pages/app/admin-enrich.server.ts | 63 ------ .../app/admin-graphql.server.test.ts | 2 +- apps/store-pages/app/admin-graphql.server.ts | 2 +- .../app/metaobject-contract.test.ts | 29 ++- .../app/metaobject-mapping.server.test.ts | 134 ++++++++++++ .../app/metaobject-mapping.server.ts | 101 +++++---- .../store-pages/app/store-sync.server.test.ts | 31 ++- apps/store-pages/app/store-sync.server.ts | 46 ++-- apps/store-pages/app/sync-runner.ts | 54 +++-- apps/store-pages/app/woosmap-transport.ts | 7 - apps/store-pages/package.json | 1 + apps/store-pages/vitest.config.ts | 12 +- packages/local-page-engine/README.md | 146 +++++++++++++ .../examples/build-example.ts | 18 ++ .../examples/example-page.ts | 122 +++++++++++ .../examples/local-page.example.json | 204 ++++++++++++++++++ packages/local-page-engine/package.json | 38 ++++ packages/local-page-engine/src/breadcrumb.ts | 28 +++ .../local-page-engine/src/enrich/admin.ts | 47 ++++ .../local-page-engine/src/enrich/nearby.ts | 79 ++----- .../src/enrich/neighbours.ts | 56 ++--- .../local-page-engine/src/enrich/transport.ts | 13 ++ packages/local-page-engine/src/index.ts | 24 +++ packages/local-page-engine/src/json-ld.ts | 83 +++++++ packages/local-page-engine/src/local-page.ts | 71 ++++++ packages/local-page-engine/src/seo.ts | 81 +++++++ packages/local-page-engine/src/slug.ts | 21 ++ packages/local-page-engine/src/static-map.ts | 50 +++++ packages/local-page-engine/src/types.ts | 189 ++++++++++++++++ .../local-page-engine/test/breadcrumb.test.ts | 41 ++++ .../test/enrich/admin.test.ts | 49 +++-- .../test/enrich/nearby.test.ts | 2 +- .../test/enrich/neighbours.test.ts | 2 +- .../local-page-engine/test/example.test.ts | 30 +++ packages/local-page-engine/test/fixtures.ts | 59 +++++ .../local-page-engine/test/json-ld.test.ts | 86 ++++++++ .../local-page-engine/test/local-page.test.ts | 95 ++++++++ packages/local-page-engine/test/seo.test.ts | 92 ++++++++ packages/local-page-engine/test/slug.test.ts | 46 ++++ .../local-page-engine/test/static-map.test.ts | 62 ++++++ packages/local-page-engine/tsconfig.json | 8 + packages/local-page-engine/vitest.config.ts | 18 ++ packages/store-search-client/README.md | 37 ++-- packages/store-search-client/src/index.ts | 7 - .../test/metaobject-mapping.test.ts | 100 --------- pnpm-lock.yaml | 31 +++ 49 files changed, 2123 insertions(+), 432 deletions(-) delete mode 100644 apps/store-pages/app/admin-enrich.server.ts create mode 100644 apps/store-pages/app/metaobject-mapping.server.test.ts rename packages/store-search-client/src/metaobject-mapping.ts => apps/store-pages/app/metaobject-mapping.server.ts (56%) delete mode 100644 apps/store-pages/app/woosmap-transport.ts create mode 100644 packages/local-page-engine/README.md create mode 100644 packages/local-page-engine/examples/build-example.ts create mode 100644 packages/local-page-engine/examples/example-page.ts create mode 100644 packages/local-page-engine/examples/local-page.example.json create mode 100644 packages/local-page-engine/package.json create mode 100644 packages/local-page-engine/src/breadcrumb.ts create mode 100644 packages/local-page-engine/src/enrich/admin.ts rename apps/store-pages/app/nearby-enrich.server.ts => packages/local-page-engine/src/enrich/nearby.ts (72%) rename apps/store-pages/app/nearby-stores.server.ts => packages/local-page-engine/src/enrich/neighbours.ts (58%) create mode 100644 packages/local-page-engine/src/enrich/transport.ts create mode 100644 packages/local-page-engine/src/index.ts create mode 100644 packages/local-page-engine/src/json-ld.ts create mode 100644 packages/local-page-engine/src/local-page.ts create mode 100644 packages/local-page-engine/src/seo.ts create mode 100644 packages/local-page-engine/src/slug.ts create mode 100644 packages/local-page-engine/src/static-map.ts create mode 100644 packages/local-page-engine/src/types.ts create mode 100644 packages/local-page-engine/test/breadcrumb.test.ts rename apps/store-pages/app/admin-enrich.server.test.ts => packages/local-page-engine/test/enrich/admin.test.ts (61%) rename apps/store-pages/app/nearby-enrich.server.test.ts => packages/local-page-engine/test/enrich/nearby.test.ts (99%) rename apps/store-pages/app/nearby-stores.server.test.ts => packages/local-page-engine/test/enrich/neighbours.test.ts (99%) create mode 100644 packages/local-page-engine/test/example.test.ts create mode 100644 packages/local-page-engine/test/fixtures.ts create mode 100644 packages/local-page-engine/test/json-ld.test.ts create mode 100644 packages/local-page-engine/test/local-page.test.ts create mode 100644 packages/local-page-engine/test/seo.test.ts create mode 100644 packages/local-page-engine/test/slug.test.ts create mode 100644 packages/local-page-engine/test/static-map.test.ts create mode 100644 packages/local-page-engine/tsconfig.json create mode 100644 packages/local-page-engine/vitest.config.ts delete mode 100644 packages/store-search-client/test/metaobject-mapping.test.ts diff --git a/ONBOARDING.md b/ONBOARDING.md index 6748949..d96f98c 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -8,7 +8,9 @@ plus a store locator and SEO store pages. ``` packages/ # reusable libraries (one per Woosmap API) ├── localities-client/ # @woosmap/localities-client: worker-safe Localities client + mappers -├── store-search-client/ # @woosmap/store-search-client: Store Search client + Store model + metaobject mapper +├── distance-client/ # @woosmap/distance-client: worker-safe Distance Matrix client +├── store-search-client/ # @woosmap/store-search-client: Store Search client + Store model +├── local-page-engine/ # @woosmap/local-page-engine: platform-neutral LocalPage document + enrichment └── shopify-app-proxy/ # @woosmap/shopify-app-proxy: server-side App Proxy HMAC + wrapper apps/ ├── checkout-autocomplete/ # full app: private key, server proxy, session-token auth, DB, OAuth diff --git a/README.md b/README.md index da141e7..7e46b80 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ checkout address autocomplete (two variants) and a store locator + SEO store pag . ├── packages/ # shared, reusable libraries (one per Woosmap API) │ ├── localities-client/ # @woosmap/localities-client (worker-safe Localities client + mappers) -│ ├── store-search-client/ # @woosmap/store-search-client (Store Search client + Store model + metaobject mapper) +│ ├── distance-client/ # @woosmap/distance-client (worker-safe Distance Matrix client) +│ ├── store-search-client/ # @woosmap/store-search-client (Store Search client + Store model) +│ ├── local-page-engine/ # @woosmap/local-page-engine (platform-neutral LocalPage document + enrichment) │ └── shopify-app-proxy/ # @woosmap/shopify-app-proxy (server-side HMAC + wrapper) └── apps/ ├── checkout-autocomplete/ # full app: private key, server-side proxy, session-token auth, DB, OAuth diff --git a/apps/store-pages/README.md b/apps/store-pages/README.md index a790b11..7ff1404 100644 --- a/apps/store-pages/README.md +++ b/apps/store-pages/README.md @@ -9,11 +9,12 @@ renders the pages. The sync job is the **only backend**. ``` apps/store-pages/ -├── shopify.app.toml # `store` metaobject definition (renderable, publishable) + scopes +├── shopify.app.toml # scopes + webhooks (the `store` metaobject is NOT declared here) ├── app/ │ ├── woosmap.server.ts # StoreSearchClient from env (PRIVATE key) -│ ├── admin-graphql.server.ts # metaobjectUpsert + enable online_store (injectable executor) ← tested -│ ├── store-sync.server.ts # the sync: iterate Woosmap → upsert metaobjects ← tested +│ ├── metaobject-mapping.server.ts # LocalPage → `store` metaobject fields + the definition schema ← tested +│ ├── admin-graphql.server.ts # metaobjectUpsert + enable online_store (injectable executor) ← tested +│ ├── store-sync.server.ts # the sync: iterate Woosmap → build a LocalPage → upsert ← tested │ └── sync-runner.ts # runnable cron entry wiring the above └── theme/templates/metaobject/ └── store.liquid # SEO page + Woosmap Static Maps + JSON-LD @@ -22,20 +23,29 @@ apps/store-pages/ ## Data flow ``` -Woosmap Store API ──(@woosmap/store-search-client)──▶ syncStores ──▶ metaobjectUpsert (store) - │ - Shopify Online Store ◀────────┘ (templates/metaobject/store.liquid) +Woosmap Store API ──(store-search-client)──▶ buildLocalPage() ──▶ localPageToMetaobjectFields + ▲ │ + Localities Nearby · Distance Matrix ───────┤ ▼ + reverse-geocode · neighbours (haversine) │ metaobjectUpsert (store) + (local-page-engine/enrich) ┘ │ + Shopify Online Store ◀───────────┘ + (templates/metaobject/store.liquid) ``` -The field keys written by the sync come straight from -[`@woosmap/store-search-client`](../../packages/store-search-client)'s -`storeToMetaobjectFields` and **must** match the metaobject definition. +This app is **one adapter** over [`@woosmap/local-page-engine`](../../packages/local-page-engine), +which owns the platform-neutral `LocalPage` document. The engine decides what a store page +*contains*; this app decides how it lands in Shopify. A feed, or a server-rendered page, would be +a sibling of `metaobject-mapping.server.ts` — not a fork of the engine. + +The field keys written by the sync come from `localPageToMetaobjectFields` +(`app/metaobject-mapping.server.ts`) and **must** match `STORE_FIELD_DEFINITIONS` declared in the +same file. `metaobject-contract.test.ts` guards that coupling, which no compiler can catch. ## ⚠️ Ownership: the metaobject is merchant-owned, by design The `store` metaobject is **merchant-owned** (type `store`, no `$app:` prefix) and is **created automatically by the sync** on first run (`ensureStoreDefinition`, from -`@woosmap/store-search-client`'s `STORE_FIELD_DEFINITIONS`). It is intentionally **not** +`STORE_FIELD_DEFINITIONS` in `app/metaobject-mapping.server.ts`). It is intentionally **not** declared in `shopify.app.toml`. Why not app-owned (`$app:store`): app-owned metaobjects are **namespaced to the owning diff --git a/apps/store-pages/app/admin-enrich.server.ts b/apps/store-pages/app/admin-enrich.server.ts deleted file mode 100644 index cc7b303..0000000 --- a/apps/store-pages/app/admin-enrich.server.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Reverse-geocodes a store (Woosmap Localities) to country-native admin values -// (Gironde, Kent…) for the store-page breadcrumb + geo context. Filled once, only -// when missing (boundaries don't change). Pure + fetch-injected. - -import { LocalitiesClient } from '@woosmap/localities-client'; -import { toTransport } from './woosmap-transport'; -import type { FetchLike } from './woosmap-transport'; -import type { MetaobjectFieldInput } from '@woosmap/store-search-client'; - -const DEFAULT_API_BASE = 'https://api.woosmap.com'; - -/** Admin levels extracted from a reverse-geocode. */ -export interface AdminAreas { - country?: string; - region?: string; - county?: string; - city?: string; -} - -/** Reverse-geocode a point to its admin areas (null on any problem). Maps Woosmap - * component types: `state`→region, `county`→county, `locality`→city. */ -export async function reverseGeocode( - fetchImpl: FetchLike, - privateKey: string, - lat: number, - lng: number, - apiBase: string = DEFAULT_API_BASE, -): Promise { - const client = new LocalitiesClient({ privateKey, privateKeyIn: 'query', baseUrl: apiBase, transport: toTransport(fetchImpl) }); - try { - const res = await client.geocode({ latLng: { lat, lng } }); - const components = res.results?.[0]?.address_components; - if (!components) return null; - const pick = (type: string): string | undefined => { - const c = components.find((comp) => comp.types.includes(type)); - if (!c) return undefined; - const name = Array.isArray(c.long_name) ? c.long_name[0] : c.long_name; - return name || undefined; - }; - return { country: pick('country'), region: pick('state'), county: pick('county'), city: pick('locality') }; - } catch { - return null; - } -} - -/** Country/region/county values, empty levels omitted. `city` isn't written — it - * comes from the base sync. */ -export function buildAdminFields(areas: AdminAreas): MetaobjectFieldInput[] { - const fields: MetaobjectFieldInput[] = []; - const push = (key: string, value: string | undefined): void => { - const trimmed = (value ?? '').trim(); - if (trimmed) fields.push({ key, value: trimmed }); - }; - push('country', areas.country); - push('region', areas.region); - push('county', areas.county); - return fields; -} - -/** True when a reverse-geocode yielded at least a region — i.e. worth writing. */ -export function hasAdmin(areas: AdminAreas | null): areas is AdminAreas { - return !!areas && (!!areas.region || !!areas.county || !!areas.city); -} diff --git a/apps/store-pages/app/admin-graphql.server.test.ts b/apps/store-pages/app/admin-graphql.server.test.ts index 80d6de9..163c371 100644 --- a/apps/store-pages/app/admin-graphql.server.test.ts +++ b/apps/store-pages/app/admin-graphql.server.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { STORE_FIELD_DEFINITIONS } from '@woosmap/store-search-client'; +import { STORE_FIELD_DEFINITIONS } from './metaobject-mapping.server'; import { AdminGraphQLError, DEFAULT_STORE_METAOBJECT_TYPE, diff --git a/apps/store-pages/app/admin-graphql.server.ts b/apps/store-pages/app/admin-graphql.server.ts index 7508cb6..5fa2786 100644 --- a/apps/store-pages/app/admin-graphql.server.ts +++ b/apps/store-pages/app/admin-graphql.server.ts @@ -6,7 +6,7 @@ import { STORE_FIELD_DEFINITIONS, STORE_METAOBJECT_TYPE, type MetaobjectFieldInput, -} from '@woosmap/store-search-client'; +} from './metaobject-mapping.server'; /** * The default metaobject type — a **merchant-owned** `store` definition (no `$app:` diff --git a/apps/store-pages/app/metaobject-contract.test.ts b/apps/store-pages/app/metaobject-contract.test.ts index 0fd44bf..90e423e 100644 --- a/apps/store-pages/app/metaobject-contract.test.ts +++ b/apps/store-pages/app/metaobject-contract.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { storeToMetaobjectFields, STORE_FIELD_DEFINITIONS, type Store } from '@woosmap/store-search-client'; +import { buildLocalPage } from '@woosmap/local-page-engine'; +import type { Store } from '@woosmap/store-search-client'; +import { localPageToMetaobjectFields, STORE_FIELD_DEFINITIONS } from './metaobject-mapping.server'; // Guards the coupling that has no compiler to catch it: every field KEY the mapper // emits must be declared in STORE_FIELD_DEFINITIONS — the schema used to CREATE the @@ -7,7 +9,7 @@ import { storeToMetaobjectFields, STORE_FIELD_DEFINITIONS, type Store } from '@w // and the schema drift, this fails instead of the sync dropping data at runtime (or // `metaobjectUpsert` rejecting an undeclared key). -/** A fully-populated store so the mapper emits every key it can. */ +/** A fully-populated store so the mapper emits every base key it can. */ const FULL_STORE: Store = { storeId: 'store_1', name: 'Store 1', @@ -22,22 +24,39 @@ const FULL_STORE: Store = { email: 'a@b.co', website: 'https://x', openingHours: { timezone: 'Europe/Paris', days: {} as never }, - types: [], - tags: [], + types: ['Shop'], + tags: ['CC'], lastUpdated: null, userProperties: null, }; +/** Every enrichment slice present, so the enriched keys are exercised too. */ +const FULL_PAGE = buildLocalPage( + FULL_STORE, + { + admin: { country: 'France', region: 'Île-de-France', county: 'Paris', city: 'Paris' }, + nearby: { updated_at: 'T', groups: [] }, + nearbyStores: [{ handle: 'h', url: '/u', name: 'N', city: 'Paris', km: 1 }], + }, + {}, + { now: '2026-08-14T00:00:00.000Z' }, +); + describe('metaobject field contract (mapper ↔ STORE_FIELD_DEFINITIONS)', () => { it('every field key the mapper emits is declared in the definition schema', () => { const declared = new Set(STORE_FIELD_DEFINITIONS.map((f) => f.key)); expect(declared.size).toBeGreaterThan(0); // sanity - const emitted = storeToMetaobjectFields(FULL_STORE).map((f) => f.key); + const emitted = localPageToMetaobjectFields(FULL_PAGE).map((f) => f.key); const missing = emitted.filter((key) => !declared.has(key)); expect(missing).toEqual([]); }); + it('exercises the enriched keys too, not just the store facts', () => { + const emitted = new Set(localPageToMetaobjectFields(FULL_PAGE).map((f) => f.key)); + expect(['nearby', 'region', 'nearby_stores'].filter((k) => !emitted.has(k))).toEqual([]); + }); + it('the schema declares the required identity fields', () => { const required = new Set(STORE_FIELD_DEFINITIONS.filter((f) => f.required).map((f) => f.key)); expect(required.has('store_id')).toBe(true); diff --git a/apps/store-pages/app/metaobject-mapping.server.test.ts b/apps/store-pages/app/metaobject-mapping.server.test.ts new file mode 100644 index 0000000..ee419a9 --- /dev/null +++ b/apps/store-pages/app/metaobject-mapping.server.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { buildLocalPage, type LocalPage, type LocalPageEnrichment } from '@woosmap/local-page-engine'; +import type { Store } from '@woosmap/store-search-client'; +import { localPageToMetaobjectFields, STORE_METAOBJECT_TYPE } from './metaobject-mapping.server'; + +const STORE: Store = { + storeId: 'f52b6ee0_4734_4e36_8929_95fb2304aa4b', + name: 'Store 1', + lat: 48.8566, + lng: 2.3522, + address1: '1 rue de Rivoli', + address2: 'Bâtiment B', + city: 'Paris', + zip: '75001', + countryCode: 'FR', + phone: '+33123456789', + email: 'store@example.com', + website: 'https://example.com/store-1', + openingHours: { timezone: 'Europe/Paris', days: {} as never }, + types: ['Shop'], + tags: ['CC'], + lastUpdated: '2026-08-01T00:00:00Z', + userProperties: null, +}; + +function page(store: Store = STORE, enrichment: LocalPageEnrichment = {}): LocalPage { + return buildLocalPage(store, enrichment, {}, { now: '2026-08-14T00:00:00.000Z' }); +} + +function byKey(fields: Array<{ key: string; value: string }>): Record { + return Object.fromEntries(fields.map((f) => [f.key, f.value])); +} + +describe('localPageToMetaobjectFields — store facts', () => { + it('flattens the store onto the metaobject keys', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect(fields).toMatchObject({ + store_id: 'f52b6ee0_4734_4e36_8929_95fb2304aa4b', + name: 'Store 1', + address1: '1 rue de Rivoli', + address2: 'Bâtiment B', + city: 'Paris', + zip: '75001', + country_code: 'FR', + phone: '+33123456789', + email: 'store@example.com', + website: 'https://example.com/store-1', + }); + }); + + it('stringifies the coordinates for number_decimal fields', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect([fields['lat'], fields['lng']]).toEqual(['48.8566', '2.3522']); + }); + + it('JSON-encodes the opening hours', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect(JSON.parse(fields['hours']!).timezone).toBe('Europe/Paris'); + }); + + it('JSON-encodes the list fields', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect([fields['types'], fields['tags']]).toEqual(['["Shop"]', '["CC"]']); + }); + + it('omits coordinates and hours when the store has none', () => { + const fields = byKey( + localPageToMetaobjectFields(page({ ...STORE, lat: null, lng: null, openingHours: null })), + ); + expect(['lat', 'lng', 'hours'].filter((k) => k in fields)).toEqual([]); + }); + + it('omits empty list fields rather than sending "[]"', () => { + const fields = byKey(localPageToMetaobjectFields(page({ ...STORE, types: [], tags: [] }))); + expect(['types', 'tags'].filter((k) => k in fields)).toEqual([]); + }); + + it('never writes description — the merchant owns the prose', () => { + const keys = localPageToMetaobjectFields(page()).map((f) => f.key); + expect(keys).not.toContain('description'); + }); +}); + +describe('localPageToMetaobjectFields — enrichment', () => { + it('emits the admin levels the page carries', () => { + const fields = byKey( + localPageToMetaobjectFields( + page(STORE, { admin: { country: 'France', region: 'Île-de-France', county: 'Paris' } }), + ), + ); + expect(fields).toMatchObject({ country: 'France', region: 'Île-de-France', county: 'Paris' }); + }); + + it('never writes city from the admin data — it comes from the store facts', () => { + const fields = byKey( + localPageToMetaobjectFields(page({ ...STORE, city: '' }, { admin: { city: 'Bordeaux' } })), + ); + expect(fields['city']).toBeUndefined(); + }); + + it('omits whitespace-only admin levels', () => { + const fields = byKey(localPageToMetaobjectFields(page(STORE, { admin: { region: ' ' } }))); + expect(fields['region']).toBeUndefined(); + }); + + it('JSON-encodes the nearby payload', () => { + const fields = byKey( + localPageToMetaobjectFields(page(STORE, { nearby: { updated_at: 'T', groups: [] } })), + ); + expect(fields['nearby']).toBe('{"updated_at":"T","groups":[]}'); + }); + + it('omits nearby entirely when the page was not enriched, so the stored value survives', () => { + const fields = byKey(localPageToMetaobjectFields(page())); + expect('nearby' in fields).toBe(false); + }); + + it('JSON-encodes the neighbouring stores', () => { + const neighbours = [{ handle: 'h', url: '/u', name: 'N', city: 'Paris', km: 2 }]; + const fields = byKey(localPageToMetaobjectFields(page(STORE, { nearbyStores: neighbours }))); + expect(JSON.parse(fields['nearby_stores']!)).toEqual(neighbours); + }); + + it('omits nearby_stores when the store has no neighbours', () => { + const fields = byKey(localPageToMetaobjectFields(page(STORE, { nearbyStores: [] }))); + expect('nearby_stores' in fields).toBe(false); + }); +}); + +describe('STORE_METAOBJECT_TYPE', () => { + it('is the merchant-owned `store` type, with no $app: prefix', () => { + expect(STORE_METAOBJECT_TYPE).toBe('store'); + }); +}); diff --git a/packages/store-search-client/src/metaobject-mapping.ts b/apps/store-pages/app/metaobject-mapping.server.ts similarity index 56% rename from packages/store-search-client/src/metaobject-mapping.ts rename to apps/store-pages/app/metaobject-mapping.server.ts index d04dad5..821e624 100644 --- a/packages/store-search-client/src/metaobject-mapping.ts +++ b/apps/store-pages/app/metaobject-mapping.server.ts @@ -1,23 +1,12 @@ -import type { Store } from './store'; +// The Shopify adapter: a LocalPage → `store` metaobject fields. +// +// This used to live in `@woosmap/store-search-client`, which meant a Shopify +// metaobject schema — enriched keys and all — was the de facto contract of the +// whole workspace. It is now the other way round: `@woosmap/local-page-engine` +// owns the contract, and this file is one consumer of it. A feed adapter, or a +// server-rendered page, is a sibling of this file, not a fork of the engine. -/** - * Maps a Woosmap {@link Store} onto the fields of a Shopify `store` metaobject, - * for `metaobjectUpsert` (Admin GraphQL). Emits the field list and a stable - * handle; the caller owns the mutation and the definition `type`. - * - * The field keys the mapper emits MUST all exist in {@link STORE_FIELD_DEFINITIONS} - * (the definition schema), which is the single source of truth used to create the - * merchant-owned `store` metaobject definition at sync time. Kept in this package - * so the schema contract lives next to the data it maps. - * - * Design choices that matter for a repeatable sync: - * - Empty values are omitted, not sent as `""`. Shopify rejects an empty - * `url` or `number_decimal`, and `metaobjectUpsert` leaves fields it isn't - * given unchanged — so omitting is both safe and correct. - * - `description` and the renderable SEO fields are deliberately NOT emitted: - * they are merchant-editable, and re-sending them each sync would clobber - * hand-written copy. Sync owns the facts; the merchant owns the prose. - */ +import type { LocalPage } from '@woosmap/local-page-engine'; /** The metaobject definition type these fields belong to (merchant-owned; no `$app:` prefix). */ export const STORE_METAOBJECT_TYPE = 'store'; @@ -31,10 +20,16 @@ export interface StoreFieldDefinition { required?: boolean; } +/** A single Shopify metaobject field value, as `metaobjectUpsert` expects. */ +export interface MetaobjectFieldInput { + key: string; + value: string; +} + /** * The `store` metaobject definition schema — the single source of truth for * creating the merchant-owned definition (see `ensureStoreDefinition`). Every key - * {@link storeToMetaobjectFields} can emit is declared here; `description` is + * {@link localPageToMetaobjectFields} can emit is declared here; `description` is * declared (merchant-editable) but never written by the sync. */ export const STORE_FIELD_DEFINITIONS: StoreFieldDefinition[] = [ @@ -53,47 +48,35 @@ export const STORE_FIELD_DEFINITIONS: StoreFieldDefinition[] = [ { key: 'hours', name: 'Opening hours', type: 'json' }, { key: 'types', name: 'Types', type: 'list.single_line_text_field' }, { key: 'tags', name: 'Tags', type: 'list.single_line_text_field' }, - // Server-side enriched nearby POIs (grouped) + `updated_at` for the TTL refresh. - // Written by the store-pages sync (not by storeToMetaobjectFields), so the block - // is rendered in HTML (SEO/GEO) instead of fetched client-side. + // Enrichment, server-side so the blocks are rendered in HTML (SEO/GEO) instead of + // fetched client-side. `nearby` carries its own `updated_at`, which drives the TTL. { key: 'nearby', name: 'Nearby POIs', type: 'json' }, - // Administrative hierarchy (country-native values) — written by the sync's - // reverse-geocode enrichment, for the breadcrumb + geo context. Filled once, - // only when missing. Slugs aren't stored: they're derivable from these values - // if/when nested URLs (area pages) land. { key: 'country', name: 'Country', type: 'single_line_text_field' }, { key: 'region', name: 'Region', type: 'single_line_text_field' }, { key: 'county', name: 'County', type: 'single_line_text_field' }, - // Server-side enriched neighbouring stores within a radius (nearest N), for the - // "other stores nearby" section — internal links between store pages (good for - // crawl/SEO). Recomputed each run from the full store set (haversine, no extra - // API calls). Written by the sync, not by storeToMetaobjectFields. { key: 'nearby_stores', name: 'Nearby stores', type: 'json' }, { key: 'description', name: 'Description', type: 'multi_line_text_field' }, ]; -/** A single Shopify metaobject field value, as `metaobjectUpsert` expects. */ -export interface MetaobjectFieldInput { - key: string; - value: string; -} - /** - * Build the Shopify metaobject handle for a store. Handles allow - * `[a-z0-9_-]`; the Woosmap `store_id` is lower-cased and any other character - * is collapsed to a single hyphen, so the handle is deterministic and the - * upsert is idempotent (one metaobject per store, re-runnable). + * Map a {@link LocalPage} onto Shopify metaobject fields. + * + * Empty values are omitted, not sent as `""`: Shopify rejects an empty `url` or + * `number_decimal`, and `metaobjectUpsert` leaves fields it isn't given unchanged. + * `description` and the renderable SEO fields are never written — they are + * merchant-editable, and the sync owns the facts, not the prose. + * + * Enrichment is emitted only when the page carries it, which preserves the runner's + * conditional behaviour for free: a fresh TTL means no `nearby` key, so the stored + * value survives. + * + * `page.seo`, `page.jsonLd` and `page.map` are deliberately unmapped — Shopify + * covers them (the `renderable` capability, and `store.liquid` builds its own). They + * exist for adapters that have no such platform. The cost is a live duplicate: + * folding the Liquid onto a `json` field fed from `page.jsonLd` would remove it, but + * that changes what the storefront renders and wants its own dev-store pass. */ -export function storeToMetaobjectHandle(store: Pick): string { - return store.storeId - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 255); -} - -/** Map a {@link Store} to Shopify metaobject fields, omitting empty values. */ -export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { +export function localPageToMetaobjectFields(page: LocalPage): MetaobjectFieldInput[] { const fields: MetaobjectFieldInput[] = []; const push = (key: string, value: string | null | undefined): void => { if (value !== null && value !== undefined && value !== '') { @@ -101,6 +84,7 @@ export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { } }; + const store = page.store; push('store_id', store.storeId); push('name', store.name); push('address1', store.address1); @@ -118,5 +102,20 @@ export function storeToMetaobjectFields(store: Store): MetaobjectFieldInput[] { push('types', store.types && store.types.length > 0 ? JSON.stringify(store.types) : null); push('tags', store.tags && store.tags.length > 0 ? JSON.stringify(store.tags) : null); + // Administrative levels. `city` is not written here — it already comes from the + // store facts above, and the reverse-geocode is only a fallback for the trail. + if (page.admin) { + push('country', page.admin.country?.trim()); + push('region', page.admin.region?.trim()); + push('county', page.admin.county?.trim()); + } + + if (page.nearby) { + push('nearby', JSON.stringify(page.nearby)); + } + if (page.nearbyStores.length > 0) { + push('nearby_stores', JSON.stringify(page.nearbyStores)); + } + return fields; } diff --git a/apps/store-pages/app/store-sync.server.test.ts b/apps/store-pages/app/store-sync.server.test.ts index e16a5fb..3154ae6 100644 --- a/apps/store-pages/app/store-sync.server.test.ts +++ b/apps/store-pages/app/store-sync.server.test.ts @@ -60,21 +60,44 @@ describe('syncStores', () => { expect(firstFields['lat']).toBe('48.8'); }); - it('merges enrich() fields into the upsert; an enrich error never aborts the store', async () => { + it('carries enrich() onto the page; an enrich error never aborts the store', async () => { const src = source([feature('store_A'), feature('store_B')]); const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); const enrich = vi.fn(async (store: Store) => { if (store.storeId === 'store_B') throw new Error('boom'); - return [{ key: 'nearby', value: '{"updated_at":"t"}' }]; + return { nearby: { updated_at: 't', groups: [] } }; }); const result = await syncStores({ source: src, upsert, enrich }); expect(result.upserted).toBe(2); // both stores upserted, even though enrich threw on B const aFields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value])); - expect(aFields['nearby']).toBe('{"updated_at":"t"}'); + expect(aFields['nearby']).toBe('{"updated_at":"t","groups":[]}'); const bFields = Object.fromEntries(upsert.mock.calls[1]![0].fields.map((f) => [f.key, f.value])); - expect(bFields['nearby']).toBeUndefined(); // enrich threw → no extra field, base upsert still ran + expect(bFields['nearby']).toBeUndefined(); // enrich threw → no key, base upsert still ran + }); + + it('emits the admin levels an enricher resolved', async () => { + const src = source([feature('store_A')]); + const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); + const enrich = async (): Promise<{ admin: { region: string; county: string } }> => ({ + admin: { region: 'Île-de-France', county: 'Paris' }, + }); + + await syncStores({ source: src, upsert, enrich }); + + const fields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value])); + expect([fields['region'], fields['county']]).toEqual(['Île-de-France', 'Paris']); + }); + + it('uses the injected clock so a run is reproducible', async () => { + const src = source([feature('store_A')]); + const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle })); + const now = vi.fn(() => '2026-08-14T00:00:00.000Z'); + + await syncStores({ source: src, upsert, now }); + + expect(now).toHaveBeenCalled(); }); it('passes a full-sync request (undefined) when no options are given', async () => { diff --git a/apps/store-pages/app/store-sync.server.ts b/apps/store-pages/app/store-sync.server.ts index c3291d8..748f604 100644 --- a/apps/store-pages/app/store-sync.server.ts +++ b/apps/store-pages/app/store-sync.server.ts @@ -8,13 +8,17 @@ // same `upsertOne` path for a single store. import { featureToStore, - storeToMetaobjectFields, - storeToMetaobjectHandle, - type MetaobjectFieldInput, type Store, type StoreFeature, type StoresSearchRequest, } from '@woosmap/store-search-client'; +import { + buildLocalPage, + storeSlug, + type LocalPageConfig, + type LocalPageEnrichment, +} from '@woosmap/local-page-engine'; +import { localPageToMetaobjectFields, type MetaobjectFieldInput } from './metaobject-mapping.server'; /** Just the client surface the sync needs — narrow, so tests inject a fake. */ export interface StoreSource { @@ -34,12 +38,16 @@ export interface SyncDeps { /** Optional progress hook (e.g. logging). */ onProgress?: (event: SyncProgress) => void; /** - * Optional per-store enrichment: extra metaobject fields to merge before the - * upsert (e.g. server-side `nearby` POIs). Returning `[]` writes nothing extra, - * so `metaobjectUpsert` leaves any existing value untouched. A throw is caught - * and does not abort the run (the store is still upserted with its base fields). + * Optional per-store enrichment. Returns whatever was resolved — an empty object + * carries nothing, so the mapper emits no enrichment keys and `metaobjectUpsert` + * leaves any existing values untouched. A throw is caught and does not abort the + * run: the store is still upserted with its base facts. */ - enrich?: (store: Store) => Promise; + enrich?: (store: Store) => Promise; + /** Per-client page config (branding, url base, SEO templates, map geometry). */ + pageConfig?: LocalPageConfig; + /** Injected clock (ISO string), so a run is reproducible in tests. */ + now?: () => string; } /** Options controlling which stores are synced. */ @@ -89,20 +97,26 @@ export function buildSyncQuery(options: SyncOptions = {}): string | undefined { } /** - * Run a sync. Iterates matching Woosmap stores, maps each to metaobject fields, - * and upserts it. One store failing does not abort the run — the error is - * collected and the sync continues, so a bad record can't block the rest. + * Run a sync. Iterates matching Woosmap stores, builds a platform-neutral + * `LocalPage` for each, maps it to metaobject fields, and upserts it. One store + * failing does not abort the run — the error is collected and the sync continues, + * so a bad record can't block the rest. + * + * The page is built here rather than in the mapper on purpose: the same document + * is what a feed or a server-rendered page would consume, so this loop is the only + * Shopify-specific thing left in the pipeline. */ export async function syncStores(deps: SyncDeps, options: SyncOptions = {}): Promise { const query = buildSyncQuery(options); const request: StoresSearchRequest | undefined = query ? { query } : undefined; + const clock = deps.now ?? ((): string => new Date().toISOString()); const result: SyncResult = { total: 0, upserted: 0, skipped: 0, failed: 0, errors: [] }; for await (const feature of deps.source.iterateStores(request)) { result.total += 1; const store = featureToStore(feature); - const handle = storeToMetaobjectHandle(store); + const handle = storeSlug(store.storeId); // A store with no id/name can't produce a valid, addressable metaobject. if (!store.storeId || !store.name || !handle) { @@ -112,14 +126,16 @@ export async function syncStores(deps: SyncDeps, options: SyncOptions = {}): Pro } try { - const fields = storeToMetaobjectFields(store); + let enrichment: LocalPageEnrichment = {}; if (deps.enrich) { try { - fields.push(...(await deps.enrich(store))); + enrichment = await deps.enrich(store); } catch { - // Enrichment must never block the base upsert — skip the extra fields. + // Enrichment must never block the base upsert — ship the facts alone. } } + const page = buildLocalPage(store, enrichment, deps.pageConfig ?? {}, { now: clock() }); + const fields = localPageToMetaobjectFields(page); await deps.upsert({ handle, fields }); result.upserted += 1; report(deps, { storeId: store.storeId, handle, status: 'upserted' }); diff --git a/apps/store-pages/app/sync-runner.ts b/apps/store-pages/app/sync-runner.ts index fc2a62a..b2dd726 100644 --- a/apps/store-pages/app/sync-runner.ts +++ b/apps/store-pages/app/sync-runner.ts @@ -4,7 +4,7 @@ // It wires the real dependencies to the pure `syncStores` logic. In the embedded // Remix app you'd instead call `syncStores` from a resource route, passing an // executor built from the request's `admin.graphql`. -import { featureToStore, storeToMetaobjectHandle, type MetaobjectFieldInput, type Store } from '@woosmap/store-search-client'; +import { featureToStore, type Store } from '@woosmap/store-search-client'; import { createStoreClient } from './woosmap.server'; import { DEFAULT_STORE_METAOBJECT_TYPE, @@ -15,9 +15,17 @@ import { upsertStoreMetaobject, } from './admin-graphql.server'; import { syncStores } from './store-sync.server'; -import { enrichNearby, isNearbyStale, parseNearbyGroups } from './nearby-enrich.server'; -import { buildAdminFields, hasAdmin, reverseGeocode } from './admin-enrich.server'; -import { buildStoreIndex, findNearbyStores } from './nearby-stores.server'; +import { + buildStoreIndex, + enrichNearby, + findNearbyStores, + hasAdmin, + isNearbyStale, + parseNearbyGroups, + reverseGeocode, + storeSlug, + type LocalPageEnrichment, +} from '@woosmap/local-page-engine'; function requireEnv(name: string): string { const value = process.env[name]; @@ -56,9 +64,9 @@ async function main(): Promise { } // Optional server-side enrichment (SEO/GEO), composed from independent steps. - // Each returns extra metaobject fields; an empty array leaves existing values - // untouched (metaobjectUpsert doesn't clear unspecified fields). - const enrichers: Array<(store: Store) => Promise> = []; + // Each returns the slice of the page it resolved; an empty object carries nothing, + // so the mapper emits no key and metaobjectUpsert leaves existing values untouched. + const enrichers: Array<(store: Store) => Promise> = []; const nearby = { refreshed: 0, kept: 0 }; const admin = { filled: 0, kept: 0 }; const nearbyStores = { withNeighbours: 0, alone: 0 }; @@ -74,15 +82,15 @@ async function main(): Promise { const now = new Date(); const timestamps = await listStoreNearbyTimestamps(execute, { type }); enrichers.push(async (store) => { - if (store.lat === null || store.lng === null) return []; - const handle = storeToMetaobjectHandle(store); + if (store.lat === null || store.lng === null) return {}; + const handle = storeSlug(store.storeId); if (!isNearbyStale(timestamps.get(handle), maxAgeDays, now)) { nearby.kept += 1; - return []; // fresh → leave the existing `nearby` untouched + return {}; // fresh → leave the existing `nearby` untouched } const data = await enrichNearby(fetch, woosmapPrivateKey, store.lat, store.lng, now.toISOString(), groups); nearby.refreshed += 1; - return [{ key: 'nearby', value: JSON.stringify(data) }]; + return { nearby: data }; }); console.log(`Nearby enrichment ON (TTL ${maxAgeDays} days, ${groups.length} group(s)).`); } @@ -93,16 +101,16 @@ async function main(): Promise { const woosmapPrivateKey = requireEnv('WOOSMAP_PRIVATE_KEY'); const present = await listStoreAdminPresence(execute, { type }); enrichers.push(async (store) => { - if (store.lat === null || store.lng === null) return []; - const handle = storeToMetaobjectHandle(store); + if (store.lat === null || store.lng === null) return {}; + const handle = storeSlug(store.storeId); if (present.has(handle)) { admin.kept += 1; - return []; // already enriched + return {}; // already enriched } const areas = await reverseGeocode(fetch, woosmapPrivateKey, store.lat, store.lng); - if (!hasAdmin(areas)) return []; + if (!hasAdmin(areas)) return {}; admin.filled += 1; - return buildAdminFields(areas); + return { admin: areas }; }); console.log('Admin enrichment ON (fill-once, no TTL).'); } @@ -129,21 +137,29 @@ async function main(): Promise { } else { nearbyStores.alone += 1; } - return [{ key: 'nearby_stores', value: JSON.stringify(list) }]; + return { nearbyStores: list }; }); } const enrich = enrichers.length > 0 - ? async (store: Store): Promise => { + ? async (store: Store): Promise => { const parts = await Promise.all(enrichers.map((run) => run(store))); - return parts.flat(); + return Object.assign({}, ...parts) as LocalPageEnrichment; } : undefined; const result = await syncStores( { source: client, + // Per-client page config. The public key is optional here: the Shopify theme + // reads its own, so the sync only needs it if a consumer wants the map URL + // baked into the document (a feed would). + pageConfig: { + urlBase: `/pages/${process.env.STORE_URL_HANDLE ?? 'stores'}`, + ...(process.env.WOOSMAP_PUBLIC_KEY ? { publicKey: process.env.WOOSMAP_PUBLIC_KEY } : {}), + ...(process.env.STORE_BRAND ? { brand: process.env.STORE_BRAND } : {}), + }, upsert: ({ handle, fields }) => upsertStoreMetaobject(execute, { type, handle, fields, status }), onProgress: (event) => { if (event.status === 'failed') { diff --git a/apps/store-pages/app/woosmap-transport.ts b/apps/store-pages/app/woosmap-transport.ts deleted file mode 100644 index 524f16e..0000000 --- a/apps/store-pages/app/woosmap-transport.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** A `fetch`-like function (injected in tests). */ -export type FetchLike = (url: string) => Promise<{ ok: boolean; json: () => Promise }>; - -/** Adapt an injected fetch-like into the client libs' Transport shape. */ -export function toTransport(fetchImpl: FetchLike) { - return (url: string) => fetchImpl(url).then((r) => ({ ok: r.ok, status: r.ok ? 200 : 502, statusText: '', json: r.json })); -} diff --git a/apps/store-pages/package.json b/apps/store-pages/package.json index 3d12023..02b5788 100644 --- a/apps/store-pages/package.json +++ b/apps/store-pages/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@woosmap/distance-client": "workspace:*", + "@woosmap/local-page-engine": "workspace:*", "@woosmap/localities-client": "workspace:*", "@woosmap/store-search-client": "workspace:*" }, diff --git a/apps/store-pages/vitest.config.ts b/apps/store-pages/vitest.config.ts index aa33bb2..9af6e6d 100644 --- a/apps/store-pages/vitest.config.ts +++ b/apps/store-pages/vitest.config.ts @@ -5,9 +5,15 @@ export default defineConfig({ include: ['app/**/*.test.ts'], coverage: { provider: 'v8', - // The sync logic, the Admin GraphQL wrapper, and the client factory are the - // tested "backend". sync-runner.ts is the thin main() entry (wiring only). - include: ['app/store-sync.server.ts', 'app/admin-graphql.server.ts', 'app/woosmap.server.ts'], + // The sync logic, the Shopify mapper, the Admin GraphQL wrapper and the client + // factory are the tested "backend". sync-runner.ts is the thin main() entry + // (wiring only), so it stays out. + include: [ + 'app/store-sync.server.ts', + 'app/metaobject-mapping.server.ts', + 'app/admin-graphql.server.ts', + 'app/woosmap.server.ts', + ], thresholds: { lines: 80, functions: 80, diff --git a/packages/local-page-engine/README.md b/packages/local-page-engine/README.md new file mode 100644 index 0000000..bda0912 --- /dev/null +++ b/packages/local-page-engine/README.md @@ -0,0 +1,146 @@ +# @woosmap/local-page-engine + +The **platform-neutral local store page model**. Store + enrichment + per-client config in, +a structured `LocalPage` document out. No HTML, no Liquid, no metaobject field keys, nothing +that assumes who renders the page. + +``` +store (Store Search) ─┐ +admin areas ─┤ +nearby POIs ─┼─▶ buildLocalPage() ─▶ LocalPage ─▶ Shopify metaobjects +neighbouring stores ─┤ ─▶ JSON feed +per-client config ─┘ ─▶ server-rendered page +``` + +One document, several adapters. That is the whole point: a new platform is an adapter, a new +client is a config. + +## Why it exists + +The enrichment was already written and tested, but it lived inside `apps/store-pages` and its +output went straight into Shopify metaobject fields. Three consequences: + +- the **SEO title, the JSON-LD and the static map were written in Liquid**, so they could not be + reused by any other platform and could not be unit-tested; +- `STORE_FIELD_DEFINITIONS` — a Shopify metaobject schema — sat in the shared + `@woosmap/store-search-client`, making the Shopify schema the de facto contract of the + workspace; +- anything that is not Shopify had to re-derive the page content from scratch. + +This package inverts that. The document is the contract; Shopify becomes one consumer of it. + +## The contract + +```ts +buildLocalPage(store, enrichment, config, { now }) => LocalPage +``` + +| Field | What it is | +| --- | --- | +| `slug`, `canonicalPath` | identity and location of the page | +| `store` | the facts, straight from Store Search | +| `admin`, `breadcrumb` | administrative hierarchy, trail with consecutive duplicates dropped | +| `nearby` | POIs by family, with travel time | +| `nearbyStores` | neighbours, for internal linking | +| `seo` | title, description, canonical path, image alt | +| `jsonLd` | `LocalBusiness`, plus `BreadcrumbList` when a hierarchy resolved | +| `map` | Woosmap Static Maps illustration | +| `computedAt` | when this document was built | + +**Pure by design.** No fetch, no clock, no filesystem: the enrichment is resolved by the caller +and passed in, and `now` is injected. That is what makes the model testable without a network, +and what lets the same function run in a CLI today and inside the platform later without a +rewrite. + +Enrichment is optional throughout — a store with no nearby data still yields a valid page, so +one failing enrichment call never costs you the page. + +## Lifted from `store.liquid`, deliberately unchanged + +The behaviour of the existing Shopify pages is preserved, so no published page changes: + +- **`storeSlug`** is byte-for-byte `storeToMetaobjectHandle`. Same input, same output, so + existing URLs and their accumulated SEO survive the move. +- **The static map** keeps the same endpoint, zoom 15 and 600×400 geometry, and the same + `alt` copy. It stays an `` with a **public** key: zero JavaScript, because crawlers and + AI answer engines do not run JS, and cacheable, because that is what keeps the 20 req/s + Static Maps quota viable. (Which is also why these requests cannot count page views — + shared caches collapse many views into one origin request.) +- **`LocalBusiness`** omits `addressRegion`, `geo` and `telephone` rather than emitting them + empty. Building it as an object also removes a class of bug Liquid invited here: a blank + optional value left a dangling comma and silently invalidated the document. +- **`BreadcrumbList`** is emitted only when a region or a county resolved, and only the last + rung carries an `item` URL — area pages do not exist yet, and declaring URLs that 404 is + worse than declaring none. + +## New: the SEO copy + +The one part that is **not** a lift. On Shopify the title and description came from the +metaobject's `renderable` capability pointing at merchant-editable fields, so nothing generated +them. A feed consumer has no such capability, so the model generates copy from templates: + +```ts +{ title: '{name} — {city} | {brand}', description: '{name}, {address}, {zip} {city}. …' } +``` + +Placeholders: `{name}` `{brand}` `{address}` `{zip}` `{city}` `{county}` `{region}` `{country}`. +Missing values collapse and the punctuation is repaired, so one template serves a whole network +whichever fields a given store happens to be missing. Override per client via `config.seo`. + +## Use + +```shell +pnpm --filter @woosmap/local-page-engine test +pnpm --filter @woosmap/local-page-engine coverage # ≥80% gate +pnpm --filter @woosmap/local-page-engine example # regenerate the reference document +``` + +[`examples/local-page.example.json`](./examples/local-page.example.json) is committed on +purpose: a document you can read is worth more in a review than the type, and it is what a +client's developer would be handed to decide whether they can consume the feed. +`test/example.test.ts` fails if it drifts from the model, because a stale reference document is +worse than none. + +## Known state: three fields the Shopify adapter does not consume yet + +`seo`, `jsonLd` and `map` are computed for every page, and on the Shopify surface they are +currently dropped — Shopify covers them another way (the `renderable` capability supplies the +title and description, and `store.liquid` builds its own JSON-LD and static-map ``). + +So the lift out of Liquid is done in TypeScript, but the Liquid original is still live: two +implementations of the same derivation, free to drift. Feeding `store.liquid` from a `json` +metaobject field carrying `page.jsonLd` would remove the duplicate, delete ~45 lines of +string-concatenated Liquid, and put the document under unit test. It changes what the storefront +renders, so it belongs in its own change with a dev-store pass. + +## The enrichment resolvers + +`src/enrich/` holds the I/O half — the only part of the package that touches the network, always +over an **injected fetch** so every path is testable without one: + +| Resolver | Calls | Notes | +| --- | --- | --- | +| `enrichNearby` | Localities Nearby + Distance Matrix | **one matrix request per travel mode**, not per POI — batching there is what keeps enrichment affordable | +| `reverseGeocode` | Localities Geocode | country-native values (Gironde, Kent), filled once | +| `findNearbyStores` | none | haversine over the in-memory store index | +| `isNearbyStale` | none | the TTL, i.e. the **second invalidation axis** | + +That second axis is the one people forget. The store axis is event-driven — a store changes, you +rebuild its page. But the geography around a store changes *without the store changing*: a new +metro exit, a car park that closed. Nothing in the store record moves, so only a TTL sweep +catches it. + +Both `fetchNearbyGroup` and `addDistances` swallow their errors by design: a store with no +nearby data still yields a valid page, so one failing call never costs you the page. + +## Not in this package + +**No rendering.** How a `LocalPage` becomes markup is the adapter's business. + +**No Shopify.** The metaobject schema and mapper live in +`apps/store-pages/app/metaobject-mapping.server.ts`, downstream of this package: the sync builds +a `LocalPage`, then `localPageToMetaobjectFields(page)` maps it. A feed adapter, or a +server-rendered page, is a sibling of that file — not a fork of this engine. + +**No measurement.** The page model records `config.directionsProvider` so a renderer knows +which map to open, but building the link — and attributing the click — belongs to the surface. diff --git a/packages/local-page-engine/examples/build-example.ts b/packages/local-page-engine/examples/build-example.ts new file mode 100644 index 0000000..331719e --- /dev/null +++ b/packages/local-page-engine/examples/build-example.ts @@ -0,0 +1,18 @@ +/** + * Write `local-page.example.json` — the reference output of the page model. + * + * The example is committed on purpose: a JSON document people can read is worth + * more in a review than the TypeScript type, and it is what a client's developer + * would be handed to decide whether they can consume the feed. + * `test/example.test.ts` fails if the committed file drifts from the model. + * + * Run: `pnpm --filter @woosmap/local-page-engine example` + */ +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { EXAMPLE_JSON } from './example-page'; + +const out = join(dirname(fileURLToPath(import.meta.url)), 'local-page.example.json'); +writeFileSync(out, EXAMPLE_JSON, 'utf8'); +console.log(`Wrote ${out}`); diff --git a/packages/local-page-engine/examples/example-page.ts b/packages/local-page-engine/examples/example-page.ts new file mode 100644 index 0000000..37377d3 --- /dev/null +++ b/packages/local-page-engine/examples/example-page.ts @@ -0,0 +1,122 @@ +/** + * The inputs behind `local-page.example.json`, kept importable so a test can + * rebuild the document and compare it to the committed file. Without that guard the + * reference output silently rots the first time the model changes, which is exactly + * the file a client's developer would be reading. + */ +import type { Store } from '@woosmap/store-search-client'; +import { buildLocalPage } from '../src/local-page'; +import type { + AdminAreas, + LocalPage, + LocalPageConfig, + NearbyData, + NearbyStore, +} from '../src/types'; + +const store: Store = { + storeId: 'FR-0421', + name: 'Berkeley Square', + lat: 48.8698, + lng: 2.3075, + address1: '27 Avenue des Champs-Élysées', + address2: '', + city: 'Paris', + zip: '75008', + countryCode: 'FR', + phone: '+33142250101', + email: 'champs-elysees@example.com', + website: 'https://example.com/stores/champs-elysees', + openingHours: { + timezone: 'Europe/Paris', + days: { + '1': [{ start: '09:30', end: '19:30' }], + '2': [{ start: '09:30', end: '19:30' }], + '3': [{ start: '09:30', end: '19:30' }], + '4': [{ start: '09:30', end: '19:30' }], + '5': [{ start: '09:30', end: '20:00' }], + '6': [{ start: '10:00', end: '20:00' }], + '7': [], + }, + }, + types: ['Flagship'], + tags: ['CLICK_AND_COLLECT', 'WHEELCHAIR_ACCESS'], + lastUpdated: '2026-08-11T09:12:44.000Z', + userProperties: { surface_m2: 780 }, +}; + +const admin: AdminAreas = { + country: 'France', + region: 'Île-de-France', + county: 'Paris', + city: 'Paris', +}; + +const nearby: NearbyData = { + updated_at: '2026-08-13T04:00:00.000Z', + groups: [ + { + key: 'transit', + title: 'Public transport', + icon: 'transit', + mode: 'walking', + items: [ + { + name: 'Franklin D. Roosevelt', + lat: 48.869034, + lng: 2.309927, + category: 'transit.station.rail.subway', + distance: '210 m', + duration: '3 mins', + }, + { + name: 'George V', + lat: 48.871944, + lng: 2.300833, + category: 'transit.station.rail.subway', + distance: '580 m', + duration: '8 mins', + }, + ], + }, + { + key: 'parking', + title: 'Parking', + icon: 'parking', + mode: 'driving', + items: [ + { + name: 'Parking Ponthieu', + lat: 48.870766, + lng: 2.308997, + category: 'business.parking', + distance: '400 m', + duration: '2 mins', + }, + ], + }, + ], +}; + +const nearbyStores: NearbyStore[] = [ + { handle: 'fr-0422', url: '/pages/stores/fr-0422', name: 'Opéra', city: 'Paris', km: 2 }, + { handle: 'fr-0438', url: '/pages/stores/fr-0438', name: 'Rivoli', city: 'Paris', km: 3 }, +]; + +const config: LocalPageConfig = { + brand: 'Acme', + urlBase: '/pages/stores', + publicKey: 'woos-public-key-referrer-restricted', + directionsProvider: 'google', +}; + +/** The reference document. Deterministic: the timestamp is fixed, not read from the clock. */ +export const EXAMPLE_PAGE: LocalPage = buildLocalPage( + store, + { admin, nearby, nearbyStores }, + config, + { now: '2026-08-14T06:00:00.000Z' }, +); + +/** Serialised exactly as it is committed, so a test can compare byte-for-byte. */ +export const EXAMPLE_JSON = `${JSON.stringify(EXAMPLE_PAGE, null, 2)}\n`; diff --git a/packages/local-page-engine/examples/local-page.example.json b/packages/local-page-engine/examples/local-page.example.json new file mode 100644 index 0000000..8b62ea1 --- /dev/null +++ b/packages/local-page-engine/examples/local-page.example.json @@ -0,0 +1,204 @@ +{ + "slug": "fr-0421", + "canonicalPath": "/pages/stores/fr-0421", + "store": { + "storeId": "FR-0421", + "name": "Berkeley Square", + "lat": 48.8698, + "lng": 2.3075, + "address1": "27 Avenue des Champs-Élysées", + "address2": "", + "city": "Paris", + "zip": "75008", + "countryCode": "FR", + "phone": "+33142250101", + "email": "champs-elysees@example.com", + "website": "https://example.com/stores/champs-elysees", + "openingHours": { + "timezone": "Europe/Paris", + "days": { + "1": [ + { + "start": "09:30", + "end": "19:30" + } + ], + "2": [ + { + "start": "09:30", + "end": "19:30" + } + ], + "3": [ + { + "start": "09:30", + "end": "19:30" + } + ], + "4": [ + { + "start": "09:30", + "end": "19:30" + } + ], + "5": [ + { + "start": "09:30", + "end": "20:00" + } + ], + "6": [ + { + "start": "10:00", + "end": "20:00" + } + ], + "7": [] + } + }, + "types": [ + "Flagship" + ], + "tags": [ + "CLICK_AND_COLLECT", + "WHEELCHAIR_ACCESS" + ], + "lastUpdated": "2026-08-11T09:12:44.000Z", + "userProperties": { + "surface_m2": 780 + } + }, + "admin": { + "country": "France", + "region": "Île-de-France", + "county": "Paris", + "city": "Paris" + }, + "breadcrumb": [ + "France", + "Île-de-France", + "Paris" + ], + "nearby": { + "updated_at": "2026-08-13T04:00:00.000Z", + "groups": [ + { + "key": "transit", + "title": "Public transport", + "icon": "transit", + "mode": "walking", + "items": [ + { + "name": "Franklin D. Roosevelt", + "lat": 48.869034, + "lng": 2.309927, + "category": "transit.station.rail.subway", + "distance": "210 m", + "duration": "3 mins" + }, + { + "name": "George V", + "lat": 48.871944, + "lng": 2.300833, + "category": "transit.station.rail.subway", + "distance": "580 m", + "duration": "8 mins" + } + ] + }, + { + "key": "parking", + "title": "Parking", + "icon": "parking", + "mode": "driving", + "items": [ + { + "name": "Parking Ponthieu", + "lat": 48.870766, + "lng": 2.308997, + "category": "business.parking", + "distance": "400 m", + "duration": "2 mins" + } + ] + } + ] + }, + "nearbyStores": [ + { + "handle": "fr-0422", + "url": "/pages/stores/fr-0422", + "name": "Opéra", + "city": "Paris", + "km": 2 + }, + { + "handle": "fr-0438", + "url": "/pages/stores/fr-0438", + "name": "Rivoli", + "city": "Paris", + "km": 3 + } + ], + "seo": { + "title": "Berkeley Square — Paris | Acme", + "description": "Berkeley Square, 27 Avenue des Champs-Élysées, 75008 Paris. Opening hours, phone number, directions and nearby transport.", + "canonicalPath": "/pages/stores/fr-0421", + "imageAlt": "Map showing the location of Berkeley Square" + }, + "jsonLd": [ + { + "@context": "https://schema.org", + "@type": "LocalBusiness", + "name": "Berkeley Square", + "address": { + "@type": "PostalAddress", + "streetAddress": "27 Avenue des Champs-Élysées", + "postalCode": "75008", + "addressLocality": "Paris", + "addressCountry": "FR", + "addressRegion": "Île-de-France" + }, + "geo": { + "@type": "GeoCoordinates", + "latitude": 48.8698, + "longitude": 2.3075 + }, + "telephone": "+33142250101" + }, + { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + { + "@type": "ListItem", + "position": 1, + "name": "France" + }, + { + "@type": "ListItem", + "position": 2, + "name": "Île-de-France" + }, + { + "@type": "ListItem", + "position": 3, + "name": "Paris" + }, + { + "@type": "ListItem", + "position": 4, + "name": "Berkeley Square", + "item": "/pages/stores/fr-0421" + } + ] + } + ], + "map": { + "url": "https://api.woosmap.com/maps/static?lat=48.8698&lng=2.3075&zoom=15&width=600&height=400&markers=%7B%22lat%22%3A48.8698%2C%22lng%22%3A2.3075%7D&key=woos-public-key-referrer-restricted", + "width": 600, + "height": 400, + "alt": "Map showing the location of Berkeley Square" + }, + "computedAt": "2026-08-14T06:00:00.000Z" +} diff --git a/packages/local-page-engine/package.json b/packages/local-page-engine/package.json new file mode 100644 index 0000000..df1e1ff --- /dev/null +++ b/packages/local-page-engine/package.json @@ -0,0 +1,38 @@ +{ + "name": "@woosmap/local-page-engine", + "version": "0.1.0", + "description": "Platform-neutral local store page model: store + enrichment + config in, a structured LocalPage document out.", + "license": "UNLICENSED", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage", + "example": "tsx examples/build-example.ts" + }, + "dependencies": { + "@woosmap/distance-client": "workspace:*", + "@woosmap/localities-client": "workspace:*", + "@woosmap/store-search-client": "workspace:*" + }, + "devDependencies": { + "@types/woosmap.map": "^1.4.27", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "~5.9.3", + "vite": "^8.2.0", + "vitest": "^4.1.10" + } +} diff --git a/packages/local-page-engine/src/breadcrumb.ts b/packages/local-page-engine/src/breadcrumb.ts new file mode 100644 index 0000000..aae27b6 --- /dev/null +++ b/packages/local-page-engine/src/breadcrumb.ts @@ -0,0 +1,28 @@ +import type { AdminAreas } from './types'; + +/** + * Administrative breadcrumb: country → region → county → city, values only, + * dropping blanks and consecutive duplicates — Paris resolves county and city + * alike, Luxembourg three levels alike, and a naive join would render + * "Luxembourg › Luxembourg › Luxembourg". Only *consecutive* repeats are dropped. + * + * The store itself is not in the trail; it is the current page. + */ +export function buildBreadcrumb(admin: AdminAreas | null | undefined): string[] { + if (!admin) { + return []; + } + + const trail: string[] = []; + for (const value of [admin.country, admin.region, admin.county, admin.city]) { + const label = value?.trim(); + if (!label) { + continue; + } + if (trail[trail.length - 1] === label) { + continue; + } + trail.push(label); + } + return trail; +} diff --git a/packages/local-page-engine/src/enrich/admin.ts b/packages/local-page-engine/src/enrich/admin.ts new file mode 100644 index 0000000..9cdf83c --- /dev/null +++ b/packages/local-page-engine/src/enrich/admin.ts @@ -0,0 +1,47 @@ +// Reverse-geocodes a store (Woosmap Localities) to country-native admin values +// (Gironde, Kent…) for the breadcrumb + geo context. Filled once, only when +// missing — administrative boundaries don't move. Transport-injected. + +import { LocalitiesClient } from '@woosmap/localities-client'; +import { toTransport } from './transport'; +import type { FetchLike } from './transport'; +import type { AdminAreas } from '../types'; + +const DEFAULT_API_BASE = 'https://api.woosmap.com'; + +/** + * Reverse-geocode a point to its admin areas (null on any problem). Maps Woosmap + * component types: `state`→region, `county`→county, `locality`→city. Values are + * country-native ("Gironde", "Kent"), which is what a visitor recognises. + * + * Possibly avoidable: Localities Nearby returns `admin_levels` in the same response + * as the POIs. Check the depth first — a sample gave country/locality/route only. + */ +export async function reverseGeocode( + fetchImpl: FetchLike, + privateKey: string, + lat: number, + lng: number, + apiBase: string = DEFAULT_API_BASE, +): Promise { + const client = new LocalitiesClient({ privateKey, privateKeyIn: 'query', baseUrl: apiBase, transport: toTransport(fetchImpl) }); + try { + const res = await client.geocode({ latLng: { lat, lng } }); + const components = res.results?.[0]?.address_components; + if (!components) return null; + const pick = (type: string): string | undefined => { + const c = components.find((comp) => comp.types.includes(type)); + if (!c) return undefined; + const name = Array.isArray(c.long_name) ? c.long_name[0] : c.long_name; + return name || undefined; + }; + return { country: pick('country'), region: pick('state'), county: pick('county'), city: pick('locality') }; + } catch { + return null; + } +} + +/** True when a reverse-geocode yielded something worth keeping. */ +export function hasAdmin(areas: AdminAreas | null): areas is AdminAreas { + return !!areas && (!!areas.region || !!areas.county || !!areas.city); +} diff --git a/apps/store-pages/app/nearby-enrich.server.ts b/packages/local-page-engine/src/enrich/nearby.ts similarity index 72% rename from apps/store-pages/app/nearby-enrich.server.ts rename to packages/local-page-engine/src/enrich/nearby.ts index 019ec2f..928f00c 100644 --- a/apps/store-pages/app/nearby-enrich.server.ts +++ b/packages/local-page-engine/src/enrich/nearby.ts @@ -1,67 +1,19 @@ // Server-side "nearby POIs" enrichment: for SEO/GEO the block must be in the // rendered HTML, not fetched in the browser. Builds the grouped POI payload for // one store from Woosmap Localities Nearby + a per-mode Distance Matrix time. -// The POI families are CONFIGURABLE (see parseNearbyGroups / NEARBY_GROUPS_JSON); -// DEFAULT_NEARBY_GROUPS is just the out-of-the-box default. Pure + transport- -// injected (uses the PRIVATE key). +// The POI families are CONFIGURABLE (see parseNearbyGroups); DEFAULT_NEARBY_GROUPS +// is just the out-of-the-box default. Transport-injected (uses the PRIVATE key). import { DistanceClient } from '@woosmap/distance-client'; import { LocalitiesClient } from '@woosmap/localities-client'; -import { toTransport } from './woosmap-transport'; -import type { FetchLike } from './woosmap-transport'; - -export type { FetchLike }; +import { toTransport } from './transport'; +import type { FetchLike } from './transport'; +import type { NearbyData, NearbyGroup, NearbyGroupSpec, NearbyPoi, TravelMode } from '../types'; const DEFAULT_API_BASE = 'https://api.woosmap.com'; -/** Distance Matrix travel mode used per group. */ -type TravelMode = 'walking' | 'driving'; - -/** One nearby POI as stored (and rendered). */ -export interface NearbyPoi { - name: string; - lat: number; - lng: number; - category: string; - /** Walking distance text, e.g. "498 m" (absent if the matrix had no result). */ - distance?: string; - /** Walking duration text, e.g. "7 mins". */ - duration?: string; -} - -/** A rendered group (one Woosmap category family). */ -interface NearbyGroup { - key: string; - title: string; - icon: string; - /** Travel mode used for this group's distances ("walk" vs "drive" in the UI). */ - mode: TravelMode; - items: NearbyPoi[]; -} - -/** The payload stored in the `nearby` metaobject field. */ -export interface NearbyData { - /** ISO timestamp of this enrichment — drives the TTL refresh. */ - updated_at: string; - groups: NearbyGroup[]; -} - -/** Definition of a nearby group: which Woosmap types, radius, cap, optional filter. */ -export interface NearbyGroupSpec { - key: string; - title: string; - icon: string; - types: string; - radius: number; - max: number; - /** Distance mode: you walk to a station, you drive to a parking/fuel/shop. */ - mode: TravelMode; - /** Keep only results whose categories intersect this list (e.g. metro/train). */ - filter?: string[]; -} - /** Default set (retail store visitor): rail transport (walking), cash/fuel/food - * (driving). Override per integration with NEARBY_GROUPS_JSON — see parseNearbyGroups. */ + * (driving). Override per client with {@link parseNearbyGroups}. */ export const DEFAULT_NEARBY_GROUPS: NearbyGroupSpec[] = [ { key: 'transit', title: 'Public transport', icon: 'transit', types: 'transit.station', radius: 1000, max: 3, mode: 'walking', filter: ['transit.station.rail.subway', 'transit.station.rail.train'] }, { key: 'cash', title: 'Cash & banks', icon: 'cash', types: 'business.finance', radius: 1000, max: 1, mode: 'driving' }, @@ -70,9 +22,9 @@ export const DEFAULT_NEARBY_GROUPS: NearbyGroupSpec[] = [ ]; /** - * Parse a `NEARBY_GROUPS_JSON` override into validated specs. Empty/absent → the - * default set. Throws on invalid JSON or a bad shape — a config error should - * surface at startup, not silently fall back to defaults. + * Parse a JSON override into validated specs. Empty/absent → the default set. + * Throws on invalid JSON or a bad shape — a config error should surface at + * startup, not silently fall back to defaults and produce quietly wrong pages. */ export function parseNearbyGroups(json: string | undefined | null): NearbyGroupSpec[] { if (!json || json.trim() === '') return DEFAULT_NEARBY_GROUPS; @@ -178,9 +130,10 @@ export async function addDistances( } /** - * Build the full nearby payload for one store: each group's POIs + per-mode - * distances + the timestamp. `groups` defaults to {@link DEFAULT_NEARBY_GROUPS}. - * `now` is injected (ISO string) for deterministic tests. Empty groups are dropped. + * Full nearby payload for one store: each group's POIs, per-mode distances, and the + * timestamp. Empty groups are dropped, `now` is injected for deterministic tests. + * + * Note the call shape: one Distance Matrix request **per travel mode**, not per POI. */ export async function enrichNearby( fetchImpl: FetchLike, @@ -203,7 +156,6 @@ export async function enrichNearby( byMode[spec.mode].push(...items); } }); - // One Distance Matrix call per mode: walking for transit, driving for the rest. await Promise.all( (Object.keys(byMode) as TravelMode[]).map((mode) => addDistances(fetchImpl, privateKey, lat, lng, byMode[mode], mode, apiBase), @@ -213,8 +165,9 @@ export async function enrichNearby( } /** - * TTL check: is a stored `updated_at` older than `maxAgeDays` (or missing/invalid)? - * Returns true when the store must be (re)enriched. + * TTL check — the second invalidation axis. The store axis is event-driven, but the + * geography around a store changes without the store changing (a new metro exit, a + * car park that closed), so only a sweep catches it. */ export function isNearbyStale(updatedAt: string | null | undefined, maxAgeDays: number, now: Date): boolean { if (!updatedAt) return true; diff --git a/apps/store-pages/app/nearby-stores.server.ts b/packages/local-page-engine/src/enrich/neighbours.ts similarity index 58% rename from apps/store-pages/app/nearby-stores.server.ts rename to packages/local-page-engine/src/enrich/neighbours.ts index 649a371..d1e4437 100644 --- a/apps/store-pages/app/nearby-stores.server.ts +++ b/packages/local-page-engine/src/enrich/neighbours.ts @@ -1,36 +1,12 @@ -// Nearest N *other* stores within a radius → the "other stores nearby" section -// (server-rendered, so the internal links help SEO). Pure, computed in memory -// (haversine) from the full store set — no extra API calls, recomputed each run so -// new stores appear on their neighbours' pages. +// Nearest N *other* stores within a radius → the "other stores nearby" section. +// Server-rendered, so the internal links help crawlers move between store pages. +// Computed in memory (haversine) from the full store set: no extra API calls, and +// recomputed each run so a new store appears on its neighbours' pages. -import { storeToMetaobjectHandle, type Store } from '@woosmap/store-search-client'; - -/** A store reduced to what the neighbour search needs. */ -export interface StoreIndexEntry { - handle: string; - name: string; - city: string | null; - lat: number; - lng: number; -} - -/** One neighbouring store, as stored (JSON-encoded) in the `nearby_stores` field. */ -export interface NearbyStore { - handle: string; - url: string; - name: string; - city: string | null; - /** Straight-line distance, rounded UP to whole km (the UI prefixes it with "~"). */ - km: number; -} - -/** Tuning for {@link findNearbyStores}. */ -export interface NearbyStoresOptions { - radiusKm?: number; - limit?: number; - /** Path prefix for a neighbour's page, e.g. `/pages/stores`. */ - urlBase?: string; -} +import type { Store } from '@woosmap/store-search-client'; +import { storeSlug } from '../slug'; +import { DEFAULT_URL_BASE } from '../local-page'; +import type { NearbyStore, NearbyStoresOptions, StoreIndexEntry } from '../types'; const EARTH_RADIUS_KM = 6371; const toRad = (deg: number): number => (deg * Math.PI) / 180; @@ -49,15 +25,21 @@ export function buildStoreIndex(stores: Store[]): StoreIndexEntry[] { const index: StoreIndexEntry[] = []; for (const store of stores) { if (store.lat === null || store.lng === null || !store.name) continue; - const handle = storeToMetaobjectHandle(store); + const handle = storeSlug(store.storeId); if (!handle) continue; index.push({ handle, name: store.name, city: store.city ?? null, lat: store.lat, lng: store.lng }); } return index; } -/** Nearest `limit` other stores within `radiusKm`, sorted by exact distance, - * distance rounded UP to whole km for display. Excludes the store itself. */ +/** + * Nearest `limit` other stores within `radiusKm`, sorted by exact distance, rounded + * up to whole km for display. Excludes the store itself. + * + * Straight-line, not road distance: it costs no API call and this section is + * internal linking, not navigation. Worth a conscious decision though — road-accurate + * distance is part of the pitch. + */ export function findNearbyStores( store: Store, index: StoreIndexEntry[], @@ -66,8 +48,8 @@ export function findNearbyStores( if (store.lat === null || store.lng === null) return []; const radiusKm = options.radiusKm ?? 10; const limit = options.limit ?? 3; - const urlBase = options.urlBase ?? '/pages/stores'; - const selfHandle = storeToMetaobjectHandle(store); + const urlBase = options.urlBase ?? DEFAULT_URL_BASE; + const selfHandle = storeSlug(store.storeId); const measured: Array<{ entry: StoreIndexEntry; km: number }> = []; for (const entry of index) { diff --git a/packages/local-page-engine/src/enrich/transport.ts b/packages/local-page-engine/src/enrich/transport.ts new file mode 100644 index 0000000..9b224cc --- /dev/null +++ b/packages/local-page-engine/src/enrich/transport.ts @@ -0,0 +1,13 @@ +/** A `fetch`-like function. Injected so the resolvers are testable without a network. */ +export type FetchLike = (url: string) => Promise<{ ok: boolean; json: () => Promise }>; + +/** Adapt an injected fetch-like into the client libs' Transport shape. */ +export function toTransport(fetchImpl: FetchLike) { + return (url: string) => + fetchImpl(url).then((r) => ({ + ok: r.ok, + status: r.ok ? 200 : 502, + statusText: '', + json: r.json, + })); +} diff --git a/packages/local-page-engine/src/index.ts b/packages/local-page-engine/src/index.ts new file mode 100644 index 0000000..1f11b0c --- /dev/null +++ b/packages/local-page-engine/src/index.ts @@ -0,0 +1,24 @@ +// The page model — pure. +export { buildLocalPage, DEFAULT_URL_BASE } from './local-page'; +export type { BuildLocalPageOptions } from './local-page'; +export { buildBreadcrumb } from './breadcrumb'; +export { buildLocalBusinessJsonLd, buildBreadcrumbJsonLd } from './json-ld'; +export { buildSeo, applyTemplate, seoValues, DEFAULT_SEO_TEMPLATES } from './seo'; +export { storeSlug, canonicalPath } from './slug'; +export { buildStaticMap, DEFAULT_STATIC_MAP } from './static-map'; + +// The enrichment resolvers — I/O, over an injected fetch. +export { + enrichNearby, + fetchNearbyGroup, + addDistances, + isNearbyStale, + parseNearbyGroups, + DEFAULT_NEARBY_GROUPS, +} from './enrich/nearby'; +export { reverseGeocode, hasAdmin } from './enrich/admin'; +export { buildStoreIndex, findNearbyStores, haversineKm } from './enrich/neighbours'; +export { toTransport } from './enrich/transport'; +export type { FetchLike } from './enrich/transport'; + +export type * from './types'; diff --git a/packages/local-page-engine/src/json-ld.ts b/packages/local-page-engine/src/json-ld.ts new file mode 100644 index 0000000..c3dae23 --- /dev/null +++ b/packages/local-page-engine/src/json-ld.ts @@ -0,0 +1,83 @@ +import type { Store } from '@woosmap/store-search-client'; +import type { AdminAreas, JsonLdDocument } from './types'; + +const SCHEMA_CONTEXT = 'https://schema.org'; + +/** + * `LocalBusiness` structured data, lifted from the `store_json_ld` Liquid capture. + * + * Built as an object rather than concatenated strings: in Liquid a blank optional + * left a dangling comma and silently invalidated the document, which search engines + * discard without saying so. Optional members are omitted, not emitted empty. + */ +export function buildLocalBusinessJsonLd( + store: Store, + admin: AdminAreas | null | undefined, +): JsonLdDocument { + const address: Record = { + '@type': 'PostalAddress', + streetAddress: store.address1, + postalCode: store.zip, + addressLocality: store.city, + addressCountry: store.countryCode, + }; + + const region = admin?.region; + if (region) { + address.addressRegion = region; + } + + const doc: JsonLdDocument = { + '@context': SCHEMA_CONTEXT, + '@type': 'LocalBusiness', + name: store.name, + address, + }; + + if (store.lat !== null && store.lng !== null) { + doc.geo = { '@type': 'GeoCoordinates', latitude: store.lat, longitude: store.lng }; + } + if (store.phone) { + doc.telephone = store.phone; + } + + return doc; +} + +/** + * `BreadcrumbList` for the administrative trail. Two behaviours kept from the + * template: nothing is emitted without a region or county (a one-rung breadcrumb is + * noise), and only the last rung carries an `item` URL — area pages do not exist + * yet, and declaring URLs that 404 is worse than declaring none. + * + * `itemUrl` should be absolute when the caller has one (Liquid's `canonical_url`). + */ +export function buildBreadcrumbJsonLd( + breadcrumb: string[], + storeName: string, + itemUrl: string, + admin: AdminAreas | null | undefined, +): JsonLdDocument | null { + if (!admin?.region && !admin?.county) { + return null; + } + + const rungs = [...breadcrumb, storeName]; + const itemListElement = rungs.map((name, index) => { + const element: Record = { + '@type': 'ListItem', + position: index + 1, + name, + }; + if (index === rungs.length - 1) { + element.item = itemUrl; + } + return element; + }); + + return { + '@context': SCHEMA_CONTEXT, + '@type': 'BreadcrumbList', + itemListElement, + }; +} diff --git a/packages/local-page-engine/src/local-page.ts b/packages/local-page-engine/src/local-page.ts new file mode 100644 index 0000000..3ebf8c7 --- /dev/null +++ b/packages/local-page-engine/src/local-page.ts @@ -0,0 +1,71 @@ +import type { Store } from '@woosmap/store-search-client'; +import { buildBreadcrumb } from './breadcrumb'; +import { buildBreadcrumbJsonLd, buildLocalBusinessJsonLd } from './json-ld'; +import { buildSeo } from './seo'; +import { canonicalPath, storeSlug } from './slug'; +import { buildStaticMap } from './static-map'; +import type { JsonLdDocument, LocalPage, LocalPageConfig, LocalPageEnrichment } from './types'; + +/** Default path prefix — matches where Shopify serves metaobject pages today. */ +export const DEFAULT_URL_BASE = '/pages/stores'; + +/** Extra inputs a caller may supply that the engine cannot derive on its own. */ +export interface BuildLocalPageOptions { + /** + * ISO timestamp stamped onto the page. Injected rather than read from the clock + * so a build is reproducible and its tests are not time-dependent. + */ + now: string; + /** + * Absolute URL of this page, when the caller knows it (Liquid's `canonical_url` + * on Shopify). Used for the breadcrumb's last `item`; falls back to the path. + */ + absoluteUrl?: string; +} + +/** + * Compose one {@link LocalPage} from a store, the enrichment already resolved, and + * the client's config. + * + * Pure: no fetch, no clock, no filesystem. Enrichment is resolved by the caller and + * `now` is injected, so the same function runs in a CLI today and inside the + * platform later without a rewrite. Enrichment is optional throughout — a store + * with no nearby data still yields a valid page. + */ +export function buildLocalPage( + store: Store, + enrichment: LocalPageEnrichment, + config: LocalPageConfig, + options: BuildLocalPageOptions, +): LocalPage { + const slug = storeSlug(store.storeId); + const path = canonicalPath(config.urlBase ?? DEFAULT_URL_BASE, slug); + + const admin = enrichment.admin ?? null; + const breadcrumb = buildBreadcrumb(admin); + + const jsonLd: JsonLdDocument[] = [buildLocalBusinessJsonLd(store, admin)]; + const breadcrumbLd = buildBreadcrumbJsonLd( + breadcrumb, + store.name, + options.absoluteUrl ?? path, + admin, + ); + if (breadcrumbLd) { + jsonLd.push(breadcrumbLd); + } + + return { + slug, + canonicalPath: path, + store, + admin, + breadcrumb, + nearby: enrichment.nearby ?? null, + nearbyStores: enrichment.nearbyStores ?? [], + seo: buildSeo(store, admin, path, config.brand, config.seo), + jsonLd, + map: buildStaticMap(store, config.publicKey, config.map), + computedAt: options.now, + }; +} diff --git a/packages/local-page-engine/src/seo.ts b/packages/local-page-engine/src/seo.ts new file mode 100644 index 0000000..7a4464c --- /dev/null +++ b/packages/local-page-engine/src/seo.ts @@ -0,0 +1,81 @@ +import type { Store } from '@woosmap/store-search-client'; +import type { AdminAreas, PageSeo, SeoTemplates } from './types'; + +/** + * Default SEO copy — the one part of the model that is not a lift from Liquid. On + * Shopify the title and description come from the `renderable` capability, so + * nothing generated them; a feed consumer has no such capability. + * + * Placeholders: `{name}` `{brand}` `{address}` `{zip}` `{city}` `{county}` + * `{region}` `{country}`. + */ +export const DEFAULT_SEO_TEMPLATES: SeoTemplates = { + title: '{name} — {city} | {brand}', + description: + '{name}, {address}, {zip} {city}. Opening hours, phone number, directions and nearby transport.', + imageAlt: 'Map showing the location of {name}', +}; + +/** Separators a template may use between optional fragments. */ +const SEPARATORS = '—|·,'; + +/** + * Substitute placeholders, then repair the punctuation. Templates are written for + * the best case, but real stores have holes and substituting blindly leaves debris + * (a trailing " | ", a doubled " — , "). Repairing it is what lets one template + * serve a whole network. + */ +export function applyTemplate(template: string, values: Record): string { + const substituted = template.replace(/\{(\w+)\}/g, (_match, key: string) => values[key] ?? ''); + const sep = `[${SEPARATORS}]`; + + return ( + substituted + .replace(/\s+/g, ' ') + // A run of separators left by empty values collapses to the first of them. + .replace(new RegExp(`\\s*(${sep})(?:\\s*${sep})+\\s*`, 'g'), ' $1 ') + // A separator immediately before sentence punctuation is debris; the stop wins. + .replace(new RegExp(`\\s*${sep}\\s*([.!?])`, 'g'), '$1') + .replace(/\s+([.!?])/g, '$1') + .replace(new RegExp(`^[\\s${SEPARATORS}]+`), '') + .replace(new RegExp(`[\\s${SEPARATORS}]+$`), '') + .trim() + ); +} + +/** The placeholder values available to the SEO templates for one store. */ +export function seoValues( + store: Store, + admin: AdminAreas | null | undefined, + brand: string | undefined, +): Record { + return { + name: store.name, + brand: brand ?? '', + address: store.address1, + zip: store.zip, + city: store.city || admin?.city || '', + county: admin?.county ?? '', + region: admin?.region ?? '', + country: admin?.country ?? '', + }; +} + +/** Build the SEO block for one store page. */ +export function buildSeo( + store: Store, + admin: AdminAreas | null | undefined, + path: string, + brand: string | undefined, + overrides: Partial = {}, +): PageSeo { + const templates = { ...DEFAULT_SEO_TEMPLATES, ...overrides }; + const values = seoValues(store, admin, brand); + + return { + title: applyTemplate(templates.title, values), + description: applyTemplate(templates.description, values), + canonicalPath: path, + imageAlt: applyTemplate(templates.imageAlt, values), + }; +} diff --git a/packages/local-page-engine/src/slug.ts b/packages/local-page-engine/src/slug.ts new file mode 100644 index 0000000..2251a96 --- /dev/null +++ b/packages/local-page-engine/src/slug.ts @@ -0,0 +1,21 @@ +/** + * Page slug from a Woosmap `store_id`. + * + * Byte-for-byte identical to the `storeToMetaobjectHandle` it replaces, so no page + * already published on Shopify changes URL — and it keeps the SEO it accumulated. + * `[a-z0-9_-]` survives; any run of anything else collapses to one hyphen, ends are + * trimmed, capped at 255. + */ +export function storeSlug(storeId: string): string { + return storeId + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 255); +} + +/** Join a path prefix and a slug into a canonical path, tolerating stray slashes. */ +export function canonicalPath(urlBase: string, slug: string): string { + const base = `/${urlBase}`.replace(/\/{2,}/g, '/').replace(/\/+$/, ''); + return `${base}/${slug}`; +} diff --git a/packages/local-page-engine/src/static-map.ts b/packages/local-page-engine/src/static-map.ts new file mode 100644 index 0000000..1b6c3f4 --- /dev/null +++ b/packages/local-page-engine/src/static-map.ts @@ -0,0 +1,50 @@ +import type { Store } from '@woosmap/store-search-client'; +import type { StaticMap, StaticMapConfig } from './types'; + +/** Geometry the Liquid template used, kept as the default so the lift is behaviour-preserving. */ +export const DEFAULT_STATIC_MAP: StaticMapConfig = { + zoom: 15, + width: 600, + height: 400, + apiBase: 'https://api.woosmap.com', +}; + +/** + * Static Maps illustration for a store, lifted from `store.liquid`. + * + * PUBLIC key only: the browser fetches the image and sends the shop domain as + * `Referer`, which the key's restriction needs. The URL is deterministic per store + * so shared caches absorb it — which also means it cannot count page views. + * + * `null` when the store has no coordinates or no key: a page without an + * illustration is still a valid page. + */ +export function buildStaticMap( + store: Store, + publicKey: string | undefined, + overrides: Partial = {}, +): StaticMap | null { + if (store.lat === null || store.lng === null || !publicKey) { + return null; + } + + const { zoom, width, height, apiBase } = { ...DEFAULT_STATIC_MAP, ...overrides }; + const marker = JSON.stringify({ lat: store.lat, lng: store.lng }); + + const params = new URLSearchParams({ + lat: String(store.lat), + lng: String(store.lng), + zoom: String(zoom), + width: String(width), + height: String(height), + markers: marker, + key: publicKey, + }); + + return { + url: `${apiBase}/maps/static?${params.toString()}`, + width, + height, + alt: `Map showing the location of ${store.name}`, + }; +} diff --git a/packages/local-page-engine/src/types.ts b/packages/local-page-engine/src/types.ts new file mode 100644 index 0000000..f0ec2e9 --- /dev/null +++ b/packages/local-page-engine/src/types.ts @@ -0,0 +1,189 @@ +import type { Store } from '@woosmap/store-search-client'; + +/** + * The contract of this package: everything a local store page needs, as data, + * with no platform in it. A Shopify adapter maps it onto metaobject fields; a + * feed serves it as-is; a server-rendered page renders it directly. + * + * Deliberately NOT in here: HTML, Liquid, CSS, metaobject field keys, or + * anything that assumes who renders the page. + */ +export interface LocalPage { + /** Stable, platform-neutral identifier for this page (derived from the store id). */ + slug: string; + /** Path the page is expected to live at, e.g. `/pages/stores/my-store`. */ + canonicalPath: string; + /** The store facts, straight from Store Search. */ + store: Store; + /** Administrative hierarchy, when a reverse-geocode (or Nearby) resolved it. */ + admin: AdminAreas | null; + /** Breadcrumb trail, values only, consecutive duplicates dropped. Excludes the store itself. */ + breadcrumb: string[]; + /** Nearby POIs grouped by family, with travel time. `null` when not enriched. */ + nearby: NearbyData | null; + /** Neighbouring stores, for internal linking between pages. */ + nearbyStores: NearbyStore[]; + /** Search-engine metadata. */ + seo: PageSeo; + /** schema.org documents, ready to be serialised into ` -{%- comment -%} BreadcrumbList (admin hierarchy) — names only for now; area-page URLs come with the area pages. {%- endcomment -%} -{%- if metaobject.region.value != blank or metaobject.county.value != blank -%} +{%- comment -%} + BreadcrumbList (admin hierarchy) — reuses the `crumbs` trail built for the visible + breadcrumb above, so the two can never disagree. Names only for now; area-page URLs + come with the area pages, and only the last rung carries an `item`. + + Still gated on a region or county: a one-rung trail is noise. +{%- endcomment -%} +{%- if crumb_region != blank or crumb_county != blank -%} {%- capture breadcrumb_ld -%} { "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ - {%- assign pos = 0 -%} - {%- if metaobject.country.value != blank -%}{%- assign pos = pos | plus: 1 -%}{% if pos > 1 %},{% endif %}{ "@type": "ListItem", "position": {{ pos }}, "name": {{ metaobject.country.value | json }} }{%- endif -%} - {%- if metaobject.region.value != blank -%}{%- assign pos = pos | plus: 1 -%}{% if pos > 1 %},{% endif %}{ "@type": "ListItem", "position": {{ pos }}, "name": {{ metaobject.region.value | json }} }{%- endif -%} - {%- if metaobject.county.value != blank and metaobject.county.value != metaobject.region.value -%}{%- assign pos = pos | plus: 1 -%}{% if pos > 1 %},{% endif %}{ "@type": "ListItem", "position": {{ pos }}, "name": {{ metaobject.county.value | json }} }{%- endif -%} - {%- if metaobject.city.value != blank and metaobject.city.value != metaobject.county.value -%}{%- assign pos = pos | plus: 1 -%}{% if pos > 1 %},{% endif %}{ "@type": "ListItem", "position": {{ pos }}, "name": {{ metaobject.city.value | json }} }{%- endif -%} - {%- assign pos = pos | plus: 1 -%}{% if pos > 1 %},{% endif %}{ "@type": "ListItem", "position": {{ pos }}, "name": {{ metaobject.name.value | json }}, "item": {{ canonical_url | json }} } + {%- for c in crumbs -%} + { "@type": "ListItem", "position": {{ forloop.index }}, "name": {{ c | json }} }, + {%- endfor -%} + { "@type": "ListItem", "position": {{ crumbs.size | plus: 1 }}, "name": {{ metaobject.name.value | json }}, "item": {{ canonical_url | json }} } ] } {%- endcapture -%} diff --git a/packages/local-page-engine/README.md b/packages/local-page-engine/README.md index bda0912..9938f3a 100644 --- a/packages/local-page-engine/README.md +++ b/packages/local-page-engine/README.md @@ -38,15 +38,32 @@ buildLocalPage(store, enrichment, config, { now }) => LocalPage | Field | What it is | | --- | --- | | `slug`, `canonicalPath` | identity and location of the page | +| `canonicalUrl` | the absolute URL, when `config.origin` is set — `null` otherwise | | `store` | the facts, straight from Store Search | +| `locale` | the BCP 47 tag the copy is written in, when `config.locale` is set | | `admin`, `breadcrumb` | administrative hierarchy, trail with consecutive duplicates dropped | -| `nearby` | POIs by family, with travel time | -| `nearbyStores` | neighbours, for internal linking | +| `nearby` | POIs by family, with travel time. `null` when not resolved | +| `nearbyStores` | neighbours, for internal linking. `null` when not resolved, `[]` when resolved empty | | `seo` | title, description, canonical path, image alt | | `jsonLd` | `LocalBusiness`, plus `BreadcrumbList` when a hierarchy resolved | -| `map` | Woosmap Static Maps illustration | +| `map` | Woosmap Static Maps illustration, with `alt` taken from `seo.imageAlt` | +| `directionsProvider` | which map a "directions" action should open, when configured | | `computedAt` | when this document was built | +### `null` is not `[]` + +For `nearby` and `nearbyStores`, the two say different things and an adapter that writes +incrementally needs both: + +- **`null`** — the resolver did not run (a fresh TTL, the enricher switched off). *Leave whatever + is stored alone.* +- **`[]`** — it ran and found nothing. *Replace the stored value*, because "this store has no + neighbour any more" is a fact worth writing: omit it and a page keeps rendering links to + neighbours it lost. + +`buildLocalPage` preserves the distinction — an absent enrichment key becomes `null`, a present +one carries through, empty array included. + **Pure by design.** No fetch, no clock, no filesystem: the enrichment is resolved by the caller and passed in, and `now` is injected. That is what makes the model testable without a network, and what lets the same function run in a CLI today and inside the platform later without a @@ -66,12 +83,26 @@ The behaviour of the existing Shopify pages is preserved, so no published page c AI answer engines do not run JS, and cacheable, because that is what keeps the 20 req/s Static Maps quota viable. (Which is also why these requests cannot count page views — shared caches collapse many views into one origin request.) + + One caveat when the document leaves your own surface: a public key is referrer-restricted, + and the restriction is checked against **whoever loads the image**, not whoever built the URL. + Hand this document to a third party and either allow-list their domain or omit + `config.publicKey` and let them build the URL from `store.lat`/`store.lng` with their own key. - **`LocalBusiness`** omits `addressRegion`, `geo` and `telephone` rather than emitting them empty. Building it as an object also removes a class of bug Liquid invited here: a blank optional value left a dangling comma and silently invalidated the document. - **`BreadcrumbList`** is emitted only when a region or a county resolved, and only the last rung carries an `item` URL — area pages do not exist yet, and declaring URLs that 404 is - worse than declaring none. + worse than declaring none. That `item` is absolute when it can be: `options.absoluteUrl` + first (Liquid's `canonical_url`), then `config.origin`, then the bare path — schema.org wants + an absolute URL, so a feed producer should configure the origin. + +One place the lift was **not** faithful, and the template was the one that was wrong: the +breadcrumb dropped a duplicate `county`/`region` and `city`/`county`, but never compared +`region` with `country`, so Luxembourg rendered as `Luxembourg › Luxembourg`. `buildBreadcrumb` +drops any *consecutive* repeat. `store.liquid` was brought in line in the same change, and now +builds its trail once and reuses it for both the visible breadcrumb and the `BreadcrumbList` — +two renderings, one rule. ## New: the SEO copy @@ -87,6 +118,13 @@ Placeholders: `{name}` `{brand}` `{address}` `{zip}` `{city}` `{county}` `{regio Missing values collapse and the punctuation is repaired, so one template serves a whole network whichever fields a given store happens to be missing. Override per client via `config.seo`. +`seo.imageAlt` also feeds `map.alt` — it is the same string in two places a renderer looks, so +it is derived once. Overriding it moves both. + +**The defaults are English**, and there is no per-language default set: a French network +overrides all three templates. `config.locale` labels the result on the document; it selects +nothing on its own, so set the two together. + ## Use ```shell @@ -108,11 +146,16 @@ currently dropped — Shopify covers them another way (the `renderable` capabili title and description, and `store.liquid` builds its own JSON-LD and static-map ``). So the lift out of Liquid is done in TypeScript, but the Liquid original is still live: two -implementations of the same derivation, free to drift. Feeding `store.liquid` from a `json` -metaobject field carrying `page.jsonLd` would remove the duplicate, delete ~45 lines of +implementations of the same derivation, free to drift. That is not hypothetical — the breadcrumb +rule *had* already drifted before either copy shipped (see above). Feeding `store.liquid` from a +`json` metaobject field carrying `page.jsonLd` would remove the duplicate, delete ~45 lines of string-concatenated Liquid, and put the document under unit test. It changes what the storefront renders, so it belongs in its own change with a dev-store pass. +Until then the honest framing is: these three fields exist for the **next** adapter, and the +engine's claim to serve several platforms is not yet demonstrated by a second consumer. The feed +CLI is what would demonstrate it. + ## The enrichment resolvers `src/enrich/` holds the I/O half — the only part of the package that touches the network, always diff --git a/packages/local-page-engine/examples/example-page.ts b/packages/local-page-engine/examples/example-page.ts index 37377d3..3d5c316 100644 --- a/packages/local-page-engine/examples/example-page.ts +++ b/packages/local-page-engine/examples/example-page.ts @@ -106,6 +106,8 @@ const nearbyStores: NearbyStore[] = [ const config: LocalPageConfig = { brand: 'Acme', urlBase: '/pages/stores', + origin: 'https://shop.example.com', + locale: 'en-GB', publicKey: 'woos-public-key-referrer-restricted', directionsProvider: 'google', }; diff --git a/packages/local-page-engine/examples/local-page.example.json b/packages/local-page-engine/examples/local-page.example.json index 8b62ea1..e24dcd4 100644 --- a/packages/local-page-engine/examples/local-page.example.json +++ b/packages/local-page-engine/examples/local-page.example.json @@ -1,6 +1,7 @@ { "slug": "fr-0421", "canonicalPath": "/pages/stores/fr-0421", + "canonicalUrl": "https://shop.example.com/pages/stores/fr-0421", "store": { "storeId": "FR-0421", "name": "Berkeley Square", @@ -68,6 +69,7 @@ "surface_m2": 780 } }, + "locale": "en-GB", "admin": { "country": "France", "region": "Île-de-France", @@ -189,7 +191,7 @@ "@type": "ListItem", "position": 4, "name": "Berkeley Square", - "item": "/pages/stores/fr-0421" + "item": "https://shop.example.com/pages/stores/fr-0421" } ] } @@ -200,5 +202,6 @@ "height": 400, "alt": "Map showing the location of Berkeley Square" }, + "directionsProvider": "google", "computedAt": "2026-08-14T06:00:00.000Z" } diff --git a/packages/local-page-engine/src/index.ts b/packages/local-page-engine/src/index.ts index 1f11b0c..5631ac5 100644 --- a/packages/local-page-engine/src/index.ts +++ b/packages/local-page-engine/src/index.ts @@ -4,7 +4,7 @@ export type { BuildLocalPageOptions } from './local-page'; export { buildBreadcrumb } from './breadcrumb'; export { buildLocalBusinessJsonLd, buildBreadcrumbJsonLd } from './json-ld'; export { buildSeo, applyTemplate, seoValues, DEFAULT_SEO_TEMPLATES } from './seo'; -export { storeSlug, canonicalPath } from './slug'; +export { storeSlug, canonicalPath, canonicalUrl } from './slug'; export { buildStaticMap, DEFAULT_STATIC_MAP } from './static-map'; // The enrichment resolvers — I/O, over an injected fetch. diff --git a/packages/local-page-engine/src/local-page.ts b/packages/local-page-engine/src/local-page.ts index 3ebf8c7..e754d62 100644 --- a/packages/local-page-engine/src/local-page.ts +++ b/packages/local-page-engine/src/local-page.ts @@ -2,7 +2,7 @@ import type { Store } from '@woosmap/store-search-client'; import { buildBreadcrumb } from './breadcrumb'; import { buildBreadcrumbJsonLd, buildLocalBusinessJsonLd } from './json-ld'; import { buildSeo } from './seo'; -import { canonicalPath, storeSlug } from './slug'; +import { canonicalPath, canonicalUrl, storeSlug } from './slug'; import { buildStaticMap } from './static-map'; import type { JsonLdDocument, LocalPage, LocalPageConfig, LocalPageEnrichment } from './types'; @@ -17,8 +17,9 @@ export interface BuildLocalPageOptions { */ now: string; /** - * Absolute URL of this page, when the caller knows it (Liquid's `canonical_url` - * on Shopify). Used for the breadcrumb's last `item`; falls back to the path. + * Absolute URL of this page, when the caller knows it per-store (Liquid's + * `canonical_url` on Shopify). Used for the breadcrumb's last `item`. Falls back to + * the origin-derived {@link LocalPage.canonicalUrl}, then to the bare path. */ absoluteUrl?: string; } @@ -40,15 +41,17 @@ export function buildLocalPage( ): LocalPage { const slug = storeSlug(store.storeId); const path = canonicalPath(config.urlBase ?? DEFAULT_URL_BASE, slug); + const absolute = canonicalUrl(config.origin, path); const admin = enrichment.admin ?? null; const breadcrumb = buildBreadcrumb(admin); + const seo = buildSeo(store, admin, path, config.brand, config.seo); const jsonLd: JsonLdDocument[] = [buildLocalBusinessJsonLd(store, admin)]; const breadcrumbLd = buildBreadcrumbJsonLd( breadcrumb, store.name, - options.absoluteUrl ?? path, + options.absoluteUrl ?? absolute ?? path, admin, ); if (breadcrumbLd) { @@ -58,14 +61,20 @@ export function buildLocalPage( return { slug, canonicalPath: path, + canonicalUrl: absolute, store, + locale: config.locale ?? null, admin, breadcrumb, nearby: enrichment.nearby ?? null, - nearbyStores: enrichment.nearbyStores ?? [], - seo: buildSeo(store, admin, path, config.brand, config.seo), + // `?? null`, not `?? []`: "the search did not run" and "it ran, no neighbour" are + // different instructions to an adapter that writes incrementally. + nearbyStores: enrichment.nearbyStores ?? null, + seo, jsonLd, - map: buildStaticMap(store, config.publicKey, config.map), + // One alt text, from the SEO templates, so an override reaches both places it appears. + map: buildStaticMap(store, config.publicKey, config.map, seo.imageAlt), + directionsProvider: config.directionsProvider ?? null, computedAt: options.now, }; } diff --git a/packages/local-page-engine/src/slug.ts b/packages/local-page-engine/src/slug.ts index 2251a96..451041e 100644 --- a/packages/local-page-engine/src/slug.ts +++ b/packages/local-page-engine/src/slug.ts @@ -19,3 +19,14 @@ export function canonicalPath(urlBase: string, slug: string): string { const base = `/${urlBase}`.replace(/\/{2,}/g, '/').replace(/\/+$/, ''); return `${base}/${slug}`; } + +/** + * Absolute URL for a page path, or `null` when no origin is configured. + * + * schema.org wants absolute URLs, so a consumer with no platform to ask (a feed) + * needs this; a Shopify theme has Liquid's `canonical_url` and can ignore it. + */ +export function canonicalUrl(origin: string | undefined, path: string): string | null { + const trimmed = origin?.trim().replace(/\/+$/, ''); + return trimmed ? `${trimmed}${path}` : null; +} diff --git a/packages/local-page-engine/src/static-map.ts b/packages/local-page-engine/src/static-map.ts index 1b6c3f4..52275f8 100644 --- a/packages/local-page-engine/src/static-map.ts +++ b/packages/local-page-engine/src/static-map.ts @@ -12,9 +12,15 @@ export const DEFAULT_STATIC_MAP: StaticMapConfig = { /** * Static Maps illustration for a store, lifted from `store.liquid`. * - * PUBLIC key only: the browser fetches the image and sends the shop domain as - * `Referer`, which the key's restriction needs. The URL is deterministic per store - * so shared caches absorb it — which also means it cannot count page views. + * PUBLIC key only: the browser fetches the image and sends the rendering domain as + * `Referer`, which the key's restriction is checked against — so the key must + * allow-list whoever renders the page, not whoever built the document. The URL is + * deterministic per store so shared caches absorb it — which also means it cannot + * count page views. + * + * `alt` is passed in rather than derived here: it is indexed copy, so it belongs to + * the SEO templates, and deriving it twice would make an override reach only one of + * the two places it shows up. * * `null` when the store has no coordinates or no key: a page without an * illustration is still a valid page. @@ -23,6 +29,7 @@ export function buildStaticMap( store: Store, publicKey: string | undefined, overrides: Partial = {}, + alt: string = `Map showing the location of ${store.name}`, ): StaticMap | null { if (store.lat === null || store.lng === null || !publicKey) { return null; @@ -45,6 +52,6 @@ export function buildStaticMap( url: `${apiBase}/maps/static?${params.toString()}`, width, height, - alt: `Map showing the location of ${store.name}`, + alt, }; } diff --git a/packages/local-page-engine/src/types.ts b/packages/local-page-engine/src/types.ts index f0ec2e9..74a831a 100644 --- a/packages/local-page-engine/src/types.ts +++ b/packages/local-page-engine/src/types.ts @@ -13,22 +13,43 @@ export interface LocalPage { slug: string; /** Path the page is expected to live at, e.g. `/pages/stores/my-store`. */ canonicalPath: string; + /** + * Absolute canonical URL, when {@link LocalPageConfig.origin} is configured. `null` + * otherwise — a consumer that knows its own origin joins it to `canonicalPath`. + * schema.org wants absolute URLs, so a feed should configure the origin. + */ + canonicalUrl: string | null; /** The store facts, straight from Store Search. */ store: Store; + /** BCP 47 tag the generated copy is written in, when configured. `null` otherwise. */ + locale: string | null; /** Administrative hierarchy, when a reverse-geocode (or Nearby) resolved it. */ admin: AdminAreas | null; /** Breadcrumb trail, values only, consecutive duplicates dropped. Excludes the store itself. */ breadcrumb: string[]; /** Nearby POIs grouped by family, with travel time. `null` when not enriched. */ nearby: NearbyData | null; - /** Neighbouring stores, for internal linking between pages. */ - nearbyStores: NearbyStore[]; + /** + * Neighbouring stores, for internal linking between pages. + * + * `null` and `[]` mean different things, and the difference is load-bearing for any + * adapter that writes incrementally: `null` is "the neighbour search did not run" + * (leave whatever is stored alone), `[]` is "it ran and this store has no + * neighbour in radius" (clear the stored list). Collapsing the two strands stale + * links on a page for good. + */ + nearbyStores: NearbyStore[] | null; /** Search-engine metadata. */ seo: PageSeo; /** schema.org documents, ready to be serialised into `