@aravindc26/velu 0.11.0 → 0.11.3

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.
Files changed (60) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1251 -115
  3. package/src/build.ts +1121 -304
  4. package/src/cli.ts +90 -26
  5. package/src/engine/_server.mjs +1684 -277
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
  8. package/src/engine/app/api/proxy/route.ts +23 -0
  9. package/src/engine/app/copy-page.css +59 -1
  10. package/src/engine/app/global.css +3157 -3
  11. package/src/engine/app/layout.tsx +56 -1
  12. package/src/engine/app/llms-file/route.ts +87 -0
  13. package/src/engine/app/llms-full-file/route.ts +62 -0
  14. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  15. package/src/engine/app/page.tsx +45 -0
  16. package/src/engine/app/robots.txt/route.ts +63 -0
  17. package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
  18. package/src/engine/app/sitemap.xml/route.ts +82 -0
  19. package/src/engine/components/assistant.tsx +16 -5
  20. package/src/engine/components/changelog-filters.tsx +114 -0
  21. package/src/engine/components/code-group.tsx +383 -0
  22. package/src/engine/components/color.tsx +118 -0
  23. package/src/engine/components/expandable.tsx +77 -0
  24. package/src/engine/components/icon.tsx +136 -0
  25. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  26. package/src/engine/components/image.tsx +111 -0
  27. package/src/engine/components/manual-api-playground.tsx +154 -0
  28. package/src/engine/components/mermaid.tsx +142 -0
  29. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  30. package/src/engine/components/openapi.tsx +1682 -0
  31. package/src/engine/components/page-feedback.tsx +153 -0
  32. package/src/engine/components/product-switcher.tsx +27 -3
  33. package/src/engine/components/prompt.tsx +90 -0
  34. package/src/engine/components/providers.tsx +1 -6
  35. package/src/engine/components/search.tsx +4 -0
  36. package/src/engine/components/sidebar-links.tsx +13 -15
  37. package/src/engine/components/synced-tabs.tsx +57 -0
  38. package/src/engine/components/toc-examples.tsx +110 -0
  39. package/src/engine/components/view.tsx +344 -0
  40. package/src/engine/generated/redirects.ts +3 -0
  41. package/src/engine/lib/changelog.ts +246 -0
  42. package/src/engine/lib/layout.shared.ts +30 -2
  43. package/src/engine/lib/llms.ts +444 -0
  44. package/src/engine/lib/navigation-normalize.mjs +481 -412
  45. package/src/engine/lib/navigation-normalize.ts +261 -54
  46. package/src/engine/lib/redirects.ts +194 -0
  47. package/src/engine/lib/source.ts +107 -4
  48. package/src/engine/lib/velu.ts +368 -2
  49. package/src/engine/mdx-components.tsx +648 -0
  50. package/src/engine/middleware.ts +66 -0
  51. package/src/engine/public/icons/cursor-dark.svg +12 -0
  52. package/src/engine/public/icons/cursor-light.svg +12 -0
  53. package/src/engine/source.config.ts +98 -1
  54. package/src/engine/src/components/PageTitle.astro +16 -5
  55. package/src/engine/src/lib/velu.ts +11 -3
  56. package/src/navigation-normalize.ts +252 -54
  57. package/src/themes.ts +6 -6
  58. package/src/validate.ts +119 -6
  59. package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
  60. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
@@ -0,0 +1,444 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, posix as posixPath } from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { getApiConfig, getLanguages, getSeoConfig } from '@/lib/velu';
6
+
7
+ export interface LlmsPageEntry {
8
+ slug: string[];
9
+ path: string;
10
+ locale: string;
11
+ pageSlug: string[];
12
+ section: string;
13
+ title: string;
14
+ description?: string;
15
+ markdown?: string;
16
+ sourceKind: 'source' | 'generated';
17
+ openapiSpec?: string;
18
+ isOpenApiOperation: boolean;
19
+ noindex: boolean;
20
+ }
21
+
22
+ interface CollectLlmsPagesOptions {
23
+ includeMarkdown?: boolean;
24
+ indexing?: 'navigable' | 'all';
25
+ }
26
+
27
+ const PRIMARY_CONFIG_NAME = 'docs.json';
28
+ const LEGACY_CONFIG_NAME = 'velu.json';
29
+
30
+ function resolveConfigPath(): string | null {
31
+ const docsPath = join(process.cwd(), PRIMARY_CONFIG_NAME);
32
+ if (existsSync(docsPath)) return docsPath;
33
+ const legacyPath = join(process.cwd(), LEGACY_CONFIG_NAME);
34
+ if (existsSync(legacyPath)) return legacyPath;
35
+ return null;
36
+ }
37
+
38
+ function resolveDocsDir(): string {
39
+ const envDocsDir = process.env.VELU_DOCS_DIR?.trim();
40
+ if (envDocsDir) return envDocsDir;
41
+ return process.cwd();
42
+ }
43
+
44
+ function resolveLocaleSlug(slugInput: string[] | undefined) {
45
+ const languages = getLanguages();
46
+ const defaultLanguage = languages[0] ?? 'en';
47
+ const slug = slugInput ?? [];
48
+ const firstSeg = slug[0];
49
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
50
+
51
+ return {
52
+ locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
53
+ pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
54
+ };
55
+ }
56
+
57
+ function parseFrontmatterMap(markdown?: string): Record<string, string> {
58
+ if (!markdown) return {};
59
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
60
+ if (!match) return {};
61
+
62
+ const output: Record<string, string> = {};
63
+ const lines = match[1].split(/\r?\n/);
64
+ for (const rawLine of lines) {
65
+ const line = rawLine.trim();
66
+ if (!line || line.startsWith('#')) continue;
67
+ const entry = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
68
+ if (!entry) continue;
69
+ const key = entry[1];
70
+ const rawValue = entry[2].trim();
71
+ output[key] = rawValue.replace(/^['"]|['"]$/g, '').trim();
72
+ }
73
+
74
+ return output;
75
+ }
76
+
77
+ function parseFrontmatterData(markdown?: string): Record<string, unknown> {
78
+ if (!markdown) return {};
79
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
80
+ if (!match) return {};
81
+ try {
82
+ const parsed = parseYaml(match[1]);
83
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function stripFrontmatter(markdown: string): string {
90
+ return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
91
+ }
92
+
93
+ function humanizeSlug(value: string): string {
94
+ const cleaned = String(value ?? '').trim().replace(/[-_]+/g, ' ');
95
+ if (!cleaned) return 'Docs';
96
+ return cleaned.replace(/\b\w/g, (char) => char.toUpperCase());
97
+ }
98
+
99
+ function slugToPath(slug: string[]): string {
100
+ const joined = slug.join('/');
101
+ if (!joined) return '/';
102
+ return `/${joined}`.replace(/\/{2,}/g, '/');
103
+ }
104
+
105
+ function pathToSlug(path: string): string {
106
+ return normalizePath(path).replace(/^\/+/, '');
107
+ }
108
+
109
+ function sectionFromSlug(pageSlug: string[], locale: string, hasI18n: boolean): string {
110
+ const root = pageSlug[0] ? humanizeSlug(pageSlug[0]) : 'Docs';
111
+ if (!hasI18n) return root;
112
+ return `${locale.toUpperCase()} - ${root}`;
113
+ }
114
+
115
+ function hasSourceFileForPage(pageSlug: string[], locale: string, hasI18n: boolean): boolean {
116
+ const docsDir = resolveDocsDir();
117
+ const rel = pageSlug.join('/');
118
+ const candidates = hasI18n
119
+ ? [
120
+ join(docsDir, locale, `${rel}.md`),
121
+ join(docsDir, locale, `${rel}.mdx`),
122
+ join(docsDir, `${rel}.md`),
123
+ join(docsDir, `${rel}.mdx`),
124
+ ]
125
+ : [
126
+ join(docsDir, `${rel}.md`),
127
+ join(docsDir, `${rel}.mdx`),
128
+ ];
129
+
130
+ return candidates.some((candidate) => existsSync(candidate));
131
+ }
132
+
133
+ const HTTP_METHODS = new Set([
134
+ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT', 'WEBHOOK',
135
+ ]);
136
+
137
+ function parseOpenApiFrontmatter(rawValue: string | undefined, defaultSpec?: string): { spec?: string; isOperation: boolean } {
138
+ if (!rawValue) return { spec: undefined, isOperation: false };
139
+ const trimmed = rawValue.trim();
140
+ if (!trimmed) return { spec: undefined, isOperation: false };
141
+
142
+ const withSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
143
+ if (withSpec) {
144
+ const method = withSpec[2].toUpperCase();
145
+ const endpoint = withSpec[3].trim();
146
+ if (!HTTP_METHODS.has(method) || !endpoint) return { spec: undefined, isOperation: false };
147
+ if (method !== 'WEBHOOK' && !endpoint.startsWith('/')) return { spec: undefined, isOperation: false };
148
+ return { spec: withSpec[1].trim(), isOperation: true };
149
+ }
150
+
151
+ const noSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
152
+ if (noSpec) {
153
+ const method = noSpec[1].toUpperCase();
154
+ const endpoint = noSpec[2].trim();
155
+ if (!HTTP_METHODS.has(method) || !endpoint) return { spec: undefined, isOperation: false };
156
+ if (method !== 'WEBHOOK' && !endpoint.startsWith('/')) return { spec: undefined, isOperation: false };
157
+ return { spec: defaultSpec?.trim(), isOperation: true };
158
+ }
159
+
160
+ return { spec: undefined, isOperation: false };
161
+ }
162
+
163
+ function resolveGeneratedDocsRoot(): string {
164
+ const primary = join(process.cwd(), 'content', 'docs');
165
+ if (existsSync(primary)) return primary;
166
+ return join(process.cwd(), '.velu-out', 'content', 'docs');
167
+ }
168
+
169
+ function collectMarkdownRelativePaths(rootDir: string): string[] {
170
+ const files: string[] = [];
171
+
172
+ function walk(currentDir: string, relPrefix: string) {
173
+ const entries = readdirSync(currentDir, { withFileTypes: true });
174
+ for (const entry of entries) {
175
+ if (entry.name.startsWith('.')) continue;
176
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
177
+ const absPath = join(currentDir, entry.name);
178
+ if (entry.isDirectory()) {
179
+ walk(absPath, relPath);
180
+ continue;
181
+ }
182
+ if (!entry.isFile()) continue;
183
+ if (!relPath.endsWith('.md') && !relPath.endsWith('.mdx')) continue;
184
+ files.push(relPath.replace(/\\/g, '/'));
185
+ }
186
+ }
187
+
188
+ if (!existsSync(rootDir)) return files;
189
+ walk(rootDir, '');
190
+ return files;
191
+ }
192
+
193
+ function normalizeBoolean(value: unknown): boolean | undefined {
194
+ if (typeof value === 'boolean') return value;
195
+ if (typeof value === 'string') {
196
+ const normalized = value.trim().toLowerCase();
197
+ if (normalized === 'true' || normalized === 'yes' || normalized === '1') return true;
198
+ if (normalized === 'false' || normalized === 'no' || normalized === '0') return false;
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function normalizeMetaTagMap(value: unknown): Record<string, string> {
204
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
205
+ const output: Record<string, string> = {};
206
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
207
+ const tag = key.trim();
208
+ if (!tag) continue;
209
+ if (typeof raw === 'string') {
210
+ const normalized = raw.trim();
211
+ if (normalized) output[tag] = normalized;
212
+ continue;
213
+ }
214
+ if (typeof raw === 'number' || typeof raw === 'boolean') {
215
+ output[tag] = String(raw);
216
+ }
217
+ }
218
+ return output;
219
+ }
220
+
221
+ function parseNoindex(frontmatterData: Record<string, unknown>, frontmatterMap: Record<string, string>): boolean {
222
+ const direct = normalizeBoolean(frontmatterData.noindex);
223
+ if (direct === true) return true;
224
+
225
+ const fallback = normalizeBoolean(frontmatterMap.noindex);
226
+ if (fallback === true) return true;
227
+
228
+ const metatags = normalizeMetaTagMap(frontmatterData.metatags);
229
+ const robots = (metatags.robots ?? '').toLowerCase();
230
+ if (!robots) return false;
231
+ return robots.includes('noindex') || robots.includes('none');
232
+ }
233
+
234
+ function isDecorativeMetaEntry(entry: string): boolean {
235
+ const trimmed = entry.trim();
236
+ if (!trimmed) return true;
237
+ if (/^---.*---$/.test(trimmed)) return true;
238
+ if (/^\[[^\]]+\](?:\[[^\]]+\])?\([^)]+\)$/.test(trimmed)) return true;
239
+ return false;
240
+ }
241
+
242
+ function resolveMetaEntry(baseDir: string, entry: string): string | null {
243
+ const normalized = posixPath.normalize(posixPath.join(baseDir || '.', entry));
244
+ if (normalized === '.' || normalized === '') return null;
245
+ if (normalized.startsWith('..')) return null;
246
+ return normalized.replace(/^\.\//, '');
247
+ }
248
+
249
+ export function collectNavigablePagePaths(): Set<string> {
250
+ const rootDir = resolveGeneratedDocsRoot();
251
+ const visible = new Set<string>();
252
+ const visited = new Set<string>();
253
+
254
+ function hasMeta(dir: string): boolean {
255
+ const metaPath = join(rootDir, dir, 'meta.json');
256
+ return existsSync(metaPath);
257
+ }
258
+
259
+ function hasPage(dir: string): boolean {
260
+ return existsSync(join(rootDir, `${dir}.md`)) || existsSync(join(rootDir, `${dir}.mdx`));
261
+ }
262
+
263
+ function readMetaPages(dir: string): string[] {
264
+ const metaPath = join(rootDir, dir, 'meta.json');
265
+ try {
266
+ const parsed = JSON.parse(readFileSync(metaPath, 'utf-8')) as { pages?: unknown };
267
+ return Array.isArray(parsed.pages)
268
+ ? parsed.pages.filter((value): value is string => typeof value === 'string')
269
+ : [];
270
+ } catch {
271
+ return [];
272
+ }
273
+ }
274
+
275
+ function walkMeta(dir: string, hiddenAncestor: boolean) {
276
+ const visitKey = `${dir}|${hiddenAncestor ? '1' : '0'}`;
277
+ if (visited.has(visitKey)) return;
278
+ visited.add(visitKey);
279
+
280
+ for (const raw of readMetaPages(dir)) {
281
+ const hiddenSelf = raw.startsWith('!');
282
+ const rawEntry = hiddenSelf ? raw.slice(1) : raw;
283
+ if (isDecorativeMetaEntry(rawEntry)) continue;
284
+
285
+ const resolved = resolveMetaEntry(dir, rawEntry);
286
+ if (!resolved) continue;
287
+
288
+ const hidden = hiddenAncestor || hiddenSelf;
289
+ if (hasPage(resolved) && !hidden) visible.add(resolved);
290
+ if (hasMeta(resolved)) walkMeta(resolved, hidden);
291
+ }
292
+ }
293
+
294
+ if (hasMeta('')) {
295
+ walkMeta('', false);
296
+ return visible;
297
+ }
298
+
299
+ // Fallback for projects without generated meta files.
300
+ for (const rel of collectMarkdownRelativePaths(rootDir)) {
301
+ const slug = rel.replace(/\.(md|mdx)$/i, '');
302
+ if (slug && slug !== 'index') visible.add(slug);
303
+ }
304
+ return visible;
305
+ }
306
+
307
+ export async function collectLlmsPages(options: CollectLlmsPagesOptions = {}): Promise<LlmsPageEntry[]> {
308
+ const includeMarkdown = options.includeMarkdown === true;
309
+ const indexing = options.indexing ?? getSeoConfig().indexing;
310
+ const generatedDocsRoot = resolveGeneratedDocsRoot();
311
+ const markdownPaths = collectMarkdownRelativePaths(generatedDocsRoot);
312
+ const navigable = indexing === 'navigable' ? collectNavigablePagePaths() : null;
313
+ const hasI18n = getLanguages().length > 1;
314
+ const defaultOpenApiSpec = getApiConfig().defaultOpenApiSpec;
315
+ const seen = new Set<string>();
316
+ const pages: LlmsPageEntry[] = [];
317
+
318
+ for (const relFilePath of markdownPaths) {
319
+ const withoutExt = relFilePath.replace(/\.(md|mdx)$/i, '');
320
+ if (withoutExt === 'index') continue;
321
+ const slug = withoutExt.split('/').filter((segment) => segment.length > 0);
322
+ if (slug.length === 0) continue;
323
+
324
+ const path = slugToPath(slug);
325
+ const slugPath = pathToSlug(path);
326
+ if (navigable && !navigable.has(slugPath)) continue;
327
+ if (seen.has(path)) continue;
328
+ seen.add(path);
329
+
330
+ const { locale, pageSlug } = resolveLocaleSlug(slug);
331
+ const filePath = join(generatedDocsRoot, relFilePath);
332
+ let markdown = '';
333
+ try {
334
+ markdown = readFileSync(filePath, 'utf-8');
335
+ } catch {
336
+ continue;
337
+ }
338
+
339
+ const frontmatter = parseFrontmatterMap(markdown);
340
+ const frontmatterData = parseFrontmatterData(markdown);
341
+ const openapiRaw = typeof frontmatter.openapi === 'string' ? frontmatter.openapi : undefined;
342
+ const openapi = parseOpenApiFrontmatter(openapiRaw, defaultOpenApiSpec);
343
+ const sourceKind: 'source' | 'generated' = hasSourceFileForPage(pageSlug, locale, hasI18n) ? 'source' : 'generated';
344
+ const noindex = parseNoindex(frontmatterData, frontmatter);
345
+
346
+ const title = frontmatter.title || humanizeSlug(pageSlug[pageSlug.length - 1] ?? slug[slug.length - 1]);
347
+ const description = frontmatter.description || undefined;
348
+
349
+ const content = includeMarkdown
350
+ ? stripFrontmatter(markdown ?? `# ${title}\n`).trim()
351
+ : undefined;
352
+
353
+ pages.push({
354
+ slug,
355
+ path,
356
+ locale,
357
+ pageSlug,
358
+ section: sectionFromSlug(pageSlug, locale, hasI18n),
359
+ title,
360
+ description,
361
+ markdown: content,
362
+ sourceKind,
363
+ openapiSpec: openapi.spec,
364
+ isOpenApiOperation: openapi.isOperation,
365
+ noindex,
366
+ });
367
+ }
368
+
369
+ pages.sort((a, b) => a.path.localeCompare(b.path));
370
+ return pages;
371
+ }
372
+
373
+ export function getSiteTitle(): string {
374
+ const configPath = resolveConfigPath();
375
+ if (!configPath) return 'Documentation';
376
+
377
+ try {
378
+ const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
379
+ if (typeof parsed.name === 'string' && parsed.name.trim().length > 0) return parsed.name.trim();
380
+ if (typeof parsed.title === 'string' && parsed.title.trim().length > 0) return parsed.title.trim();
381
+ } catch {
382
+ // ignore parse/read errors and fallback.
383
+ }
384
+
385
+ return 'Documentation';
386
+ }
387
+
388
+ export function normalizePath(value: string): string {
389
+ if (!value) return '/';
390
+ const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
391
+ const collapsed = withLeadingSlash.replace(/\/{2,}/g, '/');
392
+ if (collapsed !== '/' && collapsed.endsWith('/')) return collapsed.slice(0, -1);
393
+ return collapsed;
394
+ }
395
+
396
+ export function resolveRequestOrigin(request: Request): string {
397
+ const requestUrl = new URL(request.url);
398
+ const forwardedHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
399
+ const forwardedProto = request.headers.get('x-forwarded-proto') ?? requestUrl.protocol.replace(':', '');
400
+ const devPort = process.env.PORT?.trim();
401
+ const fallbackOrigin = (requestUrl.hostname === 'localhost' && requestUrl.port === '3000' && devPort)
402
+ ? `${requestUrl.protocol}//${requestUrl.hostname}:${devPort}`
403
+ : requestUrl.origin;
404
+ return forwardedHost ? `${forwardedProto}://${forwardedHost}` : fallbackOrigin;
405
+ }
406
+
407
+ const LLMS_FILE_CANDIDATES: Record<'llms.txt' | 'llms-full.txt', string[]> = {
408
+ 'llms.txt': ['llms.txt'],
409
+ 'llms-full.txt': ['llms-full.txt', 'llmfull.txt', 'llmfull', 'llms-full'],
410
+ };
411
+
412
+ export async function readCustomLlmsFile(filename: 'llms.txt' | 'llms-full.txt'): Promise<string | null> {
413
+ const names = LLMS_FILE_CANDIDATES[filename] ?? [filename];
414
+ const docsDir = process.env.VELU_DOCS_DIR?.trim();
415
+ if (docsDir) {
416
+ for (const name of names) {
417
+ const docsPath = join(docsDir, name);
418
+ if (!existsSync(docsPath)) continue;
419
+ try {
420
+ return await readFile(docsPath, 'utf-8');
421
+ } catch {
422
+ // ignore and continue
423
+ }
424
+ }
425
+ // In dev mode, trust source docs directory for override existence.
426
+ return null;
427
+ }
428
+
429
+ const candidates = names.flatMap((name) => [
430
+ join(process.cwd(), name),
431
+ join(process.cwd(), 'public', name),
432
+ ]);
433
+
434
+ for (const candidate of candidates) {
435
+ if (!existsSync(candidate)) continue;
436
+ try {
437
+ return await readFile(candidate, 'utf-8');
438
+ } catch {
439
+ // ignore and continue
440
+ }
441
+ }
442
+
443
+ return null;
444
+ }