@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,269 @@
1
+ /**
2
+ * YAML Frontmatter Extraction
3
+ *
4
+ * Optimized frontmatter parsing that reads only what's needed.
5
+ */
6
+
7
+ // =============================================================================
8
+ // Frontmatter Parsing
9
+ // =============================================================================
10
+
11
+ /**
12
+ * Regex to match YAML frontmatter block at the start of content.
13
+ * Matches --- at start, captures content, ends with ---
14
+ */
15
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/
16
+
17
+ /**
18
+ * Parse YAML frontmatter from content string.
19
+ *
20
+ * Uses a simple YAML parser that handles common cases:
21
+ * - key: value
22
+ * - key: "quoted value"
23
+ * - key: 'quoted value'
24
+ * - key: [array, items]
25
+ * - Multi-line values with proper indentation
26
+ *
27
+ * @param content - The file content (or just the frontmatter portion)
28
+ * @returns The parsed frontmatter object, or null if not found
29
+ */
30
+ export function parseFrontmatter(content: string): Record<string, unknown> | null {
31
+ const match = FRONTMATTER_REGEX.exec(content)
32
+ if (!match) return null
33
+
34
+ const yaml = match[1]
35
+ return parseSimpleYaml(yaml)
36
+ }
37
+
38
+ /**
39
+ * Extract just the frontmatter portion from content.
40
+ * Returns the raw YAML string without the --- delimiters.
41
+ *
42
+ * @param content - The file content
43
+ * @returns The YAML content, or null if no frontmatter
44
+ */
45
+ export function extractFrontmatterString(content: string): string | null {
46
+ const match = FRONTMATTER_REGEX.exec(content)
47
+ return match ? match[1] : null
48
+ }
49
+
50
+ /**
51
+ * Get the byte offset where frontmatter ends (after closing ---).
52
+ * Returns 0 if no frontmatter found.
53
+ *
54
+ * @param content - The file content
55
+ * @returns Byte offset after frontmatter, or 0
56
+ */
57
+ export function getFrontmatterEndOffset(content: string): number {
58
+ const match = FRONTMATTER_REGEX.exec(content)
59
+ if (!match) return 0
60
+ return match[0].length
61
+ }
62
+
63
+ // =============================================================================
64
+ // Simple YAML Parser
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Parse simple YAML into an object.
69
+ * Handles the common patterns found in markdown frontmatter.
70
+ */
71
+ function parseSimpleYaml(yaml: string): Record<string, unknown> {
72
+ const result: Record<string, unknown> = {}
73
+ const lines = yaml.split(/\r?\n/)
74
+
75
+ let currentKey: string | null = null
76
+ let currentValue: string[] = []
77
+ let inMultiline = false
78
+ let inBlockArray = false
79
+ let blockArrayItems: unknown[] = []
80
+ let multilineIndent = 0
81
+
82
+ function commitValue() {
83
+ if (currentKey) {
84
+ if (inBlockArray) {
85
+ // Commit block array
86
+ result[currentKey] = blockArrayItems
87
+ blockArrayItems = []
88
+ inBlockArray = false
89
+ } else if (currentValue.length === 1) {
90
+ result[currentKey] = parseYamlValue(currentValue[0])
91
+ } else if (currentValue.length > 1) {
92
+ result[currentKey] = currentValue.join("\n")
93
+ }
94
+ }
95
+ currentKey = null
96
+ currentValue = []
97
+ inMultiline = false
98
+ }
99
+
100
+ for (const line of lines) {
101
+ // Check for block array item (- value)
102
+ const arrayItemMatch = line.match(/^(\s+)-\s+(.*)$/)
103
+ if (arrayItemMatch && currentKey && (inBlockArray || (inMultiline && currentValue.length === 0))) {
104
+ inBlockArray = true
105
+ inMultiline = false
106
+ const itemValue = arrayItemMatch[2].trim()
107
+ blockArrayItems.push(parseYamlValue(itemValue))
108
+ continue
109
+ }
110
+
111
+ // Check for key: value pattern
112
+ const keyMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/)
113
+
114
+ if (keyMatch && !inMultiline) {
115
+ // Commit previous key-value
116
+ commitValue()
117
+
118
+ currentKey = keyMatch[1]
119
+ const valueStr = keyMatch[2].trim()
120
+
121
+ if (valueStr === "" || valueStr === "|" || valueStr === ">") {
122
+ // Multiline value or block array starts on next line
123
+ inMultiline = true
124
+ multilineIndent = 0
125
+ } else {
126
+ currentValue = [valueStr]
127
+ }
128
+ } else if (inMultiline && currentKey && !inBlockArray) {
129
+ // Continuation of multiline value
130
+ if (line.trim() === "") {
131
+ currentValue.push("")
132
+ } else {
133
+ const indent = line.search(/\S/)
134
+ if (indent > 0) {
135
+ if (multilineIndent === 0) {
136
+ multilineIndent = indent
137
+ }
138
+ currentValue.push(line.slice(multilineIndent))
139
+ } else {
140
+ // No indent means new key or end of multiline
141
+ commitValue()
142
+ // Re-process this line
143
+ const reKeyMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/)
144
+ if (reKeyMatch) {
145
+ currentKey = reKeyMatch[1]
146
+ const valueStr = reKeyMatch[2].trim()
147
+ if (valueStr === "" || valueStr === "|" || valueStr === ">") {
148
+ inMultiline = true
149
+ multilineIndent = 0
150
+ } else {
151
+ currentValue = [valueStr]
152
+ }
153
+ }
154
+ }
155
+ }
156
+ } else if (currentKey && line.trim() === "") {
157
+ // Empty line after value - keep going
158
+ } else if (!currentKey && line.trim() !== "") {
159
+ // New key-value
160
+ const newKeyMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/)
161
+ if (newKeyMatch) {
162
+ currentKey = newKeyMatch[1]
163
+ const valueStr = newKeyMatch[2].trim()
164
+ if (valueStr === "" || valueStr === "|" || valueStr === ">") {
165
+ inMultiline = true
166
+ multilineIndent = 0
167
+ } else {
168
+ currentValue = [valueStr]
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ // Commit final value
175
+ commitValue()
176
+
177
+ return result
178
+ }
179
+
180
+ /**
181
+ * Parse a single YAML value.
182
+ */
183
+ function parseYamlValue(value: string): unknown {
184
+ // Remove quotes
185
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
186
+ return value.slice(1, -1)
187
+ }
188
+
189
+ // Check for array
190
+ if (value.startsWith("[") && value.endsWith("]")) {
191
+ const inner = value.slice(1, -1)
192
+ if (inner.trim() === "") return []
193
+ return inner.split(",").map((item) => parseYamlValue(item.trim()))
194
+ }
195
+
196
+ // Check for booleans
197
+ if (value === "true") return true
198
+ if (value === "false") return false
199
+
200
+ // Check for null
201
+ if (value === "null" || value === "~") return null
202
+
203
+ // Check for numbers
204
+ const num = Number(value)
205
+ if (!isNaN(num) && value !== "") return num
206
+
207
+ // Check for dates (ISO format)
208
+ if (/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/.test(value)) {
209
+ const date = new Date(value)
210
+ if (!isNaN(date.getTime())) return date.toISOString()
211
+ }
212
+
213
+ return value
214
+ }
215
+
216
+ // =============================================================================
217
+ // Optimized File Reading
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Read only the frontmatter from a file, optimized for large files.
222
+ *
223
+ * Reads incrementally until we find the closing ---, avoiding reading
224
+ * the entire file into memory.
225
+ *
226
+ * @param path - Path to the markdown file
227
+ * @param maxBytes - Maximum bytes to read looking for frontmatter (default 8KB)
228
+ * @returns The parsed frontmatter, or null if not found
229
+ */
230
+ export async function readFrontmatter(
231
+ path: string,
232
+ maxBytes = 8192
233
+ ): Promise<Record<string, unknown> | null> {
234
+ try {
235
+ const file = Bun.file(path)
236
+ const size = file.size
237
+
238
+ // Read up to maxBytes or file size
239
+ const bytesToRead = Math.min(maxBytes, size)
240
+ const buffer = await file.slice(0, bytesToRead).text()
241
+
242
+ return parseFrontmatter(buffer)
243
+ } catch {
244
+ return null
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Read frontmatter and return the offset where body starts.
250
+ * Useful when you need both frontmatter and body.
251
+ *
252
+ * @param path - Path to the markdown file
253
+ * @returns Object with frontmatter and body start offset
254
+ */
255
+ export async function readFrontmatterWithOffset(
256
+ path: string
257
+ ): Promise<{ frontmatter: Record<string, unknown> | null; bodyOffset: number }> {
258
+ try {
259
+ const file = Bun.file(path)
260
+ const content = await file.text()
261
+
262
+ const frontmatter = parseFrontmatter(content)
263
+ const bodyOffset = getFrontmatterEndOffset(content)
264
+
265
+ return { frontmatter, bodyOffset }
266
+ } catch {
267
+ return { frontmatter: null, bodyOffset: 0 }
268
+ }
269
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @piqit/resolvers - Resolver implementations for piq v2
3
+ *
4
+ * Provides resolvers for querying structured content:
5
+ * - fileMarkdown: Query markdown files from filesystem (Node.js/Bun)
6
+ * - staticContent: Query pre-compiled content (Edge/Workers)
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ // =============================================================================
12
+ // Filesystem Resolver (Node.js/Bun only)
13
+ // =============================================================================
14
+
15
+ export { fileMarkdown } from "./file-markdown"
16
+ export type { FileMarkdownOptions, FileMarkdownResult } from "./file-markdown"
17
+
18
+ // =============================================================================
19
+ // Static Content Resolver (Edge/Workers compatible)
20
+ // =============================================================================
21
+
22
+ export { staticContent, staticResolver } from "./static"
23
+
24
+ // =============================================================================
25
+ // Path Pattern Utilities
26
+ // =============================================================================
27
+
28
+ export { compilePattern, createParamsSchema } from "./path-pattern"
29
+ export type {
30
+ CompiledPattern,
31
+ ExtractParams,
32
+ PathParams,
33
+ } from "./path-pattern"
34
+
35
+ // =============================================================================
36
+ // Frontmatter Utilities
37
+ // =============================================================================
38
+
39
+ export {
40
+ parseFrontmatter,
41
+ extractFrontmatterString,
42
+ getFrontmatterEndOffset,
43
+ readFrontmatter,
44
+ readFrontmatterWithOffset,
45
+ } from "./frontmatter"
46
+
47
+ // =============================================================================
48
+ // Markdown Utilities
49
+ // =============================================================================
50
+
51
+ export {
52
+ parseMarkdownBody,
53
+ extractHeadings,
54
+ slugify,
55
+ markdownToHtml,
56
+ readMarkdownBody,
57
+ readParsedBody,
58
+ } from "./markdown"
59
+
60
+ export type {
61
+ BodyOptions,
62
+ BodyResult,
63
+ BodyShape,
64
+ Heading,
65
+ } from "./markdown"
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Markdown Body Parsing
3
+ *
4
+ * Parses markdown body content to extract:
5
+ * - Raw markdown text
6
+ * - HTML (basic conversion)
7
+ * - Headings with slugs
8
+ */
9
+
10
+ import { getFrontmatterEndOffset } from "./frontmatter"
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ /**
17
+ * Options for parsing markdown body.
18
+ */
19
+ export interface BodyOptions {
20
+ /** Include raw markdown text */
21
+ raw?: boolean
22
+ /** Include HTML conversion */
23
+ html?: boolean
24
+ /** Extract headings with depth and slugs */
25
+ headings?: boolean
26
+ }
27
+
28
+ /**
29
+ * A heading extracted from markdown.
30
+ */
31
+ export interface Heading {
32
+ /** Heading depth (1-6) */
33
+ depth: number
34
+ /** Heading text content */
35
+ text: string
36
+ /** URL-safe slug */
37
+ slug: string
38
+ }
39
+
40
+ /**
41
+ * Result of parsing markdown body.
42
+ */
43
+ export interface BodyResult {
44
+ /** Raw markdown text (if requested) */
45
+ raw?: string
46
+ /** HTML content (if requested) */
47
+ html?: string
48
+ /** Extracted headings (if requested) */
49
+ headings?: Heading[]
50
+ }
51
+
52
+ /**
53
+ * Type-level shape of body result based on options.
54
+ */
55
+ export type BodyShape<T extends BodyOptions> = {
56
+ [K in keyof T as T[K] extends true ? K : never]: K extends "raw"
57
+ ? string
58
+ : K extends "html"
59
+ ? string
60
+ : K extends "headings"
61
+ ? Heading[]
62
+ : never
63
+ }
64
+
65
+ // =============================================================================
66
+ // Markdown Parsing
67
+ // =============================================================================
68
+
69
+ /**
70
+ * Parse markdown body content.
71
+ *
72
+ * @param content - The full file content (including frontmatter if present)
73
+ * @param options - What to extract from the markdown
74
+ * @returns The requested body parts
75
+ */
76
+ export function parseMarkdownBody(content: string, options: BodyOptions): BodyResult {
77
+ // Strip frontmatter if present
78
+ const bodyOffset = getFrontmatterEndOffset(content)
79
+ const rawBody = content.slice(bodyOffset).replace(/^[\r\n]+/, "")
80
+
81
+ const result: BodyResult = {}
82
+
83
+ if (options.raw) {
84
+ result.raw = rawBody
85
+ }
86
+
87
+ if (options.html) {
88
+ result.html = markdownToHtml(rawBody)
89
+ }
90
+
91
+ if (options.headings) {
92
+ result.headings = extractHeadings(rawBody)
93
+ }
94
+
95
+ return result
96
+ }
97
+
98
+ // =============================================================================
99
+ // Heading Extraction
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Regex to match ATX-style headings (# Heading).
104
+ */
105
+ const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm
106
+
107
+ /**
108
+ * Extract headings from markdown text.
109
+ *
110
+ * @param markdown - The raw markdown content
111
+ * @returns Array of headings with depth, text, and slug
112
+ */
113
+ export function extractHeadings(markdown: string): Heading[] {
114
+ const headings: Heading[] = []
115
+ let match: RegExpExecArray | null
116
+
117
+ const regex = new RegExp(HEADING_REGEX.source, "gm")
118
+ while ((match = regex.exec(markdown)) !== null) {
119
+ const depth = match[1].length
120
+ const text = match[2].trim()
121
+ const slug = slugify(text)
122
+
123
+ headings.push({ depth, text, slug })
124
+ }
125
+
126
+ return headings
127
+ }
128
+
129
+ /**
130
+ * Convert text to a URL-safe slug.
131
+ *
132
+ * @param text - The text to slugify
133
+ * @returns A URL-safe slug
134
+ */
135
+ export function slugify(text: string): string {
136
+ return text
137
+ .toLowerCase()
138
+ .replace(/[^\w\s-]/g, "") // Remove non-word chars
139
+ .replace(/\s+/g, "-") // Replace spaces with dashes
140
+ .replace(/--+/g, "-") // Replace multiple dashes
141
+ .replace(/^-|-$/g, "") // Trim dashes from ends
142
+ }
143
+
144
+ // =============================================================================
145
+ // Basic Markdown to HTML
146
+ // =============================================================================
147
+
148
+ /**
149
+ * Convert markdown to HTML.
150
+ *
151
+ * This is a basic implementation that handles common patterns.
152
+ * For production use, consider using a proper markdown parser.
153
+ *
154
+ * @param markdown - The raw markdown content
155
+ * @returns HTML string
156
+ */
157
+ export function markdownToHtml(markdown: string): string {
158
+ let html = markdown
159
+
160
+ // Escape HTML entities first
161
+ html = html
162
+ .replace(/&/g, "&amp;")
163
+ .replace(/</g, "&lt;")
164
+ .replace(/>/g, "&gt;")
165
+
166
+ // Headings (must be on their own line)
167
+ html = html.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>")
168
+ html = html.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>")
169
+ html = html.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>")
170
+ html = html.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>")
171
+ html = html.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>")
172
+ html = html.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>")
173
+
174
+ // Code blocks (fenced)
175
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
176
+ return `<pre><code>${code.trim()}</code></pre>`
177
+ })
178
+
179
+ // Inline code
180
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>")
181
+
182
+ // Bold
183
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
184
+ html = html.replace(/__([^_]+)__/g, "<strong>$1</strong>")
185
+
186
+ // Italic
187
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>")
188
+ html = html.replace(/_([^_]+)_/g, "<em>$1</em>")
189
+
190
+ // Links
191
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
192
+
193
+ // Images
194
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
195
+
196
+ // Horizontal rules
197
+ html = html.replace(/^---+$/gm, "<hr>")
198
+ html = html.replace(/^\*\*\*+$/gm, "<hr>")
199
+
200
+ // Unordered lists (simplified)
201
+ html = html.replace(/^[-*+]\s+(.+)$/gm, "<li>$1</li>")
202
+
203
+ // Ordered lists (simplified)
204
+ html = html.replace(/^\d+\.\s+(.+)$/gm, "<li>$1</li>")
205
+
206
+ // Wrap consecutive <li> in <ul> or <ol>
207
+ html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, "<ul>\n$1</ul>\n")
208
+
209
+ // Blockquotes
210
+ html = html.replace(/^>\s+(.+)$/gm, "<blockquote>$1</blockquote>")
211
+
212
+ // Paragraphs (wrap remaining text blocks)
213
+ // Split by double newlines and wrap non-block elements
214
+ const blocks = html.split(/\n\n+/)
215
+ html = blocks
216
+ .map((block) => {
217
+ const trimmed = block.trim()
218
+ if (!trimmed) return ""
219
+ // Don't wrap if already a block element
220
+ if (
221
+ /^<(h[1-6]|ul|ol|li|pre|blockquote|hr|p)/.test(trimmed) ||
222
+ /^<\/?(h[1-6]|ul|ol|li|pre|blockquote)>$/.test(trimmed)
223
+ ) {
224
+ return trimmed
225
+ }
226
+ return `<p>${trimmed.replace(/\n/g, "<br>")}</p>`
227
+ })
228
+ .filter(Boolean)
229
+ .join("\n")
230
+
231
+ return html
232
+ }
233
+
234
+ // =============================================================================
235
+ // File Reading Utilities
236
+ // =============================================================================
237
+
238
+ /**
239
+ * Read just the markdown body from a file (skipping frontmatter).
240
+ *
241
+ * @param path - Path to the markdown file
242
+ * @returns The raw markdown body
243
+ */
244
+ export async function readMarkdownBody(path: string): Promise<string> {
245
+ const file = Bun.file(path)
246
+ const content = await file.text()
247
+ const bodyOffset = getFrontmatterEndOffset(content)
248
+ return content.slice(bodyOffset).replace(/^\r?\n/, "")
249
+ }
250
+
251
+ /**
252
+ * Read and parse markdown body from a file.
253
+ *
254
+ * @param path - Path to the markdown file
255
+ * @param options - What to extract from the markdown
256
+ * @returns The requested body parts
257
+ */
258
+ export async function readParsedBody(path: string, options: BodyOptions): Promise<BodyResult> {
259
+ const file = Bun.file(path)
260
+ const content = await file.text()
261
+ return parseMarkdownBody(content, options)
262
+ }