@happyvertical/smrt-content 0.43.4 → 0.43.6

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 -86
  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 +941 -30
  14. package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +98 -1
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  18. package/dist/svelte/content-list-controller.js +290 -19
  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 +24 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +26 -0
  31. package/dist/svelte/index.d.ts +5 -1
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +8 -1
  34. package/package.json +16 -15
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Saved views for ContentList (#2452).
3
+ *
4
+ * A saved view is a named `DataTableSnapshot` an operator can come back to. The
5
+ * payload is persisted outside the application's control — today in
6
+ * `localStorage`, which is writable by anyone with a console — so it is treated
7
+ * exactly like a URL: untrusted on the way back in.
8
+ *
9
+ * Restoration is therefore two gates, in order:
10
+ *
11
+ * 1. `hydrateDataTableSnapshot` (smrt-ui) parses the stored payload
12
+ * structurally, upgrades version 1/2 into normalized version 3, and throws
13
+ * on anything it cannot make sense of. It intentionally runs *without*
14
+ * column ids, so it normalizes shape but does not know which columns this
15
+ * list publishes.
16
+ * 2. `sanitizeContentListViewState` (`content-list-url-state.ts`) — the same
17
+ * validator the URL path uses — then holds the hydrated state to the
18
+ * adapter's published column and operator vocabulary. A stored view can no
19
+ * more restore a filter on the hidden `description` column, a structural
20
+ * `select`/`actions` column, or a column that has since been removed than a
21
+ * crafted link can.
22
+ *
23
+ * A stale view is not an error: a snapshot that references a column which no
24
+ * longer exists restores the valid remainder and reports the drops, so the UI
25
+ * can say what it discarded instead of refusing to open the list.
26
+ *
27
+ * The storage seam is deliberately narrow (`list`/`get`/`save`/`delete`) and
28
+ * asynchronous, so the server-backed store that lands later is a drop-in
29
+ * replacement rather than a signature change. Nothing here knows about a
30
+ * backend, a tenant, or a principal.
31
+ */
32
+ import { type DataTableSnapshot, type DataTableViewState } from '@happyvertical/smrt-ui/data';
33
+ import { type ContentListStateDrop, type ContentListStateValidationOptions } from './content-list-url-state.js';
34
+ export type { ContentListStateDrop, ContentListStateDropReason, ContentListStateDropScope, } from './content-list-url-state.js';
35
+ /**
36
+ * Envelope version for a stored view. Bumped only when the envelope around the
37
+ * snapshot changes; the snapshot carries its own `version` for the state.
38
+ */
39
+ export declare const CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION = 1;
40
+ /** Prefix of the `localStorage` key a saved-view store owns. */
41
+ export declare const CONTENT_LIST_SAVED_VIEW_STORAGE_PREFIX = "smrt:content-list:saved-views";
42
+ /**
43
+ * A stored snapshot exactly as it was persisted: structurally hydrated by
44
+ * `hydrateDataTableSnapshot`, but **not** validated against this list's column
45
+ * and operator vocabulary.
46
+ *
47
+ * The raw payload is kept on purpose — a stale view has to be able to report
48
+ * what it referenced (`restoreContentListSavedView` returns those drops), and
49
+ * sanitizing on read would erase the evidence. Never hand one of these to a
50
+ * controller directly: go through `restoreContentListSavedView` when the drops
51
+ * matter, or `applyContentListViewState`, which validates its patch.
52
+ */
53
+ export type RawContentListViewSnapshot = DataTableSnapshot;
54
+ /** A named, restorable content list view. */
55
+ export interface ContentListSavedView {
56
+ /** Stable identity. Survives renames and re-saves. */
57
+ id: string;
58
+ /** Operator-facing name. */
59
+ name: string;
60
+ /** Envelope version, currently `CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION`. */
61
+ schemaVersion: number;
62
+ /**
63
+ * The persisted view payload — structurally normalized on write, and
64
+ * deliberately NOT column-validated. See {@link RawContentListViewSnapshot}.
65
+ */
66
+ snapshot: RawContentListViewSnapshot;
67
+ /** ISO timestamps, for ordering the operator's list. */
68
+ createdAt: string;
69
+ updatedAt: string;
70
+ }
71
+ /** What a caller supplies to create or update a view. */
72
+ export interface ContentListSavedViewInput {
73
+ /** Omit to create; supply to overwrite an existing view in place. */
74
+ id?: string;
75
+ name: string;
76
+ snapshot: DataTableSnapshot;
77
+ }
78
+ /**
79
+ * The persistence seam. Asynchronous on purpose: the `localStorage` default is
80
+ * synchronous underneath, but a server-backed store cannot be, and changing
81
+ * the signature later would break every consumer.
82
+ */
83
+ export interface ContentListSavedViewStore {
84
+ /**
85
+ * Every stored view, most recently updated first. Corrupt entries are
86
+ * skipped. Each `snapshot` is a {@link RawContentListViewSnapshot} — hydrated
87
+ * but unvalidated. Restore it with `restoreContentListSavedView`, or apply it
88
+ * through `applyContentListViewState`, which validates.
89
+ */
90
+ list(): Promise<ContentListSavedView[]>;
91
+ /** One view, or `null` when it is absent or unreadable. Snapshot is raw. */
92
+ get(id: string): Promise<ContentListSavedView | null>;
93
+ /** Creates or replaces a view. Throws `TypeError` on an unusable snapshot. */
94
+ save(input: ContentListSavedViewInput): Promise<ContentListSavedView>;
95
+ /** Removes a view. Resolves `false` when there was nothing to remove. */
96
+ delete(id: string): Promise<boolean>;
97
+ }
98
+ /** The concrete store also reports whether writes actually persist. */
99
+ export interface ContentListLocalSavedViewStore extends ContentListSavedViewStore {
100
+ /**
101
+ * False once the store has fallen back to memory — blocked storage, private
102
+ * browsing, or a server render. A UI can warn that views will not survive a
103
+ * reload instead of silently losing them.
104
+ */
105
+ isPersistent(): boolean;
106
+ }
107
+ /** The slice of the DOM Storage API this module uses. */
108
+ export interface ContentListSavedViewStorage {
109
+ getItem(key: string): string | null;
110
+ setItem(key: string, value: string): void;
111
+ removeItem(key: string): void;
112
+ }
113
+ export interface ContentListSavedViewStoreOptions {
114
+ /**
115
+ * Storage backend. Defaults to `globalThis.localStorage` when it is reachable
116
+ * and writable; `null` forces the in-memory store.
117
+ */
118
+ storage?: ContentListSavedViewStorage | null;
119
+ /** Distinguishes several mounted lists. Defaults to the adapter's surface id. */
120
+ surfaceId?: string;
121
+ /** Full storage key override; takes precedence over `surfaceId`. */
122
+ storageKey?: string;
123
+ /** Injectable clock, for deterministic tests. */
124
+ now?: () => Date;
125
+ /** Injectable id factory, for deterministic tests. */
126
+ createId?: () => string;
127
+ }
128
+ /** The outcome of restoring a saved view. */
129
+ export interface ContentListSavedViewRestoration {
130
+ /**
131
+ * The validated patch to apply, via `applyContentListViewState`. Selection
132
+ * and expansion are never present: a saved view names a query, not a set of
133
+ * rows someone had checked.
134
+ */
135
+ state: Partial<DataTableViewState>;
136
+ /** Everything the validator refused, for reporting to the operator. */
137
+ dropped: ContentListStateDrop[];
138
+ }
139
+ /**
140
+ * Creates the default saved-view store.
141
+ *
142
+ * Backed by `localStorage` when it is reachable and writable, and by an
143
+ * in-memory map otherwise — a server render, a private window, or a browser
144
+ * with site data blocked degrades to a store that works for the session
145
+ * instead of throwing on the first save.
146
+ */
147
+ export declare function createContentListSavedViewStore(options?: ContentListSavedViewStoreOptions): ContentListLocalSavedViewStore;
148
+ /**
149
+ * An explicitly non-persistent store, for server rendering and for tests that
150
+ * must not touch ambient storage.
151
+ */
152
+ export declare function createContentListMemorySavedViewStore(options?: Omit<ContentListSavedViewStoreOptions, 'storage'>): ContentListLocalSavedViewStore;
153
+ /**
154
+ * Validates a stored payload and returns the patch to apply.
155
+ *
156
+ * Accepts either a whole `ContentListSavedView` or a bare snapshot. Throws
157
+ * `TypeError` — via `hydrateDataTableSnapshot` — when the payload is not a
158
+ * supported snapshot at all; column-level problems are reported in `dropped`
159
+ * so a stale view still opens.
160
+ */
161
+ export declare function restoreContentListSavedView(value: unknown, options?: ContentListStateValidationOptions): ContentListSavedViewRestoration;
162
+ /**
163
+ * Builds the input for `store.save` from a controller snapshot.
164
+ *
165
+ * Selection and expansion are stripped before the snapshot is stored: a saved
166
+ * view names a query, and persisting the rows someone had checked would restore
167
+ * a selection into a list whose rows have since changed.
168
+ */
169
+ export declare function toContentListSavedViewInput(name: string, snapshot: DataTableSnapshot, options?: {
170
+ id?: string;
171
+ }): ContentListSavedViewInput;
172
+ //# sourceMappingURL=content-list-saved-views.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-list-saved-views.d.ts","sourceRoot":"","sources":["../../src/svelte/content-list-saved-views.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Saved views for ContentList (#2452).
3
+ *
4
+ * A saved view is a named `DataTableSnapshot` an operator can come back to. The
5
+ * payload is persisted outside the application's control — today in
6
+ * `localStorage`, which is writable by anyone with a console — so it is treated
7
+ * exactly like a URL: untrusted on the way back in.
8
+ *
9
+ * Restoration is therefore two gates, in order:
10
+ *
11
+ * 1. `hydrateDataTableSnapshot` (smrt-ui) parses the stored payload
12
+ * structurally, upgrades version 1/2 into normalized version 3, and throws
13
+ * on anything it cannot make sense of. It intentionally runs *without*
14
+ * column ids, so it normalizes shape but does not know which columns this
15
+ * list publishes.
16
+ * 2. `sanitizeContentListViewState` (`content-list-url-state.ts`) — the same
17
+ * validator the URL path uses — then holds the hydrated state to the
18
+ * adapter's published column and operator vocabulary. A stored view can no
19
+ * more restore a filter on the hidden `description` column, a structural
20
+ * `select`/`actions` column, or a column that has since been removed than a
21
+ * crafted link can.
22
+ *
23
+ * A stale view is not an error: a snapshot that references a column which no
24
+ * longer exists restores the valid remainder and reports the drops, so the UI
25
+ * can say what it discarded instead of refusing to open the list.
26
+ *
27
+ * The storage seam is deliberately narrow (`list`/`get`/`save`/`delete`) and
28
+ * asynchronous, so the server-backed store that lands later is a drop-in
29
+ * replacement rather than a signature change. Nothing here knows about a
30
+ * backend, a tenant, or a principal.
31
+ */
32
+ import { hydrateDataTableSnapshot, } from '@happyvertical/smrt-ui/data';
33
+ import { CONTENT_LIST_SURFACE_ID } from './content-list-controller.js';
34
+ import { sanitizeContentListViewState, } from './content-list-url-state.js';
35
+ /**
36
+ * Envelope version for a stored view. Bumped only when the envelope around the
37
+ * snapshot changes; the snapshot carries its own `version` for the state.
38
+ */
39
+ export const CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION = 1;
40
+ /** Prefix of the `localStorage` key a saved-view store owns. */
41
+ export const CONTENT_LIST_SAVED_VIEW_STORAGE_PREFIX = 'smrt:content-list:saved-views';
42
+ function storageKeyFor(options) {
43
+ if (options.storageKey)
44
+ return options.storageKey;
45
+ const surfaceId = options.surfaceId ?? CONTENT_LIST_SURFACE_ID;
46
+ return `${CONTENT_LIST_SAVED_VIEW_STORAGE_PREFIX}:v${CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION}:${surfaceId}`;
47
+ }
48
+ /**
49
+ * Resolves the ambient `localStorage`, or `null` when it is unreachable.
50
+ *
51
+ * Reading the property itself throws in some browsers when site data is
52
+ * blocked, and Safari's private mode throws on the first `setItem` rather than
53
+ * on access — so availability is probed with a real write, not assumed.
54
+ */
55
+ function resolveAmbientStorage() {
56
+ try {
57
+ const storage = globalThis.localStorage;
58
+ if (!storage)
59
+ return null;
60
+ const probe = `${CONTENT_LIST_SAVED_VIEW_STORAGE_PREFIX}:probe`;
61
+ storage.setItem(probe, '1');
62
+ storage.removeItem(probe);
63
+ return storage;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function createMemoryStorage() {
70
+ const entries = new Map();
71
+ return {
72
+ getItem: (key) => entries.get(key) ?? null,
73
+ setItem: (key, value) => {
74
+ entries.set(key, value);
75
+ },
76
+ removeItem: (key) => {
77
+ entries.delete(key);
78
+ },
79
+ };
80
+ }
81
+ function defaultCreateId() {
82
+ try {
83
+ const cryptoRef = globalThis
84
+ .crypto;
85
+ if (typeof cryptoRef?.randomUUID === 'function') {
86
+ return cryptoRef.randomUUID();
87
+ }
88
+ }
89
+ catch {
90
+ // Fall through to the non-cryptographic id below.
91
+ }
92
+ return `view-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
93
+ }
94
+ function isPlainObject(value) {
95
+ return (typeof value === 'object' &&
96
+ value !== null &&
97
+ !Array.isArray(value) &&
98
+ (Object.getPrototypeOf(value) === Object.prototype ||
99
+ Object.getPrototypeOf(value) === null));
100
+ }
101
+ /**
102
+ * Parses one stored entry, or returns `null` when it is unusable.
103
+ *
104
+ * Every stored record goes through `hydrateDataTableSnapshot`, so a hand-edited
105
+ * or truncated entry is discarded on read rather than handed to the controller.
106
+ */
107
+ function parseStoredView(value) {
108
+ if (!isPlainObject(value))
109
+ return null;
110
+ const { id, name, snapshot, createdAt, updatedAt, schemaVersion } = value;
111
+ if (typeof id !== 'string' || id.length === 0)
112
+ return null;
113
+ if (typeof name !== 'string')
114
+ return null;
115
+ if (schemaVersion !== undefined &&
116
+ schemaVersion !== CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION) {
117
+ return null;
118
+ }
119
+ let hydrated;
120
+ try {
121
+ hydrated = hydrateDataTableSnapshot(snapshot);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ const created = typeof createdAt === 'string' ? createdAt : '';
127
+ return {
128
+ id,
129
+ name,
130
+ schemaVersion: CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION,
131
+ snapshot: hydrated,
132
+ createdAt: created,
133
+ updatedAt: typeof updatedAt === 'string' ? updatedAt : created,
134
+ };
135
+ }
136
+ function sortViews(views) {
137
+ return views.sort((left, right) => {
138
+ if (left.updatedAt === right.updatedAt) {
139
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
140
+ }
141
+ return left.updatedAt < right.updatedAt ? 1 : -1;
142
+ });
143
+ }
144
+ /**
145
+ * Creates the default saved-view store.
146
+ *
147
+ * Backed by `localStorage` when it is reachable and writable, and by an
148
+ * in-memory map otherwise — a server render, a private window, or a browser
149
+ * with site data blocked degrades to a store that works for the session
150
+ * instead of throwing on the first save.
151
+ */
152
+ export function createContentListSavedViewStore(options = {}) {
153
+ const key = storageKeyFor(options);
154
+ const now = options.now ?? (() => new Date());
155
+ const createId = options.createId ?? defaultCreateId;
156
+ const requested = options.storage === undefined ? resolveAmbientStorage() : options.storage;
157
+ let storage = requested ?? createMemoryStorage();
158
+ let persistent = requested !== null && requested !== undefined;
159
+ /** Demotes to memory the first time the backing storage misbehaves. */
160
+ function degrade() {
161
+ if (!persistent)
162
+ return;
163
+ persistent = false;
164
+ storage = createMemoryStorage();
165
+ }
166
+ function readAll() {
167
+ let raw;
168
+ try {
169
+ raw = storage.getItem(key);
170
+ }
171
+ catch {
172
+ degrade();
173
+ return [];
174
+ }
175
+ if (!raw)
176
+ return [];
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(raw);
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ if (!Array.isArray(parsed))
185
+ return [];
186
+ const views = [];
187
+ for (const entry of parsed) {
188
+ const view = parseStoredView(entry);
189
+ if (view)
190
+ views.push(view);
191
+ }
192
+ return views;
193
+ }
194
+ function writeAll(views) {
195
+ try {
196
+ storage.setItem(key, JSON.stringify(views));
197
+ }
198
+ catch {
199
+ // A quota or private-mode failure must not lose the operator's view:
200
+ // demote to memory and keep the write.
201
+ degrade();
202
+ try {
203
+ storage.setItem(key, JSON.stringify(views));
204
+ }
205
+ catch {
206
+ // The in-memory fallback cannot fail; nothing further to do.
207
+ }
208
+ }
209
+ }
210
+ return {
211
+ isPersistent: () => persistent,
212
+ async list() {
213
+ return sortViews(readAll());
214
+ },
215
+ async get(id) {
216
+ return readAll().find((view) => view.id === id) ?? null;
217
+ },
218
+ async save(input) {
219
+ if (!input || typeof input.name !== 'string' || !input.name.trim()) {
220
+ throw new TypeError('A content list saved view requires a name');
221
+ }
222
+ // Hydrating on write keeps a malformed payload out of storage entirely,
223
+ // so the only tampered payloads the read path has to survive are ones
224
+ // written around this API.
225
+ const snapshot = hydrateDataTableSnapshot(input.snapshot);
226
+ const timestamp = now().toISOString();
227
+ const views = readAll();
228
+ const existing = input.id
229
+ ? views.find((view) => view.id === input.id)
230
+ : undefined;
231
+ const view = {
232
+ id: existing?.id ?? input.id ?? createId(),
233
+ name: input.name.trim(),
234
+ schemaVersion: CONTENT_LIST_SAVED_VIEW_SCHEMA_VERSION,
235
+ snapshot,
236
+ createdAt: existing?.createdAt || timestamp,
237
+ updatedAt: timestamp,
238
+ };
239
+ writeAll([...views.filter((entry) => entry.id !== view.id), view]);
240
+ return view;
241
+ },
242
+ async delete(id) {
243
+ const views = readAll();
244
+ const next = views.filter((view) => view.id !== id);
245
+ if (next.length === views.length)
246
+ return false;
247
+ writeAll(next);
248
+ return true;
249
+ },
250
+ };
251
+ }
252
+ /**
253
+ * An explicitly non-persistent store, for server rendering and for tests that
254
+ * must not touch ambient storage.
255
+ */
256
+ export function createContentListMemorySavedViewStore(options = {}) {
257
+ // `null` is the documented "force memory" signal, so the store also reports
258
+ // itself as non-persistent rather than claiming durability it does not have.
259
+ return createContentListSavedViewStore({ ...options, storage: null });
260
+ }
261
+ /**
262
+ * Validates a stored payload and returns the patch to apply.
263
+ *
264
+ * Accepts either a whole `ContentListSavedView` or a bare snapshot. Throws
265
+ * `TypeError` — via `hydrateDataTableSnapshot` — when the payload is not a
266
+ * supported snapshot at all; column-level problems are reported in `dropped`
267
+ * so a stale view still opens.
268
+ */
269
+ export function restoreContentListSavedView(value, options = {}) {
270
+ const payload = isPlainObject(value) && Object.hasOwn(value, 'snapshot')
271
+ ? value.snapshot
272
+ : value;
273
+ const hydrated = hydrateDataTableSnapshot(payload);
274
+ const { state, dropped } = sanitizeContentListViewState(hydrated.state, options);
275
+ return { state, dropped };
276
+ }
277
+ /**
278
+ * Builds the input for `store.save` from a controller snapshot.
279
+ *
280
+ * Selection and expansion are stripped before the snapshot is stored: a saved
281
+ * view names a query, and persisting the rows someone had checked would restore
282
+ * a selection into a list whose rows have since changed.
283
+ */
284
+ export function toContentListSavedViewInput(name, snapshot, options = {}) {
285
+ return {
286
+ ...(options.id ? { id: options.id } : {}),
287
+ name,
288
+ snapshot: {
289
+ ...snapshot,
290
+ state: {
291
+ ...snapshot.state,
292
+ selection: { scope: 'explicit', rowIds: [] },
293
+ selectedRowIds: [],
294
+ expandedRowIds: [],
295
+ },
296
+ },
297
+ };
298
+ }