mechanica-shared 2.0.0-alpha.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.
@@ -0,0 +1,413 @@
1
+ import { Document, isCollection, isMap, isScalar, isSeq, parse as parseYaml } from 'yaml'
2
+ import type { ContentBlock } from './types'
3
+
4
+ /**
5
+ * A parsed page document — the page envelope (everything but `content`) plus the
6
+ * content tree. This is the in-memory shape the `.page.md` codec maps to/from;
7
+ * it mirrors the dev store's `PageFile`.
8
+ */
9
+ export interface PageDoc {
10
+ name?: string
11
+ meta?: Record<string, unknown>
12
+ /** Page-scoped data overrides (defineData). */
13
+ data: Record<string, unknown>
14
+ content: ContentBlock[]
15
+ order?: number
16
+ orderAfter?: string | null
17
+ path?: string
18
+ }
19
+
20
+ /**
21
+ * Adapter that lets the codec store rich-text fields as Markdown on disk while
22
+ * keeping them as vuewrite `Block[]` JSON in page state. Supplied by the caller
23
+ * (dev server / CLI) so this module stays DOM- and vuewrite-free.
24
+ */
25
+ export interface RichTextCodec {
26
+ /** Whether a top-level prop of the given block is a rich-text field. */
27
+ isRichText(blockId: string, prop: string): boolean
28
+ /** vuewrite `Block[]` → Markdown (written to disk). */
29
+ toMarkdown(blocks: unknown): string
30
+ /** Markdown → vuewrite `Block[]` (loaded into state). */
31
+ toBlocks(markdown: string): unknown
32
+ }
33
+
34
+ /** Options for {@link parsePage} / {@link serializePage}. */
35
+ export interface PageCodecOptions {
36
+ /** Rich-text ⇄ Markdown adapter; when omitted, regions stay plain strings. */
37
+ richText?: RichTextCodec
38
+ }
39
+
40
+ /** Thrown by {@link parsePage} with the 1-based source line of the problem. */
41
+ export class PageParseError extends Error {
42
+ constructor(
43
+ message: string,
44
+ public readonly line: number,
45
+ ) {
46
+ super(`${message} (line ${line})`)
47
+ this.name = 'PageParseError'
48
+ Object.setPrototypeOf(this, PageParseError.prototype)
49
+ }
50
+ }
51
+
52
+ // ── Tokenizer ────────────────────────────────────────────────────────────────
53
+ // Structural tokens are recognized ONLY at column 0 (no leading whitespace), so
54
+ // indented YAML block-scalar / nested-mapping content in a head is never mistaken
55
+ // for a fence. Nesting comes from `:::` pairing, not indentation.
56
+
57
+ type Token =
58
+ | { kind: 'open'; blockId: string; rest: string }
59
+ | { kind: 'close'; id?: string }
60
+ | { kind: 'field'; name: string }
61
+
62
+ const CLOSE_BARE = /^:::[ \t]*$/
63
+ const CLOSE_LABELED = /^:::[ \t]*\/[ \t]*([A-Za-z][\w-]*)?[ \t]*$/
64
+ const OPEN = /^:::[ \t]+([A-Za-z][\w-]*)[ \t]*(.*)$/
65
+ const FIELD = /^@([A-Za-z][\w.-]*)[ \t]*$/
66
+
67
+ /** Classify a line as a structural token, or null when it's head/region content. */
68
+ function classify(line: string): Token | null {
69
+ if (/^[ \t]/.test(line)) return null // indented → content, never a token
70
+ let m: RegExpExecArray | null
71
+ if (CLOSE_BARE.test(line)) return { kind: 'close' }
72
+ if ((m = CLOSE_LABELED.exec(line))) return { kind: 'close', id: m[1] || undefined }
73
+ if ((m = OPEN.exec(line))) return { kind: 'open', blockId: m[1]!, rest: m[2]! }
74
+ if ((m = FIELD.exec(line))) return { kind: 'field', name: m[1]! }
75
+ return null
76
+ }
77
+
78
+ /** Leading run of backticks/tildes that opens or closes a Markdown code fence. */
79
+ function matchFence(line: string): string | null {
80
+ const m = /^[ \t]*([`~]{3,})/.exec(line)
81
+ return m ? m[1]! : null
82
+ }
83
+
84
+ /** Strip the common leading indentation from a block of lines (blank lines ignored). */
85
+ function dedent(lines: string[]): string[] {
86
+ let min = Infinity
87
+ for (const line of lines) {
88
+ if (line.trim() === '') continue
89
+ min = Math.min(min, /^[ \t]*/.exec(line)![0].length)
90
+ }
91
+ if (!isFinite(min) || min === 0) return lines.slice()
92
+ return lines.map((line) => (line.trim() === '' ? line.trimStart() : line.slice(min)))
93
+ }
94
+
95
+ /** Set `data[a.b.c] = value`, creating intermediate objects. */
96
+ function setPath(data: Record<string, unknown>, path: string, value: unknown): void {
97
+ const parts = path.split('.')
98
+ let target = data
99
+ for (let i = 0; i < parts.length - 1; i++) {
100
+ const key = parts[i]!
101
+ if (typeof target[key] !== 'object' || target[key] === null) target[key] = {}
102
+ target = target[key] as Record<string, unknown>
103
+ }
104
+ target[parts[parts.length - 1]!] = value
105
+ }
106
+
107
+ interface Frame {
108
+ block: ContentBlock
109
+ headBuf: string[]
110
+ region: { name: string; lines: string[]; fence: string | null } | null
111
+ }
112
+
113
+ /** Parse a `.page.md` document into a {@link PageDoc}. */
114
+ export function parsePage(text: string, options?: PageCodecOptions): PageDoc {
115
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
116
+ let i = 0
117
+
118
+ // 1. Frontmatter (the page envelope).
119
+ let envelope: Record<string, unknown> = {}
120
+ if (lines[0] === '---') {
121
+ let j = 1
122
+ while (j < lines.length && lines[j] !== '---') j++
123
+ if (j >= lines.length) throw new PageParseError('Unterminated frontmatter (missing closing ---)', 1)
124
+ try {
125
+ envelope = (parseYaml(lines.slice(1, j).join('\n')) as Record<string, unknown>) ?? {}
126
+ } catch (error) {
127
+ throw new PageParseError(`Invalid frontmatter YAML: ${(error as Error).message}`, 1)
128
+ }
129
+ i = j + 1
130
+ }
131
+
132
+ // 2. Block fences (the content tree).
133
+ const root: ContentBlock[] = []
134
+ const stack: Frame[] = []
135
+ const counter = { n: 0 }
136
+
137
+ const flushHead = (frame: Frame, line: number): void => {
138
+ if (frame.headBuf.length === 0) return
139
+ const yamlText = dedent(frame.headBuf).join('\n')
140
+ frame.headBuf = []
141
+ if (yamlText.trim() === '') return
142
+ let parsed: unknown
143
+ try {
144
+ parsed = parseYaml(yamlText)
145
+ } catch (error) {
146
+ throw new PageParseError(`Invalid block head YAML: ${(error as Error).message}`, line)
147
+ }
148
+ if (parsed && typeof parsed === 'object') Object.assign(frame.block.data, parsed)
149
+ }
150
+
151
+ const finalizeRegion = (frame: Frame): void => {
152
+ const region = frame.region!
153
+ const body = dedent(region.lines)
154
+ while (body.length && body[0]!.trim() === '') body.shift()
155
+ while (body.length && body[body.length - 1]!.trim() === '') body.pop()
156
+ const raw = body.join('\n')
157
+ const value = options?.richText?.isRichText(frame.block.blockId, region.name)
158
+ ? options.richText.toBlocks(raw)
159
+ : raw
160
+ setPath(frame.block.data, region.name, value)
161
+ frame.region = null
162
+ }
163
+
164
+ for (; i < lines.length; i++) {
165
+ const line = lines[i]!
166
+ const frame = stack[stack.length - 1]
167
+
168
+ // Region capture: a @field is open on the current block.
169
+ if (frame?.region) {
170
+ const region = frame.region
171
+ const fence = matchFence(line)
172
+ if (region.fence !== null) {
173
+ region.lines.push(line)
174
+ if (fence && fence.length >= region.fence.length && line.trimStart().startsWith(region.fence)) {
175
+ region.fence = null
176
+ }
177
+ continue
178
+ }
179
+ if (fence) {
180
+ region.lines.push(line)
181
+ region.fence = fence
182
+ continue
183
+ }
184
+ if (line.startsWith('\\:::') || /^\\@[A-Za-z]/.test(line)) {
185
+ region.lines.push(line.slice(1)) // de-escape a literal line-initial token
186
+ continue
187
+ }
188
+ if (classify(line) === null) {
189
+ region.lines.push(line)
190
+ continue
191
+ }
192
+ finalizeRegion(frame) // a structural token ends the region; fall through to handle it
193
+ }
194
+
195
+ const token = classify(line)
196
+ if (token === null) {
197
+ if (!frame) {
198
+ if (line.trim() === '') continue
199
+ throw new PageParseError(`Content outside any block: "${line.trim()}"`, i + 1)
200
+ }
201
+ frame.headBuf.push(line)
202
+ continue
203
+ }
204
+
205
+ if (token.kind === 'open') {
206
+ if (frame) flushHead(frame, i + 1)
207
+ const { id, slot, v } = parseAttrs(token.rest, i + 1)
208
+ const block: ContentBlock = { id: id ?? `auto${++counter.n}`, blockId: token.blockId, data: {} }
209
+ if (v !== undefined) block.v = v
210
+ attach(root, frame, block, slot, i + 1)
211
+ stack.push({ block, headBuf: [], region: null })
212
+ } else if (token.kind === 'field') {
213
+ if (!frame) throw new PageParseError(`@${token.name} outside any block`, i + 1)
214
+ flushHead(frame, i + 1)
215
+ frame.region = { name: token.name, lines: [], fence: null }
216
+ } else {
217
+ if (!frame) throw new PageParseError('Close ::: with no open block', i + 1)
218
+ flushHead(frame, i + 1)
219
+ if (token.id && token.id !== frame.block.blockId) {
220
+ throw new PageParseError(
221
+ `Labeled close "::: /${token.id}" does not match open block "::: ${frame.block.blockId}"`,
222
+ i + 1,
223
+ )
224
+ }
225
+ stack.pop()
226
+ }
227
+ }
228
+
229
+ if (stack.length) {
230
+ throw new PageParseError(`Unclosed block "::: ${stack[stack.length - 1]!.block.blockId}"`, lines.length)
231
+ }
232
+
233
+ return {
234
+ ...(typeof envelope.name === 'string' ? { name: envelope.name } : {}),
235
+ ...(envelope.meta !== undefined ? { meta: envelope.meta as Record<string, unknown> } : {}),
236
+ ...(typeof envelope.order === 'number' ? { order: envelope.order } : {}),
237
+ ...(envelope.orderAfter != null ? { orderAfter: envelope.orderAfter as string } : {}),
238
+ ...(typeof envelope.path === 'string' ? { path: envelope.path } : {}),
239
+ data: (envelope.data as Record<string, unknown>) ?? {},
240
+ content: root,
241
+ }
242
+ }
243
+
244
+ /** Parse the `#id` and `key=value` attributes after a block id on an open fence. */
245
+ function parseAttrs(rest: string, line: number): { id?: string; slot?: string; v?: number } {
246
+ const out: { id?: string; slot?: string; v?: number } = {}
247
+ for (const part of rest.trim().split(/[ \t]+/).filter(Boolean)) {
248
+ if (part.startsWith('#')) out.id = part.slice(1)
249
+ else if (part.startsWith('slot=')) out.slot = part.slice(5)
250
+ else if (/^v=\d+$/.test(part)) out.v = Number(part.slice(2))
251
+ else throw new PageParseError(`Unknown block attribute "${part}"`, line)
252
+ }
253
+ return out
254
+ }
255
+
256
+ /** Place a freshly-opened block into its parent slot (or the page root). */
257
+ function attach(
258
+ root: ContentBlock[],
259
+ parent: Frame | undefined,
260
+ block: ContentBlock,
261
+ slot: string | undefined,
262
+ line: number,
263
+ ): void {
264
+ if (!parent) {
265
+ if (slot) throw new PageParseError('A root-level block cannot have a slot', line)
266
+ root.push(block)
267
+ return
268
+ }
269
+ const owner = parent.block
270
+ if (slot) {
271
+ if (owner.children == null) owner.children = {}
272
+ if (Array.isArray(owner.children)) {
273
+ throw new PageParseError(`Block "::: ${owner.blockId}" mixes default and named slots`, line)
274
+ }
275
+ const map = owner.children as Record<string, ContentBlock[]>
276
+ ;(map[slot] ??= []).push(block)
277
+ } else {
278
+ if (owner.children == null) owner.children = []
279
+ if (!Array.isArray(owner.children)) {
280
+ throw new PageParseError(`Block "::: ${owner.blockId}" mixes default and named slots`, line)
281
+ }
282
+ owner.children.push(block)
283
+ }
284
+ }
285
+
286
+ // ── Serializer ───────────────────────────────────────────────────────────────
287
+
288
+ /** A top-level string prop becomes an `@field` region past this length, or on any newline. */
289
+ const REGION_THRESHOLD = 80
290
+ const FLOW_MAX = 72
291
+
292
+ /** Serialize a {@link PageDoc} into canonical `.page.md` text. */
293
+ export function serializePage(doc: PageDoc, options?: PageCodecOptions): string {
294
+ const envelope: Record<string, unknown> = {}
295
+ if (doc.name !== undefined) envelope.name = doc.name
296
+ if (doc.meta !== undefined) envelope.meta = doc.meta
297
+ envelope.data = doc.data ?? {}
298
+ if (doc.order !== undefined) envelope.order = doc.order
299
+ if (doc.orderAfter != null) envelope.orderAfter = doc.orderAfter
300
+ if (doc.path !== undefined) envelope.path = doc.path
301
+
302
+ const out: string[] = ['---', emitYaml(envelope), '---', '']
303
+ for (const block of doc.content) emitBlock(block, undefined, out, options)
304
+ return out.join('\n').replace(/\n+$/, '') + '\n'
305
+ }
306
+
307
+ function emitBlock(
308
+ block: ContentBlock,
309
+ slot: string | undefined,
310
+ out: string[],
311
+ options?: PageCodecOptions,
312
+ ): void {
313
+ let open = `::: ${block.blockId}`
314
+ if (block.id) open += ` #${block.id}`
315
+ if (slot) open += ` slot=${slot}`
316
+ if (block.v !== undefined) open += ` v=${block.v}`
317
+ out.push(open)
318
+
319
+ const head: Record<string, unknown> = {}
320
+ const regions: [string, string][] = []
321
+ for (const [key, value] of Object.entries(block.data)) {
322
+ // Rich-text fields are always written as a Markdown region (vuewrite Block[]
323
+ // → Markdown), regardless of length, so they read as prose on disk.
324
+ if (options?.richText?.isRichText(block.blockId, key) && Array.isArray(value)) {
325
+ regions.push([key, options.richText.toMarkdown(value)])
326
+ } else if (typeof value === 'string' && (value.includes('\n') || value.length > REGION_THRESHOLD)) {
327
+ regions.push([key, value])
328
+ } else {
329
+ head[key] = value
330
+ }
331
+ }
332
+ if (Object.keys(head).length) out.push(emitYaml(head))
333
+ for (const [name, value] of regions) {
334
+ out.push(`@${name}`)
335
+ out.push(escapeRegion(value))
336
+ }
337
+
338
+ let hasChildren = false
339
+ const children = block.children
340
+ if (Array.isArray(children)) {
341
+ for (const child of children) emitBlock(child, undefined, out, options)
342
+ hasChildren = children.length > 0
343
+ } else if (children && typeof children === 'object') {
344
+ for (const [slotName, list] of Object.entries(children)) {
345
+ for (const child of list) emitBlock(child, slotName, out, options)
346
+ if (list.length) hasChildren = true
347
+ }
348
+ }
349
+
350
+ out.push(hasChildren ? `::: /${block.blockId}` : ':::')
351
+ out.push('')
352
+ }
353
+
354
+ /** Backslash-escape any line-initial structural token in raw region text (outside code fences). */
355
+ function escapeRegion(text: string): string {
356
+ let fence: string | null = null
357
+ return text
358
+ .split('\n')
359
+ .map((line) => {
360
+ const mark = matchFence(line)
361
+ if (fence !== null) {
362
+ if (mark && mark.length >= fence.length && line.trimStart().startsWith(fence)) fence = null
363
+ return line
364
+ }
365
+ if (mark) {
366
+ fence = mark
367
+ return line
368
+ }
369
+ return classify(line) !== null ? '\\' + line : line
370
+ })
371
+ .join('\n')
372
+ }
373
+
374
+ /**
375
+ * Emit a value as YAML. The root mapping always stays block style (one prop per
376
+ * line); only nested collections collapse to flow when short and all-scalar.
377
+ */
378
+ function emitYaml(value: unknown): string {
379
+ const doc = new Document(value)
380
+ const root = doc.contents as YamlNode
381
+ if (isSeq(root)) for (const item of root.items) applyFlow(item)
382
+ else if (isMap(root)) for (const pair of root.items) applyFlow(pair.value)
383
+ return doc.toString({ lineWidth: 0 }).replace(/\n$/, '')
384
+ }
385
+
386
+ type YamlNode = any
387
+
388
+ function applyFlow(node: YamlNode): void {
389
+ if (isSeq(node)) {
390
+ for (const item of node.items) if (isCollection(item)) applyFlow(item)
391
+ node.flow = node.items.length === 0 || (node.items.every(isScalar) && inlineLen(node) <= FLOW_MAX)
392
+ } else if (isMap(node)) {
393
+ for (const pair of node.items) if (isCollection(pair.value)) applyFlow(pair.value)
394
+ node.flow =
395
+ node.items.length === 0 ||
396
+ (node.items.every((pair: YamlNode) => isScalar(pair.value)) && inlineLen(node) <= FLOW_MAX)
397
+ }
398
+ }
399
+
400
+ function inlineLen(node: YamlNode): number {
401
+ if (isScalar(node)) return String(node.value ?? 'null').length + 2
402
+ if (isSeq(node)) return 4 + node.items.reduce((sum: number, item: YamlNode) => sum + inlineLen(item) + 2, 0)
403
+ if (isMap(node)) {
404
+ return (
405
+ 4 +
406
+ node.items.reduce(
407
+ (sum: number, pair: YamlNode) => sum + String(pair.key?.value ?? pair.key).length + 2 + inlineLen(pair.value) + 2,
408
+ 0,
409
+ )
410
+ )
411
+ }
412
+ return 0
413
+ }
@@ -0,0 +1,162 @@
1
+ import { getValueByPath } from './schema'
2
+
3
+ /**
4
+ * The query engine: resolves the runtime's query keys (`usePages`,
5
+ * `usePagination`, `useFetch`) against an abstract {@link QuerySource}. One
6
+ * implementation, three callers — the dev server (live `.mech` store), the
7
+ * static export (resolved at build time and baked into `window.state.query`),
8
+ * and a future hosted backend (its own source). Pure and DOM/fs-free; the
9
+ * source supplies all I/O.
10
+ */
11
+
12
+ /** A page entry as the engine consumes it (requested data entries embedded). */
13
+ export interface PageQueryItem {
14
+ path: string
15
+ name: string
16
+ folderPath?: string | null
17
+ order?: number
18
+ orderAfter?: string | null
19
+ [dataId: string]: unknown
20
+ }
21
+
22
+ /** What a caller must supply for the engine to resolve queries. */
23
+ export interface QuerySource {
24
+ /** List all pages, with the requested page-scoped data entries embedded. */
25
+ listPages(options: { data?: { id: string }[] }): PageQueryItem[]
26
+ /** Fetch external JSON (`useFetch`). Omit to disable fetch queries. */
27
+ fetchJson?(options: { url: string } & Record<string, unknown>): Promise<unknown>
28
+ }
29
+
30
+ /** Arguments of a `getPages` query (the JSON part of its key). */
31
+ export interface PagesQueryArgs {
32
+ /** Only pages inside this folder path (e.g. `'blog'`). */
33
+ folderName?: string
34
+ /** Page-scoped data entries to embed in each result. */
35
+ data?: { id: string }[]
36
+ /**
37
+ * Sort field: `'name'`, `'path'`, or a dotted path into an included data
38
+ * entry (e.g. `'postMeta.date'`). Default: the store's page order.
39
+ */
40
+ sort?: { by: string; dir?: 'asc' | 'desc' }
41
+ /** Cap the number of results (non-paginated queries). */
42
+ limit?: number
43
+ /** Split results into pages of this size — the query becomes paginated. */
44
+ pageSize?: number
45
+ }
46
+
47
+ /** The shape a paginated `getPages` query resolves to. */
48
+ export interface PaginatedPagesResult {
49
+ items: PageQueryItem[]
50
+ /** Current page number (1-based, clamped to `pageCount`). */
51
+ page: number
52
+ pageCount: number
53
+ pageSize: number
54
+ total: number
55
+ }
56
+
57
+ /** Extra context for resolving a key (which paginated variant to slice). */
58
+ export interface QueryContext {
59
+ /** 1-based page number for paginated queries. Default 1. */
60
+ page?: number
61
+ }
62
+
63
+ /** Split a query key (`"<type>.<json-args>"`) into its type and parsed args. */
64
+ export function parseQueryKey(key: string): { type: string; args: Record<string, unknown> } {
65
+ const dot = key.indexOf('.')
66
+ if (dot === -1) return { type: key, args: {} }
67
+ const type = key.slice(0, dot)
68
+ const raw = key.slice(dot + 1)
69
+ try {
70
+ const parsed: unknown = JSON.parse(raw || '{}')
71
+ return { type, args: parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {} }
72
+ } catch {
73
+ return { type, args: {} }
74
+ }
75
+ }
76
+
77
+ /** Whether a key is a paginated `getPages` query (drives export page-splitting). */
78
+ export function isPaginatedQuery(key: string): boolean {
79
+ const { type, args } = parseQueryKey(key)
80
+ return type === 'getPages' && typeof args.pageSize === 'number' && args.pageSize > 0
81
+ }
82
+
83
+ /** Compare possibly-missing values: numbers numerically, everything else as strings. */
84
+ function compareValues(a: unknown, b: unknown): number {
85
+ if (a == null && b == null) return 0
86
+ if (a == null) return 1 // missing values sort last regardless of direction
87
+ if (b == null) return -1
88
+ if (typeof a === 'number' && typeof b === 'number') return a - b
89
+ return String(a).localeCompare(String(b))
90
+ }
91
+
92
+ /**
93
+ * Resolve a `getPages` query: filter by folder, sort, and either cap (`limit`)
94
+ * or paginate (`pageSize`). Internal ordering fields are stripped from results.
95
+ */
96
+ export function resolvePagesQuery(
97
+ source: QuerySource,
98
+ args: PagesQueryArgs,
99
+ context: QueryContext = {},
100
+ ): PageQueryItem[] | PaginatedPagesResult {
101
+ let pages = source.listPages({ data: args.data })
102
+
103
+ if (args.folderName) {
104
+ // "Pages in this folder" means its children — the folder's own index page
105
+ // (usually the page doing the listing) is excluded.
106
+ const indexPath = '/' + args.folderName.replace(/^\/+|\/+$/g, '')
107
+ pages = pages.filter((page) => page.folderPath === args.folderName && page.path !== indexPath)
108
+ }
109
+
110
+ if (args.sort?.by) {
111
+ const { by, dir } = args.sort
112
+ const sign = dir === 'desc' ? -1 : 1
113
+ pages = pages
114
+ .map((page) => ({ page, field: getValueByPath(page, by) }))
115
+ .sort((a, b) => {
116
+ // Missing values sort last in either direction.
117
+ if (a.field == null || b.field == null) return compareValues(a.field, b.field)
118
+ return compareValues(a.field, b.field) * sign
119
+ })
120
+ .map((entry) => entry.page)
121
+ }
122
+
123
+ const items = pages.map(({ order, orderAfter, folderPath, ...rest }) => rest)
124
+
125
+ if (args.pageSize && args.pageSize > 0) {
126
+ const total = items.length
127
+ const pageCount = Math.max(1, Math.ceil(total / args.pageSize))
128
+ const page = Math.min(Math.max(1, context.page ?? 1), pageCount)
129
+ return {
130
+ items: items.slice((page - 1) * args.pageSize, page * args.pageSize),
131
+ page,
132
+ pageCount,
133
+ pageSize: args.pageSize,
134
+ total,
135
+ }
136
+ }
137
+
138
+ return typeof args.limit === 'number' && args.limit >= 0 ? items.slice(0, args.limit) : items
139
+ }
140
+
141
+ /**
142
+ * Resolve any query key against a source. Unknown types resolve to `{}` (the
143
+ * runtime containers keep their initial shape). `fetch` keys require the
144
+ * source to provide `fetchJson`.
145
+ */
146
+ export async function resolveQueryKey(
147
+ source: QuerySource,
148
+ key: string,
149
+ context: QueryContext = {},
150
+ ): Promise<unknown> {
151
+ const { type, args } = parseQueryKey(key)
152
+
153
+ if (type === 'getPages') return resolvePagesQuery(source, args as PagesQueryArgs, context)
154
+
155
+ if (type === 'fetch') {
156
+ if (!source.fetchJson) return {}
157
+ if (typeof args.url !== 'string' || !args.url) return {}
158
+ return source.fetchJson(args as { url: string })
159
+ }
160
+
161
+ return {}
162
+ }