@piqit/resolvers 0.0.2

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,226 @@
1
+ /**
2
+ * Path Pattern Parsing and Matching
3
+ *
4
+ * Parses patterns like `{year}/{slug}.md` and provides:
5
+ * - Type-level extraction of parameters
6
+ * - Glob pattern generation
7
+ * - Path matching to extract params
8
+ * - Path building from params
9
+ */
10
+
11
+ // =============================================================================
12
+ // Type-level Parameter Extraction
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Extract params from a path pattern string at the type level.
17
+ *
18
+ * @example
19
+ * type Params = ExtractParams<'{year}/{slug}.md'>
20
+ * // = { year: string } & { slug: string }
21
+ */
22
+ export type ExtractParams<P extends string> = P extends `${string}{${infer Param}}${infer Rest}`
23
+ ? { [K in Param]: string } & ExtractParams<Rest>
24
+ : {}
25
+
26
+ /**
27
+ * Simplify a type by flattening intersections.
28
+ * Converts { a: string } & { b: string } to { a: string; b: string }
29
+ */
30
+ export type Simplify<T> = { [K in keyof T]: T[K] }
31
+
32
+ /**
33
+ * Extract and simplify params from a path pattern.
34
+ */
35
+ export type PathParams<P extends string> = Simplify<ExtractParams<P>>
36
+
37
+ // =============================================================================
38
+ // Compiled Pattern Interface
39
+ // =============================================================================
40
+
41
+ /**
42
+ * A compiled path pattern that can match paths and generate globs.
43
+ */
44
+ export interface CompiledPattern {
45
+ /**
46
+ * The original pattern string.
47
+ */
48
+ pattern: string
49
+
50
+ /**
51
+ * The extracted parameter names.
52
+ */
53
+ paramNames: string[]
54
+
55
+ /**
56
+ * Generate a glob pattern, optionally constraining specific params.
57
+ *
58
+ * @param constraints - Optional param values to use instead of wildcards
59
+ * @returns A glob pattern string
60
+ *
61
+ * @example
62
+ * pattern.toGlob() // '** / *.md' (with {year}/{slug}.md)
63
+ * pattern.toGlob({ year: '2024' }) // '2024/*.md'
64
+ */
65
+ toGlob(constraints?: Record<string, unknown>): string
66
+
67
+ /**
68
+ * Match a path against this pattern and extract params.
69
+ *
70
+ * @param path - The file path to match (relative to base)
71
+ * @returns The extracted params, or null if no match
72
+ *
73
+ * @example
74
+ * pattern.match('2024/hello-world.md') // { year: '2024', slug: 'hello-world' }
75
+ * pattern.match('invalid') // null
76
+ */
77
+ match(path: string): Record<string, string> | null
78
+
79
+ /**
80
+ * Build a path from params.
81
+ *
82
+ * @param params - The param values
83
+ * @returns The constructed path
84
+ *
85
+ * @example
86
+ * pattern.build({ year: '2024', slug: 'hello' }) // '2024/hello.md'
87
+ */
88
+ build(params: Record<string, string>): string
89
+ }
90
+
91
+ // =============================================================================
92
+ // Pattern Parsing
93
+ // =============================================================================
94
+
95
+ /**
96
+ * Regex to match parameter placeholders in patterns.
97
+ * Matches {paramName} where paramName is alphanumeric + underscores.
98
+ */
99
+ const PARAM_REGEX = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g
100
+
101
+ /**
102
+ * Characters that need escaping in regex.
103
+ */
104
+ const REGEX_ESCAPE = /[.*+?^${}()|[\]\\]/g
105
+
106
+ /**
107
+ * Compile a path pattern string into a usable pattern object.
108
+ *
109
+ * @param pattern - The pattern string like '{year}/{slug}.md'
110
+ * @returns A CompiledPattern instance
111
+ *
112
+ * @example
113
+ * const pattern = compilePattern('{year}/{slug}.md')
114
+ * pattern.toGlob() // '** / *.md'
115
+ * pattern.match('2024/hello.md') // { year: '2024', slug: 'hello' }
116
+ */
117
+ export function compilePattern(pattern: string): CompiledPattern {
118
+ // Extract all parameter names in order
119
+ const paramNames: string[] = []
120
+ let match: RegExpExecArray | null
121
+ const regex = new RegExp(PARAM_REGEX.source, "g")
122
+ while ((match = regex.exec(pattern)) !== null) {
123
+ paramNames.push(match[1])
124
+ }
125
+
126
+ // Build the match regex by replacing {param} with capturing groups
127
+ // Each param can match anything except path separators
128
+ const regexPattern = pattern
129
+ .replace(REGEX_ESCAPE, (char) => {
130
+ // Don't escape our param placeholders - we'll handle them separately
131
+ if (char === "{" || char === "}") return char
132
+ return "\\" + char
133
+ })
134
+ .replace(PARAM_REGEX, "([^/]+)")
135
+
136
+ const matchRegex = new RegExp(`^${regexPattern}$`)
137
+
138
+ return {
139
+ pattern,
140
+ paramNames,
141
+
142
+ toGlob(constraints?: Record<string, unknown>): string {
143
+ let glob = pattern
144
+
145
+ // Replace each param with either its constrained value or a wildcard
146
+ for (const name of paramNames) {
147
+ const value = constraints?.[name]
148
+ if (value !== undefined && value !== null) {
149
+ // Use the constrained value
150
+ glob = glob.replace(`{${name}}`, String(value))
151
+ } else {
152
+ // Use a wildcard - * matches anything except /
153
+ glob = glob.replace(`{${name}}`, "*")
154
+ }
155
+ }
156
+
157
+ return glob
158
+ },
159
+
160
+ match(path: string): Record<string, string> | null {
161
+ const result = matchRegex.exec(path)
162
+ if (!result) return null
163
+
164
+ const params: Record<string, string> = {}
165
+ for (let i = 0; i < paramNames.length; i++) {
166
+ params[paramNames[i]] = result[i + 1]
167
+ }
168
+ return params
169
+ },
170
+
171
+ build(params: Record<string, string>): string {
172
+ let result = pattern
173
+ for (const name of paramNames) {
174
+ const value = params[name]
175
+ if (value === undefined) {
176
+ throw new Error(`Missing required param: ${name}`)
177
+ }
178
+ result = result.replace(`{${name}}`, value)
179
+ }
180
+ return result
181
+ },
182
+ }
183
+ }
184
+
185
+ // =============================================================================
186
+ // Schema for Path Params
187
+ // =============================================================================
188
+
189
+ /**
190
+ * Create a StandardSchema for path params extracted from a pattern.
191
+ * This is used by the resolver to expose scanParams to piq core.
192
+ *
193
+ * @param pattern - The compiled pattern
194
+ * @returns A StandardSchema that validates param objects
195
+ */
196
+ export function createParamsSchema(
197
+ pattern: CompiledPattern
198
+ ): import("piqit").StandardSchema<Record<string, string>> {
199
+ return {
200
+ "~standard": {
201
+ version: 1,
202
+ vendor: "piqit/resolvers",
203
+ validate(value: unknown) {
204
+ if (value === null || typeof value !== "object") {
205
+ return {
206
+ issues: [{ message: "Expected object" }],
207
+ }
208
+ }
209
+
210
+ const obj = value as Record<string, unknown>
211
+
212
+ // All params should be strings if present
213
+ for (const name of pattern.paramNames) {
214
+ const val = obj[name]
215
+ if (val !== undefined && typeof val !== "string") {
216
+ return {
217
+ issues: [{ message: `Param ${name} must be a string`, path: [name] }],
218
+ }
219
+ }
220
+ }
221
+
222
+ return { value: obj as Record<string, string> }
223
+ },
224
+ },
225
+ }
226
+ }
package/src/static.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Static Content Resolver
3
+ *
4
+ * A resolver for querying pre-compiled content in edge environments
5
+ * like Cloudflare Workers where filesystem access is not available.
6
+ *
7
+ * Content is compiled at build time and bundled as a static module.
8
+ *
9
+ * @example
10
+ * // 1. Build script compiles content:
11
+ * // build-content.ts
12
+ * import { fileMarkdown } from "@piqit/resolvers";
13
+ * const posts = fileMarkdown({ ... });
14
+ * const allPosts = await posts.resolve({ scan: {}, filter: {}, select: ["params.*", "frontmatter.*", "body.*"] });
15
+ * await Bun.write("src/generated/content.ts", `export const posts = ${JSON.stringify(allPosts)};`);
16
+ *
17
+ * // 2. Worker imports and uses static resolver:
18
+ * // worker.ts
19
+ * import { posts } from "./generated/content";
20
+ * import { staticContent } from "@piqit/resolvers";
21
+ * import { piq } from "piqit";
22
+ *
23
+ * const postsResolver = staticContent(posts);
24
+ *
25
+ * const results = await piq.from(postsResolver)
26
+ * .filter({ author: "John" })
27
+ * .select("params.slug", "frontmatter.title")
28
+ * .exec();
29
+ */
30
+
31
+ import type { Resolver, StandardSchema } from "piqit"
32
+
33
+ // =============================================================================
34
+ // Filter Helpers
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Get a nested value from an object using dot-path notation.
39
+ */
40
+ function getByPath(obj: unknown, path: string): unknown {
41
+ const parts = path.split(".")
42
+ let current: unknown = obj
43
+
44
+ for (const part of parts) {
45
+ if (current == null || typeof current !== "object") {
46
+ return undefined
47
+ }
48
+ current = (current as Record<string, unknown>)[part]
49
+ }
50
+
51
+ return current
52
+ }
53
+
54
+ /**
55
+ * Default filter implementation - checks equality on frontmatter fields.
56
+ */
57
+ function defaultFilter<T>(item: T, filter: Partial<Record<string, unknown>>): boolean {
58
+ const frontmatter = (item as Record<string, unknown>).frontmatter as Record<string, unknown> | undefined
59
+
60
+ if (!frontmatter) {
61
+ return Object.keys(filter).length === 0
62
+ }
63
+
64
+ for (const [key, value] of Object.entries(filter)) {
65
+ if (frontmatter[key] !== value) {
66
+ return false
67
+ }
68
+ }
69
+
70
+ return true
71
+ }
72
+
73
+ /**
74
+ * Check if scan constraints match params.
75
+ */
76
+ function matchesScan<T>(item: T, scan: Partial<Record<string, unknown>>): boolean {
77
+ const params = (item as Record<string, unknown>).params as Record<string, unknown> | undefined
78
+
79
+ if (!params) {
80
+ return Object.keys(scan).length === 0
81
+ }
82
+
83
+ for (const [key, value] of Object.entries(scan)) {
84
+ if (value !== undefined && params[key] !== value) {
85
+ return false
86
+ }
87
+ }
88
+
89
+ return true
90
+ }
91
+
92
+ /**
93
+ * Select specific fields from an item based on select paths.
94
+ */
95
+ function selectFields<T extends object>(item: T, selectPaths: string[]): Partial<T> {
96
+ const result: Record<string, unknown> = {}
97
+
98
+ for (const path of selectPaths) {
99
+ // Handle wildcards like "params.*"
100
+ if (path.endsWith(".*")) {
101
+ const namespace = path.slice(0, -2)
102
+ const nsValue = getByPath(item, namespace)
103
+ if (nsValue && typeof nsValue === "object") {
104
+ result[namespace] = { ...nsValue as object }
105
+ }
106
+ } else {
107
+ // Regular path like "params.slug" or "frontmatter.title"
108
+ const parts = path.split(".")
109
+ const namespace = parts[0]
110
+
111
+ // Ensure namespace exists in result
112
+ if (!result[namespace]) {
113
+ result[namespace] = {}
114
+ }
115
+
116
+ // Set the value
117
+ const value = getByPath(item, path)
118
+ if (parts.length === 2) {
119
+ (result[namespace] as Record<string, unknown>)[parts[1]] = value
120
+ } else {
121
+ // Deeper path - just copy the value
122
+ result[namespace] = value
123
+ }
124
+ }
125
+ }
126
+
127
+ return result as Partial<T>
128
+ }
129
+
130
+ // =============================================================================
131
+ // Schema Factories
132
+ // =============================================================================
133
+
134
+ /**
135
+ * Create a passthrough schema that accepts any value.
136
+ * Used for static content where validation happened at build time.
137
+ */
138
+ function createPassthroughSchema<T>(): StandardSchema<T> {
139
+ return {
140
+ "~standard": {
141
+ version: 1,
142
+ vendor: "piqit/resolvers/static",
143
+ validate(value: unknown) {
144
+ return { value: value as T }
145
+ },
146
+ },
147
+ }
148
+ }
149
+
150
+ // =============================================================================
151
+ // Resolver Factory
152
+ // =============================================================================
153
+
154
+ /**
155
+ * Create a static content resolver from pre-compiled data.
156
+ *
157
+ * This resolver is designed for edge environments like Cloudflare Workers
158
+ * where filesystem access is not available. Content is compiled at build
159
+ * time and bundled as a static module.
160
+ *
161
+ * @param data - Array of pre-compiled content items
162
+ * @returns A resolver that queries the static data
163
+ *
164
+ * @example
165
+ * // In your worker:
166
+ * import { posts } from "./generated/content";
167
+ * import { staticContent } from "@piqit/resolvers";
168
+ * import { piq } from "piqit";
169
+ *
170
+ * const postsResolver = staticContent(posts);
171
+ *
172
+ * export default {
173
+ * async fetch(request: Request) {
174
+ * const results = await piq.from(postsResolver)
175
+ * .scan({ year: "2024" })
176
+ * .select("params.slug", "frontmatter.title")
177
+ * .exec();
178
+ *
179
+ * return Response.json(results);
180
+ * }
181
+ * };
182
+ */
183
+ export function staticContent<T extends object>(
184
+ data: T[]
185
+ ): Resolver<
186
+ StandardSchema<Partial<Record<string, unknown>>>,
187
+ StandardSchema<Partial<Record<string, unknown>>>,
188
+ StandardSchema<T>
189
+ > {
190
+ const scanSchema = createPassthroughSchema<Partial<Record<string, unknown>>>()
191
+ const filterSchema = createPassthroughSchema<Partial<Record<string, unknown>>>()
192
+ const resultSchema = createPassthroughSchema<T>()
193
+
194
+ return {
195
+ schema: {
196
+ scanParams: scanSchema,
197
+ filterParams: filterSchema,
198
+ result: resultSchema,
199
+ },
200
+
201
+ async resolve(spec): Promise<Partial<T>[]> {
202
+ let results = [...data]
203
+
204
+ // Apply scan constraints (filter by params)
205
+ if (spec.scan && Object.keys(spec.scan).length > 0) {
206
+ results = results.filter((item) => matchesScan(item, spec.scan!))
207
+ }
208
+
209
+ // Apply filter constraints (filter by frontmatter)
210
+ if (spec.filter && Object.keys(spec.filter).length > 0) {
211
+ results = results.filter((item) => defaultFilter(item, spec.filter!))
212
+ }
213
+
214
+ // Apply select to return only requested fields
215
+ if (spec.select && spec.select.length > 0) {
216
+ return results.map((item) => selectFields(item, spec.select))
217
+ }
218
+
219
+ return results
220
+ },
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Alias for staticContent - provides a more descriptive name for the use case.
226
+ */
227
+ export const staticResolver = staticContent