npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@v-office/website-headless

v1.1.0

Published

Framework-free behaviour layer for v-office websites, never UI: stores, pure rules and SDK runners for the search bar, results, map, showcase, quote, checkout and contact form, the rental and card models, and progressive-enhancement engines for server-ren

Readme

@v-office/website-headless

The behaviour layer of v-office websites: stores, pure rules, SDK runners, models, constants and the part vocabularies that something other than styling depends on. It never renders. A site uses it through @v-office/website-components (React surfaces over these stores) or builds its own UI over the same stores and is held to the same rules: the quote, checkout and contact flows, the insurance document gate and the checkout's structural requirements live here, not in a component.

@v-office/sdk-core           domain contracts + PMS adapters
@v-office/website-sdk        website facade (static + live namespaces)
@v-office/website-headless   ←  THIS: stores, rules, SDK runners, models, enhancement engines
@v-office/website-components imported UI over these stores (a site may build its own instead)
userland                     pages, brand components, tokens, copy

What it is not. No React, no markup, no copy (stores take messages and return kinds and numbers), no window or document in the . entry, no SDK built on its own initiative, and no site-specific default: a difference between sites is a documented option whose default is right for a site that never thought about it. Facts about the business (routes, the address decision, the favourites namespace, the backend) have no default at all.

Contents

Install and entry points

pnpm add --save-exact @v-office/website-headless @v-office/website-sdk
  • The only peer is @v-office/website-sdk >=2.25.0 <3. There is no runtime dependency. Sites pin exact versions; updates arrive through fleet rollouts.
  • maplibre-gl is not a dependency: the map engines take a loader (() => import("maplibre-gl")) from the caller.

| import | runs | contents | | ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | @v-office/website-headless | Node (build), browser | every module below; nothing touches window/document at import, nothing is registered on globalThis until a page-shared instance is claimed | | @v-office/website-headless/enhance | browser only | enhance(root, options) and the binders it composes over server-rendered markup, plus the MapLibre engines |

ENHANCE_PARTS and GATE_MARKERS are exported from both entries, so a build can render the markup contract without importing browser code.

The documented exceptions to "no DOM in ." take what they need as an argument or only touch the browser when called: assertCheckoutStructure(root) takes a DOM root; browserStorage(), the favourites store and the payment-return snapshot use Web Storage and fail soft without it; loadRentalIndex and loadRentalFacts call fetch; resultsQueryFlagScript() returns script source for the page's <head>.

Wiring a site

The SDK setup

Every SDK-using surface takes one plain-data SdkSetup ({ config, options? }). A site writes it once in src/data/sdk.ts:

import { sdkConfigFromEnv, type SdkSetup } from "@v-office/website-headless";

export const SDK: SdkSetup = {
  // Each name spelled out: Vite inlines only the `import.meta.env.NAME` it sees, so
  // `import.meta.env` passed whole can arrive without them in a build.
  config: sdkConfigFromEnv({
    CMS_BACKEND: import.meta.env.CMS_BACKEND,
    CMS_STAGE: import.meta.env.CMS_STAGE,
    HUB_API_KEY: import.meta.env.HUB_API_KEY,
    HUB_GRAPHQL_URL: import.meta.env.HUB_GRAPHQL_URL,
    HUB_V0_API_BASE_URL: import.meta.env.HUB_V0_API_BASE_URL,
    HUB_V1_API_BASE_URL: import.meta.env.HUB_V1_API_BASE_URL,
    IMAGE_PROXY_BASE_URL: import.meta.env.IMAGE_PROXY_BASE_URL,
    VOFFICE_LOCAL_DEV_ACCESS_TOKEN: import.meta.env.VOFFICE_LOCAL_DEV_ACCESS_TOKEN,
    VOFFICE_API_ENDPOINT: import.meta.env.VOFFICE_API_ENDPOINT,
    VOFFICE_SEARCH_ENDPOINT: import.meta.env.VOFFICE_SEARCH_ENDPOINT,
    VOFFICE_IMAGE_BASE_URL: import.meta.env.VOFFICE_IMAGE_BASE_URL,
  }),
  // Only the options the site uses: the setup is serialized into every island.
  options: { translationOverrides: {} },
};
  • sdkConfigFromEnv(env): The fleet's env names → SdkConfig. env is an EnvRecord; it never reads process.env; pass a literal that names each variable (above), or process.env in a Node script. Only non-empty strings count (Vite's boolean flags are ignored). Throws one SdkEnvError naming every missing or conflicting variable at once.
  • SDK_ENV: The env names (a platform contract: the AI Studio builder reads HUB_API_KEY; renaming one is a major).
  • SdkEnvError: missing: string[], conflicting: string[]; the message names variables, never a value.
  • sdkConfigFor({ backend, stage, token }): SdkConfigForInput → a full config with the platform's public endpoints. Spread extras on top: { ...sdkConfigFor(…), facilityObjectGroupRelationAttributeId: 123 }.
  • VOFFICE_ENDPOINTS, HUB_ENDPOINTS: VofficeEndpointTable / HubEndpointTable: the public https endpoints (VofficeEndpoints, HubEndpoints) per Stage ("development", "production"; STAGES lists both).
  • SdkConfig: The SDK's own WebsiteSDKConfig, with backend explicit on v10. A site with an unusual setup (a dev proxy) writes the literal.
  • SdkOptions: The SDK options as data (translationOverrides, customAttributes, rentalHighlightPrioritization, …), without searchAllRentalsAtOnce (that is SdkMode).
  • SdkSetup: { config: SdkConfig; options?: SdkOptions }.
  • Backend, Stage, Locale: "v9" | "v10"; "development" | "production"; the SDK's "de-DE" | "en-US" (there is no third locale).

sdkConfigFromEnv rules, in order:

  1. CMS_BACKEND must be v9 or v10. There is no silent default.
  2. The backend's token is required: HUB_API_KEY (v9, the public be-on key) or VOFFICE_LOCAL_DEV_ACCESS_TOKEN (v10).
  3. With CMS_STAGE (development | production) set, the platform endpoints of that stage are used and the URL variables must be absent (two sources for one value is an error, not a merge).
  4. Otherwise every URL variable of the backend is required: v9 HUB_GRAPHQL_URL, HUB_V0_API_BASE_URL, HUB_V1_API_BASE_URL, IMAGE_PROXY_BASE_URL; v10 VOFFICE_API_ENDPOINT, VOFFICE_SEARCH_ENDPOINT, VOFFICE_IMAGE_BASE_URL.

Which SDK instance

The package constructs an SDK only when asked. Every runner and store takes the SDK as its first argument, as an SdkSource: an instance, or a getter (() => WebsiteSdk | Promise<WebsiteSdk>). Every store calls a getter once, on its first request, and again only after a failed resolve, so a getter that constructs (a lazy shareSdk claim) constructs once per store.

  • createSdk(setup, mode?): Build time and scripts. The caller owns the instance: every build-time loader takes the same one (the API allows 1 request per second per instance), and the caller disposes it (await sdk.dispose()) after the last loader, in a finally, so a failed loader releases it too.
  • shareSdk(setup, mode?): Browser surfaces. Returns a SharedSdk ({ sdk, release() }): every claim with an equal setup and mode (compared as key-sorted JSON) gets the page's same instance, so one 1 req/s queue and one cache serve every surface. The last release() disposes it, unless a new claim arrives within the same task (a React StrictMode remount keeps it).
  • loadSharedSdk(setup, mode?): shareSdk with the SDK imported on first call (a dynamic import()): a page whose only SDK use is a submit (a contact form) ships no SDK code up front. Resolves to the instance shareSdk holds for the same setup.
  • resolveSdk(source): The instance behind an SdkSource (a promise). Call it once per call site and keep the result.
  • SdkMode: "default" (paged search and mapSearch) or "all-rentals-at-once" (searchAllRentalsAtOnce: at most 100 rentals priced in one call, no cursor, no mapSearch). A store or walker over such an instance takes the same value as sdkMode.
  • WebsiteSdk, SdkSource: A constructed SDK (either backend); what every store takes first.
  • describeSdkError(error): A failed SDK call and its whole cause chain as indented JSON (stacks skipped). console.error(error) does not print the cause, which is where the backend's reason is.
  • logSdkFailure(scope, error): console.error("[sdk] <scope> failed:", description), tagged by SDK operation ("search.search", "contact.submit"). Every runner in this package already logs through it.
import {
  createSdk,
  fetchLegalDocuments,
  loadRentalCatalogue,
  resolveFilters,
  shareSdk,
} from "@v-office/website-headless";

// Build time: one instance for every loader, disposed after the last one.
const sdk = createSdk(SDK);
try {
  const rentals = await loadRentalCatalogue(sdk, { locale: "de-DE" });
  const filters = resolveFilters(
    await sdk.static.filter.getFilters({ locale: "de-DE" }),
    FILTER_SPEC,
  );
  const legal = await fetchLegalDocuments(sdk, "de-DE");
  // … the plain results go to the pages; no page builds an SDK of its own
} finally {
  await sdk.dispose();
}

// Browser: claim the page's instance, release it when the surface goes.
const shared = shareSdk(SDK);
// … createSearchStore(shared.sdk, …), createQuoteFlow(shared.sdk, …)
shared.release();

Two surfaces whose setups differ in any option get two instances, each with its own rate limit. Pass the one SDK constant everywhere.

Site constants

A site writes each decision once and passes the constant to every surface that needs it, so two surfaces cannot disagree.

| constant | type | passed to | | ----------------- | ----------------------------------------------------------- | -------------------------------------------------------------- | | SDK | SdkSetup | every SDK-using surface | | SEARCH_CONTRACT | SearchContract (backend, nightRangeSearch, aliases) | search form, search store, offer chips | | GUESTS | GuestOptions (limits, child age band, default adults) | search form, quote flow, checkout flow | | DAY_PRICES | DayPrices | the booking calendars (dayPriceLabel) | | POLICY_CHOICE | PolicyChoiceMode | quote flow and checkout flow (policyChoice) | | BOOKABLE | BookableFrom | the search form's dates.bookable and the quote's notice | | ADDRESS | RentalAddressMode | rentalAddressLine (the rental heading and map) | | FAVORITES_KEY | string from favoritesStorageKey(namespace) | enhance(), createFavorites/acquireFavorites, every heart |

@v-office/website-components adds CARD (RentalCardOptions, which extends CardModelOptions) and VOICE (Voice).

Build time and browser

Everything in . runs in both places, but some calls belong to one of them:

  • Build time (they spend rate-limited requests or need full ICU): loadRentalCatalogue, fetchRentalFlags, resolveSearchGroups, measureFilters, findOfferWindows, findCampaignStart, fetchLegalDocuments, countryOptions, rentalIndexEntries, cardSource, rentalFactsMap, buildShowcaseGroups, resolveFilters. Their results are plain JSON and are passed to islands as props.
  • Browser: the stores (createSearchForm, createSearchStore, createQuoteFlow, createCheckoutFlow, createContactForm, createFavorites), loadRentalIndex, loadRentalFacts, readPaymentReturn, and all of ./enhance.

Stores

Every flow is a store: a UI reads getState(), subscribes, and calls actions. The state object is a new reference on every change and the same reference otherwise, so it works as a React external store, a Vue shallowRef, a Svelte store or a plain listener.

| export | what it is | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Store<S> | getState(): S and subscribe(listener): Unsubscribe. A listener is not called for the state current at subscription. | | WritableStore<S> | Adds setState(next \| (prev) => next); the same reference is a no-op. S is never a function itself. | | createStore(initial) | The primitive every flow builds on (a custom UI may use it for its own state). | | Listener<S>, Unsubscribe | The listener and its removal (calling it twice is harmless). |

Conventions every store follows:

  • create<Thing>(sdk?, options) returns the store plus its actions and, where it holds timers or subscriptions, a dispose() that stops them and drops late answers. A store never disposes the SDK.
  • Options are plain data (an island receives them as props). Stateful factories take now?: () => Date for tests and read it when needed; pure rules take today?: IsoDay.
  • acquire<Thing>(key, create) gives every island on the page the same instance through one ref-counted registry on globalThis[Symbol.for("@v-office/website-headless/<namespace>")], so two bundles of this package (an island chunk and a layout script) still meet.

@v-office/website-components ships the React bindings (useSearchForm, useSearchStore, useQuoteFlow, useCheckoutFlow, useGuestForm, useContactForm, useFavorites, useSdk, useLazySdk, useEnhance). Any other framework needs only this:

import { useSyncExternalStore } from "react";
import type { Store } from "@v-office/website-headless";

export function useStore<S>(store: Store<S>): S {
  return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}

A plain <script>:

import { createSearchForm } from "@v-office/website-headless";

const form = createSearchForm({ ...SEARCH_CONTRACT, guests: GUESTS });
const stop = form.subscribe((state) => {
  document.body.toggleAttribute("data-search-ready", state.resolved);
});
form.load(location.search);
// later: stop(); form.dispose();

Conventions and shared types

The rules

  • Copy never enters headless. Stores take messages objects (CheckoutMessages, ContactFormMessages, ValidationMessages) and return kinds and numbers; the components' copy tables satisfy those message types structurally. PMS strings (prices, reasons, labels) pass through verbatim.
  • Dates are strings. IsoDay (YYYY-MM-DD, a local calendar day) and IsoMonth (YYYY-MM) at every public boundary; Date only for injected clocks.
  • Real data only. Absent data is undefined, null or [], never 0 or "": no fallback town, no guessed count, no invented price. Money is the SDK's formatted string; no amount is recomputed, and an offer's amount is carried nowhere.
  • The SDK comes in first (fetchQuote(sdk, request)), and is constructed only by createSdk, shareSdk and loadSharedSdk.
  • book(), pay() and the contact submit() are user-initiated only. They create a real reservation, start a real payment and post a real request into the PMS. No test, job or smoke step calls them against a real SDK.
  • No site names. The source names no customer, domain or town; a ruling is an option.

One name per concept

| name | type (where) | meaning | | -------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | contract | SearchContract (headless url) | how the search runs on this backend; one value for the search form, the search store and the offer chips | | guests | GuestOptions (headless guests) | stepper limits, child-age band, default adults. limits.pets: 0 = no dogs, 1 = a yes/no toggle, more = a stepper; every dog control follows it | | whenEmpty | WhenEmpty (headless search) | what a filter or option that matches nothing does: "drop", "disable", "show" | | glyphs | Glyph vocabulary (headless shared); GlyphMode (components) | a closed list of glyph names the models emit (GLYPHS); components map names to icons and offer "none" \| "default" \| "mapped" | | voice | Voice (components) | how German copy addresses the reader: "neutral" (default), "du", "sie"; English ignores it. Headless carries no copy | | headingLevel | HeadingLevel \| "none" (components) | the level of a component's own heading; a custom UI decides its own | | loadOn | "near" \| "click" \| "consent" (components) | when third-party map tiles may load (default "click"). The headless map surface loads only when told: start: "demand" waits for build(), "near" loads once the container nears the viewport |

Events, storage keys and URL keys

These are contracts: renaming one is a major. Headless hands events to onEvent callbacks; the components dispatch them on window, and a custom UI dispatches the same shapes. No detail carries personal data.

| event (constant) | from | detail | | --------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | voffice:checkout (CHECKOUT_EVENT) | createCheckoutFlow onEvent | CheckoutEventDetail: { type: "rate-fault", rentalId, code } once per stay · { type: "booked", rentalId, bookingNumber, kind, payable } · { type: "payment-started", bookingNumber, method } | | voffice:quote (QUOTE_EVENT) | createQuoteFlow onEvent | QuoteEventDetail: { type: "rate-fault", rentalId, stayKey, code } once per stay | | voffice:contact (CONTACT_EVENT) | after submit() resolved { ok: true } | ContactEventDetail: { type: "sent", subject } | | merkliste:changed (FAVORITES_EVENT) | bindFavorites (dispatched by headless) | FavoritesChange: { id, saved }, once per changed rental, also for another tab's change |

| storage | key | | ------------------------ | ------------------------------------------------------------------------------------------------------ | | localStorage, favourites | `${namespace}-merkliste` (favoritesStorageKey) | | sessionStorage, checkout | voffice:checkout-context (CHECKOUT_CONTEXT_KEY), voffice:booking-return (BOOKING_SNAPSHOT_KEY) |

The URL vocabulary is SEARCH_PARAMS; the sort values are SORT_URL_VALUE.

Shared types

| type | defined in | meaning | | --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------- | | Locale, Backend, Stage | sdk | "de-DE" \| "en-US"; "v9" \| "v10"; "development" \| "production" | | SdkConfig, SdkOptions, SdkSetup, SdkMode, SdkSource, WebsiteSdk | sdk | see Wiring a site | | IsoDay, IsoMonth | shared | calendar strings | | Party, GuestLimits, ChildAgeRange, GuestCaps, GuestOptions | guests | one party model for the search form, the quote and the checkout | | PriceCents | shared | how a formatted price string is trimmed (cards, pins, calendar cells) | | Glyph | shared | the closed glyph vocabulary | | RichBlock, RichInline | shared | sanitized PMS text as data | | ContactLink, BookableFrom | shared | { label, href }; "not bookable online before from" | | StorageLike | shared | getItem, setItem, removeItem | | WhenEmpty | search | "drop" \| "disable" \| "show" | | RentalCounts, RentalDistance, DistanceKind | rental | counts and distances (absent = undefined) | | CardSource, RentalFacts, RentalFactsMap, StayPrice | cards | the one card input; build-time facts per rental; a priced stay | | Stay | checkout | { start, end, party }, a complete stay |

Search form

Module search-bar. The search bar's state and rules: a date picker and a guest picker composed, the region choice, and the pairs a native GET form submits. It never calls the SDK: the bar navigates (the form works before hydration), and the results page refines in place.

createSearchForm(options)

options is a SearchFormOptions; returns a SearchForm. Throws (at build) when fields lists "pets" while dogs are not offered (guests.limits.pets is 0).

| option | default | meaning | | ---------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | backend, nightRangeSearch, aliases | (the SearchContract) | backend is required | | fields | ["region", "dates", "guests"] | SearchField[] in render order; "pets" renders the dog control as its own field. A field that is absent leaves its URL keys carried, not owned | | regions | [] | the selectable region values (labels are UI); "region" renders only with at least one | | regionSelect | "multi" | "multi" (OR) or "single" (one region or all) | | dates | {} | the date picker's options (createDateSearch, without backend and nightRangeSearch) | | guests | {} | GuestSearchOptions (GuestOptions plus showDefault) | | childAges | "required" | "required": a child without an age stops the submit; "optional" submits the ages that exist | | preset | none | a landing page's starting search (a query string): its owned keys seed the pickers only when the URL owns none, its other pairs ride along | | now | () => new Date() | the clock |

SearchFormState: fields (the ones that render), dates (DateSearchState), guests (GuestSearchState), regions, carried (pairs the bar does not own: sort, filters, property, campaign, a site's own keys), resolved (false until load() ran: show skeletons, disable the submit), issue (SearchFormIssue: { kind: "agesMissing" } after a refused submit, cleared by the next change).

| action | what it does | | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | dates, guests | the composed DateSearch and GuestSearch; their actions drive the panels | | load(search) | read the URL (and the preset) into the pickers | | toggleRegion(value), selectRegion(value \| null), clearRegions() | the region choice; values outside regions are ignored | | pairs() | writeSearch(pickers) + carried: the GET form's hidden inputs | | submit(current?) | { ok: true, pairs } or { ok: false, issue }. Pass location.search: the carried pairs are re-read from it, so filters and sort changed in place ride along | | reset() | pickers and regions back to nothing chosen; carried pairs stay | | update(options), dispose() | change options (not the backend); stop listening |

import { createSearchForm } from "@v-office/website-headless";

const form = createSearchForm({ ...SEARCH_CONTRACT, regions: ["Nord", "Süd"], guests: GUESTS });
form.load(location.search);

const element = document.querySelector("form[data-search]");
element?.addEventListener("submit", (event) => {
  event.preventDefault();
  const result = form.submit(location.search);
  if (!result.ok) return; // state.issue: open the guest panel and ask for the ages
  const query = new URLSearchParams(result.pairs.map(([key, value]) => [key, value]));
  location.assign(`/suchen?${query.toString()}`);
});

createDateSearch(options)

The period a guest is picking (DateSearchOptions → DateSearch). The calendar always shows the guest's own stay; only what a submit sends widens (± steps). Which controls exist comes from the SDK's capability helpers for the backend.

| option | default | meaning | | ------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | backend | required | decides the flexible tab and how a ± step is spelled | | nightRangeSearch | true | whether the backend prices a minNights/maxNights window | | flexSteps | [0, 1, 2, 3, 7, 14] | ± chips in days; [] or [0] = no chip row. On v10 always, on v9 only with nightRangeSearch | | flexibleStays | ["weekend", "week", "month"] | [] removes the flexible tab (v10 only) | | nightRangeRow | false | render the Min./Max. nights row (beside exact dates and a window of at least 2 nights) | | minStayNights | 1 | closer departure days are disabled | | bookable | none | BookableFrom: from floors the calendar, the month strip and ± windows; deferred is true while today is before noticeUntil ?? from | | now | () => new Date() | read on every action, so a bar left open past midnight moves its floor |

DateSearchState: mode (DateMode: "dates" | "flexible"), from, to, flexDays, nightRange, stay (FlexibleStay | null), months, windowNights, controls (DateControls: flexibleTab, flexSteps, nightRangeRow: what to render now), floor, deferred, monthOptions (twelve months from the floor's month) and period (SearchPeriod | null, what a submit sends).

Actions: pick(day) (first click sets from, second to, a click on a finished stay starts a new one), isDisabled(day), setMode, setFlexDays, setMinNights, setMaxNights (moving one bound past the other pushes it along), setStay, toggleMonth, load(period) (a period the controls cannot show loads as empty), reset(), update(options).

Period labels

  • describePeriod(period, locale, today?) → PeriodLabel (dates, flexDays, nights, stay, months): the summary parts of a SearchPeriod, a partial pick ({ kind: "partial", from, to }) or null. No copy words: the UI adds "± {days} Tage" and the like.
  • formatStayRange(from, to, locale, today?) → "4. – 11. Sept.", "30. Dez. 2026 – 3. Jan. 2027": the month once, the year only when it is not the current one, months through Intl (the result cards use the same).

Rental jump

A rental-name search box over the site's static index (the live search has no free-text key, so this is catalogue matching, never a filter).

  • rentalIndexEntries(rentals, href, { code? }) (build) → RentalIndexEntry[] (id, name, href, code?, place? = the town). href is the site's own detail URL.
  • loadRentalIndex(url) (browser) fetches the JSON once per URL per page; a failure is not cached. Rows without a string id, name and href are dropped.
  • matchRentals(entries, query, options?) (RentalMatchOptions: limit 8, minChars 2) → RentalMatch (hits, total). Every query word must occur in the name, place or code, in both umlaut spellings ("duene" and "dune" find "Düne"); ranked code exact, name exact, name prefix, word start, contains.
  • foldSearchText(raw): lowercase, ä→ae ö→oe ü→ue ß→ss, other diacritics stripped.

URL parameters

Module url. The search funnel's URL vocabulary and its codec: written canonically, read leniently (the SDK's aliases, the legacy pets flag and a site's own aliases are read, never written).

SEARCH_PARAMS

Every key of the funnel's wire format (SearchParamKeys). Links live in bookmarks, indexes and mails: renaming one is a major.

| key | meaning | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | start, end | arrival and departure (YYYY-MM-DD); on a ± or night-range search the window's bounds | | dates, nights | v10 ± window DD-MM-YYYY,DD-MM-YYYY and its stay length | | month, weekend | v10 flexible months (MM-YYYY, repeatable) and the weekend flag | | minNights, maxNights | the night range inside a start/end window | | flex | the ± step the guest chose; UI only, never sent to the SDK | | adults, children, childrenAges, babies | the party; childrenAges is one comma list aligned to children | | petsCount, pets | dogs: search pages write petsCount, rental and checkout pages pets (an integer); old search links carry pets as a flag | | region, property | scopes (region repeatable, OR-combined, applied in the browser) | | sort, campaign | the results ordering; an offer campaign scope | | policy, kind, return, offer | the chosen rate (rental → checkout), a booking kind, the payment-return marker, an offer label carried to the rental page |

The codec

  • SearchContract: { backend, nightRangeSearch?, aliases? }. nightRangeSearch (default true): set false on a v9 hub that answers the night pair unpriced; no control writes it, nothing reads or sends it
  • SearchParamAliases: legacy keys read as a canonical one (start, end, region, adults, children, childrenAges, babies, petsCount). from/till/nights_min/nights_max are always read
  • readSearch(search, contract, today?): query string → SearchQuery (period, party, regions, carried). Tolerant: anything unreadable is "not stated". A v9 link with v10 flexible keys reads as no period; flexible months already past are dropped
  • writeSearch(query, contract, options?): SearchQuery → QueryPair[] (WriteSearchOptions: floor, defaultAdults) spelled for the backend, in the order period, party, regions, carried. Dogs as petsCount; a written period always travels with adults (defaultAdults, default 2: a dated v10 search without occupancy answers nothing). floor (default today) clamps the lower end of a ± window
  • searchHref(action, query, contract, options?): action (may carry a #fragment) plus writeSearch
  • readParty(params, aliases?): the party from any funnel URL: petsCount wins over pets, pets=true reads as 1, ages aligned to children; null without any party key
  • partyPairs(party, petsKey): the party as pairs (petsKey "petsCount" or "pets"); zero counts omitted

SearchPeriod is a period by meaning; writeSearch spells it per backend:

| kind | fields | URL | | ------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | | exact | start, end | start + end | | plusMinus | start, end (the guest's stay), days | v10: dates + nights + flex; v9: the widened start/end + minNights = maxNights + flex (needs nightRangeSearch) | | nightRange | start, end, minNights, maxNights | start + end + the pair (the window alone where the pair is not priced) | | flexible | months (IsoMonth[]), stay ("weekend" \| "week" \| "month") | v10 only: repeated month + weekend=1, nights=7, or the days of the earliest month |

import { readSearch, searchHref, type SearchContract } from "@v-office/website-headless";

const contract: SearchContract = {
  backend: "v9",
  aliases: { start: ["checkin"], end: ["checkout"] },
};
const query = readSearch(
  "?checkin=2027-05-10&checkout=2027-05-17&adults=2&sort=preis-auf",
  contract,
);
// query.period: { kind: "exact", start: "2027-05-10", end: "2027-05-17" }; query.carried: [["sort", "preis-auf"]]
const href = searchHref("/suchen", { ...query, regions: ["Nord"] }, contract);

Guests

Module guests. One party model and its rules, shared by the search form, the quote and the checkout. Babies and dogs never count against a person cap. A type whose limit is 0 is "not offered": it renders no control and keeps whatever value it arrived with ("hidden means not offered, never not honoured").

  • Party: { adults, children, childrenAges: (number | null)[], babies, pets }. adults 0 = none chosen yet; an age is null until chosen
  • GuestKind: "adults" | "children" | "babies" | "pets"
  • GuestLimits, DEFAULT_GUEST_LIMITS: stepper maxima; defaults adults 16, children 6, babies 5, pets 4. 0 = not offered; pets 1 = a yes/no choice
  • ChildAgeRange, DEFAULT_CHILD_AGE: the selectable child ages, default 2–17 (younger is a baby)
  • GuestOptions: { limits?: Partial<GuestLimits>, childAge?, defaultAdults? } (default adults 2): the GUESTS constant
  • GuestCaps: a rental's caps: maxPersons, maxAdults (availability) beat maxGuests (catalogue, used where availability states none: v9 before SDK 2.28) beat the limits; a stated cap above the limit wins (a 20-person house). childrenAllowed: false (v9 availability) offers no children or babies; maxPets (v9 availability) caps dogs, 0 offers none; petsAllowed: false: no dog can be added. A count that arrived by link and one of these rules out can be lowered and is still sent
  • guestBounds(party, { limits?, caps? }): GuestBounds: per kind a GuestBound (offered, min, max) (adults min 1). Adults win a shared person cap: children get what the adults leave. Compute the bounds from the party before an edit
  • clampParty(party, bounds): offered types clamped; a not-offered type keeps its value; adults 0 stays 0; ages follow the children count
  • agesComplete(party): every child has an age (the age prices the stay)
  • occupancyOf(party): the SDK's QuoteOccupancy; unchosen ages left out
  • stayKeyOf({ start, end, party }): dates and party as one comparable string (supersession in the quote and checkout)

createGuestSearch(options?)

The party a guest is building, as a store (the search form composes one; a custom guest UI may use it alone). Options: GuestOptions plus showDefault (default false: start at defaultAdults when the URL states no party) and caps.

State: party, shownAdults (chosen, or defaultAdults: what the stepper and the summary show and a submit sends), hasSelection, agesComplete (always true while children are not offered), bounds, ageOptions, occupancy (what a submit sends). Actions: set(kind, count), setAge(index, age | null), load(party | null) (ages outside the band become unchosen, so the guest is asked again), reset(), update(options).

Search store and live search

Module search. The page's one live search: walk, scopes, browser filters, viewport, sort, campaign and outcome. Grid, map, count, chips and filter islands read the same store, because the SDK queues searches at 1 request per second.

createSearchStore(source, options)

options is a SearchStoreOptions; returns a SearchStore (Store<SearchState> plus SearchActions and dispose()). It starts searching when created. It reads and writes the URL only through the injected history port and never creates or disposes the SDK.

| option | default | meaning | | ---------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- | | backend, nightRangeSearch, aliases | (the SearchContract) | backend is required | | rentalHref | required | a RouteTemplate with {slug} or {id} for rentals the facts give no href | | locale | required | the language of the PMS strings and the stay labels | | history | required | HistoryPort: read(), write(search, "push" \| "replace"), subscribe(onChange) (components: browserHistory()) | | seed | required | the random-sort seed, drawn once per page view | | facts | none | RentalFactsMap: build-time facts that fill what the live projection lacks and answer the browser filters | | filters | none | ResolvedFilters; without it only period and occupancy reach the SDK | | scope | none | SearchScope (below) | | favorites | none | a FavoritesPort (ids() + subscribe); required for scope.favorites. Favorites satisfies it | | campaignOf | campaignGroup | offer label → campaign group | | preset | none | applied (replace) when the URL carries no search of its own | | requireFacts | false | drop rentals the build has no facts for (no detail page was built) | | reveal | "complete" | "first-page": the first page shows at once; the browser sort, the outcome and the count wait for the rest | | sdkMode | "default" | the mode the SDK was created in | | leaveMs | FAVORITE_LEAVE_MS (280) | the favourites page's removal window; 0 = immediate (pass 0 under reduced motion) | | now | () => new Date() | the clock (also sanitizeSearch's today) |

SearchScope narrows the one result set, applied in this order: ids (a fixed set from the build: a theme, a property, a landing page) → favorites: true (the Merkliste; on v10 restricted server-side) → offers (discounted results only; labels narrows to exact PMS labels, campaign to a whole campaign, window: OffersWindowSpec makes the page search its offers window when the URL carries no period). Then the browser filters and requireFacts, the URL's ?campaign=, the map viewport and the sort.

When a request runs. On create the store sanitizes the URL (a past or unreadable stay is dropped with replace), applies preset where the URL has no search, and the offers window where it has no period. A new live search runs only when the SDK-facing part changes (period, occupancy, SDK filter keys, an id restriction, a server sort while the set is incomplete). Facts filters, scopes, the campaign and a browser sort re-derive from the loaded set without a request. A walk reads every page (sequentially, capped at 100 pages); a v9 ± search also asks the guest's own dates exactly and prefers those answers.

SearchState:

  • status: SearchStatus: "loading" until the first answer, "ready", or "error" only when nothing loaded
  • complete: every page is in (false while a first-page reveal loads, and after a later page failed: what loaded stays)
  • search, shape: the current query (sanitized, no ?) and its QueryShape
  • scope: SearchScopeKind: "search", "favorites", "offers", "ids"
  • matched: after scopes, filters and the campaign, before the viewport: what the map plots
  • items, count: after the viewport and the sort: what the list renders, and how many (leaving favourites excluded from the count)
  • allAlternatives: every result is an alternative on an exact search (show the notice); waits for complete
  • sort, sortOptions: the SortKey in the URL; every key with whether it can be chosen now
  • filters, activeFilters, filterCounts: the resolved filters; the applied ones (chips, hidden included, led by a ?property= or ?region= scope the curation has no facet for: see Filters); live counts over the listed set (key for a flag, key=value for a facet value)
  • offerLabels, campaign: distinct PMS offer labels of the scoped results (before ?campaign= narrows); the campaign in scope
  • outcome: SearchOutcome (error or empty with ways out), or null while results show or the walk has not settled
  • unusedFilterKeys: keys the backend reported as having had no effect
  • highlighted, reloading, viewportActive, leaving: the hovered card; the map's 220 ms re-settle pulse; a viewport filter is active; favourites un-hearted a moment ago (still in items, animating out)

| action | what it does | | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | setSearch(search, mode = "push") | change the query | | setSort(key) | order in place (replace) | | applyFilters(selection), removeFilter(filter), clearFilters() | the filter panel, a chip, "remove all" (push; hidden filters and implicit scopes included in clearFilters) | | setViewport(ids \| null), setReloading(on) | the map's in-view ids (results without coordinates always stay) and its pulse (the map controller calls these) | | releaseRegionScope() | drop ?region= (push) so neighbouring results come back | | setCampaign(group \| null) | the campaign chip (replace); a ?campaign= the complete results cannot honour clears itself | | setHighlighted(id \| null), retry() | the hovered card; re-run after an error | | dispose() | stop listening to the URL and the Merkliste, drop a running walk (the SDK stays) |

import {
  acquireSearchStore,
  createSearchStore,
  loadRentalFacts,
  shareSdk,
  type HistoryPort,
} from "@v-office/website-headless";

const history: HistoryPort = {
  read: () => location.search,
  write: (search, mode) => {
    const url = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
    if (mode === "push") window.history.pushState(null, "", url);
    else window.history.replaceState(window.history.state, "", url);
  },
  subscribe: (onChange) => {
    window.addEventListener("popstate", onChange);
    return () => window.removeEventListener("popstate", onChange);
  },
};

const shared = shareSdk(SDK);
const facts = await loadRentalFacts("/rental-facts.json");
const { store, release } = acquireSearchStore("results", () =>
  createSearchStore(shared.sdk, {
    ...SEARCH_CONTRACT,
    locale: "de-DE",
    rentalHref: "/unterkuenfte/{slug}",
    history,
    facts,
    seed: Math.floor(Math.random() * 2 ** 31),
  }),
);
const stop = store.subscribe((state) => {
  if (state.status === "ready") console.log(`${state.count} results`);
});
// teardown: stop(); release(); shared.release();

Page-shared stores

  • acquireSearchStore(key, create) → SearchStoreClaim ({ store, release() }): the store under key, created by the first caller. Every island of the page (grid, map, count, chips) claims the same key; the last release disposes it.
  • connectSearchStore(key, onStore) → detach: follow a store that may appear later (a satellite island hydrating before the results); onStore(null) when it is released.

Result items

SearchResultItem is one rental in the result list (grid, map and count share it): id, name, location ("" when the feed states none), href (the site's page plus the handover query), hrefWithoutDates, images (CardImage[], WebP where served), counts, highlights, rentalType, price (StayPrice | null), stay (StayWindow | null: the priced stay when it is not simply the searched dates), alternative, periods (AlternativePeriod[]), propertyId, coords, rating (from the facts only).

  • runLiveSearch(sdk, search, options): one request with the caller's SDK → SearchPage (items, shape, cursor, hasNextPage, totalCount, unusedFilterKeys). LiveSearchOptions: the contract, rentalHref, locale, facts, filters, sort (default "recommended"), cursor, rentalIdsIn (v10). The store does not use it; it is for a one-off request
  • catalogueResultItem(rental, options): a catalogue rental as a result item for a server-rendered listing (no price, no stay, a link without booking context)
  • compareUndatedSearchOrder(a, b): the order an undated search returns, as far as a build can reproduce it (numeric id ascending on v9, code-unit order on v10), so the island's swap moves nothing
  • resultCardSource(item, facts?): the CardSource of one result: live fields from the item, catalogue fields from the facts
  • ResultLinkOptions: { rentalHref, facts?, locale }

Queries and links

  • toSdkQuery(search, options): URL query → SdkSearchQuery (query, sort?, withheld). An allowlist: the period keys this backend executes, occupancy, and the resolved filters' SDK keys; every other key stays in the URL. SdkQueryOptions: the contract, sort, sdkFilterKeys, retiredKeys
  • queryShape(search, contract): QueryShape: exactDates, priced (a period and at least one adult: totals arrive, price sort possible), dated, nights, window, ownStay (the v9 ± stay asked as a second question), hasSearch
  • handoverQuery(search, options?): ResultHandoverOptions (stay, offerLabels). The ?… a result link carries: the period (or the given stay), the party (dogs as pets), at most four offer= labels and only with a period
  • externalSearchHref(link, search, contract): a link to a partner's search from the current one (ExternalSearchLink: base, params (partner name → ExternalSearchSource), dateFormat "iso", "d.m.yyyy" or "dd.mm.yyyy"). Values without data are omitted. Throws on a base that is not an absolute URL
  • sanitizeSearch(search, today): SanitizedSearch (search, dropped): an unsearchable stay (past, reversed, unreadable, half a pair) dropped with its night range and flex, instead of an error page
  • resultsQueryFlagScript(): inline <head> script source that sets html[data-results-query] before first paint when the URL carries a real search, so a prerendered listing never flashes under a search it does not answer

Sort

| SortKey | URL value (SORT_URL_VALUE) | runs | | ------------- | ---------------------------- | --------------------------------------------------------------------------------------- | | recommended | empfohlen | the backend's order; no sort sent | | price-asc | preis-auf | priced queries only; sent to the SDK and re-applied in the browser over the merged list | | price-desc | preis-ab | as price-asc | | rating | bewertung | browser only, missing values last | | area-desc | groesse | browser only | | area-asc | groesse-auf | browser only | | guests | gaeste | browser only | | bedrooms | schlafzimmer | browser only | | random | zufall | browser only, seeded per page view |

  • DEFAULT_SORT_OPTIONS: ["recommended", "price-asc", "price-desc"], the short list for a site that does not choose its own. Any known value in the URL is honoured even when the control does not offer it.
  • sortFromSearch(search) → SortKey ("recommended" when absent or unknown).
  • toSdkSort(key, shape) → the SDK ordering, or undefined (price needs a priced query).
  • sortResults(items, key, seed): stable, missing values last, never as 0.
  • SortOptionState: { key, available, reason?: "needs-price" | "needs-data" }.

Filters

The package ships no curation: which keys, groups and order a portfolio needs is measured per site. Every applied filter stays visible and removable; "hidden" means not offered as a control, never not honoured.

?region= (the search bar's region field) and ?property= (an Anlage link) narrow the results even where the curation has no facet for them. The search store then lists them first in state.activeFilters as facet chips with scope: "region" | "property" (ImplicitScope), an empty label (the copy words them, "Region: {value}", "Anlage: {value}") and the URL value verbatim as value; removeFilter and clearFilters remove them, and an empty result names them as the "filters" cause. A curated (hidden) facet for the key replaces the implicit chip with its own label and option labels, which is how a property chip shows the property's name instead of its id.

A FilterSpec is { groups: FilterGroupSpec[], retired?: string[] }; a group is { id, title, entries, hidden? }; an entry (FilterEntry) is one of:

| kind | answered by | fields | | --------- | ----------------------------------------------------------------- | --------------------------------------------------------------- | | sdk | the backend (getFilters: a boolean, or an int minimum) | key, label? (overrides the SDK's), max? (stepper maximum) | | flag | the browser, from facts.flags[key] | key, label | | minimum | the browser, from facts.counts[field] or facts.numbers[field] | key, label, field, min? (1), max | | facet | the browser, OR within, from facts.facets[key] | key, label, options: FacetOption[], multiple? (true) | | pets | occupancy petsCount | the dogs pill, placed where the entry sits |

Every entry and group may be hidden. retired keys are still executed by the backend but match nothing here: they are stripped from every query, so an old link cannot empty the page invisibly.

  • resolveFilters(sdkFilters, spec, options?): the curation resolved against sdk.static.filter.getFilters (its output passes as it is) → ResolvedFilters (groups, defs, sdkKeys, retired), serializable. An SDK key the SDK no longer returns self-hides; option and string filters in an sdk entry are skipped; int bounds default to 1..8. ResolveFiltersOptions: counts, total (from measureFilters), whenEmpty (default "drop"), whenUniversal (default "drop": a filter matching everything does not filter)
  • measureFilters(sdk, keys, { locale, baseline? }): build time, opt-in: hits per boolean key against a baseline (default "adults=1"), one request per key, sequential → FilterMeasurement (total, counts). A count the backend does not state is left out
  • facetOptions(facts, key, options?): facet options from the build's facts with their counts (FacetOptionsOptions): order "label" (default), "count-desc" or an explicit list; labels; locale (collation, default "de-DE")
  • readSelection(search, filters), writeSelection(search, filters, selection): the URL round trip of a FilterSelection (booleans, minimums, facets, pets). Writing rewrites visible defs only, keeps hidden ones, writes dogs as petsCount and never lowers the URL's dog count
  • activeFilters(search, filters): the applied filters of the curation as ActiveFilter[] (chips; id is key or key=value; the copy words them). The store's state.activeFilters adds the implicit scopes (above)
  • withoutFilter(search, filter), withoutFilters(search, filters): the query without one chip, or without every filter (dates, party and scopes kept)
  • minimumSteps(def, value): a minimum stepper's { down, up }: the first step up lands on the floor, the step down from the floor returns to "any"
  • quickFilterDefs(filters, keys): the quick strip's defs in keys order (visible booleans only)
  • FilterDef, SdkFilterDefinition, FacetOption, WhenEmpty, ImplicitScope: the resolved def union (boolean, minimum, facet, pets), the minimal shape read off getFilters (every kind it returns), one facet value, the zero-match decision, "region" | "property"
import { resolveFilters, type FilterSpec } from "@v-office/website-headless";

const FILTER_SPEC: FilterSpec = {
  groups: [
    {
      id: "features",
      title: "Ausstattung",
      entries: [
        { kind: "sdk", key: "sauna" },
        { kind: "flag", key: "seaView", label: "Meerblick" },
        { kind: "pets" },
      ],
    },
    {
      id: "size",
      title: "Größe",
      entries: [
        { kind: "minimum", key: "bedrooms", label: "Schlafzimmer", field: "bedrooms", max: 6 },
      ],
    },
  ],
};
// Build time, over the loaders' one SDK (see "Which SDK instance").
const filters = resolveFilters(
  await sdk.static.filter.getFilters({ locale: "de-DE" }),
  FILTER_SPEC,
);

Outcome

describeOutcome(input: OutcomeInput) → SearchOutcome | null (null when there are results). The store computes it; a custom UI reads state.outcome.

  • { kind: "error" }, or { kind: "empty", scope, causes, searched, relaxations }.
  • scope: "search", "favorites-none-saved", "favorites-none-match", "offers" (nothing reduced in this period, normal much of the year), "ids".
  • causes (EmptyCause[], most actionable first): "filters", "pets", "dates", "party" (more than 2 guests). An empty Merkliste has none.
  • relaxations (Relaxation[]): one concrete query per cause, guaranteed to differ from the current one: "without-filters", "without-pets", "without-dates" (removes every period key), "fewer-guests" (2 adults).
  • searched (SearchedParts): guests, `d