External widget — directory structure
Annotated tree of ext/2/source/ — the active production Svelte 5 widget. ext/1/ (Svelte 4 + Leaflet legacy) is out of scope; see decisions/two-widget-versions.
The high-level init sequence (how main.js picks a runtime and calls mount) is in init-flow.md. The CDN pointer fetch and localStorage payload cache are in cdn-and-pointer.md. The snippet loader that sits one level above this directory is in snippet-loader.md.
Tree
ext/2/source/
├── index.html # Dev test page — mounts #finder-app with a ?key= param
├── package.json # ESM project; runtime: svelte + maplibre-gl; dev: vite + @sveltejs/vite-plugin-svelte
├── vite.config.mjs # Single-file output; CSS injected at runtime (cssInjectedByJsPlugin)
├── .env.development # VITE_API_APP_URL, VITE_API_TOMTOM_KEY, R2_* keys (gitignored)
├── .env.production # Same shape; production values (gitignored)
│
├── scripts/
│ └── deploy-r2.mjs # Upload snippet.js → R2; purge Cloudflare cache
│
└── src/
├── main.js # Entry point — variant routing + Svelte mount
├── App.svelte # Root component — layout engine + preview bridge
├── Reset.svelte # CSS variables + global resets; zero DOM output
│
├── components/
│ ├── Search.svelte # Search bar, autocomplete dropdown, filter/sort trigger
│ ├── List.svelte # Location list — split, cards, list-only, overlay variants
│ ├── Map.svelte # MapLibre GL map — markers, popups, active-location sync
│ ├── Refinements.svelte # Filter + sort panel (popup or drawer)
│ └── MobileDetail.svelte # Full-screen location detail sheet (≤640 px)
│
└── lib/
├── stores.js # All Svelte writable stores + initializeFinderStores()
├── api.js # Fetch helpers — pointer, payload, autocomplete, place details
├── filtering.js # Pure filter + sort logic — no Svelte dependency
├── localization.js # i18n messages (en/es) + translate/translateCount helpers
├── mapSettings.js # Provider normalization + MapLibre style builders
├── customFields.js # Custom-field rendering, hours parsing, Field Composer
├── searchMode.js # Natural-language query parser + text normalizer
├── analytics.js # Batched analytics with sendBeacon flush
├── embed.js # finder-key extraction from DOM attribute or script src
└── directions.js # Directions URL builder (Google, Bing, DuckDuckGo, MapQuest)Root files
index.html is a local dev harness only. It creates <div id="finder-app"> and loads main.js via <script type="module">. It is not deployed.
vite.config.mjs configures a single-chunk build. cssInjectedByJsPlugin inlines all styles into the JS bundle at runtime, which avoids a separate CSS request and simplifies the customer’s embed (one <script> tag, no <link>). The output filename is snippet.js in production and snippet.dev.js in --mode development. Both land in ext/2/ (one directory up from source/), not inside source/.
scripts/
deploy-r2.mjs is a Node ESM script run after vite build via npm run deploy:02. It uploads two copies of snippet.js to Cloudflare R2:
| R2 key | Cache-Control | Purpose |
|---|---|---|
snippet.js | public, max-age=300 | Root loader reference |
ext/2/snippet.js | public, max-age=300 | Versioned widget reference |
After upload it optionally purges the Cloudflare CDN cache for both URLs. Purge is skipped when CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN are absent, which is normal in CI environments. Requires R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET in the environment; it falls back to reading .env.development when those variables are absent.
src/main.js — entry point
Resolves the finder key via embed.js, then:
- Fetches the pointer (
config.json) to get thevariantfield. - If a non-default variant is specified, dynamically imports
https://cdn.locationfinders.com/ext/v2/variants/${variant}.js. If that module exports aninitfunction it is called and the defaultAppis skipped entirely. - Falls through to
mount(App, { target, props })for thedefaultvariant or when variant load fails.
See init-flow.md for the full boot sequence including geolocation and preview-bridge wiring.
src/App.svelte — root component
App.svelte is the layout engine and preview bridge. Its responsibilities:
- Geolocation — calls
resolveUserLocation(), which triesnavigator.geolocationfirst and falls back toipinfo.io/json. - Payload loading — calls
fetchFinderPayload(finderKey, pointer.hash)and passes the result toinitializeFinderStores(). - Preview bridge — reads
window.__LF_PREVIEW_MODE,window.__LF_PREVIEW_SESSION, andwindow.__LF_PREVIEW_PAYLOADon mount; also subscribes to thelf-preview-payloadCustomEvent. Preview payloads callapplyPayload(..., { isPreview: true, preserveUiState: true })so filter state is not reset on each builder update. - Layout selection — reads
design.app.layoutfrom the payload and normalizes the value throughnormalizeLayout(). Nine named layouts are supported:split-ltr-80,split-rtl-80,split-ltr-50,split-rtl-50,map-overlay,list-only,cards-only,cards-search,cards-map. Legacy string aliases (e.g.rtl,only-map) are mapped to canonical names. - Mobile tab layout — at ≤640 px, split and overlay variants collapse into a two-tab Map/List layout with a bottom tab bar and a “peek” card above the map.
- Custom CSS scoping —
scopeCustomCSS()rewrites bare selectors to#finder-app <selector>and:rootto#finder-app, keeping customer CSS sandboxed to the widget. - Loading skeleton — a shimmer placeholder (four skeleton cards + abstract map shapes) renders while the payload is in flight. An error state replaces it on failure.
- Map branding — an optional text watermark overlaid on the map, controlled by
design.map.brandLabel,brandOpacity, andbrandEnabled.
src/Reset.svelte — CSS variables + global resets
Reset.svelte renders no DOM. It subscribes to designStore and calls applyThemeVariables() imperatively, setting all --finder-* CSS custom properties on #finder-app (or documentElement if the element is absent). It also loads DM Sans, DM Mono, and Instrument Serif from Google Fonts via <svelte:head>. The default theme palette and border-radius values live here; every incoming design.colors.* value merges over the defaults with a compatibility alias layer for renamed keys.
src/components/
Search.svelte
The search bar component. Renders the address/name text input, a geocode submit button, a filter toggle, and a sort toggle. When the user types, it debounces and calls fetchAutocompleteSuggestions() to populate a dropdown with both geocoder suggestions (Google Places, proxied via the origin API) and local location matches. Selecting a geocoder suggestion calls fetchPlaceDetails() and then setUserLocation(), triggering a refreshVisibleLocations() pass in stores.js. Accepts filterStyle, filterButtonPlacement, filterPanelStyle, sortStyle, and sortPlacement props that control how the refinements UI is surfaced.
List.svelte
Renders the filtered location list. Accepts a variant prop ("split", "cards", "list") that adjusts card layout and density. Key behaviours:
- Subscribes to
locationsStoreandactiveLocationStore. - Renders a count badge and “nearby results” or “using your location” eyebrow.
- Empty state when the filtered list is zero.
- “Show more” pagination controlled by a local
limitvariable. - Quick filter chips (
showQuickFiltersprop) rendered from enabled tag options. - Per-card custom-field rows, hours badge, distance, phone, website, and directions link sourced from
customFields.js. - Clicks set
activeLocationStore, which drives the map’s active marker.
Map.svelte
MapLibre GL map. Responsibilities:
- Resolves the map tile style via
resolveMapSettings()andbuildMapStyle()frommapSettings.js. - Renders one marker per location in
locationsStore; the active marker uses a distinct style. - Emits
markerselectandmapclickcustom events forApp.svelte’s mobile peek logic. - Popup rendered on active-location change. The popup contains location name, address, directions link, and a “View” button that sets the list’s selected item.
- Accepts
overlaySearch,suppressPopup, andbottomPaddingprops. - Re-initialises the MapLibre style (destroying and recreating the map instance) when the resolved tile-style key changes, using
getMapStyleKey()as a change signal.
Refinements.svelte
The filter and sort panel. Can render as a popup (anchored below the toolbar) or a full-width drawer, controlled by the filterPanelStyle prop. Sources filter options and sort options from refinementsStore.filterOptions and refinementsStore.sortOptions, which are built by initializeFinderStores() from the payload’s rulesConfig JSON. Writes selections back to refinementsStore, which triggers a refreshVisibleLocations() pass. Supports tag filter, country filter, radius slider, hours-open toggle, rating minimum, and advanced refinement toggles.
MobileDetail.svelte
A full-screen bottom-sheet component that renders the full location detail for the active location on narrow viewports (≤640 px). Triggered by mobileDetailOpen in App.svelte when the user taps “Details” in the peek card. Emits a close event. Contains the complete location fields rendered by the same customFields.js helpers used by List.svelte.
src/lib/
stores.js
Defines every Svelte writable store consumed by the widget:
| Store | Type | Purpose |
|---|---|---|
payloadStore | object | null | Enriched payload (design + parsedSettings merged in) |
designStore | object | design.* settings keyed by sub-namespace |
settingsStore | object | settings.* settings (unitType, searchMode, radius) |
localizationStore | object | { locale, overrides } runtime |
searchQueryStore | string | Current text input value |
filterPanelOpenStore | boolean | Filter panel open/close toggle |
refinementsStore | object | All active filter + sort state |
cachedLocationsStore | array | Raw locations from payload (unchanged between preview updates) |
locationsStore | array | Filtered + sorted locations (derived on demand) |
activeLocationStore | object | null | The location currently highlighted on the map |
userLocationStore | object | { lat, lng, isGeolocated, error } |
initializeFinderStores(payload, options) is the main write path. It:
- Walks
payload.finder.settings[]and splits entries intodesign.*,settings.*, andrefinements.*namespaces. - Parses
design.refinements.rulesConfig(JSON) to buildfilterOptionsandsortOptionsarrays with per-optionenabled/orderfields. - Merges the enriched objects back into the payload and writes all stores.
- When
options.preserveUiStateistrue(preview updates), existing search query, filter selections, radius, and sort state are carried forward — only newly unavailable tags/countries are pruned. - Calls
refreshVisibleLocations()which runsapplyFinderFilters()fromfiltering.jsand writeslocationsStoreandactiveLocationStore.
setUserLocation() and getCurrentLocations() are the two other public exports.
api.js
All network calls. Four exports:
fetchIpLocation()— callshttps://ipinfo.io/json; returns{ lat, lng, country, region, city }.fetchFinderPointer(finderKey)— fetchesCDN/finders/{key}/config.json; returns the pointer object ({ hash, variant, … }) ornull. Short TTL (5 min on CDN).fetchFinderPayload(finderKey, knownHash)— three-tier fetch: (1)localStoragekeylf-cdn-v2:{key}if hash matches, (2)CDN/finders/{key}/v/{hash}.json(immutable, year TTL), (3) origin APIVITE_API_APP_URL/ext/finders/details/{key}fallback. Writes successful CDN fetches back tolocalStorage.fetchAutocompleteSuggestions()/fetchPlaceDetails()— proxy calls toVITE_API_APP_URL/mapping/autocompleteand/mapping/places. See cdn-and-pointer.md for cache architecture detail.
filtering.js
Pure functions; no Svelte imports. The main export is applyFinderFilters(), which:
- Annotates every location with a
distancevalue (Haversine,miorkm) when user coordinates are available. - Applies text search (
matchesSimpleQueryormatchesNaturalLanguageQuerydepending onsearchMode). - Filters by selected tags, countries, and radius.
- Filters by hours-open status (
_hourscustom field viacustomFields.js). - Filters by minimum rating.
- Applies advanced refinement rules (legacy
parseRefinementValueDSL or structuredconditions[]format). - Calls
sortLocations()to produce the final ordered array.
Also exports deriveTagOptions() and deriveCountryOptions() for populating the filter panel.
localization.js
Static message tables for en and es. Exports:
translate(runtime, key, params)— resolves a message key, applies{token}substitution, and respects per-finderoverrides.translateCount(runtime, baseKey, count)— selects.onevs.othersuffix.formatDistance(runtime, value, unit)— locale-formatted number + unit string.formatLocaleTime(runtime, minutes)— minutes-since-midnight → locale time string viaIntl.DateTimeFormat.buildLocalizationRuntime(localization)— creates the{ locale, overrides }shape expected by all translation helpers. Called byinitializeFinderStores().
Adding a new locale requires an entry in LOCALIZATION_MESSAGES and a case in normalizeLocale().
mapSettings.js
Provider abstraction for MapLibre. Exports:
resolveMapSettings(mapDesign)— normalisesprovider(defaulttomtom),styleVariant(standard|dark|satellite|hybrid), andtileSet. Handles legacy tile-set string aliases.buildMapStyle({ provider, styleVariant, apiKey })— returns a MapLibre style JSON object for TomTom, Mapbox, HERE, or OSM tile endpoints.getResolvedMapApiKey(...)— applies customer-supplied key, falls back to env-var keys (VITE_API_TOMTOM_KEY, etc.), and returns empty string for OSM (no key required).providerNeedsApiKey(provider, tileSet)— returnsfalsefor OSM and for thetomtom-finder-betagifted tile set;trueotherwise. Used inApp.svelte’shasValidMapKey()guard.
See map-providers.md for the customer-facing map-provider comparison.
customFields.js
The most complex lib file. Handles:
- Custom field rendering —
getVisibleCustomFields(payload)readsdesign.listings.customFieldsConfig(JSON) and resolves display order, labels, and placement zones (aboveTitle,belowAddress, etc.).getLocationCustomFieldRowsByPlacement()groups rows for a single location into placement buckets. - Field Composer —
parseFieldComposerConfig(payload)readsdesign.listings.fieldComposerConfig(JSON).getComposerBuiltinZones()+getComposerZoneFields()map fields to six named zones (headerLeft,headerRight,bodyLeft,bodyRight,footerLeft,footerRight). - Hours —
parseHoursField(rawValue)JSON-parses the_hourscustom field.parseTimeToMinutes(value)converts"HH:MM"strings.getLocationHours(location, localization, now)returns a{ status, badge, detail, label }object used by list cards, the mobile peek card, andMobileDetail. - Address formatting —
formatCityStatePostal(address). - URL formatting —
displayUrl(url)strips thehttps?://(www.)?prefix.
searchMode.js
Two search modes coexist. default mode calls normalizeSearchText() and does a substring match on a concatenated search document. natural_language mode calls parseNaturalLanguageQuery(), which:
- Detects “open now”, “closed now”, “open on {day}”, “open after {time}”, “open before {time}” patterns and populates a
hoursConstraintsobject. - Detects explicit geo intent (
in {place}) and ZIP codes, building ageoIntentobject. - Collects tag and custom-field
fieldConstraintsby matching normalised option values. - Collects matching
advancedRefinementsby name. - Returns remaining tokens as
textTerms(stop-words filtered).
filtering.js passes the parsed result to per-location matchers rather than doing raw string comparison.
analytics.js
Exports a singleton Analytics instance. capture(event, properties) enqueues an event object. Events are flushed in batches of up to 10, or every 5 seconds, via a setInterval. On page unload, any remaining queue is sent with navigator.sendBeacon. Preview mode is detected via window.__LF_PREVIEW_MODE and suppresses all events.
embed.js
Single export: getFinderKey(). Resolution order:
document.querySelector('[finder-key]').getAttribute('finder-key')document.getElementById('finder-snippet')src query param?key=- Any
<script>whosesrccontains/ext/2/, parsed for?key=
Returns null if none of the above match. Called once in main.js before init().
directions.js
Single export: getDirectionsUrl(location, provider). Encodes a destination string from the location’s address fields and returns a provider-specific URL. Supported providers: google (default), bing, duckduckgo, mapquest.
Build output
vite build emits a single file into ext/2/ (the parent directory). cssInjectedByJsPlugin inlines all styles into the JS bundle; no separate .css file is written.
| File | Content | Cache-Control (after deploy) |
|---|---|---|
snippet.js | JS bundle with inlined CSS | public, max-age=300, must-revalidate |
The deploy:02 npm script chains vite build and node scripts/deploy-r2.mjs. After upload the 5-minute max-age means all customer pages pick up the new build within 5 minutes without any HTML change.
Key dependencies
| Package | Version (approx.) | Role |
|---|---|---|
svelte | 5.x | Component framework |
maplibre-gl | 5.x | Map rendering |
vite | 8.x | Build tool |
@sveltejs/vite-plugin-svelte | 7.x | Svelte Vite integration |
vite-plugin-css-injected-by-js | 3.x | Inline CSS into JS bundle |
@aws-sdk/client-s3 | 3.x | R2 upload (dev dependency) |
There is no TypeScript. All source files are .js or .svelte. No test runner is configured.