@v-office/website-sdk 2.5.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.mjs +25 -4
  3. package/dist/{client-C5ojrNQA.mjs → client-BNbBfJtO.mjs} +468 -189
  4. package/dist/index.d.mts +5 -4
  5. package/dist/index.mjs +3 -3
  6. package/dist/instructions/CHANGELOG.md +3 -0
  7. package/dist/instructions/MIGRATION.md +3 -0
  8. package/dist/instructions/README.md +8 -2
  9. package/dist/instructions/booking.md +39 -3
  10. package/dist/instructions/creation.md +65 -64
  11. package/dist/instructions/custom-attributes.md +556 -0
  12. package/dist/instructions/filter.md +51 -23
  13. package/dist/instructions/rentals.md +26 -2
  14. package/dist/instructions/search.md +29 -3
  15. package/dist/instructions/versions/2.5.0/MIGRATION.md +5 -0
  16. package/dist/instructions/versions/2.6.0/CHANGELOG.md +6 -0
  17. package/dist/instructions/versions/2.6.0/MIGRATION.md +6 -0
  18. package/dist/instructions/versions/2.7.0/CHANGELOG.md +44 -0
  19. package/dist/instructions/versions/2.7.0/MIGRATION.md +79 -0
  20. package/dist/instructions/versions/2.8.0/CHANGELOG.md +63 -0
  21. package/dist/instructions/versions/2.8.0/MIGRATION.md +252 -0
  22. package/dist/{rentals-Lw1qUKIj.mjs → rentals-cCejb_2d.mjs} +21 -21
  23. package/dist/{search-9yzAqHCg.mjs → search-Dzzra6IW.mjs} +16 -133
  24. package/dist/{to-rental-highlights-CxWlq76f.mjs → to-rental-highlights-OvNe9ZQS.mjs} +8 -6
  25. package/dist/translations/v10/de-DE/core.json +2 -0
  26. package/dist/translations/v10/en-US/core.json +2 -0
  27. package/instructions/CHANGELOG.md +3 -0
  28. package/instructions/MIGRATION.md +3 -0
  29. package/instructions/README.md +8 -2
  30. package/instructions/booking.md +39 -3
  31. package/instructions/creation.md +65 -64
  32. package/instructions/custom-attributes.md +556 -0
  33. package/instructions/filter.md +51 -23
  34. package/instructions/rentals.md +26 -2
  35. package/instructions/search.md +29 -3
  36. package/instructions/versions/2.5.0/MIGRATION.md +5 -0
  37. package/instructions/versions/2.6.0/CHANGELOG.md +6 -0
  38. package/instructions/versions/2.6.0/MIGRATION.md +6 -0
  39. package/instructions/versions/2.7.0/CHANGELOG.md +44 -0
  40. package/instructions/versions/2.7.0/MIGRATION.md +79 -0
  41. package/instructions/versions/2.8.0/CHANGELOG.md +63 -0
  42. package/instructions/versions/2.8.0/MIGRATION.md +252 -0
  43. package/package.json +5 -3
@@ -0,0 +1,556 @@
1
+ # Custom Attributes
2
+
3
+ Custom attributes are workspace-specific rental metadata: a sauna flag, a region, an
4
+ occupancy score, a marketing text. This document covers how to configure them, how
5
+ they behave on each backend, and what changes when a site moves from `v9` to `v10`.
6
+
7
+ They serve three separate concerns, and each is configured independently:
8
+
9
+ - **Filtering**: a stable query key a search UI can advertise and a visitor can use.
10
+ - **Display**: a localized value rendered in rental attributes or highlights.
11
+ - **Backend identity**: the workspace-specific v10 definition ID or v9 rental-data
12
+ field the SDK must talk to.
13
+
14
+ ## Two Halves: Catalog and Policy
15
+
16
+ Configuration is a join of two things that have different owners.
17
+
18
+ | | Owner | Contains |
19
+ | ------------- | --------------------- | ------------------------------------------------------------------- |
20
+ | **Catalog** | the workspace backend | definition ID, backend type, option values |
21
+ | **Selection** | your site | which attributes to use, labels, filter exposure, display, category |
22
+
23
+ You never restate a catalog fact in your configuration. `defineCustomAttributes`
24
+ joins the two, validates the result, and returns a registry the SDK consumes.
25
+
26
+ ```ts
27
+ import { defineCustomAttributes, defineWebsiteSDKOptions } from "@v-office/website-sdk";
28
+
29
+ const customAttributes = defineCustomAttributes({
30
+ catalog,
31
+ select: {
32
+ sauna: { label: { "de-DE": "Sauna", "en-US": "Sauna" } },
33
+ region: {
34
+ label: { "de-DE": "Region", "en-US": "Region" },
35
+ filter: true,
36
+ optionLabels: {
37
+ north: { "de-DE": "Norden", "en-US": "North" },
38
+ south: { "de-DE": "Süden", "en-US": "South" },
39
+ },
40
+ },
41
+ },
42
+ });
43
+
44
+ const options = defineWebsiteSDKOptions({ customAttributes });
45
+ ```
46
+
47
+ Invalid configuration throws while the SDK is being configured, which for a static
48
+ site means the build fails. Every problem is reported in one pass:
49
+
50
+ ```
51
+ Invalid custom attribute configuration (3 problems):
52
+ - sauna.label: missing a label for de-DE; the catalog supplies none, so it must be authored
53
+ - amenityTags: backend type MULTI_SELECT has no canonical SDK value type yet, so it cannot be selected
54
+ - region.optionLabels: a filterable option attribute needs a label for every option; missing south, east, west
55
+ ```
56
+
57
+ ## The Catalog
58
+
59
+ A `Record` keyed by the backend's definition key:
60
+
61
+ ```ts
62
+ type CustomAttributeCatalogDefinitionInput = {
63
+ id: string; // 22-character v10 definition ID
64
+ type:
65
+ | "BOOLEAN"
66
+ | "NUMBER"
67
+ | "SINGLE_SELECT"
68
+ | "TEXT"
69
+ | "LOCALE_TEXT"
70
+ | "MULTI_SELECT"
71
+ | "LOCALE_DAY"
72
+ | "LOCAL_TIME"
73
+ | "LOCAL_TIMERANGE"
74
+ | "MONETARY_VALUE";
75
+ options?: readonly string[]; // SINGLE_SELECT identities; values only, no labels
76
+ label?: { "de-DE"?: string; "en-US"?: string };
77
+ };
78
+ ```
79
+
80
+ ### Where it comes from
81
+
82
+ ```ts
83
+ import { fetchCustomAttributeCatalog } from "@v-office/website-sdk";
84
+
85
+ const catalog = await fetchCustomAttributeCatalog({ config });
86
+ ```
87
+
88
+ This is a v10-only build-time read using `apiEndpoint` and `accessToken`, never a
89
+ per-request lookup. Nothing is generated and nothing is committed, so definition IDs
90
+ are never copied by hand and selected definitions stay aligned with the backend. It calls
91
+ `customattributes_listPublicCustomAttributeDefinition`; the configured token must be
92
+ authorized for that operation. When a website token lacks that permission, fetch the
93
+ catalog in an authorized build environment or pin a literal catalog. The operation
94
+ returns IDs, keys, types, and option identities, but no localized labels; author
95
+ labels in `select`. Definitions that the SDK cannot map are omitted with a warning,
96
+ and the remaining catalog is schema-validated. If multiple backend definitions share
97
+ one key, every definition for that ambiguous key is omitted with one warning rather
98
+ than binding whichever row arrived first.
99
+
100
+ You can also pin a literal catalog. Doing so is the only way to get compile-time
101
+ checking of your selection keys and option values, because a fetched value is
102
+ widened to `Record<string, …>`:
103
+
104
+ ```ts
105
+ const catalog = {
106
+ sauna: { id: "GQYKYgMVh28X7VLGursbkt", type: "BOOLEAN" },
107
+ } as const satisfies CustomAttributeCatalogInput;
108
+ ```
109
+
110
+ Both forms are accepted and validated identically. The only difference is _when_ a
111
+ mistake surfaces: a literal catalog catches an unknown key in the editor, a fetched
112
+ one catches it when `defineCustomAttributes` runs.
113
+
114
+ ### Backend types the SDK can use
115
+
116
+ | Backend type | SDK type | Notes |
117
+ | -------------------- | --------- | -------------------------------------------- |
118
+ | `BOOLEAN` | `boolean` | |
119
+ | `NUMBER` | `int` | base-10 integers only |
120
+ | `SINGLE_SELECT` | `option` | option identities come from the catalog |
121
+ | `TEXT` | `string` | exact match |
122
+ | `LOCALE_TEXT` | — | unsupported: no locale-selection policy yet |
123
+ | `MULTI_SELECT` | — | unsupported: no AND/OR semantics yet |
124
+ | date, time, monetary | — | unsupported: no canonical type or parser yet |
125
+
126
+ Selecting an unsupported definition fails with a precise message rather than being
127
+ approximated. A definition you do not select is simply ignored, so unsupported types
128
+ elsewhere in the workspace never block a build.
129
+
130
+ ## The Selection
131
+
132
+ Every field is optional except a label, and each default is chosen so that omitting
133
+ a field cannot expose or broaden anything.
134
+
135
+ ```ts
136
+ type CustomAttributeOverridesInput = {
137
+ key?: string; // public query key, when the catalog key cannot be one
138
+ label?: { "de-DE"?: string; "en-US"?: string };
139
+ category?: CustomAttributeCategoryKey; // defaults to "OTHER"
140
+ display?: boolean; // defaults to true
141
+ v9?: string; // p_* rental-data binding
142
+ filter?:
143
+ | boolean // `true` means public
144
+ | "internal"
145
+ | {
146
+ exposure?: "public" | "internal";
147
+ numericBounds?: { min: number; max: number }; // int only, UI hint
148
+ allowsFalseValue?: boolean; // boolean only, defaults to false
149
+ };
150
+ optionLabels?: Record<string, { "de-DE": string; "en-US": string }>; // option only
151
+ };
152
+ ```
153
+
154
+ | Field | Default | Why that default is safe |
155
+ | ------------------ | ------------------------- | ------------------------------------------------------------------------------ |
156
+ | `filter` | absent | silence never creates a public query capability |
157
+ | `display` | `true` | only public definitions can be selected, so there is nothing private to expose |
158
+ | `allowsFalseValue` | `false` | silence never broadens matching |
159
+ | `category` | `OTHER` | presentation only — grouping, never reachability |
160
+ | `label` | the catalog label, if any | **no safe default** — see below |
161
+
162
+ `category` governs **two** things: which group the filter appears under in
163
+ `getFilters`, and which group the value appears under in the rental attribute list.
164
+ Both backends resolve it the same way, so switching v9 → v10 never moves an
165
+ attribute between groups. An attribute whose category matches a built-in group
166
+ joins that group rather than starting a parallel one.
167
+
168
+ You author the category **key** (`ESSENTIALS`); output carries its **localized
169
+ label** (`Grundlagen` in `de-DE`), exactly like a built-in filter or attribute. Never
170
+ match on the output string — it changes with locale. Override the wording through
171
+ `translationOverrides` on `RentalAttributesCategory.<KEY>.label` if you need to.
172
+
173
+ `label` is the one field that is effectively required. Humanizing a key produces
174
+ English-ish text that is simply wrong for `de-DE`, so when the catalog supplies no
175
+ label you must author one. A partial override is merged over a partial catalog
176
+ label; the merged result must cover every locale.
177
+
178
+ A display-only option attribute may omit `optionLabels`; an unlabeled option then
179
+ renders with its raw catalog identity. Once `filter` is enabled, every catalog option
180
+ must have a complete label for each supported locale because `getFilters` must return
181
+ a usable localized option list.
182
+
183
+ ### Renaming a key
184
+
185
+ Backend definition keys are human-authored and routinely contain spaces or
186
+ punctuation, which cannot appear in a query string. Use `key` to give such a
187
+ definition a public name:
188
+
189
+ ```ts
190
+ select: {
191
+ "Wäschepaket inkl.": {
192
+ key: "linenPackage",
193
+ label: { "de-DE": "Wäschepaket inklusive", "en-US": "Linen package included" },
194
+ filter: true,
195
+ },
196
+ }
197
+ ```
198
+
199
+ The attribute is then `linenPackage` everywhere downstream — in URLs, in
200
+ `getFilters`, in applied filters, and in highlight references. A catalog key that is
201
+ already URL-safe (`[A-Za-z0-9_-]+`) is used as-is and needs no override. Forgetting
202
+ the override on a key that needs one fails with:
203
+
204
+ ```
205
+ - Wäschepaket inkl.: the catalog key is not usable as a public query key.
206
+ Add a "key" override containing only letters, digits, underscore, or hyphen.
207
+ ```
208
+
209
+ Renaming is product policy, so it lives in the selection rather than the catalog:
210
+ the catalog is re-read on every build and an authored field there would be lost.
211
+
212
+ ### Public data only
213
+
214
+ **The website SDK only ever sees definitions the backend marks `PUBLIC`.** That is
215
+ enforced by the operations it calls: `listPublicCustomAttributeDefinition` for the
216
+ catalog and `listPublicCustomAttribute` for values. A private definition never
217
+ reaches the SDK at all, and there is no filter to opt out of.
218
+
219
+ Because of that, visibility is **not** part of any type you touch. There is no
220
+ visibility field on a catalog entry, none on a resolved attribute, and no rule you
221
+ have to remember to apply. Privacy is enforced where the data lives rather than by
222
+ configuration that could be got wrong.
223
+
224
+ This also closes an inference path that a display-only rule would leave open. An
225
+ `internal` filter is hidden from `getFilters` but is still parseable from a query
226
+ string, so if a private definition were reachable at all, a visitor who guessed the
227
+ key could send `?ownerNote=…` and infer values from which rentals came back. Nothing
228
+ private is reachable, so there is nothing to infer.
229
+
230
+ Because the values read is also a public operation, the backend decides at request
231
+ time what a website may see. That covers a definition whose visibility changed after
232
+ your site was built, which a build-time snapshot could not.
233
+
234
+ ### Exposure versus display
235
+
236
+ Within public data, two decisions remain independent:
237
+
238
+ | | Meaning |
239
+ | --------------- | --------------------------------- |
240
+ | filter exposure | `public`, `internal`, or absent |
241
+ | display | whether the value may be rendered |
242
+
243
+ | Filter exposure | `getFilters` | query parsing | compositions | display |
244
+ | --------------- | ------------ | ------------- | -------------- | ----------- |
245
+ | `public` | included | allowed | allowed | independent |
246
+ | `internal` | excluded | allowed | allowed | independent |
247
+ | absent | excluded | not applied | invalid target | independent |
248
+
249
+ `internal` is a UI-visibility choice, not a security boundary — it keeps a filter out
250
+ of `getFilters` while leaving it usable by your own code and by compositions. Use it
251
+ for filters you drive yourself, never to hide sensitive data; the data behind it is
252
+ public either way.
253
+
254
+ ## Value Types
255
+
256
+ | Type | Accepted query values | Rendered as |
257
+ | --------- | ----------------------------------------------------------------------- | --------------------------------------------- |
258
+ | `boolean` | `?key`, `?key=true`, `?key=1`; `false`/`0` only with `allowsFalseValue` | the label alone when true, omitted when false |
259
+ | `int` | base-10 integers only — no whitespace, fractions, `1e3`, or `0x1f` | `Label: 1.234` (localized) |
260
+ | `string` | any non-empty value, exact match | `Label: value` |
261
+ | `option` | only values the catalog declares | `Label: <option label>` |
262
+
263
+ Rendering dispatches on the declared type, never on the runtime shape of the value.
264
+ Both backends return values as strings, so a shape-based guess would read an `int`
265
+ of `"1"` as a boolean and drop a `string` of `"0"`.
266
+
267
+ When a value cannot be read as the declared type — an `int` of `"1.5"` or an option
268
+ value the catalog does not declare — the attribute is omitted rather than rendering
269
+ something misleading. A false boolean and an absent value are also omitted.
270
+
271
+ Scalar filters take **one** value. Given `?region=north,south`, the first valid value
272
+ is applied, but the key also remains in `unusedFilterKeys` because the extra token was
273
+ not applied. Combining scalar values would depend on backend semantics that are not
274
+ specified, and an AND of two option values can only return nothing.
275
+
276
+ `numericBounds` are UI hints for building a control. The parser does not enforce
277
+ them, so an advertised bound always round-trips.
278
+
279
+ ## Highlights and Attributes
280
+
281
+ A custom attribute appears in `highlights` only when its key is listed in
282
+ `rentalHighlightPrioritization`, which owns order:
283
+
284
+ ```ts
285
+ const options = defineWebsiteSDKOptions({
286
+ customAttributes,
287
+ rentalHighlightPrioritization: [
288
+ "bedrooms",
289
+ customAttributes.keys.region, // checked for a literal typed registry
290
+ "bathrooms",
291
+ ],
292
+ });
293
+ ```
294
+
295
+ The `keys` object is literal-typed when `defineCustomAttributes` receives a literal
296
+ catalog and selection. The JSON and `defineCustomAttributesFromUnknown` paths still
297
+ validate at runtime, but their keys are widened to `Record<string, string>` and
298
+ cannot catch a typo at compile time.
299
+
300
+ A built-in metadata key shadows a same-named custom attribute even when the built-in
301
+ value is absent — custom attributes are strictly a fallback there.
302
+
303
+ `getRentals` renders custom attributes into both `attributes` and `highlights`;
304
+ `search` renders them into `highlights` only.
305
+
306
+ In `attributes`, a custom attribute is placed in the group named by its resolved
307
+ `category`, alongside built-in attributes of that category rather than in a separate
308
+ bucket. The default `OTHER` therefore groups everything you did not categorize
309
+ together — set `category` when you want an attribute to sit with related built-ins.
310
+
311
+ Values are looked up by the attribute's **resolved** key, so a definition renamed
312
+ through `key` renders under its new name on every path.
313
+
314
+ ## Backend Behaviour
315
+
316
+ The same registry is passed to both backends. Each uses the part it can bind.
317
+
318
+ | Registry entry | v10 | v9 |
319
+ | -------------- | ---------------- | ------- |
320
+ | `id` only | filter + display | ignored |
321
+ | `v9` only | ignored | display |
322
+ | both | filter + display | display |
323
+
324
+ ### v10
325
+
326
+ Filtering and display both work. A filter resolves its stable key to the catalog
327
+ definition ID and sends `{ customAttributeDefinitionId, customAttributeValue }` to
328
+ REST search. Returned values resolve back through the ID index before
329
+ rendering.
330
+
331
+ ### v9
332
+
333
+ Display only. v9 has **no custom-attribute search path**, so a custom attribute is
334
+ never advertised by v9 `getFilters` and a custom-attribute query key is always
335
+ reported in `unusedFilterKeys`. Values come from rental data, addressed by the `v9`
336
+ binding:
337
+
338
+ ```ts
339
+ select: {
340
+ privateSauna: {
341
+ label: { "de-DE": "Sauna", "en-US": "Sauna" },
342
+ v9: "p_14418", // rental-data field, never the public query key
343
+ filter: true, // executes on v10; ignored by v9
344
+ },
345
+ }
346
+ ```
347
+
348
+ An attribute that exists only in v9 has no catalog entry, so every fact is authored
349
+ and it carries no `filter` field at all:
350
+
351
+ ```ts
352
+ const customAttributes = defineCustomAttributes({
353
+ catalog,
354
+ select: {},
355
+ v9Only: {
356
+ thatchedRoof: {
357
+ v9: "p_14418",
358
+ type: "boolean",
359
+ label: { "de-DE": "Reetdach", "en-US": "Thatched roof" },
360
+ },
361
+ locationType: {
362
+ v9: "p_10005",
363
+ type: "option",
364
+ label: { "de-DE": "Lage", "en-US": "Location type" },
365
+ // For v9 the authored keys are the option identities.
366
+ optionLabels: {
367
+ village: { "de-DE": "Dorf", "en-US": "Village" },
368
+ solitary: { "de-DE": "Alleinlage", "en-US": "Secluded" },
369
+ },
370
+ },
371
+ },
372
+ });
373
+ ```
374
+
375
+ ## Backend Filters and Compositions
376
+
377
+ These are **not** custom attributes and stay in `customAttributeFilterDefinitions`:
378
+
379
+ - `backendFilter`: a backend-native search key, in practice a v9 hub filter.
380
+ Advertised and executed by v9; ignored by v10.
381
+ - `composition`: a virtual boolean filter that expands into other configured
382
+ filters. Advertised only when every configured leaf can execute on the active
383
+ backend with its configured value.
384
+
385
+ ```ts
386
+ const options = defineWebsiteSDKOptions({
387
+ customAttributes,
388
+ customAttributeFilterDefinitions: [
389
+ {
390
+ key: "atLeastTwoBedrooms",
391
+ source: "backendFilter",
392
+ type: "boolean",
393
+ category: "LIVING_SLEEPING",
394
+ label: { "de-DE": "Mind. zwei Schlafzimmer", "en-US": "At least two bedrooms" },
395
+ searchable: true,
396
+ internal: false,
397
+ },
398
+ {
399
+ key: "premium",
400
+ source: "composition",
401
+ type: "complex",
402
+ category: "ESSENTIALS",
403
+ label: { "de-DE": "Premium", "en-US": "Premium" },
404
+ searchable: true,
405
+ internal: false,
406
+ expression: { all: [{ filter: "atLeastTwoBedrooms", value: true }] },
407
+ },
408
+ ],
409
+ });
410
+ ```
411
+
412
+ A composition expression supports equality leaves combined with `all`. It is
413
+ **all-or-nothing at the backend-payload level**: every leaf is validated against the
414
+ active backend before any generated input is committed. If any leaf is unknown,
415
+ unsupported, or has an invalid configured value, no composition-generated leaf
416
+ reaches GraphQL or REST and only the composition key is reported in
417
+ `unusedFilterKeys`. A direct query filter remains independent even when a failed
418
+ composition references the same key.
419
+
420
+ When every leaf succeeds, `appliedFilters` contains the composition and its expanded
421
+ leaf entries. Consumers that render removable filter chips should decide whether to
422
+ show both levels or collapse leaf entries belonging to a visible composition.
423
+
424
+ An executable `internal` leaf may be used by a public composition without becoming
425
+ a standalone `getFilters` item. A composition cannot reference another composition;
426
+ option normalization rejects nested compositions while recursive expansion is
427
+ unsupported.
428
+
429
+ `category` remains a stable authored key in configuration. `getFilters` translates
430
+ it through `RentalAttributesCategory.<KEY>.label`, including for `backendFilter` and
431
+ `composition` definitions, and returns the localized display label.
432
+
433
+ Every search query key must have one owner. Option normalization rejects period and
434
+ occupancy keys such as `start`, `adults`, and `pets`; v10 also rejects filterable
435
+ custom attributes and compositions that reuse generated v10 filter keys; v9 rejects
436
+ configured filters that reuse stable v9 filter keys. A custom attribute and a
437
+ configured custom filter also cannot share a key, and two configured filter
438
+ definitions cannot repeat one. Use the custom attribute `key` override when a
439
+ workspace definition collides.
440
+
441
+ ## Moving from v9 to v10
442
+
443
+ Switching backend changes capability. The registry and backend-specific filter
444
+ catalog determine which filters and compositions are executable, so `getFilters`
445
+ may add or remove entries.
446
+
447
+ ### What is unchanged
448
+
449
+ - The configuration object. One `customAttributes` registry serves both backends.
450
+ - Stable query keys, and therefore existing URLs.
451
+ - Display: an attribute with both an `id` and a `v9` binding renders on both.
452
+ - Backend filters and compositions remain configured, but are advertised only on
453
+ backends that can execute all required leaves.
454
+
455
+ ### What you gain
456
+
457
+ - **Custom-attribute filtering.** Definitions with a catalog `id` and an enabled
458
+ filter start appearing in `getFilters` and start being applied by search. On v9
459
+ those same keys were reported in `unusedFilterKeys`.
460
+
461
+ ### What you must change
462
+
463
+ 1. **Supply a catalog.** v9 needs none; v10 resolves definition IDs through it.
464
+ Without one, no custom attribute has an `id`, so none can filter.
465
+ 2. **Add `id` bindings.** A v9-only entry keeps working for display but never
466
+ filters. Move it from `v9Only` into `select` with a `v9` override to get both.
467
+ 3. **Re-check backend filters.** `source: "backendFilter"` is a v9 hub concept with
468
+ no v10 equivalent. v10 does not advertise or execute those keys. Any filter you
469
+ need on v10 must become a custom attribute or a built-in filter.
470
+ 4. **Re-check compositions.** A composition is only as good as its leaves. One whose
471
+ leaves are v9 backend filters is omitted from v10 `getFilters`; if its key still
472
+ arrives through a saved or hand-written URL, it is reported unused without
473
+ applying any leaf.
474
+
475
+ ### Worth knowing
476
+
477
+ - v9 exposes a small set of hub filters that have no v10 counterpart; a key such as
478
+ `pool` may exist on v9 and not on v10.
479
+ - v10 advertises far more built-in filters, so `getFilters` returns a much longer
480
+ list. Query keys are guaranteed unique within one response on both backends.
481
+
482
+ ### A configuration that works on both
483
+
484
+ ```ts
485
+ const customAttributes = defineCustomAttributes({
486
+ catalog, // empty object on a v9-only site
487
+ select: {
488
+ privateSauna: {
489
+ label: { "de-DE": "Sauna", "en-US": "Sauna" },
490
+ v9: "p_14418", // v9 renders it
491
+ filter: true, // v10 also filters on it
492
+ },
493
+ },
494
+ });
495
+ ```
496
+
497
+ On v9 this renders a sauna highlight and reports `?privateSauna=true` as unused. On
498
+ v10 the same object renders the highlight _and_ advertises and applies the filter.
499
+ Nothing in the site's URLs or configuration changes.
500
+
501
+ ## Untyped Configuration
502
+
503
+ Configuration that arrives without static types — a JSON options file, the CLI, or
504
+ an object assembled at runtime — goes through the same join:
505
+
506
+ ```ts
507
+ import { defineCustomAttributesFromUnknown } from "@v-office/website-sdk";
508
+
509
+ const customAttributes = defineCustomAttributesFromUnknown(JSON.parse(raw));
510
+ ```
511
+
512
+ The CLI does this for you. An options file passes the _inputs_, not a finished
513
+ registry:
514
+
515
+ ```json
516
+ {
517
+ "customAttributes": {
518
+ "catalog": {
519
+ "privateSauna": { "id": "GQYKYgMVh28X7VLGursbkt", "type": "BOOLEAN" }
520
+ },
521
+ "select": {
522
+ "privateSauna": {
523
+ "label": { "de-DE": "Sauna", "en-US": "Sauna" },
524
+ "filter": true
525
+ }
526
+ }
527
+ },
528
+ "rentalHighlightPrioritization": ["bedrooms", "privateSauna"]
529
+ }
530
+ ```
531
+
532
+ ```sh
533
+ website-sdk --backend v10 --options ./website-sdk-options.json filters --locale de-DE
534
+ ```
535
+
536
+ Every rule applies identically on this path; only the compile-time key and option
537
+ unions are lost.
538
+
539
+ ## Reference
540
+
541
+ Exported from `@v-office/website-sdk`:
542
+
543
+ - `defineCustomAttributes` — join a catalog with a selection, typed.
544
+ - `defineCustomAttributesFromUnknown` — the same join for untyped input.
545
+ - `fetchCustomAttributeCatalog` — read the catalog from the definitions API.
546
+ - `parseCustomAttributeCatalog` — validate a catalog that arrives as plain data.
547
+ - `CustomAttributeCatalogSchema` — the catalog schema.
548
+
549
+ Types: `CustomAttributeCatalog`, `CustomAttributeCatalogInput`,
550
+ `CustomAttributeCatalogDefinitionInput`, `CustomAttributeCategoryKey`,
551
+ `CustomAttributeOverridesInput`, `CustomAttributeSelection`, `CustomAttributeRegistry`,
552
+ `CustomAttributeRegistrySnapshot`, `ResolvedCustomAttribute`,
553
+ `V9OnlyCustomAttributeInput`.
554
+
555
+ Related: `creation.md` for options, `filter.md` for `getFilters`, `search.md` for
556
+ query parsing and applied filters, `rentals.md` for attributes and highlights.
@@ -36,14 +36,14 @@ Sample:
36
36
  [
37
37
  {
38
38
  "searchParameterQueryKey": "wifi",
39
- "label": "WiFi",
40
- "category": "ESSENTIALS",
39
+ "label": "WLAN",
40
+ "category": "Grundlagen",
41
41
  "type": "BooleanFilter"
42
42
  },
43
43
  {
44
44
  "searchParameterQueryKey": "bedrooms",
45
- "label": "Bedrooms",
46
- "category": "ROOMS",
45
+ "label": "Schlafzimmer",
46
+ "category": "Wohn- und Schlafbereiche",
47
47
  "type": "IntFilter",
48
48
  "numericBounds": {
49
49
  "min": 1,
@@ -53,22 +53,30 @@ Sample:
53
53
  {
54
54
  "searchParameterQueryKey": "region",
55
55
  "label": "Region",
56
- "category": "LOCATION",
56
+ "category": "Andere",
57
57
  "type": "OptionFilter",
58
58
  "options": [
59
59
  {
60
60
  "value": "north",
61
- "label": "North"
61
+ "label": "Norden"
62
62
  }
63
63
  ]
64
64
  }
65
65
  ]
66
66
  ```
67
67
 
68
+ `searchParameterQueryKey` and each option `value` are **stable identifiers**: they are
69
+ what you put in a URL, and they never change with locale. `label` and `category` are
70
+ **localized display strings** for the requested locale, not keys — do not group or
71
+ match on them programmatically. This holds for custom attributes too: an attribute
72
+ authored with `category: "ESSENTIALS"` is advertised with `"category": "Grundlagen"`
73
+ in `de-DE`, exactly like a built-in filter.
74
+
68
75
  Filter variants:
69
76
 
70
77
  - `BooleanFilter`: on/off query parameter.
71
78
  - `IntFilter`: numeric query parameter, optionally with `numericBounds`.
79
+ - `StringFilter`: non-empty exact-match query parameter.
72
80
  - `OptionFilter`: query parameter with localized selectable `options`.
73
81
 
74
82
  ## Configuration
@@ -97,29 +105,26 @@ Filter variants:
97
105
  }
98
106
  ```
99
107
 
100
- Optional `WebsiteSDKOptions` can add public custom filters:
108
+ Optional `WebsiteSDKOptions` add two further sources of filters.
109
+
110
+ Custom attributes come from `customAttributes`, and a definition is advertised only
111
+ when it has a public filter and the selected backend can execute it. v9 has no
112
+ custom-attribute search path, so v9 never advertises one; see `custom-attributes.md`.
113
+
114
+ Backend filters and compositions come from `customAttributeFilterDefinitions`:
101
115
 
102
116
  ```json
103
117
  {
104
118
  "customAttributeFilterDefinitions": [
105
119
  {
106
- "key": "region",
107
- "source": "customAttribute",
108
- "category": "ESSENTIALS",
120
+ "key": "atLeastTwoBedrooms",
121
+ "source": "backendFilter",
122
+ "category": "LIVING_SLEEPING",
109
123
  "label": {
110
- "de-DE": "Region",
111
- "en-US": "Region"
124
+ "de-DE": "Mind. zwei Schlafzimmer",
125
+ "en-US": "At least two bedrooms"
112
126
  },
113
- "type": "option",
114
- "options": [
115
- {
116
- "value": "north",
117
- "label": {
118
- "de-DE": "Norden",
119
- "en-US": "North"
120
- }
121
- }
122
- ],
127
+ "type": "boolean",
123
128
  "searchable": true,
124
129
  "internal": false
125
130
  }
@@ -128,7 +133,30 @@ Optional `WebsiteSDKOptions` can add public custom filters:
128
133
  }
129
134
  ```
130
135
 
131
- Only custom definitions with `searchable: true` and `internal: false` are exposed by `getFilters`.
136
+ Only definitions with `searchable: true` and `internal: false` are exposed by
137
+ `getFilters`. `backendFilter` entries are advertised by v9 only, because they have no
138
+ v10 execution path. Each query key has one owner: period and occupancy collisions
139
+ fail during option normalization, v9/v10 built-in filter collisions fail in the
140
+ corresponding backend configuration path, and a custom attribute cannot share a key
141
+ with a configured custom filter. Repeated keys within
142
+ `customAttributeFilterDefinitions` also fail during option normalization.
143
+ `getFilters` keeps a final uniqueness assertion so parse order never silently
144
+ decides ownership.
145
+
146
+ Compositions are additionally backend-aware: `getFilters` advertises one only when
147
+ every leaf exists on the active backend and its configured value is valid for that
148
+ leaf. Executable internal leaves may participate without being exposed separately.
149
+ Unknown, unsupported, or backend-specific leaves omit the composition. Nested
150
+ compositions are rejected during option normalization while recursive expansion is
151
+ unsupported.
152
+
153
+ During search, a successful composition and its expanded leaves are all returned in
154
+ the search output's `appliedFilters`; `getFilters` itself returns only discovery
155
+ metadata. See `search.md` for filter-chip guidance.
156
+
157
+ Configured category values remain stable keys such as `OUTDOORS`. The output
158
+ translates them through `RentalAttributesCategory.<KEY>.label`, honors
159
+ `translationOverrides`, and returns only the localized display label.
132
160
 
133
161
  For v10, `getFilters` also exposes the mapped BooleanFilter `pets`, labeled `Dogs welcome` / `Hunde willkommen`. It maps to one pet in search occupancy; use `petsCount` when the UI needs an exact pet count instead.
134
162