@happyvertical/smrt-content 0.43.3 → 0.43.5

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 (34) hide show
  1. package/AGENTS.md +9 -0
  2. package/agents/content-list.md +869 -0
  3. package/dist/content-query.d.ts +310 -0
  4. package/dist/content-query.d.ts.map +1 -0
  5. package/dist/contents.d.ts +22 -0
  6. package/dist/contents.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +672 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/manifest.json +22 -2
  12. package/dist/smrt-knowledge.json +38 -5
  13. package/dist/svelte/components/ContentList.svelte +1580 -281
  14. package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +306 -0
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -0
  18. package/dist/svelte/content-list-controller.js +921 -0
  19. package/dist/svelte/content-list-query.d.ts +498 -0
  20. package/dist/svelte/content-list-query.d.ts.map +1 -0
  21. package/dist/svelte/content-list-query.js +1294 -0
  22. package/dist/svelte/content-list-saved-views.d.ts +172 -0
  23. package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
  24. package/dist/svelte/content-list-saved-views.js +298 -0
  25. package/dist/svelte/content-list-url-state.d.ts +211 -0
  26. package/dist/svelte/content-list-url-state.d.ts.map +1 -0
  27. package/dist/svelte/content-list-url-state.js +856 -0
  28. package/dist/svelte/i18n.contribution.d.ts +55 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +57 -0
  31. package/dist/svelte/index.d.ts +5 -0
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +10 -0
  34. package/package.json +16 -15
@@ -0,0 +1,856 @@
1
+ /**
2
+ * Shareable URL state for ContentList (#2452).
3
+ *
4
+ * A content list view is a link: an operator narrows the list, copies the
5
+ * address bar, and a colleague opens the same result. That makes the query
6
+ * string an *untrusted* input — it arrives from whoever sent the link, not from
7
+ * the operator's own session — so every restored value is re-derived here
8
+ * against the adapter's published column and operator vocabulary rather than
9
+ * trusted as written.
10
+ *
11
+ * The module owns two things:
12
+ *
13
+ * 1. A compact, human-legible parameter shape (`q`, `type`, `status`, `sort`,
14
+ * `page`, `size`, and `<column>.<operator>` for the richer operators). A
15
+ * base64 blob would round-trip just as well but would be unreadable, and an
16
+ * unreadable link is one nobody can sanity-check before sending it.
17
+ * 2. `sanitizeContentListViewState`, the single validator shared with the
18
+ * saved-views module (`content-list-saved-views.ts`), so a crafted URL and a
19
+ * tampered stored view are held to exactly the same allowlist.
20
+ *
21
+ * ## What may be restored
22
+ *
23
+ * | Aspect | Allowlist |
24
+ * |--------|-----------|
25
+ * | filters, sorting | `CONTENT_LIST_VISIBLE_COLUMN_IDS` — the columns the surface descriptor publishes with `filter`/`sort` capability |
26
+ * | projection (order, visibility, widths, pinning) | `CONTENT_LIST_TABLE_COLUMN_IDS`, and a hidden column can never be forced visible |
27
+ * | filter operators | `CONTENT_LIST_FILTER_OPERATORS` |
28
+ * | filter values | strings only, normalized by `normalizeContentListFilterValue` |
29
+ *
30
+ * The search-only `description` column, the structural `select`/`actions`
31
+ * columns, and any column id the adapter does not publish are dropped from
32
+ * filters and sorting. Dropping — never throwing — is deliberate: a stale link
33
+ * or an out-of-date saved view must still open the list, minus the parts that
34
+ * are no longer meaningful.
35
+ *
36
+ * ## What is deliberately NOT URL state
37
+ *
38
+ * Selection and expansion are excluded, and `sanitizeContentListViewState`
39
+ * never emits them either. A shared link must not carry another operator's
40
+ * selection: the recipient would inherit a checked set they never chose, and
41
+ * the very next bulk action — delete included — would run against it. Row ids
42
+ * are also the one part of view state that leaks data (which specific contents
43
+ * someone had singled out) into a URL that gets pasted into chat and tickets.
44
+ */
45
+ import { CONTENT_LIST_ACTIONS_COLUMN_ID, CONTENT_LIST_HIDDEN_COLUMN_IDS, CONTENT_LIST_SELECTION_COLUMN_ID, CONTENT_LIST_TABLE_COLUMN_IDS, CONTENT_LIST_VISIBLE_COLUMN_IDS, normalizeContentListFilterValue, } from './content-list-controller.js';
46
+ /** Query-string parameter carrying the free-text search. */
47
+ export const CONTENT_LIST_SEARCH_PARAM = 'q';
48
+ /** Query-string parameter carrying the ordered sort rules. */
49
+ export const CONTENT_LIST_SORT_PARAM = 'sort';
50
+ /** Query-string parameter carrying the 1-based page. */
51
+ export const CONTENT_LIST_PAGE_PARAM = 'page';
52
+ /** Query-string parameter carrying the page size. */
53
+ export const CONTENT_LIST_PAGE_SIZE_PARAM = 'size';
54
+ /**
55
+ * Parameter names this module owns. A column may not shadow one of them; the
56
+ * assertion below keeps that true if a future column is ever named `q` or
57
+ * `page`.
58
+ */
59
+ export const CONTENT_LIST_RESERVED_PARAMS = [
60
+ CONTENT_LIST_SEARCH_PARAM,
61
+ CONTENT_LIST_SORT_PARAM,
62
+ CONTENT_LIST_PAGE_PARAM,
63
+ CONTENT_LIST_PAGE_SIZE_PARAM,
64
+ ];
65
+ /** Separator between a column id and a non-default operator in a param name. */
66
+ const OPERATOR_SEPARATOR = '.';
67
+ /** Separator between entries of an `in`/`notIn` list value. */
68
+ const LIST_SEPARATOR = ',';
69
+ /**
70
+ * Escape character for a list entry that contains the separator.
71
+ *
72
+ * Without it, `author in ["Smith, John"]` serializes to `Smith, John` and
73
+ * restores as two values — a silently *different* query rather than a failed
74
+ * one. Both the separator and the escape character itself are escaped on write
75
+ * and unescaped on read, so the round trip is exact.
76
+ */
77
+ const LIST_ESCAPE = '\\';
78
+ /**
79
+ * The list entry that means the VALUE `null` — "and rows with no value at all".
80
+ *
81
+ * A query string carries text, and `null` has no natural text form: writing it
82
+ * as `null` would be indistinguishable from an author actually called "null".
83
+ * This token cannot collide with any real value, BY CONSTRUCTION rather than by
84
+ * being unlikely: {@link escapeListEntry} doubles every backslash a real value
85
+ * contains, so the only two-character sequences a real entry can begin with are
86
+ * `\\` and `\,`. A lone backslash followed by `0` is therefore unreachable
87
+ * from any string — including the literal two characters `\0`, which serialize
88
+ * as `\\0` and read back as themselves.
89
+ *
90
+ * It survives URL encoding: `URLSearchParams` writes the backslash as `%5C` and
91
+ * reads it back unchanged.
92
+ */
93
+ const LIST_NULL_TOKEN = `${LIST_ESCAPE}0`;
94
+ function escapeListEntry(value) {
95
+ if (value === null)
96
+ return LIST_NULL_TOKEN;
97
+ return value.replace(/[\\,]/g, (character) => `${LIST_ESCAPE}${character}`);
98
+ }
99
+ /**
100
+ * Splits on unescaped separators only, unescaping each entry as it goes.
101
+ *
102
+ * Returns `null` for {@link LIST_NULL_TOKEN}, which is a value rather than an
103
+ * absence — an empty entry (`a,,b`) is the empty STRING and stays one.
104
+ */
105
+ function splitListValue(raw) {
106
+ const entries = [];
107
+ let current = '';
108
+ let isNull = false;
109
+ let escaped = false;
110
+ for (const character of raw) {
111
+ if (escaped) {
112
+ // A backslash followed by `0` is the null token; every real backslash
113
+ // arrives doubled, so this sequence cannot come from a string.
114
+ if (character === '0' && current === '')
115
+ isNull = true;
116
+ else
117
+ current += character;
118
+ escaped = false;
119
+ continue;
120
+ }
121
+ if (character === LIST_ESCAPE) {
122
+ escaped = true;
123
+ continue;
124
+ }
125
+ if (character === LIST_SEPARATOR) {
126
+ entries.push(isNull ? null : current);
127
+ current = '';
128
+ isNull = false;
129
+ continue;
130
+ }
131
+ current += character;
132
+ }
133
+ // A trailing lone escape is data, not a prefix: keep it rather than losing it.
134
+ if (escaped)
135
+ current += LIST_ESCAPE;
136
+ entries.push(isNull ? null : current);
137
+ return entries;
138
+ }
139
+ /**
140
+ * Columns a restored filter or sort rule may address: exactly the columns the
141
+ * surface descriptor publishes with `filter` and `sort` capability.
142
+ */
143
+ export const CONTENT_LIST_QUERYABLE_COLUMN_IDS = CONTENT_LIST_VISIBLE_COLUMN_IDS;
144
+ /**
145
+ * Operators a restored filter may use. This is the full DataTable operator
146
+ * vocabulary the adapter's evaluator implements — narrower than "anything that
147
+ * parses", so an unrecognized operator can never reach the query layer.
148
+ */
149
+ export const CONTENT_LIST_FILTER_OPERATORS = [
150
+ 'equals',
151
+ 'notEquals',
152
+ 'contains',
153
+ 'notContains',
154
+ 'startsWith',
155
+ 'endsWith',
156
+ 'in',
157
+ 'notIn',
158
+ 'gt',
159
+ 'gte',
160
+ 'lt',
161
+ 'lte',
162
+ 'isNull',
163
+ 'isNotNull',
164
+ ];
165
+ /** Operators that carry no value at all. */
166
+ const VALUELESS_OPERATORS = new Set([
167
+ 'isNull',
168
+ 'isNotNull',
169
+ ]);
170
+ /** Operators whose value is a list rather than a scalar. */
171
+ const LIST_OPERATORS = new Set(['in', 'notIn']);
172
+ /**
173
+ * Upper bound for a restored page size, mirroring the surface descriptor's
174
+ * `limits.maxQueryRows`. A link is an untrusted input into a server query
175
+ * (#2452), so `?size=1000000` must clamp rather than become a row budget.
176
+ */
177
+ export const CONTENT_LIST_MAX_PAGE_SIZE = 200;
178
+ const QUERYABLE_COLUMNS = new Set(CONTENT_LIST_QUERYABLE_COLUMN_IDS);
179
+ const HIDDEN_COLUMNS = new Set(CONTENT_LIST_HIDDEN_COLUMN_IDS);
180
+ const STRUCTURAL_COLUMNS = new Set([
181
+ CONTENT_LIST_SELECTION_COLUMN_ID,
182
+ CONTENT_LIST_ACTIONS_COLUMN_ID,
183
+ ]);
184
+ const LAYOUT_COLUMNS = new Set(CONTENT_LIST_TABLE_COLUMN_IDS);
185
+ const FILTER_OPERATORS = new Set(CONTENT_LIST_FILTER_OPERATORS);
186
+ const RESERVED_PARAMS = new Set(CONTENT_LIST_RESERVED_PARAMS);
187
+ for (const columnId of LAYOUT_COLUMNS) {
188
+ if (RESERVED_PARAMS.has(columnId)) {
189
+ throw new Error(`Content list column "${columnId}" collides with a reserved URL parameter`);
190
+ }
191
+ }
192
+ function isPlainObject(value) {
193
+ return (typeof value === 'object' &&
194
+ value !== null &&
195
+ !Array.isArray(value) &&
196
+ (Object.getPrototypeOf(value) === Object.prototype ||
197
+ Object.getPrototypeOf(value) === null));
198
+ }
199
+ /**
200
+ * Classifies a column id for filtering and sorting. The three rejection
201
+ * reasons are distinguished because they mean different things to an operator:
202
+ * an unknown column is a stale or crafted link, a hidden column is an attempt
203
+ * to reach a field the surface never publishes, and a structural column is a
204
+ * category error.
205
+ */
206
+ function classifyQueryColumn(columnId) {
207
+ if (QUERYABLE_COLUMNS.has(columnId))
208
+ return 'allowed';
209
+ if (HIDDEN_COLUMNS.has(columnId))
210
+ return 'hidden-column';
211
+ if (STRUCTURAL_COLUMNS.has(columnId))
212
+ return 'structural-column';
213
+ return 'unknown-column';
214
+ }
215
+ /** Text a filter value may be expressed as. Objects and arrays are refused. */
216
+ function filterText(value) {
217
+ if (typeof value === 'string')
218
+ return value;
219
+ if (typeof value === 'number' && Number.isFinite(value))
220
+ return String(value);
221
+ if (typeof value === 'boolean')
222
+ return String(value);
223
+ return null;
224
+ }
225
+ /**
226
+ * Normalizes one filter value the way the adapter does, so a filter restored
227
+ * from a link compares equal to the same filter built by the toolbar.
228
+ *
229
+ * THREE outcomes, not two. Conflating the last two is what let a legitimate
230
+ * value be discarded in three separate layers: each test asked whether the
231
+ * value was PRESENT rather than whether it was VALID, and `null` reads as
232
+ * absent to any check written with truthiness.
233
+ *
234
+ * - a string — the normalized value;
235
+ * - `null` — the VALUE null, which names absence ("and rows with no value");
236
+ * - `undefined` — unusable, and the only case a caller may drop.
237
+ */
238
+ function normalizedFilterValue(columnId, raw) {
239
+ // A literal `null` is a value the caller wrote deliberately, not a missing
240
+ // one. The executor lowers it to `IS NULL` / `IS NOT NULL` and the local
241
+ // evaluator matches it through `isAbsentContentValue`; dropping it here
242
+ // persisted a WIDER filter than the one in memory, so copying the link
243
+ // brought back exactly the rows it excluded.
244
+ if (raw === null)
245
+ return null;
246
+ const text = filterText(raw);
247
+ if (text === null)
248
+ return undefined;
249
+ // A blank SCALAR clears the filter, matching `applyContentListFilter`. A
250
+ // blank LIST entry is the empty string, which is a real value for a column
251
+ // that stores one — see `sanitizeFilter`.
252
+ if (text.trim() === '')
253
+ return undefined;
254
+ return normalizeContentListFilterValue(columnId, text);
255
+ }
256
+ function sanitizeFilter(raw, drops) {
257
+ if (!isPlainObject(raw)) {
258
+ drops.push({ scope: 'filter', reason: 'malformed' });
259
+ return null;
260
+ }
261
+ const columnId = raw.columnId;
262
+ if (typeof columnId !== 'string' || columnId.length === 0) {
263
+ drops.push({ scope: 'filter', reason: 'malformed' });
264
+ return null;
265
+ }
266
+ const classification = classifyQueryColumn(columnId);
267
+ if (classification !== 'allowed') {
268
+ drops.push({ scope: 'filter', reason: classification, columnId });
269
+ return null;
270
+ }
271
+ const operator = raw.operator;
272
+ if (typeof operator !== 'string' || !FILTER_OPERATORS.has(operator)) {
273
+ drops.push({
274
+ scope: 'filter',
275
+ reason: 'unsupported-operator',
276
+ columnId,
277
+ detail: typeof operator === 'string' ? operator : undefined,
278
+ });
279
+ return null;
280
+ }
281
+ const typedOperator = operator;
282
+ if (VALUELESS_OPERATORS.has(typedOperator)) {
283
+ return { columnId, operator: typedOperator };
284
+ }
285
+ if (LIST_OPERATORS.has(typedOperator)) {
286
+ if (!Array.isArray(raw.value)) {
287
+ drops.push({
288
+ scope: 'filter',
289
+ reason: 'unsupported-value',
290
+ columnId,
291
+ detail: operator,
292
+ });
293
+ return null;
294
+ }
295
+ const values = [];
296
+ for (const entry of raw.value) {
297
+ // An empty entry is the empty STRING — a real value for a column that
298
+ // stores one, and one the list encoding round-trips natively (`a,,b`).
299
+ // Only `undefined` means unusable.
300
+ const normalized = typeof entry === 'string' && entry.trim() === ''
301
+ ? ''
302
+ : normalizedFilterValue(columnId, entry);
303
+ if (normalized !== undefined && !values.includes(normalized)) {
304
+ values.push(normalized);
305
+ }
306
+ }
307
+ if (values.length === 0) {
308
+ drops.push({
309
+ scope: 'filter',
310
+ reason: 'unsupported-value',
311
+ columnId,
312
+ detail: operator,
313
+ });
314
+ return null;
315
+ }
316
+ return { columnId, operator: typedOperator, value: values };
317
+ }
318
+ const value = normalizedFilterValue(columnId, raw.value);
319
+ if (value === undefined) {
320
+ drops.push({
321
+ scope: 'filter',
322
+ reason: 'unsupported-value',
323
+ columnId,
324
+ detail: operator,
325
+ });
326
+ return null;
327
+ }
328
+ return { columnId, operator: typedOperator, value };
329
+ }
330
+ function sanitizeFilters(raw, drops) {
331
+ if (!Array.isArray(raw)) {
332
+ drops.push({ scope: 'filter', reason: 'malformed' });
333
+ return [];
334
+ }
335
+ const filters = [];
336
+ for (const entry of raw) {
337
+ const filter = sanitizeFilter(entry, drops);
338
+ if (filter)
339
+ filters.push(filter);
340
+ }
341
+ return filters;
342
+ }
343
+ function sanitizeSorting(raw, drops) {
344
+ if (!Array.isArray(raw)) {
345
+ drops.push({ scope: 'sorting', reason: 'malformed' });
346
+ return [];
347
+ }
348
+ const seen = new Set();
349
+ const sorting = [];
350
+ for (const entry of raw) {
351
+ if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
352
+ drops.push({ scope: 'sorting', reason: 'malformed' });
353
+ continue;
354
+ }
355
+ const columnId = entry.columnId;
356
+ const classification = classifyQueryColumn(columnId);
357
+ if (classification !== 'allowed') {
358
+ drops.push({ scope: 'sorting', reason: classification, columnId });
359
+ continue;
360
+ }
361
+ if (entry.direction !== 'asc' && entry.direction !== 'desc') {
362
+ drops.push({ scope: 'sorting', reason: 'malformed', columnId });
363
+ continue;
364
+ }
365
+ // First rule wins, matching the controller's own sort normalization.
366
+ if (seen.has(columnId))
367
+ continue;
368
+ seen.add(columnId);
369
+ sorting.push({ columnId, direction: entry.direction });
370
+ }
371
+ return sorting;
372
+ }
373
+ function sanitizeColumnOrder(raw, drops) {
374
+ if (!Array.isArray(raw)) {
375
+ drops.push({ scope: 'columnOrder', reason: 'malformed' });
376
+ return [];
377
+ }
378
+ const order = [];
379
+ for (const entry of raw) {
380
+ if (typeof entry !== 'string' || entry.length === 0) {
381
+ drops.push({ scope: 'columnOrder', reason: 'malformed' });
382
+ continue;
383
+ }
384
+ if (!LAYOUT_COLUMNS.has(entry)) {
385
+ drops.push({
386
+ scope: 'columnOrder',
387
+ reason: 'unknown-column',
388
+ columnId: entry,
389
+ });
390
+ continue;
391
+ }
392
+ if (!order.includes(entry))
393
+ order.push(entry);
394
+ }
395
+ return order;
396
+ }
397
+ function sanitizeColumnVisibility(raw, drops) {
398
+ if (!Array.isArray(raw)) {
399
+ drops.push({ scope: 'columnVisibility', reason: 'malformed' });
400
+ return [];
401
+ }
402
+ const entries = new Map();
403
+ for (const entry of raw) {
404
+ if (!isPlainObject(entry) ||
405
+ typeof entry.columnId !== 'string' ||
406
+ typeof entry.visible !== 'boolean') {
407
+ drops.push({ scope: 'columnVisibility', reason: 'malformed' });
408
+ continue;
409
+ }
410
+ const columnId = entry.columnId;
411
+ if (!LAYOUT_COLUMNS.has(columnId)) {
412
+ drops.push({
413
+ scope: 'columnVisibility',
414
+ reason: 'unknown-column',
415
+ columnId,
416
+ });
417
+ continue;
418
+ }
419
+ // A search-only column stays hidden no matter what a stored view asks for:
420
+ // making `description` visible would publish a field the surface
421
+ // descriptor deliberately withholds.
422
+ if (HIDDEN_COLUMNS.has(columnId)) {
423
+ if (entry.visible) {
424
+ drops.push({
425
+ scope: 'columnVisibility',
426
+ reason: 'hidden-column',
427
+ columnId,
428
+ });
429
+ }
430
+ entries.set(columnId, false);
431
+ continue;
432
+ }
433
+ entries.set(columnId, entry.visible);
434
+ }
435
+ return [...entries.entries()].map(([columnId, visible]) => ({
436
+ columnId,
437
+ visible,
438
+ }));
439
+ }
440
+ function sanitizeColumnWidths(raw, drops) {
441
+ if (!Array.isArray(raw)) {
442
+ drops.push({ scope: 'columnWidths', reason: 'malformed' });
443
+ return [];
444
+ }
445
+ const entries = new Map();
446
+ for (const entry of raw) {
447
+ if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
448
+ drops.push({ scope: 'columnWidths', reason: 'malformed' });
449
+ continue;
450
+ }
451
+ const columnId = entry.columnId;
452
+ if (!LAYOUT_COLUMNS.has(columnId)) {
453
+ drops.push({
454
+ scope: 'columnWidths',
455
+ reason: 'unknown-column',
456
+ columnId,
457
+ });
458
+ continue;
459
+ }
460
+ const width = entry.width;
461
+ if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) {
462
+ drops.push({
463
+ scope: 'columnWidths',
464
+ reason: 'unsupported-value',
465
+ columnId,
466
+ });
467
+ continue;
468
+ }
469
+ entries.set(columnId, width);
470
+ }
471
+ return [...entries.entries()].map(([columnId, width]) => ({
472
+ columnId,
473
+ width,
474
+ }));
475
+ }
476
+ function sanitizeColumnPinning(raw, drops) {
477
+ if (!Array.isArray(raw)) {
478
+ drops.push({ scope: 'columnPinning', reason: 'malformed' });
479
+ return [];
480
+ }
481
+ const entries = new Map();
482
+ for (const entry of raw) {
483
+ if (!isPlainObject(entry) || typeof entry.columnId !== 'string') {
484
+ drops.push({ scope: 'columnPinning', reason: 'malformed' });
485
+ continue;
486
+ }
487
+ const columnId = entry.columnId;
488
+ if (!LAYOUT_COLUMNS.has(columnId)) {
489
+ drops.push({
490
+ scope: 'columnPinning',
491
+ reason: 'unknown-column',
492
+ columnId,
493
+ });
494
+ continue;
495
+ }
496
+ if (entry.position !== 'start' && entry.position !== 'end') {
497
+ drops.push({
498
+ scope: 'columnPinning',
499
+ reason: 'unsupported-value',
500
+ columnId,
501
+ });
502
+ continue;
503
+ }
504
+ entries.set(columnId, entry.position);
505
+ }
506
+ return [...entries.entries()].map(([columnId, position]) => ({
507
+ columnId,
508
+ position,
509
+ }));
510
+ }
511
+ function sanitizePage(raw, drops) {
512
+ if (typeof raw !== 'number' ||
513
+ !Number.isFinite(raw) ||
514
+ !Number.isInteger(raw) ||
515
+ raw <= 0) {
516
+ drops.push({ scope: 'page', reason: 'malformed' });
517
+ return 1;
518
+ }
519
+ return raw;
520
+ }
521
+ function sanitizePageSize(raw, maxPageSize, drops) {
522
+ if (raw === null || raw === undefined)
523
+ return null;
524
+ if (typeof raw !== 'number' ||
525
+ !Number.isFinite(raw) ||
526
+ !Number.isInteger(raw) ||
527
+ raw <= 0) {
528
+ drops.push({ scope: 'pageSize', reason: 'malformed' });
529
+ return null;
530
+ }
531
+ if (raw > maxPageSize) {
532
+ drops.push({
533
+ scope: 'pageSize',
534
+ reason: 'out-of-range',
535
+ detail: String(raw),
536
+ });
537
+ return maxPageSize;
538
+ }
539
+ return raw;
540
+ }
541
+ /**
542
+ * The single validator behind both restoration paths.
543
+ *
544
+ * Every restored aspect is re-derived from the adapter's published vocabulary,
545
+ * so neither a crafted query string nor a tampered saved view can introduce a
546
+ * filter, a sort rule, or a projection on a column the surface does not
547
+ * publish. Invalid input is dropped and reported, never thrown, and selection
548
+ * and expansion are never emitted.
549
+ */
550
+ export function sanitizeContentListViewState(input, options = {}) {
551
+ const dropped = [];
552
+ if (!isPlainObject(input)) {
553
+ return { state: {}, dropped: [{ scope: 'state', reason: 'malformed' }] };
554
+ }
555
+ const maxPageSize = options.maxPageSize ?? CONTENT_LIST_MAX_PAGE_SIZE;
556
+ const state = {};
557
+ if (Object.hasOwn(input, 'search')) {
558
+ if (typeof input.search === 'string') {
559
+ state.search = input.search;
560
+ }
561
+ else {
562
+ dropped.push({ scope: 'search', reason: 'malformed' });
563
+ state.search = '';
564
+ }
565
+ }
566
+ if (Object.hasOwn(input, 'filters')) {
567
+ state.filters = sanitizeFilters(input.filters, dropped);
568
+ }
569
+ if (Object.hasOwn(input, 'sorting')) {
570
+ state.sorting = sanitizeSorting(input.sorting, dropped);
571
+ }
572
+ if (Object.hasOwn(input, 'page')) {
573
+ state.page = sanitizePage(input.page, dropped);
574
+ }
575
+ if (Object.hasOwn(input, 'pageSize')) {
576
+ state.pageSize = sanitizePageSize(input.pageSize, maxPageSize, dropped);
577
+ }
578
+ if (Object.hasOwn(input, 'columnOrder')) {
579
+ state.columnOrder = sanitizeColumnOrder(input.columnOrder, dropped);
580
+ }
581
+ if (Object.hasOwn(input, 'columnVisibility')) {
582
+ state.columnVisibility = sanitizeColumnVisibility(input.columnVisibility, dropped);
583
+ }
584
+ if (Object.hasOwn(input, 'columnWidths')) {
585
+ state.columnWidths = sanitizeColumnWidths(input.columnWidths, dropped);
586
+ }
587
+ if (Object.hasOwn(input, 'columnPinning')) {
588
+ state.columnPinning = sanitizeColumnPinning(input.columnPinning, dropped);
589
+ }
590
+ return { state, dropped };
591
+ }
592
+ /**
593
+ * Merges a patch onto a controller's current state, validating it first.
594
+ *
595
+ * INVARIANT: no exported path may apply unvalidated state to a controller.
596
+ * This function is the only application point the package publishes, and it is
597
+ * routinely composed with values that came from somewhere untrusted — a query
598
+ * string, a `localStorage` blob, an agent command. Sanitizing here means the
599
+ * composition `applyContentListViewState(controller, storedView.snapshot.state)`
600
+ * is safe even though the store's read path deliberately keeps the raw payload
601
+ * so a stale view's drops can still be reported (see
602
+ * `restoreContentListSavedView`). Sanitization is idempotent, so calling this
603
+ * with an already-validated patch changes nothing.
604
+ *
605
+ * Only the *patch* is sanitized, never the merged result: the sanitizer never
606
+ * emits selection or expansion, so sanitizing the merge would silently clear
607
+ * the operator's current selection.
608
+ *
609
+ * Restoration is a state replacement rather than a command: it is not a user
610
+ * interaction and must not reset the page the way `setSearch`/`setFilters` do.
611
+ * Aspects the patch omits — selection and expansion above all — are carried
612
+ * over from the controller untouched.
613
+ *
614
+ * Use {@link sanitizeContentListViewState} directly when the caller needs to
615
+ * report what was refused.
616
+ */
617
+ export function applyContentListViewState(controller, patch, options = {}) {
618
+ const { state: safe } = sanitizeContentListViewState(patch, options);
619
+ return controller.replaceState({ ...controller.getState(), ...safe });
620
+ }
621
+ function parameterName(name, prefix) {
622
+ return `${prefix}${name}`;
623
+ }
624
+ function filterParameterName(filter, prefix) {
625
+ return filter.operator === 'equals'
626
+ ? parameterName(filter.columnId, prefix)
627
+ : parameterName(`${filter.columnId}${OPERATOR_SEPARATOR}${filter.operator}`, prefix);
628
+ }
629
+ function serializeSorting(sorting) {
630
+ return sorting
631
+ .map((rule) => rule.direction === 'desc' ? `-${rule.columnId}` : rule.columnId)
632
+ .join(LIST_SEPARATOR);
633
+ }
634
+ /**
635
+ * Serializes a view state into shareable query parameters.
636
+ *
637
+ * The state is validated on the way out as well as on the way in: a link this
638
+ * module produces can never carry a filter or sort on a column the surface
639
+ * does not publish, even if the caller's controller somehow holds one.
640
+ * Defaults are omitted so an untouched list produces a clean URL.
641
+ */
642
+ export function contentListViewStateToSearchParams(state, options = {}) {
643
+ const prefix = options.prefix ?? '';
644
+ const defaultPageSize = options.defaultPageSize ?? null;
645
+ const { state: safe } = sanitizeContentListViewState(state, options);
646
+ const params = new URLSearchParams();
647
+ // Written verbatim so the link round-trips exactly; only a search that is
648
+ // blank once trimmed counts as the default and is omitted.
649
+ const search = safe.search ?? '';
650
+ if (search.trim()) {
651
+ params.set(parameterName(CONTENT_LIST_SEARCH_PARAM, prefix), search);
652
+ }
653
+ for (const filter of safe.filters ?? []) {
654
+ if (VALUELESS_OPERATORS.has(filter.operator)) {
655
+ params.append(filterParameterName(filter, prefix), '1');
656
+ continue;
657
+ }
658
+ if (Array.isArray(filter.value)) {
659
+ params.append(filterParameterName(filter, prefix), filter.value
660
+ .map((entry) => escapeListEntry(entry === null ? null : String(entry)))
661
+ .join(LIST_SEPARATOR));
662
+ continue;
663
+ }
664
+ if (filter.value === null) {
665
+ // `equals null` and `isNull` are the same predicate — both lower to
666
+ // `eq null`, and the local evaluator answers them identically. The
667
+ // valueless operator already has a query-string form, so the scalar null
668
+ // is written as that rather than inventing a second token for it.
669
+ const nullOperator = filter.operator === 'notEquals' ? 'isNotNull' : 'isNull';
670
+ params.append(filterParameterName({ ...filter, operator: nullOperator }, prefix), '1');
671
+ continue;
672
+ }
673
+ params.append(filterParameterName(filter, prefix), String(filter.value ?? ''));
674
+ }
675
+ if (safe.sorting?.length) {
676
+ params.set(parameterName(CONTENT_LIST_SORT_PARAM, prefix), serializeSorting(safe.sorting));
677
+ }
678
+ if (safe.page !== undefined && safe.page > 1) {
679
+ params.set(parameterName(CONTENT_LIST_PAGE_PARAM, prefix), String(safe.page));
680
+ }
681
+ if (safe.pageSize !== undefined && safe.pageSize !== defaultPageSize) {
682
+ params.set(parameterName(CONTENT_LIST_PAGE_SIZE_PARAM, prefix), safe.pageSize === null ? 'all' : String(safe.pageSize));
683
+ }
684
+ return params;
685
+ }
686
+ /**
687
+ * Writes the owned parameters into a copy of an existing query string,
688
+ * preserving every parameter this module does not own (routing keys, campaign
689
+ * tags, a sibling list's prefixed parameters).
690
+ */
691
+ export function mergeContentListViewStateIntoSearchParams(params, state, options = {}) {
692
+ const prefix = options.prefix ?? '';
693
+ const next = new URLSearchParams();
694
+ for (const [key, value] of params.entries()) {
695
+ if (!ownedParameter(key, prefix))
696
+ next.append(key, value);
697
+ }
698
+ for (const [key, value] of contentListViewStateToSearchParams(state, options).entries()) {
699
+ next.append(key, value);
700
+ }
701
+ return next;
702
+ }
703
+ /**
704
+ * True when a parameter name belongs to this module's vocabulary, and may
705
+ * therefore be *removed* while rewriting the query string.
706
+ *
707
+ * Ownership is decided by the BASE name only. A key such as `facet.contains`
708
+ * carries a known operator suffix but names no ContentList column, so it is a
709
+ * host's parameter and must survive — this module promises every parameter it
710
+ * does not own is preserved, and deleting one silently breaks the host's own
711
+ * routing on the very first filter change.
712
+ *
713
+ * This is deliberately narrower than the recognizer in
714
+ * {@link readContentListViewStateFromSearchParams}, which reports an
715
+ * operator-suffixed unknown column so a crafted `evil.contains=` stays visible.
716
+ * Reporting a refusal and deleting a parameter are different acts.
717
+ */
718
+ function ownedParameter(key, prefix) {
719
+ if (prefix && !key.startsWith(prefix))
720
+ return false;
721
+ const name = prefix ? key.slice(prefix.length) : key;
722
+ if (RESERVED_PARAMS.has(name))
723
+ return true;
724
+ const separator = name.indexOf(OPERATOR_SEPARATOR);
725
+ const columnId = separator === -1 ? name : name.slice(0, separator);
726
+ return LAYOUT_COLUMNS.has(columnId);
727
+ }
728
+ function parseInteger(raw) {
729
+ if (!/^\d+$/.test(raw.trim()))
730
+ return null;
731
+ const value = Number.parseInt(raw.trim(), 10);
732
+ return Number.isSafeInteger(value) ? value : null;
733
+ }
734
+ function parseSortParam(raw, drops) {
735
+ const rules = [];
736
+ for (const token of raw.split(LIST_SEPARATOR)) {
737
+ const trimmed = token.trim();
738
+ if (!trimmed)
739
+ continue;
740
+ const direction = trimmed.startsWith('-') ? 'desc' : 'asc';
741
+ const columnId = trimmed.startsWith('-') || trimmed.startsWith('+')
742
+ ? trimmed.slice(1)
743
+ : trimmed;
744
+ if (!columnId) {
745
+ drops.push({ scope: 'sorting', reason: 'malformed' });
746
+ continue;
747
+ }
748
+ rules.push({ columnId, direction });
749
+ }
750
+ return rules;
751
+ }
752
+ /**
753
+ * Reads a view state from query parameters and reports everything it refused.
754
+ *
755
+ * Foreign parameters are ignored silently: a shared link routinely carries
756
+ * routing and campaign keys this module knows nothing about, and reporting
757
+ * them as drops would bury the reports that matter. A parameter that *looks*
758
+ * like a content-list filter — a known column, or any name carrying a known
759
+ * operator suffix — is reported when it is refused, which is what makes a
760
+ * crafted `description=` or `evil.contains=` visible rather than silent.
761
+ */
762
+ export function readContentListViewStateFromSearchParams(params, options = {}) {
763
+ const prefix = options.prefix ?? '';
764
+ const drops = [];
765
+ const candidate = {
766
+ search: '',
767
+ filters: [],
768
+ sorting: [],
769
+ page: 1,
770
+ pageSize: options.defaultPageSize ?? null,
771
+ };
772
+ for (const [key, value] of params.entries()) {
773
+ if (prefix && !key.startsWith(prefix))
774
+ continue;
775
+ const name = prefix ? key.slice(prefix.length) : key;
776
+ if (name === CONTENT_LIST_SEARCH_PARAM) {
777
+ candidate.search = value;
778
+ continue;
779
+ }
780
+ if (name === CONTENT_LIST_SORT_PARAM) {
781
+ candidate.sorting = [
782
+ ...candidate.sorting,
783
+ ...parseSortParam(value, drops),
784
+ ];
785
+ continue;
786
+ }
787
+ if (name === CONTENT_LIST_PAGE_PARAM) {
788
+ const page = parseInteger(value);
789
+ if (page === null || page < 1) {
790
+ drops.push({ scope: 'page', reason: 'malformed', detail: value });
791
+ continue;
792
+ }
793
+ candidate.page = page;
794
+ continue;
795
+ }
796
+ if (name === CONTENT_LIST_PAGE_SIZE_PARAM) {
797
+ // `all` is the only way a link can say "unpaginated" when the host's
798
+ // default page size is a number, since an omitted parameter means default.
799
+ if (value.trim() === 'all') {
800
+ candidate.pageSize = null;
801
+ continue;
802
+ }
803
+ const size = parseInteger(value);
804
+ if (size === null || size < 1) {
805
+ drops.push({ scope: 'pageSize', reason: 'malformed', detail: value });
806
+ candidate.pageSize = options.defaultPageSize ?? null;
807
+ continue;
808
+ }
809
+ candidate.pageSize = size;
810
+ continue;
811
+ }
812
+ const separator = name.indexOf(OPERATOR_SEPARATOR);
813
+ const columnId = separator === -1 ? name : name.slice(0, separator);
814
+ const operator = separator === -1 ? 'equals' : name.slice(separator + 1);
815
+ const recognizable = LAYOUT_COLUMNS.has(columnId) ||
816
+ (separator !== -1 && FILTER_OPERATORS.has(operator)) ||
817
+ Boolean(prefix);
818
+ if (!recognizable)
819
+ continue;
820
+ if (separator !== -1 && !FILTER_OPERATORS.has(operator)) {
821
+ drops.push({
822
+ scope: 'filter',
823
+ reason: 'unsupported-operator',
824
+ columnId,
825
+ detail: operator,
826
+ });
827
+ continue;
828
+ }
829
+ candidate.filters.push({
830
+ columnId,
831
+ operator,
832
+ ...(VALUELESS_OPERATORS.has(operator)
833
+ ? {}
834
+ : LIST_OPERATORS.has(operator)
835
+ ? // An entirely empty parameter (`?status.in=`) is a list with no
836
+ // values, which the sanitizer refuses and reports. An empty entry
837
+ // WITHIN a list (`?status.in=a,`) is the empty string, which is a
838
+ // real value for a column that stores one.
839
+ { value: value === '' ? [] : splitListValue(value) }
840
+ : { value }),
841
+ });
842
+ }
843
+ const sanitized = sanitizeContentListViewState(candidate, options);
844
+ return { state: sanitized.state, dropped: [...drops, ...sanitized.dropped] };
845
+ }
846
+ /**
847
+ * Reads a view state from query parameters.
848
+ *
849
+ * The result always carries `search`, `filters`, `sorting`, `page`, and
850
+ * `pageSize` — an absent parameter restores that aspect's default, so a clean
851
+ * link restores a clean view rather than leaving stale state in place. Use
852
+ * {@link readContentListViewStateFromSearchParams} when the refusals matter.
853
+ */
854
+ export function contentListViewStateFromSearchParams(params, options = {}) {
855
+ return readContentListViewStateFromSearchParams(params, options).state;
856
+ }