mikser-io-sdk-api 2.5.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -23
- package/index.d.ts +108 -20
- package/index.js +6 -0
- package/package.json +1 -1
- package/src/asset.js +65 -0
- package/src/entities.js +146 -61
- package/src/href.js +110 -0
- package/src/routes.js +34 -0
package/README.md
CHANGED
|
@@ -105,32 +105,37 @@ Operations outside the endpoint's allowlist return `403`; missing or wrong token
|
|
|
105
105
|
|
|
106
106
|
`mikser.entities(endpointName, options)` returns a per-endpoint client. The endpoint name matches a key in your `api.endpoints` config on the server. Supported options:
|
|
107
107
|
|
|
108
|
-
| Option
|
|
108
|
+
| Option | Default | What it does |
|
|
109
109
|
|---|---|---|
|
|
110
|
-
| `token`
|
|
111
|
-
| `
|
|
112
|
-
| `fallbackToList` | `true` | If `initialUrl` is set and the fetch fails (404 in dev, wrong shape, network error), fall back to a live `list()`. Set `false` for production deploys where missing the snapshot should be a hard error. |
|
|
110
|
+
| `token` | `null` | Bearer token for endpoints declared with a token. Sent on every request. |
|
|
111
|
+
| `data` | `{}` | Pairs the client with the mikser-io `data` plugin's static-file outputs. See below. |
|
|
113
112
|
|
|
114
|
-
### `
|
|
113
|
+
### `data` — pair with the `data` plugin for fast first paint
|
|
115
114
|
|
|
116
|
-
If you have a known, predictable query that runs on every page load — a route table for an SPA, a navigation menu, a
|
|
115
|
+
If you have a known, predictable query that runs on every page load — a route table for an SPA, a navigation menu, a per-document body — paying an API round-trip for it on first paint is wasted work. The mikser-io `data` plugin lets you publish that data as static files at build/finalize time, and the SDK consumes them on the client side. The option names on both sides are the same — `catalog` and `entities` — so it reads as one config split across the network.
|
|
117
116
|
|
|
118
|
-
Mikser's `data` plugin
|
|
117
|
+
Mikser's `data` plugin has two relevant blocks:
|
|
119
118
|
|
|
120
119
|
```js
|
|
121
120
|
// mikser.config.js
|
|
122
|
-
plugins: ['documents', 'front-matter', 'data', 'api', /* ... */],
|
|
123
|
-
|
|
124
121
|
data: {
|
|
125
122
|
catalog: {
|
|
126
|
-
// out/data/sitemap.json — one
|
|
127
|
-
//
|
|
128
|
-
// catalog-entry name; the SDK below uses the matching URL path.
|
|
123
|
+
// out/data/sitemap.json — one combined file for the whole catalog,
|
|
124
|
+
// projected to just the fields your router/nav needs.
|
|
129
125
|
sitemap: {
|
|
130
126
|
query: e => e.type === 'document' && e.meta?.published && e.meta?.component,
|
|
131
127
|
pick: ['id', 'destination', 'meta.component', 'meta.route', 'meta.title'],
|
|
132
128
|
},
|
|
133
129
|
},
|
|
130
|
+
entities: {
|
|
131
|
+
// out/data/<entity.name>.page.json — one file per published document,
|
|
132
|
+
// with full content. Consumed by useDocument(id) for first-paint
|
|
133
|
+
// single-doc reads.
|
|
134
|
+
page: {
|
|
135
|
+
query: e => e.type === 'document' && e.meta?.published,
|
|
136
|
+
pick: ['id', 'meta', 'content'],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
134
139
|
},
|
|
135
140
|
|
|
136
141
|
api: {
|
|
@@ -144,29 +149,35 @@ api: {
|
|
|
144
149
|
},
|
|
145
150
|
```
|
|
146
151
|
|
|
147
|
-
On the client,
|
|
152
|
+
On the client, name the same blocks. The names you pass match the keys you used on the server side:
|
|
148
153
|
|
|
149
154
|
```js
|
|
150
155
|
const documents = createClient({ baseUrl: 'https://cms.example.com' })
|
|
151
|
-
.entities('public', {
|
|
156
|
+
.entities('public', {
|
|
157
|
+
data: {
|
|
158
|
+
catalog: 'sitemap', // /data/sitemap.json
|
|
159
|
+
entities: 'page', // /data/<entry.name>.page.json
|
|
160
|
+
},
|
|
161
|
+
})
|
|
152
162
|
```
|
|
153
163
|
|
|
154
|
-
What
|
|
164
|
+
What gets used when:
|
|
155
165
|
|
|
156
|
-
- **`live(
|
|
157
|
-
- **`
|
|
158
|
-
- **`list()`**,
|
|
166
|
+
- **`live({id})` / `useDocument(id)`** — if `data.entities` is set, the SDK fetches the matching per-entity file instead of calling the API. Needs `data.catalog` to be loaded too, so the entry's `name` is known. The SSE subscribe still opens for live updates.
|
|
167
|
+
- **`live(filter, onChange, options)` with no filter** / **`listAll()`** — if `data.catalog` is set, the SDK consults `/data/<catalog>.json` for first paint, then opens SSE.
|
|
168
|
+
- **`list()`**, **`urlFor()`**, **`query()`**, **`update()`**, **`delete()`**, **`render()`**, **`subscribe()`** — unchanged, always hit the API.
|
|
169
|
+
- **Any file fetch failure** — falls back to the live API for that call. No separate flag.
|
|
159
170
|
|
|
160
|
-
The data plugin emits each entry as `{ refId, name, date, data: {...picked} }`. The SDK strips that wrapper automatically
|
|
171
|
+
The data plugin emits each entry as `{ refId, name, date, data: {...picked} }`. The SDK strips that wrapper automatically — `onChange` and `listAll` always see plain payloads regardless of source.
|
|
161
172
|
|
|
162
|
-
This is **not** a cache. The
|
|
173
|
+
This is **not** a cache. The data plugin's files are only consulted for the initial fill; ongoing changes come over SSE on the actual API endpoint. For runtime fail-safety on the live API, that's what the api plugin's `cache: true` is for — see [mikser-io's caching docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md).
|
|
163
174
|
|
|
164
|
-
**Edge case: first-paint flash for fast-changing snapshots.** First paint renders the
|
|
175
|
+
**Edge case: first-paint flash for fast-changing snapshots.** First paint renders whatever the data plugin last wrote — which may be N seconds old — and the SSE stream then arrives and reconciles any changes since. For a route table that's invisible; routes don't move every second. For snapshots of fast-changing data (recent activity, in-stock badges, live counters) the user may briefly see stale content before SSE catches up. If that flash is visible UX, either design for it (subtle "syncing" indicator until the first SSE event arrives, or render a loading state when the snapshot age exceeds your tolerance) or skip `data.catalog` / `data.entities` for that particular client — paying the API roundtrip is the right tradeoff when the response can't be allowed to be stale.
|
|
165
176
|
|
|
166
177
|
**Snapshot-bypass warning.** Snapshots only apply when the `live()` / `listAll()` call is trivial — no filter, no sort, no skip. Add any of those and the SDK silently falls back to the live API. Since that's the kind of thing a developer can change without noticing, the SDK emits a one-time `console.warn` per `(endpoint, call kind, what-was-set)` shape:
|
|
167
178
|
|
|
168
179
|
```
|
|
169
|
-
[mikser-sdk]
|
|
180
|
+
[mikser-sdk] data.catalog is set on "public" but this live() call uses filter+sort
|
|
170
181
|
— snapshot bypassed, falling back to live list().
|
|
171
182
|
Snapshots only apply when the call is trivial (no filter/sort/skip).
|
|
172
183
|
Either remove the filter+sort from this call, or accept the API roundtrip if
|
|
@@ -184,7 +195,7 @@ The common failure mode for a CMS-backed app is "I just wanted a nav menu but pu
|
|
|
184
195
|
[mikser-sdk] list() returned 247 items (~4.2 MB) from "public" with no `fields` projection.
|
|
185
196
|
Add { fields: [...] } to narrow it, or — if this query runs on every page load —
|
|
186
197
|
move it to a `data.catalog.<name>` snapshot on the mikser side and load via:
|
|
187
|
-
entities('public', {
|
|
198
|
+
entities('public', { data: { catalog: '<name>' } })
|
|
188
199
|
Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.
|
|
189
200
|
```
|
|
190
201
|
|
package/index.d.ts
CHANGED
|
@@ -17,31 +17,36 @@ export interface EntityOptions {
|
|
|
17
17
|
/** Bearer token sent on every request to this endpoint. */
|
|
18
18
|
token?: string
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Mirrors the mikser-io `data` plugin's config block. The SDK
|
|
21
|
+
* consumes the static JSON files the data plugin writes under
|
|
22
|
+
* `out/data/` so first-paint reads come from disk (CDN-cacheable)
|
|
23
|
+
* instead of hitting the live API.
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Use `initialUrl` only when:
|
|
28
|
-
* - the snapshot URL is a different host (CDN, edge cache,
|
|
29
|
-
* pre-rendered static asset on disk)
|
|
30
|
-
* - you want to skip the live API roundtrip on boot even when
|
|
31
|
-
* mikser is up (microoptimization)
|
|
25
|
+
* The names are the same as on the server:
|
|
26
|
+
* - `data.catalog` pairs with `data.catalog.<name>` on mikser
|
|
27
|
+
* - `data.entities` pairs with `data.entities.<name>` on mikser
|
|
32
28
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* On a fetch failure the SDK silently falls back to the live API
|
|
30
|
+
* for that call — no separate flag.
|
|
35
31
|
*/
|
|
36
|
-
|
|
32
|
+
data?: DataOptions
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DataOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Name of a `data.catalog.<name>` block on the server. The SDK
|
|
38
|
+
* loads `/data/<name>.json` (one combined file) on first paint;
|
|
39
|
+
* `live()` and `listAll()` consult it before hitting the API.
|
|
40
|
+
*/
|
|
41
|
+
catalog?: string
|
|
37
42
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
+
* Name of a `data.entities.<name>` block on the server. When
|
|
44
|
+
* `live({id})` (the shape `useDocument` issues) fires, the SDK
|
|
45
|
+
* loads `/data/<entry.name>.<name>.json` (one file per entity)
|
|
46
|
+
* instead of calling the API. Requires `data.catalog` so the
|
|
47
|
+
* `entry.name` mapping is available.
|
|
43
48
|
*/
|
|
44
|
-
|
|
49
|
+
entities?: string
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -182,3 +187,86 @@ export declare class MikserError extends Error {
|
|
|
182
187
|
status: number
|
|
183
188
|
body: { error?: string } | undefined
|
|
184
189
|
}
|
|
190
|
+
|
|
191
|
+
// ────────────────────────────────────────────────────────────────────
|
|
192
|
+
// Pure utilities — framework SDKs wrap these in their own reactivity.
|
|
193
|
+
// ────────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
export interface GenerateRoutesOptions<TRoute = unknown> {
|
|
196
|
+
/** A mikser entities client (the result of createClient(...).entities(name)). */
|
|
197
|
+
client: { listAll(query: ListQuery): Promise<Array<{ id: string; meta?: Record<string, unknown> }>> }
|
|
198
|
+
/** Sift filter — defaults to "published documents that declare meta.route". */
|
|
199
|
+
filter?: Filter
|
|
200
|
+
/** Maps a catalog document to a route descriptor (or null to skip). */
|
|
201
|
+
mapRoute: (document: { id: string; meta?: Record<string, unknown> }) => TRoute | null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build-time route enumeration. Auto-paginates via listAll() and
|
|
206
|
+
* applies `mapRoute` to every catalog entity that matches the filter.
|
|
207
|
+
* `null` returns from `mapRoute` are dropped.
|
|
208
|
+
*/
|
|
209
|
+
export function generateMikserRoutes<TRoute = unknown>(
|
|
210
|
+
options: GenerateRoutesOptions<TRoute>,
|
|
211
|
+
): Promise<TRoute[]>
|
|
212
|
+
|
|
213
|
+
export interface HrefIndexOptions {
|
|
214
|
+
/** Fallback language tag for documents that don't declare meta.lang. Default 'default'. */
|
|
215
|
+
defaultLang?: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface HrefIndex {
|
|
219
|
+
/** Resolve a logical reference (`/about`) to a deployed URL for the given language. */
|
|
220
|
+
href(ref: string, lang?: string): string
|
|
221
|
+
/** Reverse — given a deployed URL, return the logical reference it belongs to. */
|
|
222
|
+
refFor(url: string | null): string | null
|
|
223
|
+
/** Alternates for a deployed URL — `current` plus the alternate-language URLs. */
|
|
224
|
+
alternates(options: { route: string | null; languages?: string[] }): {
|
|
225
|
+
current: { lang: string | null; url: string; ref: string } | null
|
|
226
|
+
alternates: Array<{ lang: string; url: string }>
|
|
227
|
+
}
|
|
228
|
+
/** Raw `ref → { lang → url }` map, for inspection / debugging. */
|
|
229
|
+
map: Record<string, Record<string, string>>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build a multilingual href lookup from a snapshot of catalog documents.
|
|
234
|
+
* Pure data transformation — wrap in a framework-specific reactive
|
|
235
|
+
* shell to drive `useHref` / `useAlternates` composables.
|
|
236
|
+
*/
|
|
237
|
+
export function createHrefIndex(
|
|
238
|
+
documents: Array<{ meta?: Record<string, unknown> }>,
|
|
239
|
+
options?: HrefIndexOptions,
|
|
240
|
+
): HrefIndex
|
|
241
|
+
|
|
242
|
+
export interface AssetRecord {
|
|
243
|
+
url: string
|
|
244
|
+
width?: number
|
|
245
|
+
height?: number
|
|
246
|
+
srcset?: string
|
|
247
|
+
alt?: string
|
|
248
|
+
meta?: Record<string, unknown>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ImageProps {
|
|
252
|
+
src: string
|
|
253
|
+
width?: number
|
|
254
|
+
height?: number
|
|
255
|
+
srcset?: string
|
|
256
|
+
alt?: string
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface AssetIndex {
|
|
260
|
+
asset(ref: string): AssetRecord | null
|
|
261
|
+
image(ref: string): ImageProps | null
|
|
262
|
+
map: Record<string, AssetRecord>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build an asset metadata lookup from a snapshot of asset entities.
|
|
267
|
+
* Pure data transformation — wrap in a framework-specific reactive
|
|
268
|
+
* shell to drive `useAsset` composables.
|
|
269
|
+
*/
|
|
270
|
+
export function createAssetIndex(
|
|
271
|
+
assets: Array<{ id: string; meta?: Record<string, unknown> }>,
|
|
272
|
+
): AssetIndex
|
package/index.js
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
// src/url.js — URL building (joinUrl, sortToParam, filterToParams)
|
|
28
28
|
// src/sse.js — SSE event parser
|
|
29
29
|
// src/entities.js — per-endpoint entities client (list / watch / live / ...)
|
|
30
|
+
// src/routes.js — generateMikserRoutes (build-time route enumeration)
|
|
31
|
+
// src/href.js — createHrefIndex (multilingual reference → URL lookup)
|
|
32
|
+
// src/asset.js — createAssetIndex (asset metadata lookup)
|
|
30
33
|
import { MikserError } from './src/error.js'
|
|
31
34
|
import { createEntitiesClient } from './src/entities.js'
|
|
32
35
|
|
|
@@ -60,3 +63,6 @@ export function createClient({
|
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
export { MikserError }
|
|
66
|
+
export { generateMikserRoutes } from './src/routes.js'
|
|
67
|
+
export { createHrefIndex } from './src/href.js'
|
|
68
|
+
export { createAssetIndex } from './src/asset.js'
|
package/package.json
CHANGED
package/src/asset.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Asset metadata index — pure data version. Framework SDKs wrap this
|
|
2
|
+
// in their own reactivity primitives and expose useAsset on top.
|
|
3
|
+
//
|
|
4
|
+
// When assets carry metadata the template needs (dimensions, srcset,
|
|
5
|
+
// alt text), looking them up by reference is cleaner than re-fetching
|
|
6
|
+
// per render. The convention is that asset entities have an `id`
|
|
7
|
+
// (used as the reference key) plus a `meta` block with the metadata.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Array<{id: string, meta?: object}>} assets Asset entities.
|
|
11
|
+
* @returns {{
|
|
12
|
+
* asset: (ref: string) => AssetRecord|null,
|
|
13
|
+
* image: (ref: string) => ImageProps|null,
|
|
14
|
+
* map: Record<string, AssetRecord>,
|
|
15
|
+
* }}
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Object} AssetRecord
|
|
18
|
+
* @property {string} url
|
|
19
|
+
* @property {number|undefined} width
|
|
20
|
+
* @property {number|undefined} height
|
|
21
|
+
* @property {string|undefined} srcset
|
|
22
|
+
* @property {string|undefined} alt
|
|
23
|
+
* @property {object|undefined} meta The raw meta block, for downstream use.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} ImageProps
|
|
26
|
+
* @property {string} src
|
|
27
|
+
* @property {number|undefined} width
|
|
28
|
+
* @property {number|undefined} height
|
|
29
|
+
* @property {string|undefined} srcset
|
|
30
|
+
* @property {string|undefined} alt
|
|
31
|
+
*/
|
|
32
|
+
export function createAssetIndex(assets) {
|
|
33
|
+
const map = {}
|
|
34
|
+
if (Array.isArray(assets)) {
|
|
35
|
+
for (const a of assets) {
|
|
36
|
+
if (!a?.id) continue
|
|
37
|
+
map[a.id] = {
|
|
38
|
+
url: a.meta?.destination ?? a.meta?.url ?? a.id,
|
|
39
|
+
width: a.meta?.width,
|
|
40
|
+
height: a.meta?.height,
|
|
41
|
+
srcset: a.meta?.srcset,
|
|
42
|
+
alt: a.meta?.alt,
|
|
43
|
+
meta: a.meta,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function asset(ref) {
|
|
49
|
+
return map[ref] ?? null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function image(ref) {
|
|
53
|
+
const a = map[ref]
|
|
54
|
+
if (!a) return null
|
|
55
|
+
return {
|
|
56
|
+
src: a.url,
|
|
57
|
+
width: a.width,
|
|
58
|
+
height: a.height,
|
|
59
|
+
srcset: a.srcset,
|
|
60
|
+
alt: a.alt,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { asset, image, map }
|
|
65
|
+
}
|
package/src/entities.js
CHANGED
|
@@ -57,18 +57,18 @@ function maybeWarnWide({ endpoint, query, envelopeOrItems, quiet }) {
|
|
|
57
57
|
`[mikser-sdk] list() returned ${items.length} items${sizeNote} from "${endpoint}" with no \`fields\` projection.\n` +
|
|
58
58
|
` Add { fields: [...] } to narrow it, or — if this query runs on every page load —\n` +
|
|
59
59
|
` move it to a \`data.catalog.<name>\` snapshot on the mikser side and load via:\n` +
|
|
60
|
-
` entities('${endpoint}', {
|
|
60
|
+
` entities('${endpoint}', { data: { catalog: '<name>' } })\n` +
|
|
61
61
|
` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
|
|
62
62
|
)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// Snapshot-bypass warning — fires when `
|
|
66
|
-
// the call is non-trivial (has filter / sort / skip), so the
|
|
67
|
-
// can't be used and the SDK falls back to the live API. The
|
|
68
|
-
// correct behavior, but it's silent: developers often set
|
|
69
|
-
// once and then add a sort to one of their useDocuments
|
|
70
|
-
// noticing the snapshot is no longer involved. Deduped
|
|
71
|
-
// (endpoint, kind, what-was-set) so a page with 3 filtered calls
|
|
65
|
+
// Snapshot-bypass warning — fires when `data.catalog` is configured
|
|
66
|
+
// but the call is non-trivial (has filter / sort / skip), so the
|
|
67
|
+
// snapshot can't be used and the SDK falls back to the live API. The
|
|
68
|
+
// bypass is correct behavior, but it's silent: developers often set
|
|
69
|
+
// data.catalog once and then add a sort to one of their useDocuments
|
|
70
|
+
// calls without noticing the snapshot is no longer involved. Deduped
|
|
71
|
+
// per (endpoint, kind, what-was-set) so a page with 3 filtered calls
|
|
72
72
|
// produces 3 warnings, not 30.
|
|
73
73
|
const _bypassedShapes = new Set()
|
|
74
74
|
function maybeWarnSnapshotBypass({ endpoint, kind, filter, sort, skip, quiet }) {
|
|
@@ -84,7 +84,7 @@ function maybeWarnSnapshotBypass({ endpoint, kind, filter, sort, skip, quiet })
|
|
|
84
84
|
_bypassedShapes.add(shape)
|
|
85
85
|
const fallback = kind === 'live' ? 'live list()' : 'paginated fetch'
|
|
86
86
|
console.warn(
|
|
87
|
-
`[mikser-sdk]
|
|
87
|
+
`[mikser-sdk] data.catalog is set on "${endpoint}" but this ${kind}() call uses ${reasonLabel} — snapshot bypassed, falling back to ${fallback}.\n` +
|
|
88
88
|
` Snapshots only apply when the call is trivial (no filter/sort/skip).\n` +
|
|
89
89
|
` Either remove the ${reasonLabel} from this call, or accept the API roundtrip if filtering is intentional.\n` +
|
|
90
90
|
` Suppress: pass { quiet: true } on the call, or set MIKSER_QUIET=1.`,
|
|
@@ -95,70 +95,107 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
95
95
|
return function entities(name, opts = {}) {
|
|
96
96
|
const {
|
|
97
97
|
token,
|
|
98
|
-
//
|
|
99
|
-
// pre-built static JSON snapshot — typically produced by the
|
|
100
|
-
// `data` plugin's catalog.<name> output. When set, live() fires
|
|
101
|
-
// onChange with the snapshot immediately, then opens the SSE
|
|
102
|
-
// subscribe stream as usual. listAll() also consults the
|
|
103
|
-
// snapshot before falling back to paginated list calls.
|
|
98
|
+
// `data` mirrors the mikser-io `data` plugin's config block:
|
|
104
99
|
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
100
|
+
// data: {
|
|
101
|
+
// catalog: 'sitemap', // pairs with data.catalog.sitemap
|
|
102
|
+
// entities: 'page', // pairs with data.entities.page
|
|
103
|
+
// }
|
|
109
104
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
105
|
+
// On the server the data plugin writes:
|
|
106
|
+
// - catalog.<name> → out/data/<name>.json (one combined file)
|
|
107
|
+
// - entities.<name> → out/data/<entity.name>.<name>.json (one file per entity)
|
|
108
|
+
//
|
|
109
|
+
// On the client:
|
|
110
|
+
// - `data.catalog` makes live() / listAll() consult
|
|
111
|
+
// /data/<this>.json on first paint, falling back to a
|
|
112
|
+
// fresh list() if the file is missing.
|
|
113
|
+
// - `data.entities` makes live({id}) consult the per-entity
|
|
114
|
+
// file /data/<entry.name>.<this>.json, falling back to a
|
|
115
|
+
// fresh list({filter:{id}}) call. Requires `data.catalog`
|
|
116
|
+
// to be loaded so the entity's `name` is known (the
|
|
117
|
+
// mapping comes from the catalog wrapper, not the id).
|
|
118
|
+
//
|
|
119
|
+
// Default URL prefix is /data/ to match the data plugin's
|
|
120
|
+
// default `dataFolder`. If a project customizes `data.dataFolder`
|
|
121
|
+
// server-side, add a matching prefix here — but for now this
|
|
122
|
+
// is hardcoded.
|
|
123
|
+
data: dataConfig = {},
|
|
120
124
|
} = opts
|
|
125
|
+
const { catalog: catalogName, entities: entitiesName } = dataConfig
|
|
121
126
|
const endpointBase = `${basePath}/${name}`
|
|
122
127
|
const queryUrl = joinUrl(baseUrl, `${endpointBase}/entities/query`)
|
|
123
128
|
const listUrl = joinUrl(baseUrl, `${endpointBase}/entities`)
|
|
124
129
|
const subscribeUrl = joinUrl(baseUrl, `${endpointBase}/entities/subscribe`)
|
|
125
130
|
const renderUrl = joinUrl(baseUrl, `${endpointBase}/render`)
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
// Default URL prefix matches the data plugin's default folder.
|
|
133
|
+
const DATA_PREFIX = '/data'
|
|
134
|
+
|
|
135
|
+
const catalogUrl = catalogName
|
|
136
|
+
? joinUrl(baseUrl, `${DATA_PREFIX}/${catalogName}.json`)
|
|
129
137
|
: null
|
|
130
138
|
|
|
139
|
+
// id → entity.name index, populated when the catalog snapshot
|
|
140
|
+
// loads. Lets `live({id})` derive the per-entity file URL
|
|
141
|
+
// without re-fetching the catalog or guessing.
|
|
142
|
+
const nameById = new Map()
|
|
143
|
+
|
|
131
144
|
// Cached snapshot promise — concurrent live() / listAll() calls
|
|
132
145
|
// share one fetch. Cleared on error so a flaky connection can
|
|
133
146
|
// recover on the next call.
|
|
134
147
|
let snapshotPromise = null
|
|
135
148
|
async function loadSnapshot() {
|
|
136
|
-
if (!
|
|
149
|
+
if (!catalogUrl) return null
|
|
137
150
|
if (snapshotPromise) return snapshotPromise
|
|
138
151
|
snapshotPromise = (async () => {
|
|
139
152
|
try {
|
|
140
|
-
const res = await doFetch(
|
|
153
|
+
const res = await doFetch(catalogUrl, {
|
|
141
154
|
method: 'GET',
|
|
142
155
|
headers: { accept: 'application/json', ...defaultHeaders },
|
|
143
156
|
})
|
|
144
|
-
if (!res.ok)
|
|
145
|
-
if (!fallbackToList) {
|
|
146
|
-
throw new MikserError(res.status, res.statusText, null, resolvedInitialUrl)
|
|
147
|
-
}
|
|
148
|
-
return null
|
|
149
|
-
}
|
|
157
|
+
if (!res.ok) return null
|
|
150
158
|
const payload = await res.json()
|
|
151
|
-
return unwrapSnapshot(payload)
|
|
159
|
+
return unwrapSnapshot(payload, nameById)
|
|
152
160
|
} catch (err) {
|
|
153
161
|
// Clear the cached promise so the next call can retry.
|
|
154
162
|
snapshotPromise = null
|
|
155
|
-
if (!fallbackToList) throw err
|
|
156
163
|
return null
|
|
157
164
|
}
|
|
158
165
|
})()
|
|
159
166
|
return snapshotPromise
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
// Per-entity file fetch — for live({id}) when data.entities is
|
|
170
|
+
// configured. Always returns either the entity or null (null
|
|
171
|
+
// means "fall back to the API"). Never throws.
|
|
172
|
+
async function loadEntityFile(id) {
|
|
173
|
+
if (!entitiesName) return null
|
|
174
|
+
const entityName = nameById.get(id)
|
|
175
|
+
if (!entityName) {
|
|
176
|
+
// The catalog isn't loaded yet, or this id wasn't in it.
|
|
177
|
+
// Trigger a catalog load lazily; the next call will hit
|
|
178
|
+
// the populated map.
|
|
179
|
+
await loadSnapshot()
|
|
180
|
+
if (!nameById.has(id)) return null
|
|
181
|
+
}
|
|
182
|
+
const url = joinUrl(baseUrl, `${DATA_PREFIX}/${nameById.get(id)}.${entitiesName}.json`)
|
|
183
|
+
try {
|
|
184
|
+
const res = await doFetch(url, {
|
|
185
|
+
method: 'GET',
|
|
186
|
+
headers: { accept: 'application/json', ...defaultHeaders },
|
|
187
|
+
})
|
|
188
|
+
if (!res.ok) return null
|
|
189
|
+
const payload = await res.json()
|
|
190
|
+
// Per-entity files are single-object wrappers:
|
|
191
|
+
// { refId, name, date, data: {...} }
|
|
192
|
+
// unwrapEntityFile pulls out `data`.
|
|
193
|
+
return unwrapEntityFile(payload)
|
|
194
|
+
} catch {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
/**
|
|
163
200
|
* Run a list query. Returns the standard envelope:
|
|
164
201
|
* { items, page, limit, total, totalPages, hasNext, hasPrev }.
|
|
@@ -263,7 +300,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
263
300
|
// require re-querying the live endpoint anyway, so any
|
|
264
301
|
// non-trivial query falls through to the paginated fetch.
|
|
265
302
|
const trivial = !query.filter && !query.sort && !query.skip
|
|
266
|
-
if (trivial &&
|
|
303
|
+
if (trivial && catalogUrl) {
|
|
267
304
|
const snapshot = await loadSnapshot()
|
|
268
305
|
if (snapshot) {
|
|
269
306
|
return query.fields
|
|
@@ -272,7 +309,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
272
309
|
}
|
|
273
310
|
// Snapshot unavailable — fall through to paginated fetch.
|
|
274
311
|
}
|
|
275
|
-
if (!trivial &&
|
|
312
|
+
if (!trivial && catalogUrl) {
|
|
276
313
|
maybeWarnSnapshotBypass({
|
|
277
314
|
endpoint: name, kind: 'listAll',
|
|
278
315
|
filter: query.filter, sort: query.sort, skip: query.skip,
|
|
@@ -386,35 +423,60 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
386
423
|
|
|
387
424
|
const loop = (async () => {
|
|
388
425
|
try {
|
|
389
|
-
|
|
390
|
-
// query is trivial enough that the pre-built array
|
|
391
|
-
// reflects what list() would return — anything more
|
|
392
|
-
// specific (filter, sort, skip) goes through list()
|
|
393
|
-
// so the caller's intent is honored.
|
|
394
|
-
let usedSnapshot = false
|
|
426
|
+
let usedFastPath = false
|
|
395
427
|
const trivial = !filter && !sort && !skip
|
|
396
|
-
|
|
428
|
+
|
|
429
|
+
// Per-entity file fast path. When the call is a
|
|
430
|
+
// single-id lookup (the shape useDocument issues)
|
|
431
|
+
// and `data.entities` is configured, fetch the
|
|
432
|
+
// pre-built file the data plugin wrote instead of
|
|
433
|
+
// calling the API. Falls back to list() if the
|
|
434
|
+
// file isn't there.
|
|
435
|
+
const isSingleIdLookup = (
|
|
436
|
+
entitiesName &&
|
|
437
|
+
filter && typeof filter === 'object' &&
|
|
438
|
+
Object.keys(filter).length === 1 &&
|
|
439
|
+
'id' in filter && filter.id != null
|
|
440
|
+
)
|
|
441
|
+
if (isSingleIdLookup) {
|
|
442
|
+
const entity = await loadEntityFile(filter.id)
|
|
443
|
+
if (entity) {
|
|
444
|
+
if (disposed || ac.signal.aborted) return
|
|
445
|
+
items = fields ? [pickFields(entity, fields)] : [entity]
|
|
446
|
+
onChange(items)
|
|
447
|
+
usedFastPath = true
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Catalog snapshot fast path. Used only when the
|
|
452
|
+
// call is trivial enough that the pre-built array
|
|
453
|
+
// reflects what list() would return — anything
|
|
454
|
+
// more specific (filter, sort, skip) goes through
|
|
455
|
+
// list() so the caller's intent is honored.
|
|
456
|
+
if (!usedFastPath && trivial && catalogUrl) {
|
|
397
457
|
const snapshot = await loadSnapshot()
|
|
398
458
|
if (snapshot) {
|
|
399
459
|
if (disposed || ac.signal.aborted) return
|
|
400
460
|
items = fields ? snapshot.map(i => pickFields(i, fields)) : snapshot
|
|
401
461
|
onChange(items)
|
|
402
|
-
|
|
462
|
+
usedFastPath = true
|
|
403
463
|
}
|
|
404
464
|
}
|
|
405
|
-
|
|
465
|
+
|
|
466
|
+
// Bypass warning for cases where the user opted into
|
|
467
|
+
// a snapshot but their call doesn't fit either fast
|
|
468
|
+
// path. The single-id lookup is its own valid shape,
|
|
469
|
+
// so don't warn for it.
|
|
470
|
+
if (!usedFastPath && !isSingleIdLookup && !trivial && catalogUrl) {
|
|
406
471
|
maybeWarnSnapshotBypass({
|
|
407
472
|
endpoint: name, kind: 'live',
|
|
408
473
|
filter, sort, skip, quiet,
|
|
409
474
|
})
|
|
410
475
|
}
|
|
411
|
-
|
|
476
|
+
|
|
477
|
+
if (!usedFastPath) {
|
|
412
478
|
// Pass { quiet } so list()'s wide-warning honors
|
|
413
|
-
// the live() caller's quiet opt.
|
|
414
|
-
// does its own snapshot-path warning below for
|
|
415
|
-
// the case where the initial fill came from
|
|
416
|
-
// /data/<name>.json — those callers already
|
|
417
|
-
// opted into the narrow shape, so no warning.
|
|
479
|
+
// the live() caller's quiet opt.
|
|
418
480
|
const env = await list({ filter, sort, fields, limit, skip }, { quiet })
|
|
419
481
|
if (disposed || ac.signal.aborted) return
|
|
420
482
|
items = env.items
|
|
@@ -526,15 +588,25 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
526
588
|
// - plain array of entities: [{ id, meta, ... }, ...]
|
|
527
589
|
// - list() envelope: { items: [...], page, ... }
|
|
528
590
|
// Returns null on unrecognised shapes so the caller can decide to fall
|
|
529
|
-
// back to a fresh list() call
|
|
530
|
-
function unwrapSnapshot(payload) {
|
|
591
|
+
// back to a fresh list() call.
|
|
592
|
+
function unwrapSnapshot(payload, nameById) {
|
|
531
593
|
if (Array.isArray(payload)) {
|
|
532
594
|
if (payload.length === 0) return []
|
|
533
595
|
// Heuristic: data-plugin entries have `refId` + `data`; treat any
|
|
534
596
|
// object with `data` as a wrapped entry and unwrap. Anything else
|
|
535
597
|
// is a plain array.
|
|
536
598
|
if (payload[0] && typeof payload[0] === 'object' && 'data' in payload[0]) {
|
|
537
|
-
return payload
|
|
599
|
+
return payload
|
|
600
|
+
.map(entry => {
|
|
601
|
+
if (!entry || !entry.data) return null
|
|
602
|
+
// Side-table: stash entity.name keyed by entity.id so
|
|
603
|
+
// live({id}) can compute per-entity file URLs later.
|
|
604
|
+
if (nameById && entry.data.id != null && entry.name != null) {
|
|
605
|
+
nameById.set(entry.data.id, entry.name)
|
|
606
|
+
}
|
|
607
|
+
return entry.data
|
|
608
|
+
})
|
|
609
|
+
.filter(Boolean)
|
|
538
610
|
}
|
|
539
611
|
return payload
|
|
540
612
|
}
|
|
@@ -544,6 +616,19 @@ function unwrapSnapshot(payload) {
|
|
|
544
616
|
return null
|
|
545
617
|
}
|
|
546
618
|
|
|
619
|
+
// Per-entity files are single-object wrappers — `{ refId, name, date,
|
|
620
|
+
// data: {...} }` — written by `data.entities.<name>` on the server.
|
|
621
|
+
// Returns the unwrapped data, or null if the shape isn't recognised.
|
|
622
|
+
function unwrapEntityFile(payload) {
|
|
623
|
+
if (!payload || typeof payload !== 'object') return null
|
|
624
|
+
if ('data' in payload && payload.data && typeof payload.data === 'object') {
|
|
625
|
+
return payload.data
|
|
626
|
+
}
|
|
627
|
+
// Plain entity (someone hand-wrote a JSON file, no wrapper) — accept it.
|
|
628
|
+
if ('id' in payload) return payload
|
|
629
|
+
return null
|
|
630
|
+
}
|
|
631
|
+
|
|
547
632
|
// Project an entity object to only the fields requested. Dotted paths
|
|
548
633
|
// supported ("meta.title" → object with `meta.title` set, other meta
|
|
549
634
|
// keys dropped). Used by the snapshot fast paths in list/live so a
|
package/src/href.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Multilingual href resolution — pure data version. Framework SDKs
|
|
2
|
+
// (Vue/React/Svelte) wrap this in their own reactivity primitives and
|
|
3
|
+
// expose useHref / useAlternates composables on top.
|
|
4
|
+
//
|
|
5
|
+
// The "logical reference → deployed URL" mapping lets the consumer
|
|
6
|
+
// link to a content-shaped identifier (`/about`) and have it resolve
|
|
7
|
+
// to whatever URL the current locale serves it at (`/en/about`,
|
|
8
|
+
// `/fr/a-propos`, etc.). Convention is three meta fields:
|
|
9
|
+
//
|
|
10
|
+
// meta.href: '/about' (logical reference)
|
|
11
|
+
// meta.lang: 'en' (language this doc represents)
|
|
12
|
+
// meta.route: '/en/about' (deployed URL)
|
|
13
|
+
//
|
|
14
|
+
// Anything missing `meta.href` is excluded from the index.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Array<{meta?: object}>} documents Catalog documents with meta.
|
|
18
|
+
* @param {Object} [options]
|
|
19
|
+
* @param {string} [options.defaultLang='default'] Fallback language tag
|
|
20
|
+
* for documents that don't declare meta.lang. The literal string
|
|
21
|
+
* `'default'` is treated as a fallback bucket in lookups.
|
|
22
|
+
* @returns {{
|
|
23
|
+
* href: (ref: string, lang?: string) => string,
|
|
24
|
+
* refFor: (url: string|null) => string|null,
|
|
25
|
+
* alternates: (opts: { route: string|null, languages?: string[] }) => { current: {lang, url, ref}|null, alternates: Array<{lang, url}> },
|
|
26
|
+
* map: Record<string, Record<string, string>>,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function createHrefIndex(documents, { defaultLang = 'default' } = {}) {
|
|
30
|
+
const map = {}
|
|
31
|
+
if (Array.isArray(documents)) {
|
|
32
|
+
for (const document of documents) {
|
|
33
|
+
const ref = document?.meta?.href
|
|
34
|
+
if (!ref) continue
|
|
35
|
+
const lang = document.meta?.lang ?? defaultLang
|
|
36
|
+
const url = document.meta?.route ?? document.meta?.destination ?? ref
|
|
37
|
+
if (!map[ref]) map[ref] = {}
|
|
38
|
+
map[ref][lang] = url
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a logical reference to a deployed URL.
|
|
44
|
+
*
|
|
45
|
+
* Fallback chain: requested lang → `'default'` bucket → any
|
|
46
|
+
* available language → the input reference unchanged (so broken
|
|
47
|
+
* references stay visible rather than silently becoming undefined).
|
|
48
|
+
*/
|
|
49
|
+
function href(ref, lang) {
|
|
50
|
+
const target = lang ?? defaultLang
|
|
51
|
+
const entry = map[ref]
|
|
52
|
+
if (!entry) return ref
|
|
53
|
+
return entry[target]
|
|
54
|
+
?? entry['default']
|
|
55
|
+
?? Object.values(entry)[0]
|
|
56
|
+
?? ref
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reverse lookup — given a deployed URL, return the logical
|
|
61
|
+
* reference it belongs to (or null if it's not in the index).
|
|
62
|
+
*/
|
|
63
|
+
function refFor(url) {
|
|
64
|
+
if (url == null) return null
|
|
65
|
+
for (const [ref, byLang] of Object.entries(map)) {
|
|
66
|
+
if (Object.values(byLang).includes(url)) return ref
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Alternates for a deployed URL — useful for hreflang tags and
|
|
73
|
+
* language switchers.
|
|
74
|
+
*
|
|
75
|
+
* `languages` controls the alternate set:
|
|
76
|
+
* - omitted: only return languages that actually exist in the
|
|
77
|
+
* catalog for this ref. Right shape for hreflang (don't
|
|
78
|
+
* advertise translations that don't exist).
|
|
79
|
+
* - provided as an array: return one entry per requested
|
|
80
|
+
* language, using href()'s fallback chain when a translation
|
|
81
|
+
* doesn't exist. Right shape for language switchers (show
|
|
82
|
+
* every locale the app supports).
|
|
83
|
+
*
|
|
84
|
+
* The current page's own language is excluded from `alternates` —
|
|
85
|
+
* it's what `current` is for. Callers that want it included can
|
|
86
|
+
* prepend `current` themselves.
|
|
87
|
+
*/
|
|
88
|
+
function alternates({ route, languages } = {}) {
|
|
89
|
+
if (route == null) return { current: null, alternates: [] }
|
|
90
|
+
const ref = refFor(route)
|
|
91
|
+
if (ref == null) return { current: null, alternates: [] }
|
|
92
|
+
const entry = map[ref] ?? {}
|
|
93
|
+
const currentLang = Object.entries(entry).find(([, url]) => url === route)?.[0] ?? null
|
|
94
|
+
const current = { lang: currentLang, url: route, ref }
|
|
95
|
+
|
|
96
|
+
let list
|
|
97
|
+
if (Array.isArray(languages)) {
|
|
98
|
+
list = languages
|
|
99
|
+
.filter(lang => lang !== currentLang)
|
|
100
|
+
.map(lang => ({ lang, url: href(ref, lang) }))
|
|
101
|
+
} else {
|
|
102
|
+
list = Object.entries(entry)
|
|
103
|
+
.filter(([lang]) => lang !== currentLang && lang !== 'default')
|
|
104
|
+
.map(([lang, url]) => ({ lang, url }))
|
|
105
|
+
}
|
|
106
|
+
return { current, alternates: list }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { href, refFor, alternates, map }
|
|
110
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Build-time route enumeration. Given a mikser entities client and a
|
|
2
|
+
// mapRoute function, return the array of route descriptors produced by
|
|
3
|
+
// applying `mapRoute` to every catalog entity that matches the filter.
|
|
4
|
+
//
|
|
5
|
+
// Auto-paginates via the client's listAll() under the hood — no manual
|
|
6
|
+
// limit, no silent truncation on large catalogs.
|
|
7
|
+
//
|
|
8
|
+
// Framework-agnostic: the `mapRoute` return shape is whatever your build
|
|
9
|
+
// pipeline expects (vite-ssg routes, Next pages, SvelteKit entries, etc.).
|
|
10
|
+
// Framework SDKs re-export this with their own typed mapRoute signatures.
|
|
11
|
+
|
|
12
|
+
const DEFAULT_FILTER = { 'meta.published': true, 'meta.route': { $exists: true } }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {Object} options.client A mikser entities client (the result of
|
|
17
|
+
* createClient(...).entities(name)).
|
|
18
|
+
* @param {Object} [options.filter] Sift filter — defaults to "published
|
|
19
|
+
* documents that declare meta.route".
|
|
20
|
+
* @param {Function} options.mapRoute (document) => routeDescriptor | null.
|
|
21
|
+
* Null returns are dropped.
|
|
22
|
+
* @returns {Promise<Array>} The mapped route descriptors.
|
|
23
|
+
*/
|
|
24
|
+
export async function generateMikserRoutes({
|
|
25
|
+
client,
|
|
26
|
+
filter = DEFAULT_FILTER,
|
|
27
|
+
mapRoute,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!client) throw new Error('generateMikserRoutes: { client } is required')
|
|
30
|
+
if (!mapRoute) throw new Error('generateMikserRoutes: { mapRoute } is required')
|
|
31
|
+
|
|
32
|
+
const items = await client.listAll({ filter, fields: ['id', 'meta'] })
|
|
33
|
+
return items.map(mapRoute).filter(r => r != null)
|
|
34
|
+
}
|