@creative-locator/headless
v2.21.0
Published
Framework-agnostic platform adapters for Creative Locator. Drop-in implementations of the @creative-locator/core ApiClient / I18n / Storage / ConfigSource interfaces for headless consumers (Astro, Next.js, Eleventy, vanilla HTML) that talk to any WordPres
Readme
@creative-locator/headless
Drop-in platform adapters for Creative Locator consumers running outside WordPress.
Provides concrete implementations of the four @creative-locator/core adapter interfaces so that any host environment — Astro, Next.js, Eleventy, vanilla HTML, Remix, SvelteKit, or anything else that can render Leaflet in a browser — can render a Creative Locator against a Creative Locator REST backend over HTTP.
What's included
| Adapter | Interface | Purpose |
| ------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HttpApiAdapter | ApiClient | Generic fetch-based client against {baseUrl}/dealers, /search, /geocode, /taxonomies, /analytics/*. Optional custom headers and fetch override. |
| IdentityI18nAdapter | I18n | Single-locale default. Returns source strings unchanged; implements sprintf for positional placeholders. |
| BrowserStorageAdapter | Storage | window.localStorage wrapper with graceful degradation for private browsing and SSR. |
| InMemoryStorageAdapter | Storage | Map-backed Storage for SSR and tests. |
| StaticConfigAdapter | ConfigSource | Takes a LocatorConfig at construction time. |
Install
pnpm add @creative-locator/core @creative-locator/headless leaflet leaflet.markerclusterQuick start
Recommended (post-v2.9.0)
One-call factory — sensible defaults and a single import:
import { createHeadlessLocator } from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
createHeadlessLocator({
container: '#my-locator',
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
});That's the entire boot sequence on a vanilla HTML page. The factory:
- Resolves
containerfrom a CSS selector viadocument.querySelector(or accepts a pre-resolvedHTMLElement). - Constructs
HttpApiAdapter/IdentityI18nAdapter/BrowserStorageAdapter/StaticConfigAdapterwith sensible defaults. - Merges any partial
LocatorConfigyou pass over the bundleddefaultLocatorConfigso you only specify the fields you care about. - Throws a descriptive error when the selector doesn't match — silent-mount-into-null was the most common pre-#476 misconfiguration.
- Auto-detects SSR contexts and falls back to in-memory storage so server-side construction never blows up.
Custom config + auth headers
createHeadlessLocator({
container: document.getElementById('my-locator')!,
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
apiHeaders: { Authorization: `Bearer ${myJwt}` },
config: {
layout: 'directory',
defaultRadius: 25,
maxResults: 100,
},
});Per-adapter override
When you need a custom ApiClient (e.g. with retry middleware) but want the factory's defaults for everything else:
import { createHeadlessLocator, HttpApiAdapter } from '@creative-locator/headless';
createHeadlessLocator({
container: '#my-locator',
apiUrl: 'https://example.com/wp-json/creative-locator/v1',
adapters: {
api: new MyRetryingApiClient(/* ... */),
},
});Advanced (full adapter control)
If you need fine-grained control over every adapter, the four constructors are still exported and the legacy four-adapter recipe still works:
import { createLocator, type PlatformAdapters, type LocatorConfig } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
const locatorConfig: LocatorConfig = {
// ... your locator configuration. See @creative-locator/core's LocatorConfig type.
};
const adapters: PlatformAdapters = {
api: new HttpApiAdapter({
baseUrl: 'https://example.com/wp-json/creative-locator/v1',
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter(locatorConfig),
};
const container = document.querySelector<HTMLElement>('#my-locator')!;
createLocator(container, { adapters });Framework recipes
Astro
Most common pattern: render the locator as an island in an .astro page.
---
// src/pages/locator.astro
import '@creative-locator/core/styles/style.scss';
---
<div id="my-locator"></div>
<script>
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
createLocator(document.querySelector('#my-locator')!, {
adapters: {
api: new HttpApiAdapter({
baseUrl: import.meta.env.PUBLIC_LOCATOR_API_URL,
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig — see @creative-locator/core types */
}),
},
});
</script>The <script> block runs in the browser, so all adapters work as expected. Set PUBLIC_LOCATOR_API_URL in .env to your WordPress site's REST base.
Next.js (App Router, client component)
The locator must mount in a Client Component because Leaflet touches window.
// app/components/Locator.tsx
'use client';
import { useEffect, useRef } from 'react';
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
export function Locator() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const handle = createLocator(ref.current, {
adapters: {
api: new HttpApiAdapter({ baseUrl: process.env.NEXT_PUBLIC_LOCATOR_API_URL! }),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
return () => handle?.destroy?.();
}, []);
return <div ref={ref} />;
}Then in your page:
// app/locator/page.tsx
import dynamic from 'next/dynamic';
const Locator = dynamic(() => import('@/components/Locator').then((m) => m.Locator), {
ssr: false,
});
export default function LocatorPage() {
return <Locator />;
}SvelteKit (+page.svelte with onMount)
Same pattern as Next.js — defer to onMount so SSR doesn't try to touch window.
<!-- src/routes/locator/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { createLocator } from '@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from '@creative-locator/headless';
import '@creative-locator/core/styles/style.scss';
let container: HTMLDivElement;
onMount(() => {
const handle = createLocator(container, {
adapters: {
api: new HttpApiAdapter({
baseUrl: import.meta.env.PUBLIC_LOCATOR_API_URL,
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
return () => handle?.destroy?.();
});
</script>
<div bind:this={container}></div>Vanilla HTML
Smallest possible setup — no framework, just an ESM <script type="module">:
<!doctype html>
<link rel="stylesheet" href="/node_modules/@creative-locator/core/dist/style.css" />
<div id="my-locator"></div>
<script type="module">
import { createLocator } from 'https://esm.sh/@creative-locator/core';
import {
HttpApiAdapter,
IdentityI18nAdapter,
BrowserStorageAdapter,
StaticConfigAdapter,
} from 'https://esm.sh/@creative-locator/headless';
createLocator(document.querySelector('#my-locator'), {
adapters: {
api: new HttpApiAdapter({
baseUrl: 'https://your-site.com/wp-json/creative-locator/v1',
}),
i18n: new IdentityI18nAdapter(),
storage: new BrowserStorageAdapter(),
config: new StaticConfigAdapter({
/* LocatorConfig */
}),
},
});
</script>SSR / hydration notes
- All adapters are client-only by design (Leaflet requires
window/document). BrowserStorageAdaptergracefully no-ops whenwindow.localStorageis unavailable (private browsing, SSR contexts) — safe to instantiate at module top-level without anif (typeof window …)guard.- For SSR-rendered pages: defer
createLocator()(or the framework wrappers'<DealerLocator />) to a client-only mount (Astro<script>, Next.jsuseEffectin a Client Component, SvelteKitonMount, etc.).
Server-render the static shell with renderLocatorShell (post-v2.9.0)
For real SSR — emitting the locator's static HTML scaffolding from your server-render path so visitors see the shell before JS hydrates — use renderLocatorShell:
import { renderLocatorShell } from '@creative-locator/headless';
// Server-side: produce the shell as a string and emit it inside
// your response. The `instanceId` should match the container's id
// so the client picks up the same skip-link / list-panel ids.
const shell = renderLocatorShell({ instanceId: 'my-locator' });
res.send(`
<div id="my-locator" class="creative-locator crloc-dealer-locator">${shell}</div>
<script type="module">
import { createHeadlessLocator } from '@creative-locator/headless';
createHeadlessLocator({
container: '#my-locator',
apiUrl: '${apiUrl}',
});
</script>
`);createHeadlessLocator detects the pre-rendered shell on the client and skips re-injection — the server-rendered DOM is preserved through hydration. The empty-container case still works for client-only mounts: when no shell is present, createHeadlessLocator calls renderLocatorShell internally before passing the container to createLocator.
Pass labels for translations and mapPanelInert: false if you've audited your Leaflet integration for keyboard a11y:
renderLocatorShell({
instanceId: 'my-locator',
mapPanelInert: false,
labels: {
searchRegion: 'Buscar ubicaciones',
resultsRegion: 'Resultados',
skipToResults: 'Ir a resultados',
skipFiltersToResults: 'Saltar filtros e ir a resultados',
mapDescription: 'Mapa interactivo de ubicaciones.',
},
});The shell is the single source of truth shared with WordPress's server-side block render — see packages/wordpress/includes/Blocks/DealerLocatorBlock.php::render_shell() for the cross-language counterpart.
TypeScript
All adapters ship with full .d.ts definitions. The HttpApiAdapter constructor takes a typed HttpApiAdapterOptions:
interface HttpApiAdapterOptions {
baseUrl: string;
headers?: Record<string, string>;
fetch?: typeof fetch;
}Override fetch for testing or for environments where the global fetch needs middleware (auth tokens, request logging, retry).
License
GPL-2.0-or-later.
