diff --git a/.eslintignore b/.eslintignore
index d2d6eed..42fe272 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,5 +1,7 @@
node_modules
build
public/build
+dist
+coverage
*/*.yml
.shopify
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index c2e4453..064f74b 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -17,7 +17,10 @@ module.exports = {
env: {
browser: true,
commonjs: true,
- es6: true,
+ // es2022, not es6: `parserOptions.ecmaVersion: latest` lets the parser read modern
+ // syntax but says nothing about globals, so `es6` left `globalThis` undeclared and
+ // `no-undef` fired on every use of it.
+ es2022: true,
},
ignorePatterns: ["!**/.server", "!**/.client"],
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f506111
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,53 @@
+# The gate the PR description used to be. Every number the reviewer is asked to
+# trust — tests green, coverage over the threshold, no type error, no lint error — is
+# measured here on every push instead of once, by hand, on someone's laptop.
+#
+# One job on purpose: the workspace builds in seconds and the steps share the install.
+# Split it when something here starts being worth waiting for on its own.
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ # A new push supersedes the run in flight for the same ref.
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ # The workspace's `engines` range excludes Node 21 and 22.0–22.11.
+ node-version: 20.19
+ cache: pnpm
+
+ # `--frozen-lockfile` is the point: a PR that edits a package.json without
+ # committing the lockfile fails here rather than resolving something new in CI.
+ # The packages' `prepare` scripts build `packages/*/dist` as part of this, which
+ # is what the apps typecheck and test against.
+ - run: pnpm install --frozen-lockfile
+
+ # The root scripts, not `pnpm -r …` directly, so CI runs exactly what a developer
+ # runs locally — including the libs build the apps resolve through.
+ - run: pnpm typecheck
+
+ - run: pnpm lint
+
+ # Coverage, not plain `test`: the per-package vitest configs carry the 80%
+ # thresholds, so this is the step that actually enforces them.
+ - run: pnpm coverage
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/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts b/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts
index c283bf0..f7e06e5 100644
--- a/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts
+++ b/apps/checkout-autocomplete-lite/extensions/woosmap-address-autocomplete/shopify.d.ts
@@ -1,18 +1,18 @@
import '@shopify/ui-extensions';
-//@ts-ignore
+//@ts-expect-error module paths are declared, not resolved
declare module './src/suggest.ts' {
const shopify: import('@shopify/ui-extensions/purchase.address-autocomplete.suggest').Api;
const globalThis: { shopify: typeof shopify };
}
-//@ts-ignore
+//@ts-expect-error module paths are declared, not resolved
declare module './src/format-suggestion.ts' {
const shopify: import('@shopify/ui-extensions/purchase.address-autocomplete.format-suggestion').Api;
const globalThis: { shopify: typeof shopify };
}
-//@ts-ignore
+//@ts-expect-error module paths are declared, not resolved
declare module './src/woosmap.ts' {
const shopify:
| import('@shopify/ui-extensions/purchase.address-autocomplete.suggest').Api
diff --git a/apps/checkout-autocomplete/package.json b/apps/checkout-autocomplete/package.json
index 834107d..546ced1 100644
--- a/apps/checkout-autocomplete/package.json
+++ b/apps/checkout-autocomplete/package.json
@@ -17,7 +17,7 @@
"prisma": "prisma",
"graphql-codegen": "graphql-codegen",
"vite": "vite",
- "typecheck": "react-router typegen && tsc --noEmit",
+ "typecheck": "prisma generate && react-router typegen && tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
diff --git a/apps/store-pages/README.md b/apps/store-pages/README.md
index a790b11..5da864b 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
@@ -87,6 +97,18 @@ pnpm --filter woosmap-store-pages sync
It is idempotent, so re-running just re-syncs. Set `STORE_METAOBJECT_TYPE` only to point at
an existing definition of another type (the default is `store`).
+The sync also builds a `LocalPage` per store (`@woosmap/local-page-engine`) and maps it. The
+page config is optional — Shopify supplies most of it another way — but a consumer that reads
+the document rather than the theme will want it:
+
+| Variable | Effect |
+| --- | --- |
+| `STORE_URL_HANDLE` | path pages live under (`/pages//…`, default `stores`). Drives the definition's URL handle, the page's canonical path **and** the neighbour links — one value, so they cannot drift |
+| `STORE_BRAND` | `{brand}` in the generated SEO copy |
+| `STORE_LOCALE` | BCP 47 tag recorded on the document. It labels the copy; it does not translate it (override the templates for that) |
+| `STORE_PAGE_ORIGIN` | e.g. `https://shop.example.com` → absolute canonical and schema.org URLs. Liquid has `canonical_url`, so only an off-platform consumer needs this |
+| `WOOSMAP_PUBLIC_KEY` | bakes the static-map URL into the document. The theme reads its own key, so the sync only needs this for a consumer that has no theme |
+
**4. Render the pages.** Copy `theme/templates/metaobject/store.liquid` into the theme
(Online Store → Themes → Edit code → Templates → new `metaobject/store` template), set the
Woosmap **public** key (theme setting `woosmap_public_key` or shop metafield
@@ -145,6 +167,13 @@ is delivered as a file to copy into the merchant's theme. It renders the store d
map is a Woosmap asset by design, not a theme asset.
- SEO title/description come from the renderable capability. `description` is merchant-owned
and is **never overwritten by the sync**.
+- The administrative breadcrumb is built **once** in the template and reused by the
+ `BreadcrumbList` JSON-LD, dropping blanks and consecutive duplicates — the same rule as
+ `buildBreadcrumb` in the engine. Keep the two in step until the JSON-LD is fed from
+ `page.jsonLd` and the duplicate goes away.
+- "Other stores nearby" is rewritten on every sync that runs the neighbour search, **including
+ when a store no longer has any** — the sync writes `[]` so the section disappears. A store
+ whose neighbours were not recomputed keeps the ones it had.
## Before syncing thousands of stores: verify
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..2940c15
--- /dev/null
+++ b/apps/store-pages/app/metaobject-mapping.server.test.ts
@@ -0,0 +1,141 @@
+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('writes "[]" when the search ran and found none, so stale neighbours are cleared', () => {
+ // Not a no-op: omitting the key would leave yesterday's list in the metaobject and
+ // `store.liquid` would keep rendering links to it.
+ const fields = byKey(localPageToMetaobjectFields(page(STORE, { nearbyStores: [] })));
+ expect(fields['nearby_stores']).toBe('[]');
+ });
+
+ it('omits nearby_stores when the search never ran, so the stored value survives', () => {
+ const fields = byKey(localPageToMetaobjectFields(page()));
+ 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 53%
rename from packages/store-search-client/src/metaobject-mapping.ts
rename to apps/store-pages/app/metaobject-mapping.server.ts
index d04dad5..ead6fb6 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,42 @@ 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.
+ *
+ * `nearby_stores` is the case where that rule needs care. The neighbour search runs
+ * on every sync, and a store can legitimately end up with none — a neighbour closed,
+ * or the radius was tightened. That result must be written as `[]`, because omitting
+ * it would leave yesterday's neighbours in place and `store.liquid` renders them as
+ * links to pages that may no longer exist. So the test is `!== null` (did the search
+ * run?), not `.length > 0` (did it find anything?).
+ *
+ * `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 +91,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 +109,21 @@ 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 !== null) {
+ // `"[]"` is a real instruction — it clears neighbours that are no longer in radius.
+ 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..246273d 100644
--- a/apps/store-pages/app/store-sync.server.test.ts
+++ b/apps/store-pages/app/store-sync.server.test.ts
@@ -1,6 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import type { Store, StoreFeature, StoresSearchRequest } from '@woosmap/store-search-client';
-import { buildSyncQuery, syncStores, type MetaobjectUpserter } from './store-sync.server';
+import {
+ buildSyncQuery,
+ mergeEnrichment,
+ syncStores,
+ type MetaobjectUpserter,
+} from './store-sync.server';
function feature(id: string, name = id, coords: [number, number] = [2.3, 48.8]): StoreFeature {
return {
@@ -42,6 +47,43 @@ describe('buildSyncQuery', () => {
});
});
+describe('mergeEnrichment', () => {
+ it('combines the slices independent enrichers resolved', () => {
+ expect(
+ mergeEnrichment([{ admin: { region: 'Kent' } }, { nearbyStores: [] }, {}]),
+ ).toEqual({ admin: { region: 'Kent' }, nearbyStores: [] });
+ });
+
+ it('carries a resolved empty array through — it is an instruction, not a no-op', () => {
+ expect('nearbyStores' in mergeEnrichment([{ nearbyStores: [] }])).toBe(true);
+ });
+
+ it('carries a resolved null through', () => {
+ expect(mergeEnrichment([{ nearby: null }])).toEqual({ nearby: null });
+ });
+
+ it('ignores an undefined slice rather than overwriting one someone else filled', () => {
+ expect(mergeEnrichment([{ admin: { region: 'Kent' } }, { admin: undefined }])).toEqual({
+ admin: { region: 'Kent' },
+ });
+ });
+
+ it('keeps the first writer on a collision and reports it', () => {
+ const onCollision = vi.fn();
+ const merged = mergeEnrichment(
+ [{ admin: { region: 'Kent' } }, { admin: { region: 'Sussex' } }],
+ onCollision,
+ );
+ expect([merged.admin, onCollision.mock.calls]).toEqual([{ region: 'Kent' }, [['admin']]]);
+ });
+
+ it('does not need a collision handler to stay safe', () => {
+ expect(mergeEnrichment([{ nearby: null }, { nearby: { updated_at: 't', groups: [] } }])).toEqual(
+ { nearby: null },
+ );
+ });
+});
+
describe('syncStores', () => {
it('upserts every store with a stable handle and reports a summary', async () => {
const src = source([feature('store_A'), feature('store_B')]);
@@ -60,21 +102,61 @@ 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('stamps every store from the injected clock rather than reading the wall clock', async () => {
+ const src = source([feature('store_A'), feature('store_B')]);
+ const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle }));
+ const now = vi.fn(() => '2026-08-14T00:00:00.000Z');
+ // The page's `computedAt` is not mapped onto a metaobject field, so the observable
+ // contract is that the clock is consulted once per store and never bypassed.
+ const spy = vi.spyOn(Date.prototype, 'toISOString');
+
+ await syncStores({ source: src, upsert, now });
+
+ expect([now.mock.calls.length, spy.mock.calls.length]).toEqual([2, 0]);
+ spy.mockRestore();
+ });
+
+ it('clears a slice an enricher resolved as empty, instead of leaving the stored value', async () => {
+ const src = source([feature('store_A')]);
+ const upsert = vi.fn(async ({ handle }) => ({ id: `gid://${handle}`, handle }));
+ // The neighbour search ran and found nobody in radius: `[]` must reach the upsert,
+ // or `store.liquid` keeps rendering the previous run's links.
+ const enrich = async (): Promise<{ nearbyStores: [] }> => ({ nearbyStores: [] });
+
+ await syncStores({ source: src, upsert, enrich });
+
+ const fields = Object.fromEntries(upsert.mock.calls[0]![0].fields.map((f) => [f.key, f.value]));
+ expect(fields['nearby_stores']).toBe('[]');
});
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..0b766df 100644
--- a/apps/store-pages/app/store-sync.server.ts
+++ b/apps/store-pages/app/store-sync.server.ts
@@ -8,13 +8,50 @@
// 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';
+
+/**
+ * Merge the enrichment slices several independent enrichers resolved for one store.
+ *
+ * Spelt out rather than `Object.assign(...)` + a cast: each enricher owns exactly one
+ * slice, so two of them writing the same one is a wiring mistake, and a spread would
+ * swallow it — last writer wins, silently, with the compiler talked out of the way.
+ * Here the first writer keeps the slice and `onCollision` gets told. Not fatal: an
+ * enrichment problem must never cost a store its base facts.
+ *
+ * `undefined` means "did not resolve" and never overwrites a slice someone else
+ * filled. `null` and `[]` are resolved values and do carry through — that is how the
+ * neighbour search says "this store has none any more".
+ */
+export function mergeEnrichment(
+ parts: LocalPageEnrichment[],
+ onCollision?: (key: keyof LocalPageEnrichment) => void,
+): LocalPageEnrichment {
+ const merged: LocalPageEnrichment = {};
+ for (const part of parts) {
+ for (const key of Object.keys(part) as Array) {
+ if (part[key] === undefined) {
+ continue;
+ }
+ if (key in merged) {
+ onCollision?.(key);
+ continue;
+ }
+ Object.assign(merged, { [key]: part[key] });
+ }
+ }
+ return merged;
+}
/** Just the client surface the sync needs — narrow, so tests inject a fake. */
export interface StoreSource {
@@ -34,12 +71,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 +130,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 +159,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..21f7d2e 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,
@@ -14,10 +14,18 @@ import {
listStoreNearbyTimestamps,
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 { mergeEnrichment, syncStores } from './store-sync.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];
@@ -43,12 +51,14 @@ async function main(): Promise {
});
const client = createStoreClient();
+ // Where the pages live. One expression, used by the definition's url handle, the
+ // page's canonical path and the neighbour links — they cannot be allowed to drift.
+ const urlHandle = process.env.STORE_URL_HANDLE ?? 'stores';
+ const urlBase = `/pages/${urlHandle}`;
+
// Create the merchant-owned definition on first run, else ensure its capabilities
// (public URL + SEO mapping). Idempotent.
- const outcome = await ensureStoreDefinition(execute, {
- type,
- urlHandle: process.env.STORE_URL_HANDLE ?? 'stores',
- });
+ const outcome = await ensureStoreDefinition(execute, { type, urlHandle });
if (outcome === 'created') {
console.log('Created the merchant-owned `store` metaobject definition (online_store + renderable SEO + publishable).');
} else if (outcome === 'updated') {
@@ -56,9 +66,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 +84,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 +103,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).');
}
@@ -113,7 +123,6 @@ async function main(): Promise {
if (process.env.ENRICH_NEARBY_STORES === 'true') {
const radiusKm = Number(process.env.NEARBY_STORES_RADIUS_KM ?? '10');
const limit = Number(process.env.NEARBY_STORES_LIMIT ?? '3');
- const urlBase = `/pages/${process.env.STORE_URL_HANDLE ?? 'stores'}`;
const allStores: Store[] = [];
for await (const feature of client.iterateStores()) {
allStores.push(featureToStore(feature));
@@ -129,21 +138,37 @@ async function main(): Promise {
} else {
nearbyStores.alone += 1;
}
- return [{ key: 'nearby_stores', value: JSON.stringify(list) }];
+ // Always a resolved value, `[]` included: the search ran, so the stored list is
+ // replaced. Omitting it for a store with no neighbour left would keep rendering
+ // yesterday's links.
+ 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 mergeEnrichment(parts, (key) =>
+ console.error(` ! two enrichers both resolved \`${key}\`; keeping the first.`),
+ );
}
: 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). STORE_PAGE_ORIGIN likewise — Liquid
+ // has `canonical_url`, so only an off-platform consumer needs absolute URLs.
+ pageConfig: {
+ urlBase,
+ ...(process.env.WOOSMAP_PUBLIC_KEY ? { publicKey: process.env.WOOSMAP_PUBLIC_KEY } : {}),
+ ...(process.env.STORE_BRAND ? { brand: process.env.STORE_BRAND } : {}),
+ ...(process.env.STORE_PAGE_ORIGIN ? { origin: process.env.STORE_PAGE_ORIGIN } : {}),
+ ...(process.env.STORE_LOCALE ? { locale: process.env.STORE_LOCALE } : {}),
+ },
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/theme/templates/metaobject/store.liquid b/apps/store-pages/theme/templates/metaobject/store.liquid
index e2e64cf..2908186 100644
--- a/apps/store-pages/theme/templates/metaobject/store.liquid
+++ b/apps/store-pages/theme/templates/metaobject/store.liquid
@@ -23,8 +23,22 @@
- {%- comment -%} Administrative breadcrumb — values only, consecutive duplicates dropped (city-states). {%- endcomment -%}
- {%- capture crumb_raw -%}{% if metaobject.country.value != blank %}{{ metaobject.country.value }}|{% endif %}{% if metaobject.region.value != blank %}{{ metaobject.region.value }}|{% endif %}{% if metaobject.county.value != blank and metaobject.county.value != metaobject.region.value %}{{ metaobject.county.value }}|{% endif %}{% if metaobject.city.value != blank and metaobject.city.value != metaobject.county.value %}{{ metaobject.city.value }}|{% endif %}{%- endcapture -%}
+ {%- comment -%}
+ Administrative breadcrumb — values only, blanks and CONSECUTIVE duplicates
+ dropped. Each level is compared with the one above it, so Paris (county = city)
+ renders once and Luxembourg (country = region = county = city) renders once
+ instead of four times. Country vs region is compared too — leaving that pair out
+ is what used to render "Luxembourg › Luxembourg".
+
+ The trail is built once here and reused by the BreadcrumbList at the bottom of
+ this file: two renderings, one rule. Same rule as `buildBreadcrumb` in
+ @woosmap/local-page-engine — keep them in step.
+ {%- endcomment -%}
+ {%- assign crumb_country = metaobject.country.value | strip -%}
+ {%- assign crumb_region = metaobject.region.value | strip -%}
+ {%- assign crumb_county = metaobject.county.value | strip -%}
+ {%- assign crumb_city = metaobject.city.value | strip -%}
+ {%- capture crumb_raw -%}{% if crumb_country != blank %}{{ crumb_country }}|{% endif %}{% if crumb_region != blank and crumb_region != crumb_country %}{{ crumb_region }}|{% endif %}{% if crumb_county != blank and crumb_county != crumb_region %}{{ crumb_county }}|{% endif %}{% if crumb_city != blank and crumb_city != crumb_county %}{{ crumb_city }}|{% endif %}{%- endcapture -%}
{%- assign crumbs = crumb_raw | split: '|' -%}
{%- if crumbs.size > 0 -%}