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.
- package/LICENSE +21 -0
- package/README.md +10 -0
- package/dist/index.js +476 -0
- package/dist/page-format.js +301 -0
- package/dist/types/fields.d.ts +34 -0
- package/dist/types/generate-page.d.ts +85 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/migrate.d.ts +19 -0
- package/dist/types/page-format.d.ts +43 -0
- package/dist/types/query-engine.d.ts +83 -0
- package/dist/types/schema.d.ts +29 -0
- package/dist/types/types.d.ts +122 -0
- package/dist/types/validate-links.d.ts +25 -0
- package/package.json +46 -0
- package/src/fields.ts +106 -0
- package/src/generate-page.ts +175 -0
- package/src/index.ts +66 -0
- package/src/migrate.ts +44 -0
- package/src/page-format.ts +413 -0
- package/src/query-engine.ts +162 -0
- package/src/schema.ts +137 -0
- package/src/types.ts +126 -0
- package/src/validate-links.ts +56 -0
|
@@ -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
|
+
}
|