shelving 1.272.0 → 1.273.1
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.
- package/db/cache/DBCache.d.ts +1 -1
- package/db/cache/DBCache.js +3 -2
- package/db/index.d.ts +1 -0
- package/db/index.js +1 -0
- package/db/provider/MemoryDBProvider.d.ts +15 -2
- package/db/provider/MemoryDBProvider.js +33 -47
- package/db/provider/StorageDBProvider.d.ts +79 -0
- package/db/provider/StorageDBProvider.js +163 -0
- package/package.json +1 -1
- package/ui/app/App.md +2 -0
- package/ui/misc/MetaContext.d.ts +4 -3
- package/ui/misc/MetaContext.js +6 -4
- package/ui/misc/MetaContext.test.tsx +33 -3
- package/ui/misc/MetaContext.tsx +6 -4
- package/ui/router/Navigation.d.ts +2 -2
- package/ui/router/Navigation.js +5 -4
- package/ui/router/Navigation.md +2 -1
- package/ui/router/Navigation.test.tsx +29 -0
- package/ui/router/Navigation.tsx +5 -5
- package/ui/util/meta.d.ts +3 -2
- package/ui/util/meta.js +6 -3
- package/ui/util/meta.test.ts +17 -0
- package/ui/util/meta.ts +8 -4
package/db/cache/DBCache.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Identifier, Item } from "../../util/item.js";
|
|
|
3
3
|
import type { Query } from "../../util/query.js";
|
|
4
4
|
import type { Collection } from "../collection/Collection.js";
|
|
5
5
|
import type { DBProvider } from "../provider/DBProvider.js";
|
|
6
|
-
import
|
|
6
|
+
import { MemoryDBProvider } from "../provider/MemoryDBProvider.js";
|
|
7
7
|
import type { ItemStore } from "../store/ItemStore.js";
|
|
8
8
|
import type { QueryStore } from "../store/QueryStore.js";
|
|
9
9
|
import { CollectionCache } from "./CollectionCache.js";
|
package/db/cache/DBCache.js
CHANGED
|
@@ -2,6 +2,7 @@ import { awaitDispose } from "../../util/dispose.js";
|
|
|
2
2
|
import { setMapItem } from "../../util/map.js";
|
|
3
3
|
import { getSource } from "../../util/source.js";
|
|
4
4
|
import { CacheDBProvider } from "../provider/CacheDBProvider.js";
|
|
5
|
+
import { MemoryDBProvider } from "../provider/MemoryDBProvider.js";
|
|
5
6
|
import { CollectionCache } from "./CollectionCache.js";
|
|
6
7
|
/**
|
|
7
8
|
* Cache of `CollectionCache` objects for multiple collections.
|
|
@@ -28,8 +29,8 @@ export class DBCache {
|
|
|
28
29
|
memory;
|
|
29
30
|
constructor(provider) {
|
|
30
31
|
this.provider = provider;
|
|
31
|
-
// If the provider chain contains a `CacheDBProvider`, reuse its memory so we can seed stores synchronously.
|
|
32
|
-
this.memory = getSource(CacheDBProvider, provider)?.memory;
|
|
32
|
+
// If the provider is itself in-memory (e.g. `MemoryDBProvider` or `StorageDBProvider`), or the chain contains a `CacheDBProvider`, reuse its memory so we can seed stores synchronously.
|
|
33
|
+
this.memory = provider instanceof MemoryDBProvider ? provider : getSource(CacheDBProvider, provider)?.memory;
|
|
33
34
|
}
|
|
34
35
|
_get(collection) {
|
|
35
36
|
return this._collections.get(collection);
|
package/db/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./provider/MockDBProvider.js";
|
|
|
14
14
|
export * from "./provider/PostgreSQLProvider.js";
|
|
15
15
|
export * from "./provider/SQLiteProvider.js";
|
|
16
16
|
export * from "./provider/SQLProvider.js";
|
|
17
|
+
export * from "./provider/StorageDBProvider.js";
|
|
17
18
|
export * from "./provider/ThroughDBProvider.js";
|
|
18
19
|
export * from "./provider/ValidationDBProvider.js";
|
|
19
20
|
export * from "./store/ItemStore.js";
|
package/db/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./provider/MockDBProvider.js";
|
|
|
14
14
|
export * from "./provider/PostgreSQLProvider.js";
|
|
15
15
|
export * from "./provider/SQLiteProvider.js";
|
|
16
16
|
export * from "./provider/SQLProvider.js";
|
|
17
|
+
export * from "./provider/StorageDBProvider.js";
|
|
17
18
|
export * from "./provider/ThroughDBProvider.js";
|
|
18
19
|
export * from "./provider/ValidationDBProvider.js";
|
|
19
20
|
export * from "./store/ItemStore.js";
|
|
@@ -16,7 +16,9 @@ import { DBProvider } from "./DBProvider.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export declare class MemoryDBProvider<I extends Identifier = Identifier, T extends Data = Data> extends DBProvider<I, T> {
|
|
18
18
|
/** List of tables in `{ name: MemoryTable }` format. */
|
|
19
|
-
|
|
19
|
+
protected _tables: {
|
|
20
|
+
[K in string]?: MemoryTable<I, T>;
|
|
21
|
+
};
|
|
20
22
|
/**
|
|
21
23
|
* Get (or lazily create) the `MemoryTable` backing a collection.
|
|
22
24
|
*
|
|
@@ -25,6 +27,15 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
|
|
|
25
27
|
* @see https://shelving.cc/db/MemoryDBProvider/getTable
|
|
26
28
|
*/
|
|
27
29
|
getTable<II extends I, TT extends T>(collection: Collection<string, II, TT>): MemoryTable<II, TT>;
|
|
30
|
+
/**
|
|
31
|
+
* Create a new `MemoryTable` for a collection (without registering it — use `getTable()` for that).
|
|
32
|
+
* - Override point for subclasses that back collections with a specialised table, e.g. `StorageDBProvider`.
|
|
33
|
+
*
|
|
34
|
+
* @param collection Collection to create a table for.
|
|
35
|
+
* @example provider.createTable(users) // MemoryTable
|
|
36
|
+
* @see https://shelving.cc/db/MemoryDBProvider/createTable
|
|
37
|
+
*/
|
|
38
|
+
createTable<II extends I, TT extends T>(collection: Collection<string, II, TT>): MemoryTable<II, TT>;
|
|
28
39
|
getItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<OptionalItem<II, TT>>;
|
|
29
40
|
getItemSequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): OptionalItemSequence<II, TT>;
|
|
30
41
|
addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
|
|
@@ -46,6 +57,7 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
|
|
|
46
57
|
* @see https://shelving.cc/db/MemoryDBProvider/setItems
|
|
47
58
|
*/
|
|
48
59
|
setItems<II extends I, TT extends T>(collection: Collection<string, II, TT>, items: Items<II, TT>): void;
|
|
60
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
49
61
|
}
|
|
50
62
|
/**
|
|
51
63
|
* In-memory table holding the items of a single collection for a `MemoryDBProvider`.
|
|
@@ -59,7 +71,7 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
|
|
|
59
71
|
*
|
|
60
72
|
* @see https://shelving.cc/db/MemoryTable
|
|
61
73
|
*/
|
|
62
|
-
export declare class MemoryTable<I extends Identifier, T extends Data> {
|
|
74
|
+
export declare class MemoryTable<I extends Identifier, T extends Data> implements AsyncDisposable {
|
|
63
75
|
/** Actual data in this table. */
|
|
64
76
|
protected readonly _data: Map<I, Item<I, T>>;
|
|
65
77
|
/**
|
|
@@ -203,4 +215,5 @@ export declare class MemoryTable<I extends Identifier, T extends Data> {
|
|
|
203
215
|
deleteQuery(query: Query<Item<I, T>>): void;
|
|
204
216
|
setItems(items: Items<I, T>): void;
|
|
205
217
|
setItemsSequence(sequence: AsyncIterable<Items<I, T>>): AsyncIterable<Items<I, T>>;
|
|
218
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
206
219
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StringSchema } from "../../schema/StringSchema.js";
|
|
2
2
|
import { DeferredSequence } from "../../sequence/DeferredSequence.js";
|
|
3
3
|
import { requireArray } from "../../util/array.js";
|
|
4
|
+
import { awaitDispose } from "../../util/dispose.js";
|
|
4
5
|
import { isArrayEqual } from "../../util/equal.js";
|
|
5
6
|
import { getItem } from "../../util/item.js";
|
|
6
7
|
import { countItems } from "../../util/iterate.js";
|
|
@@ -28,7 +29,18 @@ export class MemoryDBProvider extends DBProvider {
|
|
|
28
29
|
* @see https://shelving.cc/db/MemoryDBProvider/getTable
|
|
29
30
|
*/
|
|
30
31
|
getTable(collection) {
|
|
31
|
-
return (this._tables[collection.name] ||=
|
|
32
|
+
return (this._tables[collection.name] ||= this.createTable(collection));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a new `MemoryTable` for a collection (without registering it — use `getTable()` for that).
|
|
36
|
+
* - Override point for subclasses that back collections with a specialised table, e.g. `StorageDBProvider`.
|
|
37
|
+
*
|
|
38
|
+
* @param collection Collection to create a table for.
|
|
39
|
+
* @example provider.createTable(users) // MemoryTable
|
|
40
|
+
* @see https://shelving.cc/db/MemoryDBProvider/createTable
|
|
41
|
+
*/
|
|
42
|
+
createTable(collection) {
|
|
43
|
+
return new MemoryTable(collection);
|
|
32
44
|
}
|
|
33
45
|
async getItem(collection, id) {
|
|
34
46
|
return this.getTable(collection).getItem(id);
|
|
@@ -77,6 +89,11 @@ export class MemoryDBProvider extends DBProvider {
|
|
|
77
89
|
setItems(collection, items) {
|
|
78
90
|
this.getTable(collection).setItems(items);
|
|
79
91
|
}
|
|
92
|
+
// Implement `AsyncDisposable`
|
|
93
|
+
async [Symbol.asyncDispose]() {
|
|
94
|
+
await awaitDispose(...Object.values(this._tables), // Dispose all tables.
|
|
95
|
+
super[Symbol.asyncDispose]());
|
|
96
|
+
}
|
|
80
97
|
}
|
|
81
98
|
/**
|
|
82
99
|
* In-memory table holding the items of a single collection for a `MemoryDBProvider`.
|
|
@@ -214,11 +231,7 @@ export class MemoryTable {
|
|
|
214
231
|
const oldItem = this._data.get(id);
|
|
215
232
|
if (!oldItem)
|
|
216
233
|
return;
|
|
217
|
-
|
|
218
|
-
if (this._data.get(id) !== nextItem) {
|
|
219
|
-
this._data.set(id, nextItem);
|
|
220
|
-
this.next.resolve();
|
|
221
|
-
}
|
|
234
|
+
this.setItem(id, updateData(oldItem, updates));
|
|
222
235
|
}
|
|
223
236
|
/**
|
|
224
237
|
* Delete an item by its id.
|
|
@@ -287,16 +300,8 @@ export class MemoryTable {
|
|
|
287
300
|
* @see https://shelving.cc/db/MemoryTable/setQuery
|
|
288
301
|
*/
|
|
289
302
|
setQuery(query, data) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const item = getItem(id, data);
|
|
293
|
-
if (this._data.get(id) !== item) {
|
|
294
|
-
this._data.set(id, item);
|
|
295
|
-
changed = true;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
if (changed)
|
|
299
|
-
this.next.resolve();
|
|
303
|
+
for (const { id } of queryWritableItems(this._data.values(), query))
|
|
304
|
+
this.setItem(id, data);
|
|
300
305
|
}
|
|
301
306
|
/**
|
|
302
307
|
* Apply partial updates to every item matching a query.
|
|
@@ -307,41 +312,16 @@ export class MemoryTable {
|
|
|
307
312
|
* @see https://shelving.cc/db/MemoryTable/updateQuery
|
|
308
313
|
*/
|
|
309
314
|
updateQuery(query, updates) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const oldItem = this._data.get(id);
|
|
313
|
-
if (!oldItem)
|
|
314
|
-
continue;
|
|
315
|
-
const nextItem = updateData(oldItem, updates);
|
|
316
|
-
if (this._data.get(id) !== nextItem) {
|
|
317
|
-
this._data.set(id, nextItem);
|
|
318
|
-
changed = true;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (changed)
|
|
322
|
-
this.next.resolve();
|
|
315
|
+
for (const { id } of queryWritableItems(this._data.values(), query))
|
|
316
|
+
this.updateItem(id, updates);
|
|
323
317
|
}
|
|
324
318
|
deleteQuery(query) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (this._data.has(id)) {
|
|
328
|
-
this._data.delete(id);
|
|
329
|
-
changed = true;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
if (changed)
|
|
333
|
-
this.next.resolve();
|
|
319
|
+
for (const { id } of queryWritableItems(this._data.values(), query))
|
|
320
|
+
this.deleteItem(id);
|
|
334
321
|
}
|
|
335
322
|
setItems(items) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (this._data.get(item.id) !== item) {
|
|
339
|
-
this._data.set(item.id, item);
|
|
340
|
-
changed = true;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (changed)
|
|
344
|
-
this.next.resolve();
|
|
323
|
+
for (const item of items)
|
|
324
|
+
this.setItem(item.id, item);
|
|
345
325
|
}
|
|
346
326
|
async *setItemsSequence(sequence) {
|
|
347
327
|
for await (const items of sequence) {
|
|
@@ -349,4 +329,10 @@ export class MemoryTable {
|
|
|
349
329
|
yield items;
|
|
350
330
|
}
|
|
351
331
|
}
|
|
332
|
+
// Implement `AsyncDisposable`
|
|
333
|
+
async [Symbol.asyncDispose]() {
|
|
334
|
+
await awaitDispose(
|
|
335
|
+
// Empty by default.
|
|
336
|
+
);
|
|
337
|
+
}
|
|
352
338
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Data } from "../../util/data.js";
|
|
2
|
+
import type { Identifier, Item } from "../../util/item.js";
|
|
3
|
+
import type { Collection } from "../collection/Collection.js";
|
|
4
|
+
import { MemoryDBProvider, MemoryTable } from "./MemoryDBProvider.js";
|
|
5
|
+
/**
|
|
6
|
+
* In-memory database provider that persists every collection to a `Storage` — `localStorage`, `sessionStorage`, or anything else with the same interface.
|
|
7
|
+
*
|
|
8
|
+
* - Extends `MemoryDBProvider`, so all reads, queries, and realtime sequences are served synchronously from memory, and it can seed `ItemStore` / `QueryStore` and act as the cache inside `CacheDBProvider`. The only difference is that collections are backed by `StorageTable` instead of `MemoryTable`.
|
|
9
|
+
* - The storage is a required argument (there is no default), so server code that constructs this provider must reference `localStorage` / `sessionStorage` itself — surfacing the mistake at the callsite instead of deep inside this class.
|
|
10
|
+
* - Treat the persisted data as best-effort: quota is shared across the origin (typically ~5MB) and users can clear it at any time. Data read back from storage is unvalidated — wrap this provider in `ValidationDBProvider` if it may have been written by an older version of your app.
|
|
11
|
+
* - If the storage is unusable (writes blocked by browser settings, or private browsing with zero quota), the provider degrades to memory-only operation — check the `persistent` flag to warn users their changes won't be saved.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const provider = new StorageDBProvider(localStorage);
|
|
15
|
+
* if (!provider.persistent) console.warn("Changes won't be saved on this device.");
|
|
16
|
+
* const id = await provider.addItem(users, { name: "Dave" });
|
|
17
|
+
*
|
|
18
|
+
* @see https://shelving.cc/db/StorageDBProvider
|
|
19
|
+
*/
|
|
20
|
+
export declare class StorageDBProvider<I extends Identifier = Identifier, T extends Data = Data> extends MemoryDBProvider<I, T> {
|
|
21
|
+
/**
|
|
22
|
+
* Prefix for every storage key written by this provider.
|
|
23
|
+
*
|
|
24
|
+
* @see https://shelving.cc/db/StorageDBProvider/prefix
|
|
25
|
+
*/
|
|
26
|
+
readonly prefix: string;
|
|
27
|
+
/**
|
|
28
|
+
* Whether this provider is actually persisting to storage.
|
|
29
|
+
* - `false` when storage exists but is unusable (e.g. blocked by browser settings, or private browsing with zero quota) — the provider still works, but data only lives in memory and is lost when the page closes.
|
|
30
|
+
*
|
|
31
|
+
* @see https://shelving.cc/db/StorageDBProvider/persistent
|
|
32
|
+
*/
|
|
33
|
+
readonly persistent: boolean;
|
|
34
|
+
/** Storage being persisted to, or `undefined` when operating memory-only. */
|
|
35
|
+
private readonly _storage;
|
|
36
|
+
/**
|
|
37
|
+
* @param storage `Storage` instance to persist to, e.g. `localStorage` or `sessionStorage` (required so environments without one, e.g. the server, fail at the callsite).
|
|
38
|
+
* @param prefix Prefix for every storage key written by this provider, so it can share an origin's storage with other code (defaults to `"shelving:"`).
|
|
39
|
+
*/
|
|
40
|
+
constructor(storage: Storage, prefix?: string);
|
|
41
|
+
/** Back collections with a `StorageTable`, or a plain `MemoryTable` when operating memory-only. */
|
|
42
|
+
createTable<II extends I, TT extends T>(collection: Collection<string, II, TT>): MemoryTable<II, TT>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `MemoryTable` that persists its items to a `Storage` instance — self-contained, so it can also be used independently of `StorageDBProvider`.
|
|
46
|
+
*
|
|
47
|
+
* - Hydrates itself from storage on construction, then persists each write *before* applying it to memory, so a failed write (e.g. `QuotaExceededError` when the origin's quota is full) throws and leaves memory and storage consistent.
|
|
48
|
+
* - Items are stored as JSON under keys formatted as `{prefix}{collection}:{id}`, so values must be JSON-serializable. The id in the key is authoritative — an `id` stored inside the JSON is overridden.
|
|
49
|
+
* - Listens for `storage` events, so changes made in other tabs/windows update memory and notify realtime sequences. Dispose the table (or its provider) to remove the listener.
|
|
50
|
+
*
|
|
51
|
+
* @see https://shelving.cc/db/StorageTable
|
|
52
|
+
*/
|
|
53
|
+
export declare class StorageTable<I extends Identifier, T extends Data> extends MemoryTable<I, T> {
|
|
54
|
+
/** Prefix for every storage key belonging to this table, e.g. `"shelving:users:"`. */
|
|
55
|
+
private readonly _prefix;
|
|
56
|
+
/** Storage this table persists to. */
|
|
57
|
+
private readonly _storage;
|
|
58
|
+
/** Attached `storage` event listener (so it can be detached on dispose), or `undefined` if none was attached. */
|
|
59
|
+
private readonly _listener;
|
|
60
|
+
constructor(collection: Collection<string, I, T>, storage: Storage, prefix?: string);
|
|
61
|
+
/** Load every persisted item of this table's collection into memory (directly, skipping the persist-back in `setItem()`). */
|
|
62
|
+
private _hydrate;
|
|
63
|
+
/** Storage key for an item of this table. */
|
|
64
|
+
private _key;
|
|
65
|
+
/** Identifier encoded in a storage key of this table. */
|
|
66
|
+
private _decodeKey;
|
|
67
|
+
/** Parse a stored JSON value into an item with the given id, or `undefined` if it's malformed. */
|
|
68
|
+
private _parse;
|
|
69
|
+
/**
|
|
70
|
+
* Apply a `storage` event (a change made in another tab/window) to this table.
|
|
71
|
+
* - Reuses `setItem()` / `deleteItem()` — re-persisting the value already in storage is a no-op, and same-tab writes don't fire `storage` events, so this can't loop.
|
|
72
|
+
*/
|
|
73
|
+
private _syncStorage;
|
|
74
|
+
/** Persist to storage first — a failure (e.g. `QuotaExceededError`) throws before memory changes, keeping the two consistent. */
|
|
75
|
+
setItem(id: I, data: Item<I, T> | T): void;
|
|
76
|
+
/** Remove from storage first, keeping storage and memory consistent. */
|
|
77
|
+
deleteItem(id: I): void;
|
|
78
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { UnsupportedError } from "../../error/UnsupportedError.js";
|
|
2
|
+
import { StringSchema } from "../../schema/StringSchema.js";
|
|
3
|
+
import { isData } from "../../util/data.js";
|
|
4
|
+
import { awaitDispose } from "../../util/dispose.js";
|
|
5
|
+
import { getItem } from "../../util/item.js";
|
|
6
|
+
import { MemoryDBProvider, MemoryTable } from "./MemoryDBProvider.js";
|
|
7
|
+
/**
|
|
8
|
+
* In-memory database provider that persists every collection to a `Storage` — `localStorage`, `sessionStorage`, or anything else with the same interface.
|
|
9
|
+
*
|
|
10
|
+
* - Extends `MemoryDBProvider`, so all reads, queries, and realtime sequences are served synchronously from memory, and it can seed `ItemStore` / `QueryStore` and act as the cache inside `CacheDBProvider`. The only difference is that collections are backed by `StorageTable` instead of `MemoryTable`.
|
|
11
|
+
* - The storage is a required argument (there is no default), so server code that constructs this provider must reference `localStorage` / `sessionStorage` itself — surfacing the mistake at the callsite instead of deep inside this class.
|
|
12
|
+
* - Treat the persisted data as best-effort: quota is shared across the origin (typically ~5MB) and users can clear it at any time. Data read back from storage is unvalidated — wrap this provider in `ValidationDBProvider` if it may have been written by an older version of your app.
|
|
13
|
+
* - If the storage is unusable (writes blocked by browser settings, or private browsing with zero quota), the provider degrades to memory-only operation — check the `persistent` flag to warn users their changes won't be saved.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const provider = new StorageDBProvider(localStorage);
|
|
17
|
+
* if (!provider.persistent) console.warn("Changes won't be saved on this device.");
|
|
18
|
+
* const id = await provider.addItem(users, { name: "Dave" });
|
|
19
|
+
*
|
|
20
|
+
* @see https://shelving.cc/db/StorageDBProvider
|
|
21
|
+
*/
|
|
22
|
+
export class StorageDBProvider extends MemoryDBProvider {
|
|
23
|
+
/**
|
|
24
|
+
* Prefix for every storage key written by this provider.
|
|
25
|
+
*
|
|
26
|
+
* @see https://shelving.cc/db/StorageDBProvider/prefix
|
|
27
|
+
*/
|
|
28
|
+
prefix;
|
|
29
|
+
/**
|
|
30
|
+
* Whether this provider is actually persisting to storage.
|
|
31
|
+
* - `false` when storage exists but is unusable (e.g. blocked by browser settings, or private browsing with zero quota) — the provider still works, but data only lives in memory and is lost when the page closes.
|
|
32
|
+
*
|
|
33
|
+
* @see https://shelving.cc/db/StorageDBProvider/persistent
|
|
34
|
+
*/
|
|
35
|
+
persistent;
|
|
36
|
+
/** Storage being persisted to, or `undefined` when operating memory-only. */
|
|
37
|
+
_storage;
|
|
38
|
+
/**
|
|
39
|
+
* @param storage `Storage` instance to persist to, e.g. `localStorage` or `sessionStorage` (required so environments without one, e.g. the server, fail at the callsite).
|
|
40
|
+
* @param prefix Prefix for every storage key written by this provider, so it can share an origin's storage with other code (defaults to `"shelving:"`).
|
|
41
|
+
*/
|
|
42
|
+
constructor(storage, prefix = "shelving:") {
|
|
43
|
+
super();
|
|
44
|
+
this.prefix = prefix;
|
|
45
|
+
if (!storage)
|
|
46
|
+
throw new UnsupportedError("StorageDBProvider requires a Storage, e.g. localStorage or sessionStorage", {
|
|
47
|
+
caller: StorageDBProvider,
|
|
48
|
+
});
|
|
49
|
+
// Probe with a write+remove: private browsing can expose a `Storage` whose every write throws.
|
|
50
|
+
try {
|
|
51
|
+
const probe = `${prefix}?`;
|
|
52
|
+
storage.setItem(probe, "?");
|
|
53
|
+
storage.removeItem(probe);
|
|
54
|
+
this.persistent = true;
|
|
55
|
+
this._storage = storage;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Unusable storage — degrade to memory-only.
|
|
59
|
+
this.persistent = false;
|
|
60
|
+
this._storage = undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Back collections with a `StorageTable`, or a plain `MemoryTable` when operating memory-only. */
|
|
64
|
+
createTable(collection) {
|
|
65
|
+
return this._storage ? new StorageTable(collection, this._storage, this.prefix) : super.createTable(collection);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `MemoryTable` that persists its items to a `Storage` instance — self-contained, so it can also be used independently of `StorageDBProvider`.
|
|
70
|
+
*
|
|
71
|
+
* - Hydrates itself from storage on construction, then persists each write *before* applying it to memory, so a failed write (e.g. `QuotaExceededError` when the origin's quota is full) throws and leaves memory and storage consistent.
|
|
72
|
+
* - Items are stored as JSON under keys formatted as `{prefix}{collection}:{id}`, so values must be JSON-serializable. The id in the key is authoritative — an `id` stored inside the JSON is overridden.
|
|
73
|
+
* - Listens for `storage` events, so changes made in other tabs/windows update memory and notify realtime sequences. Dispose the table (or its provider) to remove the listener.
|
|
74
|
+
*
|
|
75
|
+
* @see https://shelving.cc/db/StorageTable
|
|
76
|
+
*/
|
|
77
|
+
export class StorageTable extends MemoryTable {
|
|
78
|
+
/** Prefix for every storage key belonging to this table, e.g. `"shelving:users:"`. */
|
|
79
|
+
_prefix;
|
|
80
|
+
/** Storage this table persists to. */
|
|
81
|
+
_storage;
|
|
82
|
+
/** Attached `storage` event listener (so it can be detached on dispose), or `undefined` if none was attached. */
|
|
83
|
+
_listener;
|
|
84
|
+
constructor(collection, storage, prefix = "shelving:") {
|
|
85
|
+
super(collection);
|
|
86
|
+
this._storage = storage;
|
|
87
|
+
this._prefix = `${prefix}${collection.name}:`;
|
|
88
|
+
this._hydrate();
|
|
89
|
+
// Changes made in other tabs/windows arrive as `storage` events on the global scope.
|
|
90
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
91
|
+
this._listener = event => this._syncStorage(event);
|
|
92
|
+
globalThis.addEventListener("storage", this._listener);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Load every persisted item of this table's collection into memory (directly, skipping the persist-back in `setItem()`). */
|
|
96
|
+
_hydrate() {
|
|
97
|
+
for (let i = 0; i < this._storage.length; i++) {
|
|
98
|
+
const key = this._storage.key(i);
|
|
99
|
+
if (key?.startsWith(this._prefix)) {
|
|
100
|
+
const id = this._decodeKey(key);
|
|
101
|
+
const item = this._parse(id, this._storage.getItem(key));
|
|
102
|
+
if (item)
|
|
103
|
+
this._data.set(id, item);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Storage key for an item of this table. */
|
|
108
|
+
_key(id) {
|
|
109
|
+
return `${this._prefix}${id}`;
|
|
110
|
+
}
|
|
111
|
+
/** Identifier encoded in a storage key of this table. */
|
|
112
|
+
_decodeKey(key) {
|
|
113
|
+
const raw = key.slice(this._prefix.length);
|
|
114
|
+
return (this.collection.id instanceof StringSchema ? raw : Number(raw)); // `as I` needed: TypeScript can't narrow I from the schema check.
|
|
115
|
+
}
|
|
116
|
+
/** Parse a stored JSON value into an item with the given id, or `undefined` if it's malformed. */
|
|
117
|
+
_parse(id, value) {
|
|
118
|
+
if (value) {
|
|
119
|
+
try {
|
|
120
|
+
const data = JSON.parse(value);
|
|
121
|
+
if (isData(data))
|
|
122
|
+
return getItem(id, data); // `as T` needed: stored values are unvalidated — wrap the provider in `ValidationDBProvider` to validate them.
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Malformed values are skipped, not deleted — they may belong to other code sharing the prefix.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Apply a `storage` event (a change made in another tab/window) to this table.
|
|
131
|
+
* - Reuses `setItem()` / `deleteItem()` — re-persisting the value already in storage is a no-op, and same-tab writes don't fire `storage` events, so this can't loop.
|
|
132
|
+
*/
|
|
133
|
+
_syncStorage({ storageArea, key, newValue }) {
|
|
134
|
+
if (storageArea !== this._storage)
|
|
135
|
+
return;
|
|
136
|
+
if (key === null) {
|
|
137
|
+
this.deleteQuery({}); // A `null` key means the other tab called `storage.clear()`.
|
|
138
|
+
}
|
|
139
|
+
else if (key.startsWith(this._prefix)) {
|
|
140
|
+
const id = this._decodeKey(key);
|
|
141
|
+
const item = newValue !== null ? this._parse(id, newValue) : undefined;
|
|
142
|
+
item ? this.setItem(id, item) : this.deleteItem(id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Persist to storage first — a failure (e.g. `QuotaExceededError`) throws before memory changes, keeping the two consistent. */
|
|
146
|
+
setItem(id, data) {
|
|
147
|
+
const item = getItem(id, data);
|
|
148
|
+
if (this._data.get(id) !== item)
|
|
149
|
+
this._storage.setItem(this._key(id), JSON.stringify(item));
|
|
150
|
+
super.setItem(id, item);
|
|
151
|
+
}
|
|
152
|
+
/** Remove from storage first, keeping storage and memory consistent. */
|
|
153
|
+
deleteItem(id) {
|
|
154
|
+
if (this._data.has(id))
|
|
155
|
+
this._storage.removeItem(this._key(id));
|
|
156
|
+
super.deleteItem(id);
|
|
157
|
+
}
|
|
158
|
+
// Implement `AsyncDisposable`
|
|
159
|
+
async [Symbol.asyncDispose]() {
|
|
160
|
+
await awaitDispose(() => this._listener && globalThis.removeEventListener("storage", this._listener), // Stop listening for `storage` events.
|
|
161
|
+
super[Symbol.asyncDispose]());
|
|
162
|
+
}
|
|
163
|
+
}
|
package/package.json
CHANGED
package/ui/app/App.md
CHANGED
|
@@ -46,6 +46,8 @@ export function MyApp() {
|
|
|
46
46
|
}
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
**Setting a `root` URL on `<App>` is strongly recommended.** `root` is the base that site-absolute links and assets (`/favicon.png`, `/base.css`) resolve against, the `<base>` tag rendered by `<HTML>`, and the prefix `<Router>` strips when matching routes. When it's unset, merging a `url` into the meta context derives `root` from that URL's origin (e.g. `https://example.com/`) — a safe fallback for apps served from the domain root, but wrong for apps deployed under a sub-path, so set it explicitly whenever your app doesn't live at `/`.
|
|
50
|
+
|
|
49
51
|
`<App>` accepts all `PossibleMeta` props (`app`, `root`, `url`, `title`, `language`, `tags`, etc.) and merges them into the context it provides to children. On mount it adds the theme class to `document.body`, which activates the CSS custom property tokens defined in `App.module.css`; on unmount it removes it.
|
|
50
52
|
|
|
51
53
|
For a documentation site, hand an extracted tree to `<TreeApp>` instead — see the `shelving/extract` guide.
|
package/ui/misc/MetaContext.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export declare function requireMeta(meta?: PossibleMeta): Meta;
|
|
|
28
28
|
*/
|
|
29
29
|
export interface MetaURL extends Meta {
|
|
30
30
|
url: ImmutableURL;
|
|
31
|
+
root: ImmutableURL;
|
|
31
32
|
/** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
|
|
32
33
|
path: AbsolutePath;
|
|
33
34
|
/** The `?query` params of `url` extracted as a flat set of parameters. */
|
|
@@ -38,9 +39,9 @@ export interface MetaURL extends Meta {
|
|
|
38
39
|
*
|
|
39
40
|
* @param meta A set of new possible meta data to combine into the current meta context.
|
|
40
41
|
* @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
|
|
41
|
-
* @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
|
|
42
|
-
* @throws RequiredError If the current meta has no `url`.
|
|
43
|
-
* @throws RequiredError If the current meta `url`
|
|
42
|
+
* @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
|
|
43
|
+
* @throws RequiredError If the current meta has no `url` or no `root`.
|
|
44
|
+
* @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
|
|
44
45
|
* @example const { path, params } = requireMetaURL();
|
|
45
46
|
* @see https://shelving.cc/ui/requireMetaURL
|
|
46
47
|
*/
|
package/ui/misc/MetaContext.js
CHANGED
|
@@ -30,9 +30,9 @@ export function requireMeta(meta) {
|
|
|
30
30
|
*
|
|
31
31
|
* @param meta A set of new possible meta data to combine into the current meta context.
|
|
32
32
|
* @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
|
|
33
|
-
* @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
|
|
34
|
-
* @throws RequiredError If the current meta has no `url`.
|
|
35
|
-
* @throws RequiredError If the current meta `url`
|
|
33
|
+
* @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
|
|
34
|
+
* @throws RequiredError If the current meta has no `url` or no `root`.
|
|
35
|
+
* @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
|
|
36
36
|
* @example const { path, params } = requireMetaURL();
|
|
37
37
|
* @see https://shelving.cc/ui/requireMetaURL
|
|
38
38
|
*/
|
|
@@ -40,9 +40,11 @@ export function requireMetaURL(meta, caller = requireMetaURL) {
|
|
|
40
40
|
const { url, root, ...combined } = requireMeta(meta);
|
|
41
41
|
if (!url)
|
|
42
42
|
throw new RequiredError("Meta URL is required", { received: url, caller });
|
|
43
|
+
if (!root)
|
|
44
|
+
throw new RequiredError("Meta root is required", { received: root, caller });
|
|
43
45
|
const path = matchURLPrefix(url, root, caller);
|
|
44
46
|
if (!path)
|
|
45
|
-
throw new RequiredError("Meta URL and meta root must share
|
|
47
|
+
throw new RequiredError("Meta URL and meta root must share a root", { url, root, caller });
|
|
46
48
|
const params = getURIParams(url, caller);
|
|
47
49
|
return { ...combined, url, root, path, params };
|
|
48
50
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
-
import { RequiredError } from "shelving/error";
|
|
5
4
|
import { createMeta, MetaContext, requireMetaURL } from "shelving/ui";
|
|
5
|
+
import { requireURL } from "shelving/util/url";
|
|
6
6
|
|
|
7
7
|
/** Render `requireMetaURL().path` from inside a component so its `use(MetaContext)` call is valid. */
|
|
8
8
|
function Probe(): ReactNode {
|
|
@@ -38,8 +38,28 @@ describe("requireMetaURL", () => {
|
|
|
38
38
|
expect(html).toBe("/enquiry/loan");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("defaults root to the url's origin when root is unset", () => {
|
|
42
|
+
const html = renderToStaticMarkup(
|
|
43
|
+
<MetaContext value={createMeta({ url: "http://x.com/enquiry/loan" })}>
|
|
44
|
+
<Probe />
|
|
45
|
+
</MetaContext>,
|
|
46
|
+
);
|
|
47
|
+
expect(html).toBe("/enquiry/loan");
|
|
48
|
+
});
|
|
49
|
+
|
|
41
50
|
test("throws RequiredError when url is unset", () => {
|
|
42
|
-
expect(() => renderToStaticMarkup(<Probe />)).toThrow(
|
|
51
|
+
expect(() => renderToStaticMarkup(<Probe />)).toThrow("Meta URL is required");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("throws RequiredError when root is unset and was not derived", () => {
|
|
55
|
+
// Bypass `createMeta()` deliberately — merged meta always derives a root, so this backup check only fires for hand-built meta.
|
|
56
|
+
expect(() =>
|
|
57
|
+
renderToStaticMarkup(
|
|
58
|
+
<MetaContext value={{ url: requireURL("http://x.com/foo") }}>
|
|
59
|
+
<Probe />
|
|
60
|
+
</MetaContext>,
|
|
61
|
+
),
|
|
62
|
+
).toThrow("Meta root is required");
|
|
43
63
|
});
|
|
44
64
|
|
|
45
65
|
test("throws RequiredError when url and root are on different origins", () => {
|
|
@@ -49,6 +69,16 @@ describe("requireMetaURL", () => {
|
|
|
49
69
|
<Probe />
|
|
50
70
|
</MetaContext>,
|
|
51
71
|
),
|
|
52
|
-
).toThrow(
|
|
72
|
+
).toThrow("Meta URL and meta root must share a root");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("throws RequiredError when url is outside the root's path", () => {
|
|
76
|
+
expect(() =>
|
|
77
|
+
renderToStaticMarkup(
|
|
78
|
+
<MetaContext value={createMeta({ root: "http://x.com/app/", url: "http://x.com/other" })}>
|
|
79
|
+
<Probe />
|
|
80
|
+
</MetaContext>,
|
|
81
|
+
),
|
|
82
|
+
).toThrow("Meta URL and meta root must share a root");
|
|
53
83
|
});
|
|
54
84
|
});
|
package/ui/misc/MetaContext.tsx
CHANGED
|
@@ -37,6 +37,7 @@ export function requireMeta(meta?: PossibleMeta): Meta {
|
|
|
37
37
|
*/
|
|
38
38
|
export interface MetaURL extends Meta {
|
|
39
39
|
url: ImmutableURL;
|
|
40
|
+
root: ImmutableURL;
|
|
40
41
|
/** The path of `url` relative to `meta.root` (i.e. the _site-root-relative_ path). */
|
|
41
42
|
path: AbsolutePath;
|
|
42
43
|
/** The `?query` params of `url` extracted as a flat set of parameters. */
|
|
@@ -48,17 +49,18 @@ export interface MetaURL extends Meta {
|
|
|
48
49
|
*
|
|
49
50
|
* @param meta A set of new possible meta data to combine into the current meta context.
|
|
50
51
|
* @param caller Function to attribute thrown `RequiredError`s to (defaults to `requireMetaURL`).
|
|
51
|
-
* @returns A `Meta` object with a defined `url`, plus `path` and `params` properties combined in.
|
|
52
|
-
* @throws RequiredError If the current meta has no `url`.
|
|
53
|
-
* @throws RequiredError If the current meta `url`
|
|
52
|
+
* @returns A `Meta` object with a defined `url` and `root`, plus `path` and `params` properties combined in.
|
|
53
|
+
* @throws RequiredError If the current meta has no `url` or no `root`.
|
|
54
|
+
* @throws RequiredError If the current meta `url` and `root` do not share a root (different origin, or `url` outside `root`'s path).
|
|
54
55
|
* @example const { path, params } = requireMetaURL();
|
|
55
56
|
* @see https://shelving.cc/ui/requireMetaURL
|
|
56
57
|
*/
|
|
57
58
|
export function requireMetaURL(meta?: PossibleMeta, caller: AnyCaller = requireMetaURL): MetaURL {
|
|
58
59
|
const { url, root, ...combined } = requireMeta(meta);
|
|
59
60
|
if (!url) throw new RequiredError("Meta URL is required", { received: url, caller });
|
|
61
|
+
if (!root) throw new RequiredError("Meta root is required", { received: root, caller });
|
|
60
62
|
const path = matchURLPrefix(url, root, caller);
|
|
61
|
-
if (!path) throw new RequiredError("Meta URL and meta root must share
|
|
63
|
+
if (!path) throw new RequiredError("Meta URL and meta root must share a root", { url, root, caller });
|
|
62
64
|
const params = getURIParams(url, caller);
|
|
63
65
|
return { ...combined, url, root, path, params };
|
|
64
66
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type PossibleMeta } from "../util/index.js";
|
|
3
3
|
import type { OptionalChildProps } from "../util/props.js";
|
|
4
4
|
/**
|
|
5
5
|
* Props for `<Navigation>` — initial `Meta` (url/base) plus optional `children`.
|
|
@@ -13,7 +13,7 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {
|
|
|
13
13
|
* - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
|
|
14
14
|
* - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
|
|
15
15
|
* - Listens for `popstate` to sync the store with browser back/forward.
|
|
16
|
-
* - Publishes the live URL
|
|
16
|
+
* - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
|
|
17
17
|
*
|
|
18
18
|
* Exactly one `<Navigation>` per app — nested routers share this single store.
|
|
19
19
|
*
|
package/ui/router/Navigation.js
CHANGED
|
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
|
|
3
3
|
import { useInstance } from "../../react/useInstance.js";
|
|
4
4
|
import { useStore } from "../../react/useStore.js";
|
|
5
5
|
import { MetaContext, requireMeta } from "../misc/MetaContext.js";
|
|
6
|
+
import { mergeMeta } from "../util/index.js";
|
|
6
7
|
import { NavigationContext } from "./NavigationContext.js";
|
|
7
8
|
import { NavigationStore } from "./NavigationStore.js";
|
|
8
9
|
/**
|
|
@@ -10,7 +11,7 @@ import { NavigationStore } from "./NavigationStore.js";
|
|
|
10
11
|
* - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
|
|
11
12
|
* - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
|
|
12
13
|
* - Listens for `popstate` to sync the store with browser back/forward.
|
|
13
|
-
* - Publishes the live URL
|
|
14
|
+
* - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
|
|
14
15
|
*
|
|
15
16
|
* Exactly one `<Navigation>` per app — nested routers share this single store.
|
|
16
17
|
*
|
|
@@ -20,8 +21,8 @@ import { NavigationStore } from "./NavigationStore.js";
|
|
|
20
21
|
* @see https://shelving.cc/ui/Navigation
|
|
21
22
|
*/
|
|
22
23
|
export function Navigation({ children, ...meta }) {
|
|
23
|
-
const
|
|
24
|
-
const nav = useInstance(NavigationStore, url, root);
|
|
24
|
+
const current = requireMeta(meta);
|
|
25
|
+
const nav = useInstance(NavigationStore, current.url, current.root);
|
|
25
26
|
useStore(nav);
|
|
26
27
|
useEffect(() => {
|
|
27
28
|
if (typeof document === "undefined" || typeof window === "undefined")
|
|
@@ -47,5 +48,5 @@ export function Navigation({ children, ...meta }) {
|
|
|
47
48
|
window.removeEventListener("popstate", onPopState);
|
|
48
49
|
};
|
|
49
50
|
}, [nav]);
|
|
50
|
-
return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: { url: nav.value
|
|
51
|
+
return (_jsx(NavigationContext, { value: nav, children: _jsx(MetaContext, { value: mergeMeta(current, { url: nav.value }, Navigation), children: children }) }));
|
|
51
52
|
}
|
package/ui/router/Navigation.md
CHANGED
|
@@ -6,7 +6,8 @@ The top-level navigation provider for a client-side app. It owns a single `Navig
|
|
|
6
6
|
|
|
7
7
|
- Same-origin anchor clicks are intercepted automatically and turned into `forward()` calls. Add a `download` attribute to an anchor to opt out.
|
|
8
8
|
- It listens for `popstate` so the store stays in sync with browser back/forward.
|
|
9
|
-
- It initialises the store from the surrounding `<Meta>` url/base, so set those on an ancestor `<HTML>` / `<Page>`.
|
|
9
|
+
- It initialises the store from the surrounding `<Meta>` url/base, so set those on an ancestor `<App>` / `<HTML>` / `<Page>`. In the browser the store falls back to `window.location.href` when no url is set.
|
|
10
|
+
- The meta it publishes always has a `root` — when none is set anywhere, `root` defaults to the live URL's origin. Setting `root` explicitly on `<App>` / `<HTML>` is still strongly recommended, especially for apps served under a sub-path.
|
|
10
11
|
- `<Router>` works with no `<Navigation>` at all (SSR, static rendering, tests) — `<Navigation>` is only what makes the URL *live* on the client.
|
|
11
12
|
|
|
12
13
|
## Usage
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
|
+
import { Navigation, requireMetaURL } from "shelving/ui";
|
|
5
|
+
|
|
6
|
+
/** Render `requireMetaURL().path` from inside a component so its `use(MetaContext)` call is valid. */
|
|
7
|
+
function Probe(): ReactNode {
|
|
8
|
+
return requireMetaURL().path;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("Navigation", () => {
|
|
12
|
+
test("publishes merged meta with a root derived from the url", () => {
|
|
13
|
+
const html = renderToStaticMarkup(
|
|
14
|
+
<Navigation url="http://x.com/a/b">
|
|
15
|
+
<Probe />
|
|
16
|
+
</Navigation>,
|
|
17
|
+
);
|
|
18
|
+
expect(html).toBe("/a/b");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resolves a relative url against an explicit root", () => {
|
|
22
|
+
const html = renderToStaticMarkup(
|
|
23
|
+
<Navigation root="http://x.com/app/" url="./sub">
|
|
24
|
+
<Probe />
|
|
25
|
+
</Navigation>,
|
|
26
|
+
);
|
|
27
|
+
expect(html).toBe("/sub");
|
|
28
|
+
});
|
|
29
|
+
});
|
package/ui/router/Navigation.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { type ReactElement, useEffect } from "react";
|
|
|
2
2
|
import { useInstance } from "../../react/useInstance.js";
|
|
3
3
|
import { useStore } from "../../react/useStore.js";
|
|
4
4
|
import { MetaContext, requireMeta } from "../misc/MetaContext.js";
|
|
5
|
-
import type
|
|
5
|
+
import { mergeMeta, type PossibleMeta } from "../util/index.js";
|
|
6
6
|
import type { OptionalChildProps } from "../util/props.js";
|
|
7
7
|
import { NavigationContext } from "./NavigationContext.js";
|
|
8
8
|
import { NavigationStore } from "./NavigationStore.js";
|
|
@@ -19,7 +19,7 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {}
|
|
|
19
19
|
* - Owns a single `NavigationStore` initialised from the surrounding `<Meta>` url/base.
|
|
20
20
|
* - Intercepts same-origin anchor clicks (excluding `download` anchors) and turns them into `forward()` calls.
|
|
21
21
|
* - Listens for `popstate` to sync the store with browser back/forward.
|
|
22
|
-
* - Publishes the live URL
|
|
22
|
+
* - Publishes the live URL into the `Meta` context via `mergeMeta()`, so descendant `<Router>`s re-render on navigation and merge invariants hold (e.g. `root` defaults to the live URL's origin when unset).
|
|
23
23
|
*
|
|
24
24
|
* Exactly one `<Navigation>` per app — nested routers share this single store.
|
|
25
25
|
*
|
|
@@ -29,8 +29,8 @@ export interface NavigationProps extends PossibleMeta, OptionalChildProps {}
|
|
|
29
29
|
* @see https://shelving.cc/ui/Navigation
|
|
30
30
|
*/
|
|
31
31
|
export function Navigation({ children, ...meta }: NavigationProps): ReactElement {
|
|
32
|
-
const
|
|
33
|
-
const nav = useInstance(NavigationStore, url, root);
|
|
32
|
+
const current = requireMeta(meta);
|
|
33
|
+
const nav = useInstance(NavigationStore, current.url, current.root);
|
|
34
34
|
useStore(nav);
|
|
35
35
|
|
|
36
36
|
useEffect(() => {
|
|
@@ -63,7 +63,7 @@ export function Navigation({ children, ...meta }: NavigationProps): ReactElement
|
|
|
63
63
|
|
|
64
64
|
return (
|
|
65
65
|
<NavigationContext value={nav}>
|
|
66
|
-
<MetaContext value={{ url: nav.value
|
|
66
|
+
<MetaContext value={mergeMeta(current, { url: nav.value }, Navigation)}>{children}</MetaContext>
|
|
67
67
|
</NavigationContext>
|
|
68
68
|
);
|
|
69
69
|
}
|
package/ui/util/meta.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AnyCaller } from "../../util/function.js";
|
|
|
4
4
|
import { type PossibleLink } from "../../util/link.js";
|
|
5
5
|
import type { Nullish } from "../../util/null.js";
|
|
6
6
|
import { type ImmutableURI, type PossibleURI, type PossibleURIParams } from "../../util/uri.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ImmutableURL, type PossibleURL } from "../../util/url.js";
|
|
8
8
|
/**
|
|
9
9
|
* Set of named meta `<meta />` tags in `{ name: content }` format.
|
|
10
10
|
*
|
|
@@ -75,7 +75,7 @@ export interface Meta {
|
|
|
75
75
|
* @see https://shelving.cc/ui/PossibleMeta
|
|
76
76
|
*/
|
|
77
77
|
export interface PossibleMeta extends Omit<Meta, "root" | "url" | "links" | "scripts" | "modules" | "stylesheets"> {
|
|
78
|
-
/** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. */
|
|
78
|
+
/** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. Defaults to the origin of `url` when unset. */
|
|
79
79
|
readonly root?: PossibleURL | undefined;
|
|
80
80
|
/**
|
|
81
81
|
* New URL for the page.
|
|
@@ -116,6 +116,7 @@ export declare function joinTitles(...titles: (string | undefined)[]): string;
|
|
|
116
116
|
*
|
|
117
117
|
* - `title` is merged with `joinTitles()`.
|
|
118
118
|
* - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
|
|
119
|
+
* - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
|
|
119
120
|
* - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
|
|
120
121
|
*
|
|
121
122
|
* @param meta1 The existing fully-resolved `Meta` to merge into.
|
package/ui/util/meta.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getDictionaryItems } from "../../util/dictionary.js";
|
|
2
2
|
import { requireLink } from "../../util/link.js";
|
|
3
3
|
import { withURIParams } from "../../util/uri.js";
|
|
4
|
-
import { requireURL } from "../../util/url.js";
|
|
4
|
+
import { ImmutableURL, requireURL } from "../../util/url.js";
|
|
5
5
|
/**
|
|
6
6
|
* Turn a deconstructed `MetaCSP` into a `Content-Security-Policy` string.
|
|
7
7
|
*
|
|
@@ -33,6 +33,7 @@ export function joinTitles(...titles) {
|
|
|
33
33
|
*
|
|
34
34
|
* - `title` is merged with `joinTitles()`.
|
|
35
35
|
* - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
|
|
36
|
+
* - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
|
|
36
37
|
* - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
|
|
37
38
|
*
|
|
38
39
|
* @param meta1 The existing fully-resolved `Meta` to merge into.
|
|
@@ -44,8 +45,10 @@ export function joinTitles(...titles) {
|
|
|
44
45
|
*/
|
|
45
46
|
export function mergeMeta(meta1, meta2, caller = mergeMeta) {
|
|
46
47
|
const title = joinTitles(meta2.title, meta1.title);
|
|
47
|
-
const
|
|
48
|
-
const url = mergeMetaURL(
|
|
48
|
+
const explicitRoot = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
|
|
49
|
+
const url = mergeMetaURL(explicitRoot, meta1.url, meta2.url, meta2.params, caller);
|
|
50
|
+
// A meta with a `url` must always have a `root` — only correct for apps served from the origin's `/`, so sub-path deployments must set `root` explicitly.
|
|
51
|
+
const root = explicitRoot ?? (url ? new ImmutableURL("/", url) : undefined);
|
|
49
52
|
return {
|
|
50
53
|
...meta1,
|
|
51
54
|
...meta2,
|
package/ui/util/meta.test.ts
CHANGED
|
@@ -60,4 +60,21 @@ describe("mergeMeta", () => {
|
|
|
60
60
|
expect(meta2.links?.icon?.href).toBe("http://x.com/favicon.png");
|
|
61
61
|
expect(meta2.links?.canonical?.href).toBe("http://x.com/page");
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
test("defaults root to the origin of the url when root is unset", () => {
|
|
65
|
+
const meta = createMeta({ url: "http://x.com/sub/page" });
|
|
66
|
+
expect(meta.url?.href).toBe("http://x.com/sub/page");
|
|
67
|
+
expect(meta.root?.href).toBe("http://x.com/");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("keeps an explicit root instead of defaulting from the url", () => {
|
|
71
|
+
const meta = createMeta({ root: "http://x.com/app/", url: "http://x.com/app/page" });
|
|
72
|
+
expect(meta.root?.href).toBe("http://x.com/app/");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("leaves root unset when url is also unset", () => {
|
|
76
|
+
const meta = createMeta({ title: "Title" });
|
|
77
|
+
expect(meta.root).toBeUndefined();
|
|
78
|
+
expect(meta.url).toBeUndefined();
|
|
79
|
+
});
|
|
63
80
|
});
|
package/ui/util/meta.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AnyCaller } from "../../util/function.js";
|
|
|
4
4
|
import { type PossibleLink, requireLink } from "../../util/link.js";
|
|
5
5
|
import type { Nullish } from "../../util/null.js";
|
|
6
6
|
import { type ImmutableURI, type PossibleURI, type PossibleURIParams, withURIParams } from "../../util/uri.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ImmutableURL, type PossibleURL, requireURL } from "../../util/url.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Set of named meta `<meta />` tags in `{ name: content }` format.
|
|
@@ -85,7 +85,7 @@ export interface Meta {
|
|
|
85
85
|
* @see https://shelving.cc/ui/PossibleMeta
|
|
86
86
|
*/
|
|
87
87
|
export interface PossibleMeta extends Omit<Meta, "root" | "url" | "links" | "scripts" | "modules" | "stylesheets"> {
|
|
88
|
-
/** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. */
|
|
88
|
+
/** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. Defaults to the origin of `url` when unset. */
|
|
89
89
|
readonly root?: PossibleURL | undefined;
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -139,6 +139,7 @@ export function joinTitles(...titles: (string | undefined)[]): string {
|
|
|
139
139
|
*
|
|
140
140
|
* - `title` is merged with `joinTitles()`.
|
|
141
141
|
* - `url` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f`
|
|
142
|
+
* - `root` defaults to the origin of the resolved `url` (e.g. `https://x.com/`) when not set explicitly, so merged meta with a `url` always has a `root`.
|
|
142
143
|
* - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`root`, so they stay correct no matter where they are later rendered.
|
|
143
144
|
*
|
|
144
145
|
* @param meta1 The existing fully-resolved `Meta` to merge into.
|
|
@@ -151,8 +152,11 @@ export function joinTitles(...titles: (string | undefined)[]): string {
|
|
|
151
152
|
export function mergeMeta(meta1: Meta, meta2: PossibleMeta, caller: AnyCaller = mergeMeta): Meta {
|
|
152
153
|
const title = joinTitles(meta2.title, meta1.title);
|
|
153
154
|
|
|
154
|
-
const
|
|
155
|
-
const url = mergeMetaURL(
|
|
155
|
+
const explicitRoot = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller);
|
|
156
|
+
const url = mergeMetaURL(explicitRoot, meta1.url, meta2.url, meta2.params, caller);
|
|
157
|
+
|
|
158
|
+
// A meta with a `url` must always have a `root` — only correct for apps served from the origin's `/`, so sub-path deployments must set `root` explicitly.
|
|
159
|
+
const root = explicitRoot ?? (url ? new ImmutableURL("/", url) : undefined);
|
|
156
160
|
|
|
157
161
|
return {
|
|
158
162
|
...meta1,
|