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 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 | Default | What it does |
108
+ | Option | Default | What it does |
109
109
  |---|---|---|
110
- | `token` | `null` | Bearer token for endpoints declared with a token. Sent on every request. |
111
- | `initialUrl` | `null` | Static-snapshot URL see below. |
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
- ### `initialUrl` — pair with the `data` plugin for fast first paint
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 category list — paying an API round-trip for it on first paint is wasted work. The right shape is to publish that data as a static file at build/finalize time and have the SDK pick it up.
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 does the publishing side. A `catalog.<name>` entry writes one JSON file per name under `out/data/`; `<name>` is whatever you want to call it — name it after the role the snapshot plays in your app (`sitemap`, `menu`, `tags`, `categories`, etc.). Example for a router-driving snapshot:
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 entry per published, component-having
127
- // document, projected to just the routing fields. `sitemap` is the
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, point `entities()` at the matching file. The URL path mirrors the catalog name: `data.catalog.sitemap` `/data/sitemap.json`, `data.catalog.menu` → `/data/menu.json`, and so on.
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', { initialUrl: '/data/sitemap.json' })
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 `initialUrl` changes:
164
+ What gets used when:
155
165
 
156
- - **`live(filter, onChange, options)`** fires `onChange` with the snapshot immediately (no API round-trip), then opens the SSE subscribe stream as usual. So `useMikserRoutes` / `useMikserPages` get a populated route table before the network even settles.
157
- - **`listAll()`** consults the snapshot first and only falls back to paginated `list()` calls if the snapshot is missing or `fallbackToList: true` and the fetch failed.
158
- - **`list()`**, `urlFor()`, `query()`, `update()`, `delete()`, `render()`, `subscribe()` are unchanged — they always go to the API.
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 and hands `onChange` / `listAll` a plain array of the `data` payloads, so a static snapshot looks identical to a live response.
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 snapshot is only consulted for the initial fill; ongoing changes come over SSE on the actual API endpoint. For runtime fail-safety on per-id reads, 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).
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 snapshot — which may be N seconds old, depending on when mikser last wrote it — 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 (e.g. show a subtle "syncing" indicator until the first SSE event arrives, or render a loading state when the snapshot age exceeds your tolerance) or skip `initialUrl` for that particular query — paying the API roundtrip is the right tradeoff when the response can't be allowed to be stale.
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] initialUrl is set on "public" but this live() call uses filter+sort
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', { initialUrl: '/data/<name>.json' })
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
- * URL (relative to baseUrl or absolute) of a pre-built static JSON
21
- * snapshot of the entity set. When set, `live()` and `listAll()`
22
- * read from this URL on first call instead of hitting the live API.
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
- * Largely obsolete with the api plugin's per-query disk cache
25
- * (mikser-io ^6.25.0) the cache writes to the same URL the live
26
- * API serves, and a reverse proxy can fail over to it transparently.
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
- * Otherwise, just configure your reverse proxy to fail over to the
34
- * cache on backend errors and the live URL Just Works.
29
+ * On a fetch failure the SDK silently falls back to the live API
30
+ * for that call no separate flag.
35
31
  */
36
- initialUrl?: string
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
- * When the snapshot fetch fails (404 in dev, network error,
39
- * unrecognised shape), default behaviour is to silently fall
40
- * back to a fresh `list()` call. Set false for production
41
- * environments where you require the snapshot to be present.
42
- * @default true
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
- fallbackToList?: boolean
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-sdk-api",
3
- "version": "2.5.1",
3
+ "version": "3.1.0",
4
4
  "description": "Client SDK for mikser-io's api plugin — query the document catalog from the browser or Node",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
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}', { initialUrl: '/data/<name>.json' })\n` +
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 `initialUrl` is configured but
66
- // the call is non-trivial (has filter / sort / skip), so the snapshot
67
- // can't be used and the SDK falls back to the live API. The bypass is
68
- // correct behavior, but it's silent: developers often set initialUrl
69
- // once and then add a sort to one of their useDocuments calls without
70
- // noticing the snapshot is no longer involved. Deduped per
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] initialUrl is set on "${endpoint}" but this ${kind}() call uses ${reasonLabel} — snapshot bypassed, falling back to ${fallback}.\n` +
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
- // initialUrl: optional URL (relative to baseUrl or absolute) for a
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
- // The fetched JSON is unwrapped automatically — the data
106
- // plugin emits `[{ refId, name, date, data }, ...]`; we
107
- // return the `.data` payloads. Plain arrays and { items }
108
- // envelopes are passed through unchanged.
100
+ // data: {
101
+ // catalog: 'sitemap', // pairs with data.catalog.sitemap
102
+ // entities: 'page', // pairs with data.entities.page
103
+ // }
109
104
  //
110
- // Pre-built snapshots are the fast first-paint path:
111
- // CDN-cached, no API roundtrip, no SSE latency tax on boot.
112
- // Pair the snapshot's data-plugin filter with your live()
113
- // filter so the initial state matches what SSE will send.
114
- initialUrl,
115
- // When initialUrl fetch fails (404 in dev, network error,
116
- // wrong shape), default behavior is to log and fall back to
117
- // a fresh list() call keeps dev mode trivial. Set false
118
- // for environments where you require the snapshot.
119
- fallbackToList = true,
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
- const resolvedInitialUrl = initialUrl
128
- ? (/^https?:\/\//.test(initialUrl) ? initialUrl : joinUrl(baseUrl, initialUrl))
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 (!resolvedInitialUrl) return null
149
+ if (!catalogUrl) return null
137
150
  if (snapshotPromise) return snapshotPromise
138
151
  snapshotPromise = (async () => {
139
152
  try {
140
- const res = await doFetch(resolvedInitialUrl, {
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 && resolvedInitialUrl) {
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 && resolvedInitialUrl) {
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
- // Snapshot fast path. Use it only when the caller's
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
- if (trivial && resolvedInitialUrl) {
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
- usedSnapshot = true
462
+ usedFastPath = true
403
463
  }
404
464
  }
405
- if (!trivial && resolvedInitialUrl) {
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
- if (!usedSnapshot) {
476
+
477
+ if (!usedFastPath) {
412
478
  // Pass { quiet } so list()'s wide-warning honors
413
- // the live() caller's quiet opt. live() also
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 (when fallbackToList is true).
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.map(entry => entry.data).filter(Boolean)
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
+ }