@nifrajs/content 2.14.1 → 3.2.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
@@ -15,6 +15,31 @@ bun add @nifrajs/content
15
15
  - Reference: <https://nifra.dev/docs>
16
16
  - AI-readable: <https://nifra.dev/llms.txt>
17
17
 
18
+ ## Typed indexes and joins
19
+
20
+ Build an equality lookup and deterministic query index from a baked collection. `where` is field
21
+ equality, `range` supports typed string/number comparisons, sorting is stable (slug is the tie-breaker),
22
+ and pages use an opaque cursor rather than an offset:
23
+
24
+ ```ts
25
+ const posts = indexCollection(await bakeCollection(blog), {
26
+ by: "section",
27
+ sort: { field: "publishedAt", dir: "desc" },
28
+ })
29
+
30
+ const page = posts.query({ where: { section: "news" }, limit: 20 })
31
+ const next = page.nextCursor === undefined
32
+ ? undefined
33
+ : posts.query({ where: { section: "news" }, cursor: page.nextCursor, limit: 20 })
34
+ ```
35
+
36
+ `posts.baked` is JSON-safe and can be serialized into an edge bundle; `fromBakedIndex` rehydrates it
37
+ without filesystem access. `joinCollections(left, right, "id")` gives one-to-many rows by default.
38
+ Pass `{ cardinality: "one-to-one" }` to require unique right-side keys; duplicate right keys fail the
39
+ build loudly. The join key must exist on both frontmatter types with the same TypeScript value type.
40
+ These APIs index only validated local/baked content; credentialed CMS ingestion and tenant data remain
41
+ outside this public package.
42
+
18
43
  MIT
19
44
 
20
45
  ## For AI agents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/content",
3
- "version": "2.14.1",
3
+ "version": "3.2.0",
4
4
  "description": "Typed, schema-validated content collections for nifra - Markdown + frontmatter, framework-agnostic.",
5
5
  "keywords": [
6
6
  "bun",
package/src/fs.ts CHANGED
@@ -59,7 +59,7 @@ interface CachedEntry<Frontmatter> {
59
59
  }
60
60
 
61
61
  interface CollectionSnapshot<Frontmatter> {
62
- readonly signature: string
62
+ readonly fingerprint: string
63
63
  readonly entries: Array<Entry<Frontmatter>>
64
64
  }
65
65
 
@@ -108,14 +108,14 @@ export function defineCollection<S extends StandardSchemaV1>(
108
108
  return {
109
109
  async all() {
110
110
  const files = listFiles()
111
- const signature = files.map((file) => `${file.file}\0${file.stamp}`).join("\0")
112
- if (collectionCache?.signature === signature) return [...collectionCache.entries]
111
+ const fingerprint = files.map((file) => `${file.file}\0${file.stamp}`).join("\0")
112
+ if (collectionCache?.fingerprint === fingerprint) return [...collectionCache.entries]
113
113
 
114
114
  const entries = await Promise.all(
115
115
  files.map((file) => readEntry(file.path, slugOf(file.file), file.stamp)),
116
116
  )
117
117
  collectionCache = {
118
- signature,
118
+ fingerprint,
119
119
  entries,
120
120
  }
121
121
  return [...entries]
package/src/index.ts CHANGED
@@ -140,3 +140,627 @@ export function fromBaked<Frontmatter>(
140
140
  get: (slug) => Promise.resolve(bySlug.get(slug) ?? null),
141
141
  }
142
142
  }
143
+
144
+ /** A string frontmatter field name accepted by the index APIs. */
145
+ export type ContentFieldKey<Frontmatter extends Record<string, unknown>> = Extract<
146
+ keyof Frontmatter,
147
+ string
148
+ >
149
+
150
+ /** A declared sort order. Ties are always broken by slug, then source order. */
151
+ export interface IndexSort<Frontmatter extends Record<string, unknown>> {
152
+ readonly field: ContentFieldKey<Frontmatter>
153
+ readonly dir: "asc" | "desc"
154
+ }
155
+
156
+ /** Field-equality filters. Arbitrary predicates are intentionally not part of baked indexes. */
157
+ export type IndexWhere<Frontmatter extends Record<string, unknown>> = Partial<{
158
+ [K in ContentFieldKey<Frontmatter>]: Frontmatter[K]
159
+ }>
160
+
161
+ /** Range operators are available for string and number fields only. */
162
+ export type IndexRange<Value> = [Extract<Value, string | number>] extends [never]
163
+ ? never
164
+ : {
165
+ readonly gt?: Extract<Value, string | number>
166
+ readonly gte?: Extract<Value, string | number>
167
+ readonly lt?: Extract<Value, string | number>
168
+ readonly lte?: Extract<Value, string | number>
169
+ }
170
+
171
+ /** Typed range filters derived from the collection's frontmatter. */
172
+ export type IndexRanges<Frontmatter extends Record<string, unknown>> = Partial<{
173
+ [K in ContentFieldKey<Frontmatter>]: IndexRange<Frontmatter[K]>
174
+ }>
175
+
176
+ /** A bounded, cursor-based index query. The cursor is opaque and tied to its filter + sort. */
177
+ export interface IndexQueryOptions<Frontmatter extends Record<string, unknown>> {
178
+ readonly where?: IndexWhere<Frontmatter>
179
+ readonly range?: IndexRanges<Frontmatter>
180
+ readonly sort?: IndexSort<Frontmatter>
181
+ readonly cursor?: string
182
+ /** Defaults to 20; values above 100 are rejected to bound request-driven work. */
183
+ readonly limit?: number
184
+ }
185
+
186
+ /** One page from an indexed collection. `nextCursor` is absent on the final page. */
187
+ export interface IndexPage<Frontmatter extends Record<string, unknown>> {
188
+ readonly items: ReadonlyArray<Entry<Frontmatter>>
189
+ readonly nextCursor?: string
190
+ }
191
+
192
+ /** The JSON-safe lookup bucket stored in a baked index. */
193
+ export interface BakedIndexBucket {
194
+ readonly key: string
195
+ readonly entryIndexes: ReadonlyArray<number>
196
+ }
197
+
198
+ /** JSON-safe index data. It contains no functions, Map, Set, filesystem handle, or request data. */
199
+ export interface BakedCollectionIndex<
200
+ Frontmatter extends Record<string, unknown>,
201
+ By extends ContentFieldKey<Frontmatter>,
202
+ > {
203
+ readonly entries: ReadonlyArray<Entry<Frontmatter>>
204
+ readonly by: By
205
+ readonly sort: IndexSort<Frontmatter>
206
+ /** Entry indexes in the default deterministic order. */
207
+ readonly order: ReadonlyArray<number>
208
+ readonly buckets: ReadonlyArray<BakedIndexBucket>
209
+ }
210
+
211
+ /** A build-time/runtime index over validated, baked entries. */
212
+ export interface IndexedCollection<
213
+ Frontmatter extends Record<string, unknown>,
214
+ By extends ContentFieldKey<Frontmatter>,
215
+ > {
216
+ readonly entries: ReadonlyArray<Entry<Frontmatter>>
217
+ readonly by: By
218
+ readonly baked: BakedCollectionIndex<Frontmatter, By>
219
+ /** O(1) lookup after the build/rehydration map is constructed. Missing keys return no rows. */
220
+ readonly lookup: (value: Frontmatter[By]) => ReadonlyArray<Entry<Frontmatter>>
221
+ /** All entries in the index's default stable order. */
222
+ readonly all: () => ReadonlyArray<Entry<Frontmatter>>
223
+ readonly query: (options?: IndexQueryOptions<Frontmatter>) => IndexPage<Frontmatter>
224
+ }
225
+
226
+ /** Keys whose frontmatter value types are identical on both sides of a join. */
227
+ export type SharedKey<
228
+ Left extends Record<string, unknown>,
229
+ Right extends Record<string, unknown>,
230
+ > = {
231
+ [K in ContentFieldKey<Left> & ContentFieldKey<Right>]: [Left[K]] extends [Right[K]]
232
+ ? [Right[K]] extends [Left[K]]
233
+ ? K
234
+ : never
235
+ : never
236
+ }[ContentFieldKey<Left> & ContentFieldKey<Right>]
237
+
238
+ export type JoinCardinality = "one-to-many" | "one-to-one"
239
+
240
+ export type JoinedRow<
241
+ Left extends Record<string, unknown>,
242
+ Right extends Record<string, unknown>,
243
+ Cardinality extends JoinCardinality,
244
+ > = {
245
+ readonly left: Entry<Left>
246
+ readonly right: Cardinality extends "one-to-one"
247
+ ? Entry<Right> | null
248
+ : ReadonlyArray<Entry<Right>>
249
+ }
250
+
251
+ /** JSON-safe joined rows, ordered by the left index and then the right index. */
252
+ export interface BakedCollectionJoin<
253
+ Left extends Record<string, unknown>,
254
+ Right extends Record<string, unknown>,
255
+ On extends SharedKey<Left, Right>,
256
+ Cardinality extends JoinCardinality,
257
+ > {
258
+ readonly on: On
259
+ readonly cardinality: Cardinality
260
+ readonly rows: ReadonlyArray<JoinedRow<Left, Right, Cardinality>>
261
+ }
262
+
263
+ /** A deterministic cross-collection join. No database or network layer is involved. */
264
+ export interface IndexedJoin<
265
+ Left extends Record<string, unknown>,
266
+ Right extends Record<string, unknown>,
267
+ On extends SharedKey<Left, Right>,
268
+ Cardinality extends JoinCardinality,
269
+ > {
270
+ readonly entries: ReadonlyArray<JoinedRow<Left, Right, Cardinality>>
271
+ readonly baked: BakedCollectionJoin<Left, Right, On, Cardinality>
272
+ }
273
+
274
+ export interface IndexOptions<
275
+ Frontmatter extends Record<string, unknown>,
276
+ By extends ContentFieldKey<Frontmatter>,
277
+ > {
278
+ readonly by: By
279
+ readonly sort?: IndexSort<Frontmatter>
280
+ }
281
+
282
+ const DEFAULT_INDEX_PAGE_SIZE = 20
283
+ const MAX_INDEX_PAGE_SIZE = 100
284
+ const MAX_CURSOR_LENGTH = 4096
285
+
286
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
287
+ typeof value === "object" && value !== null && !Array.isArray(value)
288
+
289
+ /** Stable JSON for frontmatter keys/filter fingerprints. It rejects values JSON cannot preserve. */
290
+ const stableJson = (value: unknown, seen = new Set<object>()): string | undefined => {
291
+ if (value === undefined) return undefined
292
+ if (value === null) return "null"
293
+ if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value)
294
+ if (typeof value === "number") {
295
+ if (!Number.isFinite(value)) throw new TypeError("non-finite number")
296
+ return JSON.stringify(value)
297
+ }
298
+ if (typeof value !== "object") throw new TypeError("non-JSON value")
299
+ if (seen.has(value)) throw new TypeError("cyclic value")
300
+ seen.add(value)
301
+ try {
302
+ if (Array.isArray(value)) {
303
+ const items = value.map((item) => {
304
+ const encoded = stableJson(item, seen)
305
+ if (encoded === undefined) throw new TypeError("undefined array item")
306
+ return encoded
307
+ })
308
+ return `[${items.join(",")}]`
309
+ }
310
+ const prototype = Object.getPrototypeOf(value)
311
+ if (prototype !== Object.prototype && prototype !== null)
312
+ throw new TypeError("non-plain object")
313
+ const fields: string[] = []
314
+ for (const key of Object.keys(value).sort()) {
315
+ const encoded = stableJson(Reflect.get(value, key), seen)
316
+ if (encoded === undefined) throw new TypeError("undefined object field")
317
+ fields.push(`${JSON.stringify(key)}:${encoded}`)
318
+ }
319
+ return `{${fields.join(",")}}`
320
+ } finally {
321
+ seen.delete(value)
322
+ }
323
+ }
324
+
325
+ const keyFor = (value: unknown, field: string): string | undefined => {
326
+ try {
327
+ return stableJson(value)
328
+ } catch {
329
+ throw new TypeError(`@nifrajs/content: field "${field}" is not JSON-indexable`)
330
+ }
331
+ }
332
+
333
+ const compareValues = (left: unknown, right: unknown): number => {
334
+ if (left === right) return 0
335
+ if (left === undefined) return 1
336
+ if (right === undefined) return -1
337
+ if (typeof left === "number" && typeof right === "number") return left < right ? -1 : 1
338
+ if (typeof left === "string" && typeof right === "string") return left < right ? -1 : 1
339
+ if (typeof left === "boolean" && typeof right === "boolean") return left ? 1 : -1
340
+ const leftKey = stableJson(left) ?? ""
341
+ const rightKey = stableJson(right) ?? ""
342
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
343
+ }
344
+
345
+ const compareEntries = <Frontmatter extends Record<string, unknown>>(
346
+ entries: ReadonlyArray<Entry<Frontmatter>>,
347
+ leftIndex: number,
348
+ rightIndex: number,
349
+ sort: IndexSort<Frontmatter>,
350
+ ): number => {
351
+ const left = entries[leftIndex]
352
+ const right = entries[rightIndex]
353
+ if (left === undefined || right === undefined) return left === undefined ? 1 : -1
354
+ let result = compareValues(left.frontmatter[sort.field], right.frontmatter[sort.field])
355
+ if (sort.dir === "desc") result = -result
356
+ if (result !== 0) return result
357
+ result = left.slug < right.slug ? -1 : left.slug > right.slug ? 1 : 0
358
+ return result !== 0 ? result : leftIndex - rightIndex
359
+ }
360
+
361
+ const sortedOrder = <Frontmatter extends Record<string, unknown>>(
362
+ entries: ReadonlyArray<Entry<Frontmatter>>,
363
+ sort: IndexSort<Frontmatter>,
364
+ ): number[] =>
365
+ entries.map((_, index) => index).sort((left, right) => compareEntries(entries, left, right, sort))
366
+
367
+ const assertPageSize = (value: number | undefined): number => {
368
+ const limit = value ?? DEFAULT_INDEX_PAGE_SIZE
369
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_INDEX_PAGE_SIZE) {
370
+ throw new RangeError(
371
+ `@nifrajs/content: index page limit must be an integer from 1 to ${MAX_INDEX_PAGE_SIZE}`,
372
+ )
373
+ }
374
+ return limit
375
+ }
376
+
377
+ const filterFingerprint = (where: unknown, range: unknown): string => {
378
+ try {
379
+ return stableJson({ where: where ?? null, range: range ?? null }) ?? "null"
380
+ } catch {
381
+ throw new TypeError("@nifrajs/content: index filters must contain JSON values")
382
+ }
383
+ }
384
+
385
+ interface CursorPayload {
386
+ readonly v: 1
387
+ readonly sortField: string
388
+ readonly dir: "asc" | "desc"
389
+ readonly filter: string
390
+ readonly position: number
391
+ }
392
+
393
+ const encodeCursor = (payload: CursorPayload): string => encodeURIComponent(JSON.stringify(payload))
394
+
395
+ const decodeCursor = (cursor: string): CursorPayload => {
396
+ if (cursor.length === 0 || cursor.length > MAX_CURSOR_LENGTH) {
397
+ throw new TypeError("@nifrajs/content: invalid index cursor")
398
+ }
399
+ let parsed: unknown
400
+ try {
401
+ parsed = JSON.parse(decodeURIComponent(cursor))
402
+ } catch {
403
+ throw new TypeError("@nifrajs/content: invalid index cursor")
404
+ }
405
+ if (!isRecord(parsed)) throw new TypeError("@nifrajs/content: invalid index cursor")
406
+ const version = parsed.v
407
+ const sortField = parsed.sortField
408
+ const dir = parsed.dir
409
+ const filter = parsed.filter
410
+ const position = parsed.position
411
+ if (
412
+ version !== 1 ||
413
+ typeof sortField !== "string" ||
414
+ (dir !== "asc" && dir !== "desc") ||
415
+ typeof filter !== "string" ||
416
+ typeof position !== "number" ||
417
+ !Number.isSafeInteger(position) ||
418
+ position < -1
419
+ ) {
420
+ throw new TypeError("@nifrajs/content: invalid index cursor")
421
+ }
422
+ return { v: 1, sortField, dir, filter, position }
423
+ }
424
+
425
+ const createIndexedCollection = <
426
+ Frontmatter extends Record<string, unknown>,
427
+ By extends ContentFieldKey<Frontmatter>,
428
+ >(
429
+ baked: BakedCollectionIndex<Frontmatter, By>,
430
+ ): IndexedCollection<Frontmatter, By> => {
431
+ if (
432
+ typeof baked.by !== "string" ||
433
+ !isRecord(baked.sort) ||
434
+ typeof baked.sort.field !== "string" ||
435
+ (baked.sort.dir !== "asc" && baked.sort.dir !== "desc") ||
436
+ !Array.isArray(baked.entries) ||
437
+ !Array.isArray(baked.order) ||
438
+ !Array.isArray(baked.buckets)
439
+ ) {
440
+ throw new TypeError("@nifrajs/content: malformed baked index")
441
+ }
442
+ const order = [...baked.order]
443
+ const seenIndexes = new Set<number>()
444
+ for (const index of order) {
445
+ if (
446
+ !Number.isSafeInteger(index) ||
447
+ index < 0 ||
448
+ index >= baked.entries.length ||
449
+ seenIndexes.has(index)
450
+ ) {
451
+ throw new TypeError("@nifrajs/content: malformed baked index order")
452
+ }
453
+ seenIndexes.add(index)
454
+ }
455
+ if (seenIndexes.size !== baked.entries.length) {
456
+ throw new TypeError("@nifrajs/content: malformed baked index order")
457
+ }
458
+ const lookup = new Map<string, number[]>()
459
+ const bucketIndexes = new Set<number>()
460
+ for (const bucket of baked.buckets) {
461
+ if (
462
+ !isRecord(bucket) ||
463
+ typeof bucket.key !== "string" ||
464
+ !Array.isArray(bucket.entryIndexes)
465
+ ) {
466
+ throw new TypeError("@nifrajs/content: malformed baked index bucket")
467
+ }
468
+ if (lookup.has(bucket.key))
469
+ throw new TypeError("@nifrajs/content: duplicate baked index bucket")
470
+ const indexes: number[] = []
471
+ for (const index of bucket.entryIndexes) {
472
+ if (!Number.isSafeInteger(index) || !seenIndexes.has(index)) {
473
+ throw new TypeError("@nifrajs/content: malformed baked index bucket")
474
+ }
475
+ if (bucketIndexes.has(index)) {
476
+ throw new TypeError("@nifrajs/content: duplicate baked index entry")
477
+ }
478
+ bucketIndexes.add(index)
479
+ indexes.push(index)
480
+ }
481
+ lookup.set(bucket.key, indexes)
482
+ }
483
+ const entries = baked.entries
484
+ const orderedEntries = (): ReadonlyArray<Entry<Frontmatter>> =>
485
+ order.flatMap((index) => {
486
+ const entry = entries[index]
487
+ return entry === undefined ? [] : [entry]
488
+ })
489
+ const all = (): ReadonlyArray<Entry<Frontmatter>> => orderedEntries()
490
+ const query = (options: IndexQueryOptions<Frontmatter> = {}): IndexPage<Frontmatter> => {
491
+ const limit = assertPageSize(options.limit)
492
+ const where = options.where
493
+ const range = options.range
494
+ if (where !== undefined && !isRecord(where)) {
495
+ throw new TypeError("@nifrajs/content: index where must be an object")
496
+ }
497
+ if (range !== undefined && !isRecord(range)) {
498
+ throw new TypeError("@nifrajs/content: index range must be an object")
499
+ }
500
+ const sort = options.sort ?? baked.sort
501
+ if (
502
+ !isRecord(sort) ||
503
+ typeof sort.field !== "string" ||
504
+ (sort.dir !== "asc" && sort.dir !== "desc")
505
+ ) {
506
+ throw new TypeError("@nifrajs/content: malformed index sort")
507
+ }
508
+ const filter = filterFingerprint(where, range)
509
+ const queryOrder =
510
+ sort.field === baked.sort.field && sort.dir === baked.sort.dir
511
+ ? order
512
+ : sortedOrder(entries, sort)
513
+ const filtered = queryOrder.filter((index) => {
514
+ const entry = entries[index]
515
+ if (entry === undefined) return false
516
+ const frontmatter: Record<string, unknown> = entry.frontmatter
517
+ if (where !== undefined) {
518
+ for (const [field, expected] of Object.entries(where)) {
519
+ if (!Object.hasOwn(frontmatter, field)) return false
520
+ if (keyFor(frontmatter[field], field) !== keyFor(expected, field)) return false
521
+ }
522
+ }
523
+ if (range !== undefined) {
524
+ for (const [field, rawRule] of Object.entries(range)) {
525
+ if (!Object.hasOwn(frontmatter, field) || !isRecord(rawRule)) {
526
+ throw new TypeError("@nifrajs/content: malformed index range")
527
+ }
528
+ const actual = frontmatter[field]
529
+ if (
530
+ typeof actual !== "string" &&
531
+ (typeof actual !== "number" || !Number.isFinite(actual))
532
+ ) {
533
+ return false
534
+ }
535
+ for (const operator of ["gt", "gte", "lt", "lte"] as const) {
536
+ if (!Object.hasOwn(rawRule, operator)) continue
537
+ const expected = rawRule[operator]
538
+ if (
539
+ (typeof expected !== "string" &&
540
+ (typeof expected !== "number" || !Number.isFinite(expected))) ||
541
+ typeof expected !== typeof actual
542
+ ) {
543
+ throw new TypeError("@nifrajs/content: malformed index range")
544
+ }
545
+ const comparison = compareValues(actual, expected)
546
+ if (
547
+ (operator === "gt" && comparison <= 0) ||
548
+ (operator === "gte" && comparison < 0) ||
549
+ (operator === "lt" && comparison >= 0) ||
550
+ (operator === "lte" && comparison > 0)
551
+ ) {
552
+ return false
553
+ }
554
+ }
555
+ }
556
+ }
557
+ return true
558
+ })
559
+ const cursor = options.cursor === undefined ? undefined : decodeCursor(options.cursor)
560
+ let start = 0
561
+ if (cursor !== undefined) {
562
+ // A cursor is a position in ONE query's result order, so it is only meaningful against the query
563
+ // that issued it - a different sort or filter makes the same number address a different entry.
564
+ // That check is separate from running off the end: an index that shrank between pages leaves a
565
+ // matching cursor pointing past the last row, which is an exhausted page, not a bad cursor.
566
+ if (
567
+ cursor.sortField !== String(sort.field) ||
568
+ cursor.dir !== sort.dir ||
569
+ cursor.filter !== filter
570
+ ) {
571
+ throw new TypeError("@nifrajs/content: cursor does not match this index query")
572
+ }
573
+ start = Math.min(cursor.position + 1, filtered.length)
574
+ }
575
+ const pageIndexes = filtered.slice(start, start + limit)
576
+ const items = pageIndexes.flatMap((index) => {
577
+ const entry = entries[index]
578
+ return entry === undefined ? [] : [entry]
579
+ })
580
+ const end = start + items.length
581
+ return end < filtered.length
582
+ ? {
583
+ items,
584
+ nextCursor: encodeCursor({
585
+ v: 1,
586
+ sortField: String(sort.field),
587
+ dir: sort.dir,
588
+ filter,
589
+ position: end - 1,
590
+ }),
591
+ }
592
+ : { items }
593
+ }
594
+ return {
595
+ entries,
596
+ by: baked.by,
597
+ baked,
598
+ lookup: (value) => {
599
+ const key = keyFor(value, String(baked.by))
600
+ if (key === undefined) return []
601
+ return (lookup.get(key) ?? []).flatMap((index) => {
602
+ const entry = entries[index]
603
+ return entry === undefined ? [] : [entry]
604
+ })
605
+ },
606
+ all,
607
+ query,
608
+ }
609
+ }
610
+
611
+ /** Build a deterministic typed index from already validated baked entries. */
612
+ export function indexCollection<
613
+ Frontmatter extends Record<string, unknown>,
614
+ By extends ContentFieldKey<Frontmatter>,
615
+ >(
616
+ baked: BakedCollection<Frontmatter>,
617
+ options: IndexOptions<Frontmatter, By>,
618
+ ): IndexedCollection<Frontmatter, By> {
619
+ const sort = options.sort ?? { field: options.by, dir: "asc" as const }
620
+ const order = sortedOrder(baked.entries, sort)
621
+ const buckets = new Map<string, number[]>()
622
+ for (const index of order) {
623
+ const entry = baked.entries[index]
624
+ if (entry === undefined) continue
625
+ const value = entry.frontmatter[options.by]
626
+ const key = keyFor(value, String(options.by))
627
+ if (key === undefined) continue
628
+ const indexes = buckets.get(key)
629
+ if (indexes === undefined) buckets.set(key, [index])
630
+ else indexes.push(index)
631
+ }
632
+ const bakedIndex: BakedCollectionIndex<Frontmatter, By> = {
633
+ entries: baked.entries,
634
+ by: options.by,
635
+ sort,
636
+ order,
637
+ buckets: [...buckets].map(([key, entryIndexes]) => ({ key, entryIndexes })),
638
+ }
639
+ return createIndexedCollection(bakedIndex)
640
+ }
641
+
642
+ /** Rehydrate a JSON-serialized index at the edge without filesystem access. */
643
+ export function fromBakedIndex<
644
+ Frontmatter extends Record<string, unknown>,
645
+ By extends ContentFieldKey<Frontmatter>,
646
+ >(baked: BakedCollectionIndex<Frontmatter, By>): IndexedCollection<Frontmatter, By> {
647
+ return createIndexedCollection(baked)
648
+ }
649
+
650
+ const buildJoin = <
651
+ Left extends Record<string, unknown>,
652
+ Right extends Record<string, unknown>,
653
+ LeftBy extends ContentFieldKey<Left>,
654
+ RightBy extends ContentFieldKey<Right>,
655
+ On extends SharedKey<Left, Right>,
656
+ Cardinality extends JoinCardinality,
657
+ >(
658
+ left: IndexedCollection<Left, LeftBy>,
659
+ right: IndexedCollection<Right, RightBy>,
660
+ on: On,
661
+ cardinality: Cardinality,
662
+ ): IndexedJoin<Left, Right, On, Cardinality> => {
663
+ const rightByKey = new Map<string, Entry<Right>[]>()
664
+ for (const index of right.baked.order) {
665
+ const entry = right.entries[index]
666
+ if (entry === undefined) continue
667
+ const key = keyFor(entry.frontmatter[on], String(on))
668
+ if (key === undefined) continue
669
+ const existing = rightByKey.get(key)
670
+ if (existing === undefined) rightByKey.set(key, [entry])
671
+ else {
672
+ if (cardinality === "one-to-one") {
673
+ throw new Error(`@nifrajs/content: duplicate right join key for "${String(on)}"`)
674
+ }
675
+ existing.push(entry)
676
+ }
677
+ }
678
+ const rows: Array<JoinedRow<Left, Right, Cardinality>> = []
679
+ for (const index of left.baked.order) {
680
+ const entry = left.entries[index]
681
+ if (entry === undefined) continue
682
+ const key = keyFor(entry.frontmatter[on], String(on))
683
+ const matches = key === undefined ? undefined : rightByKey.get(key)
684
+ rows.push({
685
+ left: entry,
686
+ right: (cardinality === "one-to-one"
687
+ ? (matches?.[0] ?? null)
688
+ : [...(matches ?? [])]) as JoinedRow<Left, Right, Cardinality>["right"],
689
+ })
690
+ }
691
+ const baked: BakedCollectionJoin<Left, Right, On, Cardinality> = {
692
+ on,
693
+ cardinality,
694
+ rows,
695
+ }
696
+ return { entries: rows, baked }
697
+ }
698
+
699
+ /** Join two indexes on a real same-typed frontmatter key. Default cardinality is one-to-many. */
700
+ export function joinCollections<
701
+ Left extends Record<string, unknown>,
702
+ Right extends Record<string, unknown>,
703
+ LeftBy extends ContentFieldKey<Left>,
704
+ RightBy extends ContentFieldKey<Right>,
705
+ On extends SharedKey<Left, Right>,
706
+ >(
707
+ left: IndexedCollection<Left, LeftBy>,
708
+ right: IndexedCollection<Right, RightBy>,
709
+ on: On,
710
+ options?: { readonly cardinality?: "one-to-many" },
711
+ ): IndexedJoin<Left, Right, On, "one-to-many">
712
+ export function joinCollections<
713
+ Left extends Record<string, unknown>,
714
+ Right extends Record<string, unknown>,
715
+ LeftBy extends ContentFieldKey<Left>,
716
+ RightBy extends ContentFieldKey<Right>,
717
+ On extends SharedKey<Left, Right>,
718
+ >(
719
+ left: IndexedCollection<Left, LeftBy>,
720
+ right: IndexedCollection<Right, RightBy>,
721
+ on: On,
722
+ options: { readonly cardinality: "one-to-one" },
723
+ ): IndexedJoin<Left, Right, On, "one-to-one">
724
+ export function joinCollections<
725
+ Left extends Record<string, unknown>,
726
+ Right extends Record<string, unknown>,
727
+ LeftBy extends ContentFieldKey<Left>,
728
+ RightBy extends ContentFieldKey<Right>,
729
+ On extends SharedKey<Left, Right>,
730
+ >(
731
+ left: IndexedCollection<Left, LeftBy>,
732
+ right: IndexedCollection<Right, RightBy>,
733
+ on: On,
734
+ options?: { readonly cardinality?: JoinCardinality },
735
+ ): IndexedJoin<Left, Right, On, JoinCardinality> {
736
+ const cardinality = options?.cardinality ?? "one-to-many"
737
+ return buildJoin(left, right, on, cardinality)
738
+ }
739
+
740
+ /** Rehydrate a deterministic join artifact. */
741
+ export function fromBakedJoin<
742
+ Left extends Record<string, unknown>,
743
+ Right extends Record<string, unknown>,
744
+ On extends SharedKey<Left, Right>,
745
+ Cardinality extends JoinCardinality,
746
+ >(
747
+ baked: BakedCollectionJoin<Left, Right, On, Cardinality>,
748
+ ): IndexedJoin<Left, Right, On, Cardinality> {
749
+ if (!isRecord(baked) || !Array.isArray(baked.rows)) {
750
+ throw new TypeError("@nifrajs/content: malformed baked join")
751
+ }
752
+ if (baked.cardinality !== "one-to-many" && baked.cardinality !== "one-to-one") {
753
+ throw new TypeError("@nifrajs/content: malformed baked join")
754
+ }
755
+ for (const row of baked.rows) {
756
+ if (!isRecord(row) || !isRecord(row.left)) {
757
+ throw new TypeError("@nifrajs/content: malformed baked join row")
758
+ }
759
+ const validRight =
760
+ baked.cardinality === "one-to-many"
761
+ ? Array.isArray(row.right)
762
+ : row.right === null || isRecord(row.right)
763
+ if (!validRight) throw new TypeError("@nifrajs/content: malformed baked join row")
764
+ }
765
+ return { entries: baked.rows, baked }
766
+ }
package/src/mdx.ts CHANGED
@@ -15,8 +15,25 @@
15
15
  * MDX is JSX-oriented, so this targets the JSX family (React/Preact/Solid) via `jsxImportSource`. Needs
16
16
  * `@mdx-js/mdx` installed (an optional peer - lazy-loaded at build time only).
17
17
  */
18
+
19
+ import { fileURLToPath } from "node:url"
18
20
  import type { BunPlugin } from "bun"
19
21
 
22
+ /** Convert Bun's file-URL pathname form (`/D:/…`) into a path the host filesystem can open. */
23
+ function normalizeFilePath(path: string): string {
24
+ let value = path.split("?", 1)[0] ?? path
25
+ if (value.startsWith("file://")) {
26
+ try {
27
+ value = fileURLToPath(value)
28
+ } catch {
29
+ // Let Bun report an invalid path at the actual read boundary.
30
+ }
31
+ }
32
+ if (process.platform !== "win32") return value
33
+ if (/^\/[A-Za-z]:[\\/]/.test(value)) value = value.slice(1)
34
+ return value.replaceAll("/", "\\")
35
+ }
36
+
20
37
  export interface MdxPluginOptions {
21
38
  /** JSX runtime source the compiled MDX imports from - `"react"` (default), `"preact"`, `"solid-js"`. */
22
39
  readonly jsxImportSource?: string
@@ -59,7 +76,7 @@ export function mdxBunPlugin(options: MdxPluginOptions = {}): BunPlugin {
59
76
  // Tolerate a `?query` suffix - `nifra dev` (Vite) appends an import-cache-busting query so edited
60
77
  // `.mdx` re-SSR; strip it before reading off disk.
61
78
  build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
62
- const path = args.path.split("?")[0] ?? args.path
79
+ const path = normalizeFilePath(args.path)
63
80
  const source = await Bun.file(path).text()
64
81
  const compiled = await compile(source, {
65
82
  jsxImportSource,