Skip to content

Separate the local page from Shopify - #5

Open
gaelsimon wants to merge 4 commits into
mainfrom
feat/local-page-engine
Open

Separate the local page from Shopify#5
gaelsimon wants to merge 4 commits into
mainfrom
feat/local-page-engine

Conversation

@gaelsimon

@gaelsimon gaelsimon commented Aug 17, 2026

Copy link
Copy Markdown
Member

What

@woosmap/local-page-engine owns the local page contract: store + enrichment + per-client config in, a LocalPage out. Shopify becomes one adapter over it (app/metaobject-mapping.server.ts); a feed or a server-rendered page would sit next to that file.

storeSlug is byte-for-byte the handle it replaces, so no published page changes URL. The static map keeps the same endpoint, zoom and geometry.

Why

Three things blocked any non-Shopify consumer:

  • SEO title, JSON-LD and the static-map URL were written in Liquid, so they couldn't be reused or unit-tested.
  • STORE_FIELD_DEFINITIONS, a Shopify metaobject schema, sat in the shared store-search-client and was the workspace's de facto contract. It moved into the app.
  • The enrichment resolvers (Nearby + Distance Matrix, reverse-geocode, neighbours, TTL) lived in the app. They moved into the engine, over an injected fetch.

Fixed in review

  • nearbyStores defaulted to [], so the page couldn't say whether the neighbour search had run. The mapper wrote nearby_stores only when non-empty, so a store that lost its last neighbour kept the old list and store.liquid kept rendering those links. Now NearbyStore[] | null: null leaves the stored value alone, [] clears it.
  • config.directionsProvider was accepted and read by nothing. It's on the page now.
  • map.alt was hardcoded English while seo.imageAlt honoured the template override. The map takes its alt from the SEO block.
  • No way to produce the absolute URL BreadcrumbList wants. Added config.origin, and config.locale to record the copy's language.
  • store.liquid's breadcrumb deduped county/region and city/county but never region/country ("Luxembourg › Luxembourg"). Same rule as the engine now, built once for both the nav and the JSON-LD.

CI

There wasn't any. .github/workflows/ci.yml runs install --frozen-lockfile, typecheck, lint and coverage on every push and PR — coverage rather than test, since the 80% thresholds are in the vitest configs. It caught two things:

  • Typecheck failed on a fresh clone: the apps resolve packages/* through their built dist and nothing built them. An app's tests could also pass against a stale engine. Each library now builds on prepare.
  • eslint . had 5 'globalThis' is not defined errors, from env: es6 in the root config. Now es2022, plus a root lint script.

Verification

  • 345 tests, up from 235 on main
  • Engine 98.97% statements / 91.82% branches; store-pages 98.25% / 93.75%
  • typecheck and lint clean from a dist-less tree with only pnpm install
  • theme-check reports only the two known RemoteAsset warnings
  • examples/local-page.example.json is committed, with a drift test

Known state and follow-ups

page.seo, page.jsonLd and page.map aren't mapped onto metaobject fields: Shopify covers them via the renderable capability and the template's own JSON-LD and <img>. So the engine and the template both derive them, and the breadcrumb bug above is what that duplication looks like in practice. Feeding the Liquid from page.jsonLd would remove it, but it changes what the storefront renders and wants a dev-store pass.

  • The feed CLI, with bounded concurrency for the 429 risk on a full-network run. Also the second consumer that would justify the abstraction.
  • The public key in page.map is referrer-restricted against whoever loads the image, so a third-party consumer needs their own key or an allow-listed domain.
  • pnpm-workspace.yaml sets trustPolicy and blockExoticSubdeps, which need pnpm >= 10.21 / 10.26. The pinned packageManager is older, so they're ignored today.
  • @types/woosmap.map marks address/contact/open/weekly_opening required and spells the timestamp lastUpdated; the API omits them and returns last_updated.
  • The second commit mixes unrelated workspace fixes into this PR.

…onto it

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.
- checkout-autocomplete: `typecheck` now runs `prisma generate` first. Without a
  generated client, `@prisma/client` exports no `PrismaClient` and the app failed
  typecheck on any fresh clone.
- .eslintignore: add `dist` and `coverage`. Build output was being linted, so every
  package reported errors on its own emitted `.d.ts`.
- Drop two unused imports and swap three `@ts-ignore` for `@ts-expect-error` in the
  extension's declaration file — the five errors that were hidden behind the
  build-output noise.
`nearbyStores` defaulted to `[]`, so the page could not say whether the neighbour
search had run. The mapper keyed off `.length > 0`, so a store that lost its last
neighbour got no `nearby_stores` key and kept rendering yesterday's links. Now
`NearbyStore[] | null`: `null` leaves the stored value, `[]` clears it.

Also:
- `directionsProvider` is recorded on the page instead of being silently dropped
- `map.alt` comes from `seo.imageAlt`, so an override reaches both
- `config.origin` gives an absolute `canonicalUrl` and breadcrumb `item`
- `config.locale` labels the copy's language
- `store.liquid` drops consecutive breadcrumb duplicates like the engine does, and
  builds the trail once for both the visible nav and the JSON-LD
- `mergeEnrichment` replaces `Object.assign(...) as …`, under the coverage gate
- the clock test checks the injected clock is used per store

345 tests, engine 98.97%, store-pages 98.25%.
install --frozen-lockfile → typecheck → lint → coverage, via the root scripts so CI
and a developer run the same thing. `coverage`, not `test`: the 80% thresholds live in
the vitest configs.

Two things it would have failed on:
- typecheck on a fresh clone. The apps resolve `packages/*` through their built
  `dist`, no package had a `prepare`, nothing ordered a build — and an app's tests
  could pass against a stale engine. Each library now builds on `prepare`.
- `eslint .` reported 5 `'globalThis' is not defined`: `env: es6` predates it. Now
  `es2022`, plus a root `lint` script.

`packageManager` is pinned because pnpm/action-setup reads it. Note `trustPolicy` and
`blockExoticSubdeps` in pnpm-workspace.yaml need pnpm >= 10.21 / 10.26 and are ignored
today; bumping the pin is its own change.
@gaelsimon gaelsimon changed the title feat(local-page-engine): own the local page contract, invert Shopify onto it feat(local-page-engine): separate the local page from Shopify Aug 19, 2026
@gaelsimon gaelsimon changed the title feat(local-page-engine): separate the local page from Shopify Separate the local page from Shopify Aug 19, 2026
@gaelsimon
gaelsimon requested a review from cmillett August 19, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant