@v-office/website-sdk 2.7.0 → 2.9.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 (45) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.mjs +25 -4
  3. package/dist/{client-45_ofP_d.mjs → client-CYK_5Vk7.mjs} +385 -187
  4. package/dist/index.d.mts +24 -4
  5. package/dist/index.mjs +3 -3
  6. package/dist/instructions/CHANGELOG.md +4 -1
  7. package/dist/instructions/MIGRATION.md +4 -1
  8. package/dist/instructions/README.md +9 -3
  9. package/dist/instructions/creation.md +82 -66
  10. package/dist/instructions/custom-attributes.md +560 -0
  11. package/dist/instructions/filter.md +62 -23
  12. package/dist/instructions/rentals.md +43 -2
  13. package/dist/instructions/search.md +29 -3
  14. package/dist/instructions/versions/2.5.0/MIGRATION.md +5 -0
  15. package/dist/instructions/versions/2.6.0/CHANGELOG.md +3 -25
  16. package/dist/instructions/versions/2.6.0/MIGRATION.md +4 -72
  17. package/dist/instructions/versions/2.7.0/CHANGELOG.md +44 -0
  18. package/dist/instructions/versions/2.7.0/MIGRATION.md +79 -0
  19. package/dist/instructions/versions/2.8.0/CHANGELOG.md +63 -0
  20. package/dist/instructions/versions/2.8.0/MIGRATION.md +252 -0
  21. package/dist/instructions/versions/2.9.0/CHANGELOG.md +37 -0
  22. package/dist/instructions/versions/2.9.0/MIGRATION.md +153 -0
  23. package/dist/{rentals-Lw1qUKIj.mjs → rentals-CFod9H4m.mjs} +21 -21
  24. package/dist/{search-wcoZw2rt.mjs → search-CO9ID9op.mjs} +16 -133
  25. package/dist/{to-rental-highlights-CxWlq76f.mjs → to-rental-highlights-B6a_fi0j.mjs} +33 -8
  26. package/dist/translations/v10/de-DE/core.json +2 -0
  27. package/dist/translations/v10/en-US/core.json +2 -0
  28. package/instructions/CHANGELOG.md +4 -1
  29. package/instructions/MIGRATION.md +4 -1
  30. package/instructions/README.md +9 -3
  31. package/instructions/creation.md +82 -66
  32. package/instructions/custom-attributes.md +560 -0
  33. package/instructions/filter.md +62 -23
  34. package/instructions/rentals.md +43 -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 +3 -25
  38. package/instructions/versions/2.6.0/MIGRATION.md +4 -72
  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/instructions/versions/2.9.0/CHANGELOG.md +37 -0
  44. package/instructions/versions/2.9.0/MIGRATION.md +153 -0
  45. package/package.json +5 -3
@@ -0,0 +1,560 @@
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 is the ordered allowlist:
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
+ Compositions may reference stable v9 built-ins directly; `backendFilter` is only for
386
+ additional v9 keys. SDK configuration does not create those keys in Hub; ask someone
387
+ at be-on! with Hub access to add or confirm the filter before configuring it.
388
+
389
+ ```ts
390
+ const options = defineWebsiteSDKOptions({
391
+ customAttributes,
392
+ customAttributeFilterDefinitions: [
393
+ {
394
+ key: "atLeastTwoBedrooms",
395
+ source: "backendFilter",
396
+ type: "boolean",
397
+ category: "LIVING_SLEEPING",
398
+ label: { "de-DE": "Mind. zwei Schlafzimmer", "en-US": "At least two bedrooms" },
399
+ searchable: true,
400
+ internal: false,
401
+ },
402
+ {
403
+ key: "premium",
404
+ source: "composition",
405
+ type: "complex",
406
+ category: "ESSENTIALS",
407
+ label: { "de-DE": "Premium", "en-US": "Premium" },
408
+ searchable: true,
409
+ internal: false,
410
+ expression: { all: [{ filter: "atLeastTwoBedrooms", value: true }] },
411
+ },
412
+ ],
413
+ });
414
+ ```
415
+
416
+ A composition expression supports equality leaves combined with `all`. It is
417
+ **all-or-nothing at the backend-payload level**: every leaf is validated against the
418
+ active backend before any generated input is committed. If any leaf is unknown,
419
+ unsupported, or has an invalid configured value, no composition-generated leaf
420
+ reaches GraphQL or REST and only the composition key is reported in
421
+ `unusedFilterKeys`. A direct query filter remains independent even when a failed
422
+ composition references the same key.
423
+
424
+ When every leaf succeeds, `appliedFilters` contains the composition and its expanded
425
+ leaf entries. Consumers that render removable filter chips should decide whether to
426
+ show both levels or collapse leaf entries belonging to a visible composition.
427
+
428
+ An executable `internal` leaf may be used by a public composition without becoming
429
+ a standalone `getFilters` item. A composition cannot reference another composition;
430
+ option normalization rejects nested compositions while recursive expansion is
431
+ unsupported.
432
+
433
+ `category` remains a stable authored key in configuration. `getFilters` translates
434
+ it through `RentalAttributesCategory.<KEY>.label`, including for `backendFilter` and
435
+ `composition` definitions, and returns the localized display label.
436
+
437
+ Every search query key must have one owner. Option normalization rejects period and
438
+ occupancy keys such as `start`, `adults`, and `pets`; v10 also rejects filterable
439
+ custom attributes and compositions that reuse generated v10 filter keys; v9 rejects
440
+ configured filters that reuse stable v9 filter keys. A custom attribute and a
441
+ configured custom filter also cannot share a key, and two configured filter
442
+ definitions cannot repeat one. Use the custom attribute `key` override when a
443
+ workspace definition collides.
444
+
445
+ ## Moving from v9 to v10
446
+
447
+ Switching backend changes capability. The registry and backend-specific filter
448
+ catalog determine which filters and compositions are executable, so `getFilters`
449
+ may add or remove entries.
450
+
451
+ ### What is unchanged
452
+
453
+ - The configuration object. One `customAttributes` registry serves both backends.
454
+ - Stable query keys, and therefore existing URLs.
455
+ - Display: an attribute with both an `id` and a `v9` binding renders on both.
456
+ - Backend filters and compositions remain configured, but are advertised only on
457
+ backends that can execute all required leaves.
458
+
459
+ ### What you gain
460
+
461
+ - **Custom-attribute filtering.** Definitions with a catalog `id` and an enabled
462
+ filter start appearing in `getFilters` and start being applied by search. On v9
463
+ those same keys were reported in `unusedFilterKeys`.
464
+
465
+ ### What you must change
466
+
467
+ 1. **Supply a catalog.** v9 needs none; v10 resolves definition IDs through it.
468
+ Without one, no custom attribute has an `id`, so none can filter.
469
+ 2. **Add `id` bindings.** A v9-only entry keeps working for display but never
470
+ filters. Move it from `v9Only` into `select` with a `v9` override to get both.
471
+ 3. **Re-check backend filters.** `source: "backendFilter"` is a v9 hub concept with
472
+ no v10 equivalent. v10 does not advertise or execute those keys. Any filter you
473
+ need on v10 must become a custom attribute or a built-in filter.
474
+ 4. **Re-check compositions.** A composition is only as good as its leaves. One whose
475
+ leaves are v9 backend filters is omitted from v10 `getFilters`; if its key still
476
+ arrives through a saved or hand-written URL, it is reported unused without
477
+ applying any leaf.
478
+
479
+ ### Worth knowing
480
+
481
+ - v9 exposes a small set of hub filters that have no v10 counterpart; a key such as
482
+ `pool` may exist on v9 and not on v10.
483
+ - v10 advertises far more built-in filters, so `getFilters` returns a much longer
484
+ list. Query keys are guaranteed unique within one response on both backends.
485
+
486
+ ### A configuration that works on both
487
+
488
+ ```ts
489
+ const customAttributes = defineCustomAttributes({
490
+ catalog, // empty object on a v9-only site
491
+ select: {
492
+ privateSauna: {
493
+ label: { "de-DE": "Sauna", "en-US": "Sauna" },
494
+ v9: "p_14418", // v9 renders it
495
+ filter: true, // v10 also filters on it
496
+ },
497
+ },
498
+ });
499
+ ```
500
+
501
+ On v9 this renders a sauna highlight and reports `?privateSauna=true` as unused. On
502
+ v10 the same object renders the highlight _and_ advertises and applies the filter.
503
+ Nothing in the site's URLs or configuration changes.
504
+
505
+ ## Untyped Configuration
506
+
507
+ Configuration that arrives without static types — a JSON options file, the CLI, or
508
+ an object assembled at runtime — goes through the same join:
509
+
510
+ ```ts
511
+ import { defineCustomAttributesFromUnknown } from "@v-office/website-sdk";
512
+
513
+ const customAttributes = defineCustomAttributesFromUnknown(JSON.parse(raw));
514
+ ```
515
+
516
+ The CLI does this for you. An options file passes the _inputs_, not a finished
517
+ registry:
518
+
519
+ ```json
520
+ {
521
+ "customAttributes": {
522
+ "catalog": {
523
+ "privateSauna": { "id": "GQYKYgMVh28X7VLGursbkt", "type": "BOOLEAN" }
524
+ },
525
+ "select": {
526
+ "privateSauna": {
527
+ "label": { "de-DE": "Sauna", "en-US": "Sauna" },
528
+ "filter": true
529
+ }
530
+ }
531
+ },
532
+ "rentalHighlightPrioritization": ["bedrooms", "privateSauna"]
533
+ }
534
+ ```
535
+
536
+ ```sh
537
+ website-sdk --backend v10 --options ./website-sdk-options.json filters --locale de-DE
538
+ ```
539
+
540
+ Every rule applies identically on this path; only the compile-time key and option
541
+ unions are lost.
542
+
543
+ ## Reference
544
+
545
+ Exported from `@v-office/website-sdk`:
546
+
547
+ - `defineCustomAttributes` — join a catalog with a selection, typed.
548
+ - `defineCustomAttributesFromUnknown` — the same join for untyped input.
549
+ - `fetchCustomAttributeCatalog` — read the catalog from the definitions API.
550
+ - `parseCustomAttributeCatalog` — validate a catalog that arrives as plain data.
551
+ - `CustomAttributeCatalogSchema` — the catalog schema.
552
+
553
+ Types: `CustomAttributeCatalog`, `CustomAttributeCatalogInput`,
554
+ `CustomAttributeCatalogDefinitionInput`, `CustomAttributeCategoryKey`,
555
+ `CustomAttributeOverridesInput`, `CustomAttributeSelection`, `CustomAttributeRegistry`,
556
+ `CustomAttributeRegistrySnapshot`, `ResolvedCustomAttribute`,
557
+ `V9OnlyCustomAttributeInput`.
558
+
559
+ Related: `creation.md` for options, `filter.md` for `getFilters`, `search.md` for
560
+ query parsing and applied filters, `rentals.md` for attributes and highlights.