mikser-io-sdk-api 3.0.0 → 3.3.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 +171 -1
- package/index.d.ts +145 -0
- package/index.js +6 -0
- package/package.json +2 -2
- package/src/asset.js +65 -0
- package/src/entities.js +209 -11
- package/src/href.js +110 -0
- package/src/routes.js +34 -0
package/README.md
CHANGED
|
@@ -227,6 +227,100 @@ Response envelope: `{ items, page, limit, total, totalPages, hasNext, hasPrev }`
|
|
|
227
227
|
|
|
228
228
|
Use dotted-path keys for nested fields (`'meta.price': { $gt: 20 }`). Nested object literals (`{ meta: { price: { $gt: 20 } } }`) are interpreted as deep-equality — same gotcha as Mongo.
|
|
229
229
|
|
|
230
|
+
### `expand` — inline-resolve referenced entities in one trip
|
|
231
|
+
|
|
232
|
+
Mikser entities can carry references to other entities via `$`-prefixed front-matter keys ([ADR-0007](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/decisions/0007-references-declaration-and-expansion.md)). On the wire, the SDK projects them back to plain names — so a document with `$author: /authors/dick` in YAML shows up as `meta.author = '/authors/dick'` in the response. **Always**: every response has `$`-keys stripped, regardless of whether you asked for expansion. The convention is engine-side; the wire shape is clean.
|
|
233
|
+
|
|
234
|
+
Pass `expand: [...]` to inline the resolved entity in place of the ref string — multi-hop graph fetches collapse to one round-trip.
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
// Source on the server:
|
|
238
|
+
// ---
|
|
239
|
+
// layout: article
|
|
240
|
+
// title: Launch
|
|
241
|
+
// $author: /authors/dick
|
|
242
|
+
// $hero: /images/launch-hero
|
|
243
|
+
// $related: ['/blog/follow-up', '/blog/changelog']
|
|
244
|
+
// ---
|
|
245
|
+
|
|
246
|
+
// Without expand — refs come back as strings (normalized form: no $).
|
|
247
|
+
const { items } = await docs.list({ filter: { id: '/blog/launch.md' } })
|
|
248
|
+
items[0].meta.author // '/authors/dick' — string
|
|
249
|
+
items[0].meta.hero // '/images/launch-hero' — string
|
|
250
|
+
items[0].meta.related // ['/blog/follow-up', '/blog/changelog']
|
|
251
|
+
|
|
252
|
+
// With expand — refs come back as full entity objects.
|
|
253
|
+
const { items: hydrated } = await docs.list({
|
|
254
|
+
filter: { id: '/blog/launch.md' },
|
|
255
|
+
expand: ['author', 'hero'],
|
|
256
|
+
})
|
|
257
|
+
hydrated[0].meta.author.meta.name // 'Dick Marinov'
|
|
258
|
+
hydrated[0].meta.hero.meta.alt // 'Launch screen hero'
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Multi-hop chains** — dot-notation walks through expanded entities:
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
const { items } = await docs.list({
|
|
265
|
+
filter: { id: '/blog/launch.md' },
|
|
266
|
+
expand: ['author.organization'],
|
|
267
|
+
})
|
|
268
|
+
items[0].meta.author.meta.organization.meta.name // 'Almero Digital'
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Each segment in the path that lands on a `$`-keyed field gets expanded. The path also expands every intermediate hop — `expand: ['author.organization']` expands `$author` AND walks into the resolved author's `$organization`.
|
|
272
|
+
|
|
273
|
+
**Array iteration with `*`** — for sections, related lists, or any `$`-keyed array:
|
|
274
|
+
|
|
275
|
+
```js
|
|
276
|
+
// $related: ['/blog/follow-up', '/blog/changelog']
|
|
277
|
+
expand: ['related']
|
|
278
|
+
// → meta.related[0] and meta.related[1] are full entity objects
|
|
279
|
+
|
|
280
|
+
// Mixed nesting + iteration — landing page with section blocks:
|
|
281
|
+
// sections:
|
|
282
|
+
// - { type: hero, $image: /images/hero }
|
|
283
|
+
// - { type: features, $image: /images/feat-a }
|
|
284
|
+
expand: ['sections.*.image']
|
|
285
|
+
// → each section's $image becomes the resolved image entity
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Multiple paths in one call**:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
const { items } = await docs.list({
|
|
292
|
+
filter: { id: '/landing.md' },
|
|
293
|
+
expand: [
|
|
294
|
+
'hero',
|
|
295
|
+
'sections.*.image',
|
|
296
|
+
'sections.*.cta.target',
|
|
297
|
+
'author.organization',
|
|
298
|
+
],
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Path forms** — the SDK accepts both `'author'` (normalized) and `'$author'` (canonical) and forwards either to the api, which accepts both. Use whichever feels natural at the call site; they're equivalent.
|
|
303
|
+
|
|
304
|
+
**Server-side caps** — exceeding any cap returns a `MikserError` with `status === 422`:
|
|
305
|
+
|
|
306
|
+
| Cap | Default | Configured at | What triggers it |
|
|
307
|
+
|---|---|---|---|
|
|
308
|
+
| `maxDepth` | 5 | `api.expand.maxDepth` | One path is longer than this (`a.b.c.d.e.f` at default) |
|
|
309
|
+
| `maxPaths` | 20 | `api.expand.maxPaths` | The `expand` array has more entries than this |
|
|
310
|
+
| `maxResolved` | 100 | `api.expand.maxResolved` | Total entity lookups for the request (across all paths) exceeded |
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
try {
|
|
314
|
+
await docs.list({ filter: {...}, expand: tooManyPaths })
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (err.status === 422) { /* tighten the expand spec */ }
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Missing targets and cycles are silently left as strings.** If `$author: /authors/missing` doesn't resolve, the response carries `meta.author === '/authors/missing'` — a string at the position you asked to expand. Same shape for cycle breaks. Per ADR-0007 B6 this is by design: the response shape stays consistent, and "string where we asked for an object" is the unambiguous signal that resolution stopped there.
|
|
321
|
+
|
|
322
|
+
**Transport**: same GET-first strategy as the rest of `list()` — `expand` is serialized as a comma-separated URL param when the request fits in the URL, falls back to POST body otherwise. CDN caching works the same way on either form; the `expand` value is part of the cache key.
|
|
323
|
+
|
|
230
324
|
### `urlFor(query)` — GET-form URL
|
|
231
325
|
|
|
232
326
|
Build a URL for the GET form of the same query. Useful when the response should be CDN-cacheable, or you want a sharable link.
|
|
@@ -235,11 +329,37 @@ Build a URL for the GET form of the same query. Useful when the response should
|
|
|
235
329
|
const url = docs.urlFor({
|
|
236
330
|
filter: { 'meta.published': true, 'meta.price': { $gt: 20 } },
|
|
237
331
|
sort: { 'meta.date': -1 },
|
|
332
|
+
expand: ['author', 'hero'],
|
|
238
333
|
limit: 10,
|
|
239
334
|
})
|
|
240
|
-
// http://localhost:3001/api/public/entities?
|
|
335
|
+
// http://localhost:3001/api/public/entities?
|
|
336
|
+
// meta.published=true&meta.price.$gt=20&sort=-meta.date&expand=author,hero&limit=10
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`expand` is serialized as a comma-separated value so the URL is a stable cache key — same response for the same URL across requests and CDN nodes.
|
|
340
|
+
|
|
341
|
+
### `cacheKeyFor(query)` — the nginx fast-path hint
|
|
342
|
+
|
|
343
|
+
The api plugin caches GET responses to disk under a filename derived from the query. Both server and SDK hash the same query the same way, so `list()` appends `&cache=<hash>` to its GET URL automatically — nginx can then serve cached files via `try_files` without computing the hash itself (no Lua, no rewrite module). See the [api plugin's caching docs](https://github.com/almero-digital-marketing/mikser-io/blob/main/documentation/caching.md) for the full nginx config.
|
|
344
|
+
|
|
345
|
+
```js
|
|
346
|
+
const query = {
|
|
347
|
+
filter: { 'meta.published': true },
|
|
348
|
+
expand: ['author'],
|
|
349
|
+
limit: 10,
|
|
350
|
+
}
|
|
351
|
+
const key = await docs.cacheKeyFor(query)
|
|
352
|
+
// '4f3a2c1d8e9b6f7a' (16 hex chars; same value the server uses on disk)
|
|
353
|
+
|
|
354
|
+
// Compose a cacheable URL by hand (rare — list() does this automatically).
|
|
355
|
+
// urlFor() guarantees a `?` because the query has params; for the empty-
|
|
356
|
+
// query case `cacheKeyFor()` returns `'index'` and you'd skip appending
|
|
357
|
+
// `cache` (the server stores empty-query responses as `index.json`).
|
|
358
|
+
const url = docs.urlFor(query) + '&cache=' + key
|
|
241
359
|
```
|
|
242
360
|
|
|
361
|
+
Returns `'index'` for empty queries — the server stores that case under `index.json`, the conventional default-snapshot name a `try_files` directive falls through to. The server strips the `cache` param before computing its own hash, so a wrong/stale client value just causes a cache miss in nginx (graceful fallback to mikser); no poisoning is possible because the server is always the source of truth for filename choice.
|
|
362
|
+
|
|
243
363
|
### `pages(query)` — async iterator
|
|
244
364
|
|
|
245
365
|
```js
|
|
@@ -250,6 +370,53 @@ for await (const env of docs.pages({ filter: { type: 'document' }, limit: 50 }))
|
|
|
250
370
|
}
|
|
251
371
|
```
|
|
252
372
|
|
|
373
|
+
`pages()` and `listAll()` both accept `expand` in the query — the parameter applies to every page in the iteration. For sitemap-style enumeration with one-hop hydration (`expand: ['author']`), this keeps the build to one round-trip per page rather than N per entity.
|
|
374
|
+
|
|
375
|
+
### `paginator(options)` — stateful client-side paginator
|
|
376
|
+
|
|
377
|
+
Wraps `list()` with page-at-a-time state. Each navigation call (`goTo` / `next` / `prev`) fetches exactly **one page** from the server — no upfront load of the full collection. Right for UI navigation; use `pages()` or `listAll()` for SSG sitemap enumeration that needs every page.
|
|
378
|
+
|
|
379
|
+
```js
|
|
380
|
+
const docs = client.entities('public')
|
|
381
|
+
|
|
382
|
+
const paginator = docs.paginator({
|
|
383
|
+
filter: { 'meta.layout': 'post' },
|
|
384
|
+
sort: { 'meta.date': -1 },
|
|
385
|
+
pageSize: 10,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
await paginator.goTo(1) // one HTTP request → first 10 items
|
|
389
|
+
paginator.items // those 10 items
|
|
390
|
+
paginator.page // 1
|
|
391
|
+
paginator.pages // total page count (server-computed)
|
|
392
|
+
paginator.totalItems // total item count
|
|
393
|
+
paginator.hasNext, hasPrev
|
|
394
|
+
paginator.pageNumbers // [{ num, url, isCurrent }, ...]
|
|
395
|
+
|
|
396
|
+
await paginator.next() // one HTTP request → items 11..20
|
|
397
|
+
await paginator.goTo(5) // one HTTP request → items 41..50
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
State accessors are getters — the value you read is always the result of the last completed fetch. Wrap in your framework's reactive primitive (React's `useState`, Vue's `ref`, Svelte stores) to re-render when navigation completes.
|
|
401
|
+
|
|
402
|
+
Options:
|
|
403
|
+
- `filter`, `sort`, `fields`, `expand` — same shape as `list()`. Applied on every page fetch.
|
|
404
|
+
- `pageSize` (default `10`) — items per page. Positive integer.
|
|
405
|
+
- `urlFor` (default `(p) => p === 1 ? '/' : '/<p>/'`) — build per-page hrefs for the `pageNumbers` array. Override for SPA hash routing (`(p) => '#/page/' + p`) or query-param style (`(p) => '?page=' + p`).
|
|
406
|
+
|
|
407
|
+
Errors thrown by `next()` at the last page, `prev()` at the first page, and `goTo()` on an invalid page number — so a UI layer can disable nav controls based on `hasNext` / `hasPrev` rather than catch.
|
|
408
|
+
|
|
409
|
+
#### vs. `list()` directly
|
|
410
|
+
|
|
411
|
+
| Use | Pattern |
|
|
412
|
+
|---|---|
|
|
413
|
+
| One-shot fetch of a specific page | `await docs.list({ filter, page: 3, limit: 10 })` |
|
|
414
|
+
| Stateful UI navigation | `const p = docs.paginator({ ... }); await p.next()` |
|
|
415
|
+
| Server enumeration (SSG, indexing) | `for await (const env of docs.pages({ filter }))` |
|
|
416
|
+
| Live-updating feed | `docs.live(filter, onChange)` |
|
|
417
|
+
|
|
418
|
+
`paginator` is sugar over `list` for the most common UI pattern — keeps the "current page" out of your component state.
|
|
419
|
+
|
|
253
420
|
### `watch(query, { signal })` — live subscription via SSE
|
|
254
421
|
|
|
255
422
|
Open a Server-Sent Events stream and yield events as matching entities change. The lowest-level real-time primitive — useful when you want raw events.
|
|
@@ -293,6 +460,7 @@ const dispose = docs.live(
|
|
|
293
460
|
{
|
|
294
461
|
sort: { 'meta.date': -1 },
|
|
295
462
|
fields: ['id', 'meta.title', 'meta.date', 'meta.summary'],
|
|
463
|
+
expand: ['author', 'hero'], // hydrate refs on the initial snapshot
|
|
296
464
|
limit: 20,
|
|
297
465
|
signal: abortController?.signal, // optional external abort
|
|
298
466
|
onError: err => console.error(err), // optional error sink
|
|
@@ -303,6 +471,8 @@ const dispose = docs.live(
|
|
|
303
471
|
dispose()
|
|
304
472
|
```
|
|
305
473
|
|
|
474
|
+
**`expand` and SSE deltas.** When passed in `options`, `expand` is applied to the *initial* snapshot. The SSE delta stream that follows emits raw entities — a `create` or `update` event replaces the previously-expanded item with the unexpanded shape until the next refetch. If you need always-expanded items through live updates, call `list({ ..., expand })` on the cadence you care about instead, or wait for server-side expand-on-subscribe (not shipped yet — track ADR-0007 follow-ups).
|
|
475
|
+
|
|
306
476
|
Equivalent to:
|
|
307
477
|
|
|
308
478
|
```js
|
package/index.d.ts
CHANGED
|
@@ -139,6 +139,60 @@ export interface LiveOptions {
|
|
|
139
139
|
onError?: (err: unknown) => void
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Options for {@link EntitiesClient.paginator}.
|
|
144
|
+
*/
|
|
145
|
+
export interface PaginatorOptions extends Omit<ListQuery, 'page' | 'limit' | 'skip'> {
|
|
146
|
+
/** Items per page. Default: 10. Must be a positive integer. */
|
|
147
|
+
pageSize?: number
|
|
148
|
+
/**
|
|
149
|
+
* Builds the href for each pageNumbers entry. Defaults to mikser's
|
|
150
|
+
* SSG URL convention — page 1 at `/`, page N at `/<N>/`.
|
|
151
|
+
*/
|
|
152
|
+
urlFor?: (page: number) => string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One entry in the paginator's pageNumbers array — usable directly
|
|
157
|
+
* by pagination nav components.
|
|
158
|
+
*/
|
|
159
|
+
export interface PageNumber {
|
|
160
|
+
num: number
|
|
161
|
+
url: string
|
|
162
|
+
isCurrent: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stateful paginator returned from {@link EntitiesClient.paginator}.
|
|
167
|
+
* Each navigation method (goTo / next / prev) fetches ONE page from
|
|
168
|
+
* the server. State accessors are getters — the value you read is
|
|
169
|
+
* always the result of the last completed fetch.
|
|
170
|
+
*/
|
|
171
|
+
export interface Paginator<T = unknown> {
|
|
172
|
+
/** Items in the current page. Empty until goTo() / next() has resolved. */
|
|
173
|
+
readonly items: T[]
|
|
174
|
+
/** Current page number (1-indexed). 1 before the first fetch. */
|
|
175
|
+
readonly page: number
|
|
176
|
+
/** Total page count (server-computed). 1 before the first fetch. */
|
|
177
|
+
readonly pages: number
|
|
178
|
+
/** Total item count across all pages (server-computed). */
|
|
179
|
+
readonly totalItems: number
|
|
180
|
+
/** Page size used for all fetches. */
|
|
181
|
+
readonly pageSize: number
|
|
182
|
+
readonly hasNext: boolean
|
|
183
|
+
readonly hasPrev: boolean
|
|
184
|
+
/** True after the first successful fetch. */
|
|
185
|
+
readonly loaded: boolean
|
|
186
|
+
/** Per-page nav entries — `[{ num, url, isCurrent }, ...]`. */
|
|
187
|
+
readonly pageNumbers: PageNumber[]
|
|
188
|
+
/** Fetch a specific page. Throws on invalid page number. */
|
|
189
|
+
goTo(page: number): Promise<Paginator<T>>
|
|
190
|
+
/** Fetch the next page. Throws if already at the last page. */
|
|
191
|
+
next(): Promise<Paginator<T>>
|
|
192
|
+
/** Fetch the previous page. Throws if already at the first page. */
|
|
193
|
+
prev(): Promise<Paginator<T>>
|
|
194
|
+
}
|
|
195
|
+
|
|
142
196
|
export interface EntitiesClient {
|
|
143
197
|
/** POST /entities/query — body-based, supports any sift filter. */
|
|
144
198
|
list<T = unknown>(query?: ListQuery): Promise<ListEnvelope<T>>
|
|
@@ -153,6 +207,13 @@ export interface EntitiesClient {
|
|
|
153
207
|
* for catalogs too large to hold in memory.
|
|
154
208
|
*/
|
|
155
209
|
listAll<T = unknown>(query?: ListQuery): Promise<T[]>
|
|
210
|
+
/**
|
|
211
|
+
* Stateful paginator over list(). Each goTo / next / prev fetches
|
|
212
|
+
* ONE page from the server — no upfront load of the full
|
|
213
|
+
* collection. Right for UI navigation; wrong for SSG sitemap
|
|
214
|
+
* enumeration (use pages() or listAll() for that).
|
|
215
|
+
*/
|
|
216
|
+
paginator<T = unknown>(options?: PaginatorOptions): Paginator<T>
|
|
156
217
|
/**
|
|
157
218
|
* Open an SSE stream and yield events as matching entities change.
|
|
158
219
|
* Compose with list() for initial state, then watch() for updates.
|
|
@@ -187,3 +248,87 @@ export declare class MikserError extends Error {
|
|
|
187
248
|
status: number
|
|
188
249
|
body: { error?: string } | undefined
|
|
189
250
|
}
|
|
251
|
+
|
|
252
|
+
// ────────────────────────────────────────────────────────────────────
|
|
253
|
+
// Pure utilities — framework SDKs wrap these in their own reactivity.
|
|
254
|
+
// ────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export interface GenerateRoutesOptions<TRoute = unknown> {
|
|
257
|
+
/** A mikser entities client (the result of createClient(...).entities(name)). */
|
|
258
|
+
client: { listAll(query: ListQuery): Promise<Array<{ id: string; meta?: Record<string, unknown> }>> }
|
|
259
|
+
/** Sift filter — defaults to "published documents that declare meta.route". */
|
|
260
|
+
filter?: Filter
|
|
261
|
+
/** Maps a catalog document to a route descriptor (or null to skip). */
|
|
262
|
+
mapRoute: (document: { id: string; meta?: Record<string, unknown> }) => TRoute | null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build-time route enumeration. Auto-paginates via listAll() and
|
|
267
|
+
* applies `mapRoute` to every catalog entity that matches the filter.
|
|
268
|
+
* `null` returns from `mapRoute` are dropped.
|
|
269
|
+
*/
|
|
270
|
+
export function generateMikserRoutes<TRoute = unknown>(
|
|
271
|
+
options: GenerateRoutesOptions<TRoute>,
|
|
272
|
+
): Promise<TRoute[]>
|
|
273
|
+
|
|
274
|
+
export interface HrefIndexOptions {
|
|
275
|
+
/** Fallback language tag for documents that don't declare meta.lang. Default 'default'. */
|
|
276
|
+
defaultLang?: string
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface HrefIndex {
|
|
280
|
+
/** Resolve a logical reference (`/about`) to a deployed URL for the given language. */
|
|
281
|
+
href(ref: string, lang?: string): string
|
|
282
|
+
/** Reverse — given a deployed URL, return the logical reference it belongs to. */
|
|
283
|
+
refFor(url: string | null): string | null
|
|
284
|
+
/** Alternates for a deployed URL — `current` plus the alternate-language URLs. */
|
|
285
|
+
alternates(options: { route: string | null; languages?: string[] }): {
|
|
286
|
+
current: { lang: string | null; url: string; ref: string } | null
|
|
287
|
+
alternates: Array<{ lang: string; url: string }>
|
|
288
|
+
}
|
|
289
|
+
/** Raw `ref → { lang → url }` map, for inspection / debugging. */
|
|
290
|
+
map: Record<string, Record<string, string>>
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Build a multilingual href lookup from a snapshot of catalog documents.
|
|
295
|
+
* Pure data transformation — wrap in a framework-specific reactive
|
|
296
|
+
* shell to drive `useHref` / `useAlternates` composables.
|
|
297
|
+
*/
|
|
298
|
+
export function createHrefIndex(
|
|
299
|
+
documents: Array<{ meta?: Record<string, unknown> }>,
|
|
300
|
+
options?: HrefIndexOptions,
|
|
301
|
+
): HrefIndex
|
|
302
|
+
|
|
303
|
+
export interface AssetRecord {
|
|
304
|
+
url: string
|
|
305
|
+
width?: number
|
|
306
|
+
height?: number
|
|
307
|
+
srcset?: string
|
|
308
|
+
alt?: string
|
|
309
|
+
meta?: Record<string, unknown>
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface ImageProps {
|
|
313
|
+
src: string
|
|
314
|
+
width?: number
|
|
315
|
+
height?: number
|
|
316
|
+
srcset?: string
|
|
317
|
+
alt?: string
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface AssetIndex {
|
|
321
|
+
asset(ref: string): AssetRecord | null
|
|
322
|
+
image(ref: string): ImageProps | null
|
|
323
|
+
map: Record<string, AssetRecord>
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build an asset metadata lookup from a snapshot of asset entities.
|
|
328
|
+
* Pure data transformation — wrap in a framework-specific reactive
|
|
329
|
+
* shell to drive `useAsset` composables.
|
|
330
|
+
*/
|
|
331
|
+
export function createAssetIndex(
|
|
332
|
+
assets: Array<{ id: string; meta?: Record<string, unknown> }>,
|
|
333
|
+
): AssetIndex
|
|
334
|
+
|
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": "3.
|
|
3
|
+
"version": "3.3.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",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test:run": "vitest run"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"vitest": "^
|
|
26
|
+
"vitest": "^4.1.8"
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
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
|
@@ -7,6 +7,36 @@ import { bearer, jsonOrThrow } from './http.js'
|
|
|
7
7
|
import { joinUrl, sortToParam, filterToParams } from './url.js'
|
|
8
8
|
import { parseSseEvent } from './sse.js'
|
|
9
9
|
|
|
10
|
+
// Build the URLSearchParams body that both `urlFor()` and `cacheKeyFor()`
|
|
11
|
+
// serialize. Extracted as a helper so the two paths can't drift — the
|
|
12
|
+
// cache-key hash MUST be computed against the same byte sequence the
|
|
13
|
+
// GET URL carries, otherwise client and server will land on different
|
|
14
|
+
// filenames.
|
|
15
|
+
function buildQueryParams(query, params) {
|
|
16
|
+
const { filter, sort, fields, page, limit, skip, expand } = query
|
|
17
|
+
if (page != null) params.set('page', String(page))
|
|
18
|
+
if (limit != null) params.set('limit', String(limit))
|
|
19
|
+
if (skip != null) params.set('skip', String(skip))
|
|
20
|
+
if (sort) params.set('sort', sortToParam(sort))
|
|
21
|
+
if (fields) params.set('fields', fields.join(','))
|
|
22
|
+
if (expand && expand.length) params.set('expand', expand.join(','))
|
|
23
|
+
if (filter) filterToParams(filter, params)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// First 16 hex chars of sha256(str) — matches the server's algorithm in
|
|
27
|
+
// `cacheNameForQueryString`. Uses the standard Web Crypto API which is
|
|
28
|
+
// available in browsers and Node 18+ as `globalThis.crypto.subtle`.
|
|
29
|
+
async function sha256HexPrefix16(str) {
|
|
30
|
+
const bytes = new TextEncoder().encode(str)
|
|
31
|
+
const buf = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
32
|
+
const arr = new Uint8Array(buf)
|
|
33
|
+
let hex = ''
|
|
34
|
+
for (let i = 0; i < 8; i++) {
|
|
35
|
+
hex += arr[i].toString(16).padStart(2, '0')
|
|
36
|
+
}
|
|
37
|
+
return hex
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
// Conservative URL-length ceiling for the GET form of list(). Real
|
|
11
41
|
// browsers and proxies vary (Chrome ~32k, IIS ~16k, nginx default
|
|
12
42
|
// 8k, some CDNs 4k), but the safest interop floor for list-with-
|
|
@@ -218,8 +248,25 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
218
248
|
const forcePost = opts.method === 'POST'
|
|
219
249
|
|
|
220
250
|
if (!forcePost) {
|
|
221
|
-
|
|
251
|
+
let url = urlFor(query)
|
|
222
252
|
if (url.length <= GET_MAX_URL) {
|
|
253
|
+
// Append the cache-key routing hint per the nginx
|
|
254
|
+
// try_files contract. Server strips this param
|
|
255
|
+
// before hashing the query, so client and server
|
|
256
|
+
// agree on the cache filename. Without this hint
|
|
257
|
+
// nginx can't serve cache files directly (it would
|
|
258
|
+
// need Lua to compute the hash from $args). See
|
|
259
|
+
// ADR-0007 §B9 and the api plugin's
|
|
260
|
+
// cacheNameForQueryString comment block.
|
|
261
|
+
//
|
|
262
|
+
// Skipped when there's nothing to hash (empty
|
|
263
|
+
// query → 'index.json' on the server).
|
|
264
|
+
const cacheKey = await cacheKeyFor(query)
|
|
265
|
+
if (cacheKey !== 'index') {
|
|
266
|
+
url = url.includes('?')
|
|
267
|
+
? `${url}&cache=${cacheKey}`
|
|
268
|
+
: `${url}?cache=${cacheKey}`
|
|
269
|
+
}
|
|
223
270
|
const res = await doFetch(url, {
|
|
224
271
|
method: 'GET',
|
|
225
272
|
headers: {
|
|
@@ -253,19 +300,47 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
253
300
|
* Build a URL for the GET form of the same query — useful when
|
|
254
301
|
* the response should be CDN-cacheable, or when the caller wants
|
|
255
302
|
* a sharable link. Operators map to `.$op` URL-param suffixes.
|
|
303
|
+
*
|
|
304
|
+
* `expand` is serialized as a comma-separated list per ADR-0007
|
|
305
|
+
* B7 (api keeps the GET form cache-stable). Paths can use either
|
|
306
|
+
* canonical (`$author`) or normalized (`author`) form — the api
|
|
307
|
+
* accepts both. Each entry walks $-keyed reference fields and
|
|
308
|
+
* inlines the resolved entity in the response. Use `*` for array
|
|
309
|
+
* iteration: `['sections.*.image']`.
|
|
256
310
|
*/
|
|
257
311
|
function urlFor(query = {}) {
|
|
258
312
|
const url = new URL(listUrl)
|
|
259
|
-
|
|
260
|
-
if (page != null) url.searchParams.set('page', String(page))
|
|
261
|
-
if (limit != null) url.searchParams.set('limit', String(limit))
|
|
262
|
-
if (skip != null) url.searchParams.set('skip', String(skip))
|
|
263
|
-
if (sort) url.searchParams.set('sort', sortToParam(sort))
|
|
264
|
-
if (fields) url.searchParams.set('fields', fields.join(','))
|
|
265
|
-
if (filter) filterToParams(filter, url.searchParams)
|
|
313
|
+
buildQueryParams(query, url.searchParams)
|
|
266
314
|
return url.toString()
|
|
267
315
|
}
|
|
268
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Compute the cache-routing-hint key for a query — the same hash
|
|
319
|
+
* the server would store the response under. Use it to build a
|
|
320
|
+
* URL that nginx can serve from disk via:
|
|
321
|
+
*
|
|
322
|
+
* try_files /api/<endpoint>/entities/$arg_cache.json @proxy
|
|
323
|
+
*
|
|
324
|
+
* The default `list()` already appends this automatically. Call
|
|
325
|
+
* `cacheKeyFor()` directly when you need the bare hash — e.g.
|
|
326
|
+
* to build a cacheable URL for a sharable link or for a custom
|
|
327
|
+
* fetch path. Returns `'index'` when the query has no params
|
|
328
|
+
* (the server caches that case under `index.json`).
|
|
329
|
+
*
|
|
330
|
+
* Algorithm: build the same URLSearchParams `list()` would
|
|
331
|
+
* build, take its `.toString()`, sha256 the bytes, take the
|
|
332
|
+
* first 16 hex chars (64 bits). Server uses the same algorithm
|
|
333
|
+
* — collisions are vanishingly unlikely across realistic cache
|
|
334
|
+
* populations.
|
|
335
|
+
*/
|
|
336
|
+
async function cacheKeyFor(query = {}) {
|
|
337
|
+
const params = new URLSearchParams()
|
|
338
|
+
buildQueryParams(query, params)
|
|
339
|
+
const search = params.toString()
|
|
340
|
+
if (!search) return 'index'
|
|
341
|
+
return await sha256HexPrefix16(search)
|
|
342
|
+
}
|
|
343
|
+
|
|
269
344
|
/**
|
|
270
345
|
* Iterate result pages without manual page bookkeeping. Yields
|
|
271
346
|
* each response envelope until `hasNext` is false.
|
|
@@ -340,6 +415,11 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
340
415
|
async function* watch(query = {}, { signal } = {}) {
|
|
341
416
|
const url = new URL(subscribeUrl)
|
|
342
417
|
if (query.filter) filterToParams(query.filter, url.searchParams)
|
|
418
|
+
// Forward `expand` to the server so the graph-subscription
|
|
419
|
+
// path is engaged (the api delegates to runtime.refs and
|
|
420
|
+
// emits already-expanded entities). Without expand the SSE
|
|
421
|
+
// stream emits bare entities — existing behavior preserved.
|
|
422
|
+
if (query.expand?.length) url.searchParams.set('expand', query.expand.join(','))
|
|
343
423
|
|
|
344
424
|
const res = await doFetch(url.toString(), {
|
|
345
425
|
method: 'GET',
|
|
@@ -407,6 +487,7 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
407
487
|
function live(filter, onChange, options = {}) {
|
|
408
488
|
const {
|
|
409
489
|
sort, fields, limit, skip,
|
|
490
|
+
expand,
|
|
410
491
|
quiet,
|
|
411
492
|
signal: externalSignal,
|
|
412
493
|
onError = (err) => console.error('mikser-io-sdk-api live error:', err),
|
|
@@ -477,13 +558,21 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
477
558
|
if (!usedFastPath) {
|
|
478
559
|
// Pass { quiet } so list()'s wide-warning honors
|
|
479
560
|
// the live() caller's quiet opt.
|
|
480
|
-
|
|
561
|
+
//
|
|
562
|
+
// `expand` flows through to the initial snapshot
|
|
563
|
+
// call AND to watch() below, so the server's
|
|
564
|
+
// graph-subscription path (runtime.refs) emits
|
|
565
|
+
// already-expanded entities on every mutation
|
|
566
|
+
// within the expansion graph. Update events
|
|
567
|
+
// replace items in place with the new expanded
|
|
568
|
+
// shape, keeping the consumer's view consistent.
|
|
569
|
+
const env = await list({ filter, sort, fields, limit, skip, expand }, { quiet })
|
|
481
570
|
if (disposed || ac.signal.aborted) return
|
|
482
571
|
items = env.items
|
|
483
572
|
onChange(items)
|
|
484
573
|
}
|
|
485
574
|
|
|
486
|
-
for await (const event of watch({ filter }, { signal: ac.signal })) {
|
|
575
|
+
for await (const event of watch({ filter, expand }, { signal: ac.signal })) {
|
|
487
576
|
if (disposed) return
|
|
488
577
|
switch (event.type) {
|
|
489
578
|
case 'create':
|
|
@@ -578,7 +667,116 @@ export function createEntitiesClient({ baseUrl, basePath, fetch: doFetch, header
|
|
|
578
667
|
return res.arrayBuffer()
|
|
579
668
|
}
|
|
580
669
|
|
|
581
|
-
|
|
670
|
+
/**
|
|
671
|
+
* Stateful paginator over list(). Each call to goTo / next /
|
|
672
|
+
* prev fetches ONE page from the server — the items the
|
|
673
|
+
* server returns are exactly the slice the user asked for.
|
|
674
|
+
* No upfront load of the full collection.
|
|
675
|
+
*
|
|
676
|
+
* const paginator = docs.paginator({
|
|
677
|
+
* filter: { 'meta.layout': 'post' },
|
|
678
|
+
* sort: { 'meta.date': -1 },
|
|
679
|
+
* pageSize: 10,
|
|
680
|
+
* })
|
|
681
|
+
* await paginator.goTo(1)
|
|
682
|
+
* paginator.items, paginator.page, paginator.pages, paginator.hasNext
|
|
683
|
+
* await paginator.next()
|
|
684
|
+
*
|
|
685
|
+
* State accessors are getters — the value you read is always
|
|
686
|
+
* the result of the last completed fetch. Wrap in your
|
|
687
|
+
* framework's reactive primitive (useState / ref / store) to
|
|
688
|
+
* re-render when navigation completes.
|
|
689
|
+
*
|
|
690
|
+
* @param {Object} [opts]
|
|
691
|
+
* @param {Object} [opts.filter] Same as list() filter
|
|
692
|
+
* @param {Object} [opts.sort] Same as list() sort
|
|
693
|
+
* @param {string[]} [opts.fields] Same as list() fields
|
|
694
|
+
* @param {string[]} [opts.expand] Same as list() expand
|
|
695
|
+
* @param {number} [opts.pageSize=10] Items per page
|
|
696
|
+
* @param {(page:number)=>string} [opts.urlFor] Builds the
|
|
697
|
+
* href for each pageNumbers entry. Default matches mikser's
|
|
698
|
+
* SSG convention (`/` for page 1, `/<N>/` for the rest).
|
|
699
|
+
*/
|
|
700
|
+
function paginator({
|
|
701
|
+
pageSize = 10,
|
|
702
|
+
urlFor: urlForFn,
|
|
703
|
+
...query
|
|
704
|
+
} = {}) {
|
|
705
|
+
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
706
|
+
throw new TypeError(`paginator: pageSize must be a positive integer (got ${pageSize})`)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const buildUrl = urlForFn || ((p) => p === 1 ? '/' : `/${p}/`)
|
|
710
|
+
|
|
711
|
+
let state = {
|
|
712
|
+
page: 1,
|
|
713
|
+
items: [],
|
|
714
|
+
pages: 1,
|
|
715
|
+
totalItems: 0,
|
|
716
|
+
loaded: false,
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function pageNumbers() {
|
|
720
|
+
const out = []
|
|
721
|
+
for (let p = 1; p <= state.pages; p++) {
|
|
722
|
+
out.push({ num: p, url: buildUrl(p), isCurrent: p === state.page })
|
|
723
|
+
}
|
|
724
|
+
return out
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function goTo(page) {
|
|
728
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
729
|
+
throw new RangeError(`paginator.goTo: page must be a positive integer (got ${page})`)
|
|
730
|
+
}
|
|
731
|
+
const result = await list({
|
|
732
|
+
...query,
|
|
733
|
+
page,
|
|
734
|
+
limit: pageSize,
|
|
735
|
+
})
|
|
736
|
+
state = {
|
|
737
|
+
page: result.page ?? page,
|
|
738
|
+
items: result.items ?? [],
|
|
739
|
+
pages: result.totalPages ?? 1,
|
|
740
|
+
totalItems: result.total ?? 0,
|
|
741
|
+
loaded: true,
|
|
742
|
+
}
|
|
743
|
+
return api
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function next() {
|
|
747
|
+
if (!state.loaded) return goTo(1)
|
|
748
|
+
if (state.page >= state.pages) {
|
|
749
|
+
throw new RangeError('paginator.next: already at the last page')
|
|
750
|
+
}
|
|
751
|
+
return goTo(state.page + 1)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function prev() {
|
|
755
|
+
if (!state.loaded) return goTo(1)
|
|
756
|
+
if (state.page <= 1) {
|
|
757
|
+
throw new RangeError('paginator.prev: already at the first page')
|
|
758
|
+
}
|
|
759
|
+
return goTo(state.page - 1)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const api = {
|
|
763
|
+
get items() { return state.items },
|
|
764
|
+
get page() { return state.page },
|
|
765
|
+
get pages() { return state.pages },
|
|
766
|
+
get totalItems() { return state.totalItems },
|
|
767
|
+
get pageSize() { return pageSize },
|
|
768
|
+
get hasNext() { return state.page < state.pages },
|
|
769
|
+
get hasPrev() { return state.page > 1 },
|
|
770
|
+
get loaded() { return state.loaded },
|
|
771
|
+
get pageNumbers() { return pageNumbers() },
|
|
772
|
+
goTo,
|
|
773
|
+
next,
|
|
774
|
+
prev,
|
|
775
|
+
}
|
|
776
|
+
return api
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return { list, listAll, urlFor, cacheKeyFor, pages, paginator, watch, live, update, delete: remove, render }
|
|
582
780
|
}
|
|
583
781
|
}
|
|
584
782
|
|
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
|
+
}
|