@pyreon/zero 0.24.5 → 0.24.6
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/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/ai.ts
DELETED
|
@@ -1,623 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AI integration utilities for Zero.
|
|
3
|
-
*
|
|
4
|
-
* - llms.txt auto-generation from routes and API routes
|
|
5
|
-
* - JSON-LD auto-inference from route meta + Meta props
|
|
6
|
-
* - AI plugin manifest (/.well-known/ai-plugin.json) from API routes
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* ```ts
|
|
10
|
-
* // vite.config.ts
|
|
11
|
-
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
12
|
-
*
|
|
13
|
-
* export default {
|
|
14
|
-
* plugins: [
|
|
15
|
-
* aiPlugin({
|
|
16
|
-
* name: "My App",
|
|
17
|
-
* origin: "https://example.com",
|
|
18
|
-
* description: "A modern web application",
|
|
19
|
-
* }),
|
|
20
|
-
* ],
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*/
|
|
24
|
-
import type { Plugin } from 'vite'
|
|
25
|
-
import { parseFileRoutes } from './fs-router'
|
|
26
|
-
|
|
27
|
-
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
export interface AiPluginConfig {
|
|
30
|
-
/** App/API name. */
|
|
31
|
-
name: string
|
|
32
|
-
/** App description for AI agents. */
|
|
33
|
-
description: string
|
|
34
|
-
/** Base URL. e.g. "https://example.com" */
|
|
35
|
-
origin: string
|
|
36
|
-
/** Contact email (required by OpenAI plugin spec). */
|
|
37
|
-
contactEmail?: string
|
|
38
|
-
/** Legal info URL. */
|
|
39
|
-
legalUrl?: string
|
|
40
|
-
/** Logo URL for the plugin. */
|
|
41
|
-
logoUrl?: string
|
|
42
|
-
/** Routes directory relative to project root. Default: "src/routes" */
|
|
43
|
-
routesDir?: string
|
|
44
|
-
/** API routes directory relative to project root. Default: "src/api" */
|
|
45
|
-
apiDir?: string
|
|
46
|
-
/**
|
|
47
|
-
* API route descriptions — map of pattern to description.
|
|
48
|
-
* Used for llms.txt and ai-plugin.json.
|
|
49
|
-
*
|
|
50
|
-
* @example
|
|
51
|
-
* ```ts
|
|
52
|
-
* apiDescriptions: {
|
|
53
|
-
* "GET /api/posts": "List all blog posts, supports ?page=N&limit=N",
|
|
54
|
-
* "GET /api/posts/:id": "Get a single post by ID",
|
|
55
|
-
* "POST /api/posts": "Create a new post (requires auth)",
|
|
56
|
-
* }
|
|
57
|
-
* ```
|
|
58
|
-
*/
|
|
59
|
-
apiDescriptions?: Record<string, string>
|
|
60
|
-
/**
|
|
61
|
-
* Page descriptions — map of URL path to description.
|
|
62
|
-
* Used for llms.txt. Falls back to route meta.title/description.
|
|
63
|
-
*/
|
|
64
|
-
pageDescriptions?: Record<string, string>
|
|
65
|
-
/**
|
|
66
|
-
* Additional content to append to llms.txt.
|
|
67
|
-
* Useful for authentication instructions, rate limits, etc.
|
|
68
|
-
*/
|
|
69
|
-
llmsExtra?: string
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ─── llms.txt generation ────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Generate llms.txt content from route files and config.
|
|
76
|
-
*
|
|
77
|
-
* Format follows the llms.txt proposal:
|
|
78
|
-
* ```
|
|
79
|
-
* # {name}
|
|
80
|
-
* > {description}
|
|
81
|
-
*
|
|
82
|
-
* ## Pages
|
|
83
|
-
* - [/about](/about): About page
|
|
84
|
-
*
|
|
85
|
-
* ## API
|
|
86
|
-
* - GET /api/posts: List posts
|
|
87
|
-
* ```
|
|
88
|
-
*
|
|
89
|
-
* @internal Exported for testing.
|
|
90
|
-
*/
|
|
91
|
-
export function generateLlmsTxt(
|
|
92
|
-
routeFiles: string[],
|
|
93
|
-
apiFiles: string[],
|
|
94
|
-
config: AiPluginConfig,
|
|
95
|
-
): string {
|
|
96
|
-
const lines: string[] = []
|
|
97
|
-
|
|
98
|
-
// Header
|
|
99
|
-
lines.push(`# ${config.name}`)
|
|
100
|
-
lines.push(`> ${config.description}`)
|
|
101
|
-
lines.push('')
|
|
102
|
-
|
|
103
|
-
// Pages section
|
|
104
|
-
const routes = parseFileRoutes(routeFiles)
|
|
105
|
-
const pages = routes.filter(
|
|
106
|
-
(r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound
|
|
107
|
-
&& !r.isCatchAll && !r.urlPath.includes(':'),
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
if (pages.length > 0) {
|
|
111
|
-
lines.push('## Pages')
|
|
112
|
-
lines.push('')
|
|
113
|
-
for (const page of pages) {
|
|
114
|
-
const desc = config.pageDescriptions?.[page.urlPath]
|
|
115
|
-
const url = `${config.origin}${page.urlPath === '/' ? '' : page.urlPath}`
|
|
116
|
-
if (desc) {
|
|
117
|
-
lines.push(`- [${page.urlPath}](${url}): ${desc}`)
|
|
118
|
-
} else {
|
|
119
|
-
lines.push(`- [${page.urlPath}](${url})`)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
lines.push('')
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Dynamic routes (documented separately — AI needs to know about params)
|
|
126
|
-
const dynamicRoutes = routes.filter(
|
|
127
|
-
(r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound
|
|
128
|
-
&& (r.urlPath.includes(':') || r.isCatchAll),
|
|
129
|
-
)
|
|
130
|
-
if (dynamicRoutes.length > 0) {
|
|
131
|
-
lines.push('## Dynamic Pages')
|
|
132
|
-
lines.push('')
|
|
133
|
-
for (const route of dynamicRoutes) {
|
|
134
|
-
const desc = config.pageDescriptions?.[route.urlPath]
|
|
135
|
-
if (desc) {
|
|
136
|
-
lines.push(`- ${route.urlPath}: ${desc}`)
|
|
137
|
-
} else {
|
|
138
|
-
lines.push(`- ${route.urlPath}`)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
lines.push('')
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// API section
|
|
145
|
-
const apiPatterns = parseApiFiles(apiFiles)
|
|
146
|
-
if (apiPatterns.length > 0 || config.apiDescriptions) {
|
|
147
|
-
lines.push('## API Endpoints')
|
|
148
|
-
lines.push('')
|
|
149
|
-
|
|
150
|
-
// From apiDescriptions (most detailed — user-provided)
|
|
151
|
-
if (config.apiDescriptions) {
|
|
152
|
-
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
153
|
-
lines.push(`- ${endpoint}: ${desc}`)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// From auto-discovered API files (only those not already described)
|
|
158
|
-
const describedPatterns = new Set(
|
|
159
|
-
Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+/, '')),
|
|
160
|
-
)
|
|
161
|
-
for (const pattern of apiPatterns) {
|
|
162
|
-
if (!describedPatterns.has(pattern)) {
|
|
163
|
-
lines.push(`- ${pattern}`)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
lines.push('')
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Extra content
|
|
170
|
-
if (config.llmsExtra) {
|
|
171
|
-
lines.push(config.llmsExtra)
|
|
172
|
-
lines.push('')
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return lines.join('\n')
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Generate llms-full.txt — expanded version with more detail.
|
|
180
|
-
* Includes all route metadata and API descriptions.
|
|
181
|
-
*
|
|
182
|
-
* @internal Exported for testing.
|
|
183
|
-
*/
|
|
184
|
-
export function generateLlmsFullTxt(
|
|
185
|
-
routeFiles: string[],
|
|
186
|
-
apiFiles: string[],
|
|
187
|
-
config: AiPluginConfig,
|
|
188
|
-
): string {
|
|
189
|
-
const lines: string[] = []
|
|
190
|
-
|
|
191
|
-
lines.push(`# ${config.name} — Full Reference`)
|
|
192
|
-
lines.push(`> ${config.description}`)
|
|
193
|
-
lines.push('')
|
|
194
|
-
lines.push(`Base URL: ${config.origin}`)
|
|
195
|
-
lines.push('')
|
|
196
|
-
|
|
197
|
-
// All pages with details
|
|
198
|
-
const routes = parseFileRoutes(routeFiles)
|
|
199
|
-
const pages = routes.filter(
|
|
200
|
-
(r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound,
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
if (pages.length > 0) {
|
|
204
|
-
lines.push('## All Routes')
|
|
205
|
-
lines.push('')
|
|
206
|
-
for (const page of pages) {
|
|
207
|
-
const desc = config.pageDescriptions?.[page.urlPath] ?? ''
|
|
208
|
-
const dynamic = page.urlPath.includes(':') ? ' (dynamic)' : ''
|
|
209
|
-
const catchAll = page.isCatchAll ? ' (catch-all)' : ''
|
|
210
|
-
lines.push(`### ${page.urlPath}${dynamic}${catchAll}`)
|
|
211
|
-
if (desc) lines.push(desc)
|
|
212
|
-
lines.push(`- File: ${page.filePath}`)
|
|
213
|
-
lines.push(`- Render mode: ${page.renderMode}`)
|
|
214
|
-
lines.push('')
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// API endpoints with full detail
|
|
219
|
-
if (config.apiDescriptions) {
|
|
220
|
-
lines.push('## API Reference')
|
|
221
|
-
lines.push('')
|
|
222
|
-
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
223
|
-
lines.push(`### ${endpoint}`)
|
|
224
|
-
lines.push(desc)
|
|
225
|
-
lines.push('')
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (config.llmsExtra) {
|
|
230
|
-
lines.push('## Additional Information')
|
|
231
|
-
lines.push('')
|
|
232
|
-
lines.push(config.llmsExtra)
|
|
233
|
-
lines.push('')
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return lines.join('\n')
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ─── JSON-LD auto-inference ─────────────────────────────────────────────────
|
|
240
|
-
|
|
241
|
-
export interface InferJsonLdOptions {
|
|
242
|
-
/** Page URL. */
|
|
243
|
-
url: string
|
|
244
|
-
/** Page title. */
|
|
245
|
-
title?: string
|
|
246
|
-
/** Page description. */
|
|
247
|
-
description?: string
|
|
248
|
-
/** Page image. */
|
|
249
|
-
image?: string
|
|
250
|
-
/** Site name. */
|
|
251
|
-
siteName?: string
|
|
252
|
-
/** Page type hint. */
|
|
253
|
-
type?: 'website' | 'article' | 'product' | 'profile'
|
|
254
|
-
/** Article metadata. */
|
|
255
|
-
publishedTime?: string
|
|
256
|
-
/** Article author. */
|
|
257
|
-
author?: string
|
|
258
|
-
/** Article tags. */
|
|
259
|
-
tags?: string[]
|
|
260
|
-
/** Breadcrumb path segments. */
|
|
261
|
-
breadcrumbs?: Array<{ name: string; url: string }>
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Auto-infer JSON-LD structured data from page metadata.
|
|
266
|
-
*
|
|
267
|
-
* Returns an array of JSON-LD objects (multiple schemas can apply to one page).
|
|
268
|
-
* For example, an article page gets both `Article` and `BreadcrumbList`.
|
|
269
|
-
*
|
|
270
|
-
* @example
|
|
271
|
-
* ```tsx
|
|
272
|
-
* const schemas = inferJsonLd({
|
|
273
|
-
* url: "https://example.com/blog/my-post",
|
|
274
|
-
* title: "My Post",
|
|
275
|
-
* description: "A great article",
|
|
276
|
-
* type: "article",
|
|
277
|
-
* author: "Vit Bokisch",
|
|
278
|
-
* publishedTime: "2026-03-31",
|
|
279
|
-
* })
|
|
280
|
-
* // → [Article schema, BreadcrumbList schema]
|
|
281
|
-
* ```
|
|
282
|
-
*/
|
|
283
|
-
export function inferJsonLd(options: InferJsonLdOptions): Record<string, unknown>[] {
|
|
284
|
-
const schemas: Record<string, unknown>[] = []
|
|
285
|
-
|
|
286
|
-
// Base: WebPage or Article
|
|
287
|
-
if (options.type === 'article') {
|
|
288
|
-
const article: Record<string, unknown> = {
|
|
289
|
-
'@context': 'https://schema.org',
|
|
290
|
-
'@type': 'Article',
|
|
291
|
-
headline: options.title,
|
|
292
|
-
url: options.url,
|
|
293
|
-
}
|
|
294
|
-
if (options.description) article.description = options.description
|
|
295
|
-
if (options.image) article.image = options.image
|
|
296
|
-
if (options.publishedTime) article.datePublished = options.publishedTime
|
|
297
|
-
if (options.author) {
|
|
298
|
-
article.author = { '@type': 'Person', name: options.author }
|
|
299
|
-
}
|
|
300
|
-
if (options.tags && options.tags.length > 0) {
|
|
301
|
-
article.keywords = options.tags.join(', ')
|
|
302
|
-
}
|
|
303
|
-
if (options.siteName) {
|
|
304
|
-
article.publisher = { '@type': 'Organization', name: options.siteName }
|
|
305
|
-
}
|
|
306
|
-
schemas.push(article)
|
|
307
|
-
} else if (options.type === 'product') {
|
|
308
|
-
const product: Record<string, unknown> = {
|
|
309
|
-
'@context': 'https://schema.org',
|
|
310
|
-
'@type': 'Product',
|
|
311
|
-
name: options.title,
|
|
312
|
-
url: options.url,
|
|
313
|
-
}
|
|
314
|
-
if (options.description) product.description = options.description
|
|
315
|
-
if (options.image) product.image = options.image
|
|
316
|
-
schemas.push(product)
|
|
317
|
-
} else {
|
|
318
|
-
const webpage: Record<string, unknown> = {
|
|
319
|
-
'@context': 'https://schema.org',
|
|
320
|
-
'@type': 'WebPage',
|
|
321
|
-
name: options.title,
|
|
322
|
-
url: options.url,
|
|
323
|
-
}
|
|
324
|
-
if (options.description) webpage.description = options.description
|
|
325
|
-
if (options.image) webpage.thumbnailUrl = options.image
|
|
326
|
-
schemas.push(webpage)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// BreadcrumbList from URL path or explicit breadcrumbs
|
|
330
|
-
if (options.breadcrumbs && options.breadcrumbs.length > 0) {
|
|
331
|
-
schemas.push({
|
|
332
|
-
'@context': 'https://schema.org',
|
|
333
|
-
'@type': 'BreadcrumbList',
|
|
334
|
-
itemListElement: options.breadcrumbs.map((bc, i) => ({
|
|
335
|
-
'@type': 'ListItem',
|
|
336
|
-
position: i + 1,
|
|
337
|
-
name: bc.name,
|
|
338
|
-
item: bc.url,
|
|
339
|
-
})),
|
|
340
|
-
})
|
|
341
|
-
} else {
|
|
342
|
-
// Auto-generate breadcrumbs from URL path
|
|
343
|
-
const urlObj = safeParseUrl(options.url)
|
|
344
|
-
if (urlObj) {
|
|
345
|
-
const segments = urlObj.pathname.split('/').filter(Boolean)
|
|
346
|
-
if (segments.length > 0) {
|
|
347
|
-
const items = [
|
|
348
|
-
{ '@type': 'ListItem', position: 1, name: 'Home', item: urlObj.origin },
|
|
349
|
-
]
|
|
350
|
-
let path = ''
|
|
351
|
-
for (let i = 0; i < segments.length; i++) {
|
|
352
|
-
path += `/${segments[i]}`
|
|
353
|
-
items.push({
|
|
354
|
-
'@type': 'ListItem',
|
|
355
|
-
position: i + 2,
|
|
356
|
-
name: capitalize(segments[i]!.replace(/-/g, ' ')),
|
|
357
|
-
item: `${urlObj.origin}${path}`,
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
schemas.push({
|
|
361
|
-
'@context': 'https://schema.org',
|
|
362
|
-
'@type': 'BreadcrumbList',
|
|
363
|
-
itemListElement: items,
|
|
364
|
-
})
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return schemas
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ─── AI plugin manifest ─────────────────────────────────────────────────────
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Generate an OpenAI-compatible AI plugin manifest.
|
|
376
|
-
*
|
|
377
|
-
* Follows the /.well-known/ai-plugin.json spec.
|
|
378
|
-
*
|
|
379
|
-
* @internal Exported for testing.
|
|
380
|
-
*/
|
|
381
|
-
export function generateAiPluginManifest(config: AiPluginConfig): Record<string, unknown> {
|
|
382
|
-
return {
|
|
383
|
-
schema_version: 'v1',
|
|
384
|
-
name_for_human: config.name,
|
|
385
|
-
name_for_model: config.name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''),
|
|
386
|
-
description_for_human: config.description,
|
|
387
|
-
description_for_model: config.description,
|
|
388
|
-
auth: { type: 'none' },
|
|
389
|
-
api: {
|
|
390
|
-
type: 'openapi',
|
|
391
|
-
url: `${config.origin}/.well-known/openapi.yaml`,
|
|
392
|
-
},
|
|
393
|
-
logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,
|
|
394
|
-
contact_email: config.contactEmail ?? '',
|
|
395
|
-
legal_info_url: config.legalUrl ?? `${config.origin}/legal`,
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Generate a minimal OpenAPI 3.0 spec from API route descriptions.
|
|
401
|
-
*
|
|
402
|
-
* @internal Exported for testing.
|
|
403
|
-
*/
|
|
404
|
-
export function generateOpenApiSpec(
|
|
405
|
-
apiFiles: string[],
|
|
406
|
-
config: AiPluginConfig,
|
|
407
|
-
): Record<string, unknown> {
|
|
408
|
-
const paths: Record<string, Record<string, unknown>> = {}
|
|
409
|
-
|
|
410
|
-
// From user-provided descriptions
|
|
411
|
-
if (config.apiDescriptions) {
|
|
412
|
-
for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {
|
|
413
|
-
const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/)
|
|
414
|
-
if (match) {
|
|
415
|
-
const method = match[1]!.toLowerCase()
|
|
416
|
-
const path = match[2]!
|
|
417
|
-
// Convert :param to {param} for OpenAPI
|
|
418
|
-
const openApiPath = path.replace(/:(\w+)/g, '{$1}')
|
|
419
|
-
if (!paths[openApiPath]) paths[openApiPath] = {}
|
|
420
|
-
paths[openApiPath][method] = {
|
|
421
|
-
summary: desc,
|
|
422
|
-
responses: { '200': { description: 'Success' } },
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Auto-discovered API files (fill in gaps)
|
|
429
|
-
for (const pattern of parseApiFiles(apiFiles)) {
|
|
430
|
-
const openApiPath = pattern.replace(/:(\w+)/g, '{$1}')
|
|
431
|
-
if (!paths[openApiPath]) {
|
|
432
|
-
paths[openApiPath] = {
|
|
433
|
-
get: {
|
|
434
|
-
summary: `${openApiPath} endpoint`,
|
|
435
|
-
responses: { '200': { description: 'Success' } },
|
|
436
|
-
},
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
openapi: '3.0.0',
|
|
443
|
-
info: {
|
|
444
|
-
title: config.name,
|
|
445
|
-
description: config.description,
|
|
446
|
-
version: '1.0.0',
|
|
447
|
-
},
|
|
448
|
-
servers: [{ url: config.origin }],
|
|
449
|
-
paths,
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ─── Vite plugin ────────────────────────────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* AI integration Vite plugin.
|
|
457
|
-
*
|
|
458
|
-
* Generates at build time:
|
|
459
|
-
* - `/llms.txt` — concise site summary for AI agents
|
|
460
|
-
* - `/llms-full.txt` — detailed reference for AI agents
|
|
461
|
-
* - `/.well-known/ai-plugin.json` — OpenAI plugin manifest
|
|
462
|
-
* - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes
|
|
463
|
-
*
|
|
464
|
-
* In dev, serves these files via middleware.
|
|
465
|
-
*
|
|
466
|
-
* @example
|
|
467
|
-
* ```ts
|
|
468
|
-
* import { aiPlugin } from "@pyreon/zero/ai"
|
|
469
|
-
*
|
|
470
|
-
* export default {
|
|
471
|
-
* plugins: [
|
|
472
|
-
* aiPlugin({
|
|
473
|
-
* name: "My App",
|
|
474
|
-
* origin: "https://example.com",
|
|
475
|
-
* description: "A modern web application",
|
|
476
|
-
* apiDescriptions: {
|
|
477
|
-
* "GET /api/posts": "List blog posts",
|
|
478
|
-
* "GET /api/posts/:id": "Get post by ID",
|
|
479
|
-
* },
|
|
480
|
-
* }),
|
|
481
|
-
* ],
|
|
482
|
-
* }
|
|
483
|
-
* ```
|
|
484
|
-
*/
|
|
485
|
-
export function aiPlugin(config: AiPluginConfig): Plugin {
|
|
486
|
-
let root = ''
|
|
487
|
-
let isBuild = false
|
|
488
|
-
let routeFiles: string[] = []
|
|
489
|
-
let apiFiles: string[] = []
|
|
490
|
-
|
|
491
|
-
return {
|
|
492
|
-
name: 'pyreon-zero-ai',
|
|
493
|
-
enforce: 'post',
|
|
494
|
-
|
|
495
|
-
configResolved(resolvedConfig) {
|
|
496
|
-
root = resolvedConfig.root
|
|
497
|
-
isBuild = resolvedConfig.command === 'build'
|
|
498
|
-
},
|
|
499
|
-
|
|
500
|
-
async buildStart() {
|
|
501
|
-
// Scan for route and API files
|
|
502
|
-
try {
|
|
503
|
-
const { join } = await import('node:path')
|
|
504
|
-
|
|
505
|
-
const routesDir = join(root, config.routesDir ?? 'src/routes')
|
|
506
|
-
const apiDir = join(root, config.apiDir ?? 'src/api')
|
|
507
|
-
|
|
508
|
-
routeFiles = await scanDir(routesDir, routesDir)
|
|
509
|
-
apiFiles = await scanDir(apiDir, apiDir)
|
|
510
|
-
} catch {
|
|
511
|
-
// Directories may not exist
|
|
512
|
-
}
|
|
513
|
-
},
|
|
514
|
-
|
|
515
|
-
configureServer(server) {
|
|
516
|
-
server.middlewares.use(async (req, res, next) => {
|
|
517
|
-
const url = req.url ?? ''
|
|
518
|
-
|
|
519
|
-
if (url === '/llms.txt') {
|
|
520
|
-
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
521
|
-
res.end(generateLlmsTxt(routeFiles, apiFiles, config))
|
|
522
|
-
return
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
if (url === '/llms-full.txt') {
|
|
526
|
-
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
527
|
-
res.end(generateLlmsFullTxt(routeFiles, apiFiles, config))
|
|
528
|
-
return
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (url === '/.well-known/ai-plugin.json') {
|
|
532
|
-
res.setHeader('Content-Type', 'application/json')
|
|
533
|
-
res.end(JSON.stringify(generateAiPluginManifest(config), null, 2))
|
|
534
|
-
return
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (url === '/.well-known/openapi.yaml' || url === '/.well-known/openapi.json') {
|
|
538
|
-
res.setHeader('Content-Type', 'application/json')
|
|
539
|
-
res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2))
|
|
540
|
-
return
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
next()
|
|
544
|
-
})
|
|
545
|
-
},
|
|
546
|
-
|
|
547
|
-
async generateBundle() {
|
|
548
|
-
if (!isBuild) return
|
|
549
|
-
|
|
550
|
-
this.emitFile({
|
|
551
|
-
type: 'asset',
|
|
552
|
-
fileName: 'llms.txt',
|
|
553
|
-
source: generateLlmsTxt(routeFiles, apiFiles, config),
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
this.emitFile({
|
|
557
|
-
type: 'asset',
|
|
558
|
-
fileName: 'llms-full.txt',
|
|
559
|
-
source: generateLlmsFullTxt(routeFiles, apiFiles, config),
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
this.emitFile({
|
|
563
|
-
type: 'asset',
|
|
564
|
-
fileName: '.well-known/ai-plugin.json',
|
|
565
|
-
source: JSON.stringify(generateAiPluginManifest(config), null, 2),
|
|
566
|
-
})
|
|
567
|
-
|
|
568
|
-
this.emitFile({
|
|
569
|
-
type: 'asset',
|
|
570
|
-
fileName: '.well-known/openapi.json',
|
|
571
|
-
source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2),
|
|
572
|
-
})
|
|
573
|
-
},
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
578
|
-
|
|
579
|
-
function parseApiFiles(files: string[]): string[] {
|
|
580
|
-
return files
|
|
581
|
-
.filter((f) => f.endsWith('.ts') || f.endsWith('.js'))
|
|
582
|
-
.map((f) => {
|
|
583
|
-
let path = f.replace(/\.\w+$/, '').replace(/\/index$/, '')
|
|
584
|
-
if (!path.startsWith('/')) path = `/${path}`
|
|
585
|
-
// Convert [param] to :param
|
|
586
|
-
path = path.replace(/\[\.\.\.(\w+)\]/g, ':$1*').replace(/\[(\w+)\]/g, ':$1')
|
|
587
|
-
return `/api${path === '/' ? '' : path}`
|
|
588
|
-
})
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
async function scanDir(dir: string, base: string): Promise<string[]> {
|
|
592
|
-
const { readdir, stat } = await import('node:fs/promises')
|
|
593
|
-
const { join, relative } = await import('node:path')
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
const entries = await readdir(dir)
|
|
597
|
-
const files: string[] = []
|
|
598
|
-
for (const entry of entries) {
|
|
599
|
-
const full = join(dir, entry)
|
|
600
|
-
const s = await stat(full)
|
|
601
|
-
if (s.isDirectory()) {
|
|
602
|
-
files.push(...(await scanDir(full, base)))
|
|
603
|
-
} else {
|
|
604
|
-
files.push(relative(base, full))
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return files
|
|
608
|
-
} catch {
|
|
609
|
-
return []
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function safeParseUrl(url: string): URL | null {
|
|
614
|
-
try {
|
|
615
|
-
return new URL(url)
|
|
616
|
-
} catch {
|
|
617
|
-
return null
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function capitalize(s: string): string {
|
|
622
|
-
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
623
|
-
}
|