@navai/voice-frontend 0.1.0 → 0.1.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,483 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ const DEFAULT_EXTENSIONS = [
7
+ ".ts",
8
+ ".tsx",
9
+ ".js",
10
+ ".jsx",
11
+ ".mts",
12
+ ".mtsx",
13
+ ".cts",
14
+ ".ctsx",
15
+ ".mjs",
16
+ ".mjsx",
17
+ ".cjs",
18
+ ".cjsx"
19
+ ];
20
+
21
+ async function main() {
22
+ const args = parseArgs(process.argv.slice(2));
23
+ if (args.help) {
24
+ printHelp();
25
+ return;
26
+ }
27
+
28
+ const projectRoot = path.resolve(process.cwd(), args.projectRoot ?? ".");
29
+ const srcRoot = path.resolve(projectRoot, args.srcRoot ?? "src");
30
+ const outputFile = path.resolve(projectRoot, args.outputFile ?? "src/ai/generated-module-loaders.ts");
31
+ const outputDir = path.dirname(outputFile);
32
+ const envFile = path.resolve(projectRoot, args.envFile ?? ".env");
33
+ const defaultFunctionsFolder = args.defaultFunctionsFolder ?? "src/ai/functions-modules";
34
+ const defaultRoutesFile = args.defaultRoutesFile ?? "src/ai/routes.ts";
35
+ const typeImport = args.typeImport ?? "@navai/voice-frontend";
36
+ const exportName = args.exportName ?? "NAVAI_WEB_MODULE_LOADERS";
37
+
38
+ const envFileValues = await readEnvFile(envFile);
39
+ const configuredFunctionsFolders =
40
+ readOptional(process.env.NAVAI_FUNCTIONS_FOLDERS) ??
41
+ readOptional(envFileValues.NAVAI_FUNCTIONS_FOLDERS);
42
+ const configuredRoutesFile =
43
+ readOptional(process.env.NAVAI_ROUTES_FILE) ?? readOptional(envFileValues.NAVAI_ROUTES_FILE);
44
+ const functionsFolders = configuredFunctionsFolders ?? defaultFunctionsFolder;
45
+ const routesFile = configuredRoutesFile ?? defaultRoutesFile;
46
+
47
+ const defaultRoutesModuleFile = await resolveModulePathFromFs(defaultRoutesFile, projectRoot);
48
+
49
+ const configuredFunctionTokens = functionsFolders
50
+ .split(",")
51
+ .map((token) => token.trim())
52
+ .filter(Boolean);
53
+ const functionTokens =
54
+ configuredFunctionTokens.length > 0 ? configuredFunctionTokens : [defaultFunctionsFolder];
55
+ const functionMatchers = functionTokens.map((token) => createPathMatcher(token));
56
+
57
+ let selectedFunctionFiles = await collectFilesForTokens({
58
+ tokens: functionTokens,
59
+ projectRoot,
60
+ srcRoot,
61
+ outputFile
62
+ });
63
+ selectedFunctionFiles = dedupeAbsolutePaths(selectedFunctionFiles).filter((absolutePath) => {
64
+ const sourcePath = sourcePathFromAbsolute(srcRoot, absolutePath);
65
+ return functionMatchers.some((matcher) => matcher(sourcePath));
66
+ });
67
+
68
+ if (selectedFunctionFiles.length === 0 && configuredFunctionTokens.length > 0) {
69
+ const fallbackMatcher = createPathMatcher(defaultFunctionsFolder);
70
+ selectedFunctionFiles = await collectFilesForTokens({
71
+ tokens: [defaultFunctionsFolder],
72
+ projectRoot,
73
+ srcRoot,
74
+ outputFile
75
+ });
76
+ selectedFunctionFiles = dedupeAbsolutePaths(selectedFunctionFiles).filter((absolutePath) =>
77
+ fallbackMatcher(sourcePathFromAbsolute(srcRoot, absolutePath))
78
+ );
79
+ console.warn(
80
+ `[navai] NAVAI_FUNCTIONS_FOLDERS did not match any module: "${functionsFolders}". Falling back to "${defaultFunctionsFolder}".`
81
+ );
82
+ }
83
+
84
+ const selectedFiles = new Set(selectedFunctionFiles);
85
+ const routesModuleFile = await resolveModulePathFromFs(routesFile, projectRoot);
86
+ if (routesModuleFile) {
87
+ const shouldIncludeConfiguredRoutes =
88
+ !defaultRoutesModuleFile ||
89
+ path.resolve(routesModuleFile) !== path.resolve(defaultRoutesModuleFile);
90
+ if (shouldIncludeConfiguredRoutes) {
91
+ selectedFiles.add(routesModuleFile);
92
+ }
93
+ } else {
94
+ console.warn(`[navai] Route module "${routesFile}" was not found in src.`);
95
+ }
96
+
97
+ const orderedFiles = [...selectedFiles].sort((left, right) => left.localeCompare(right));
98
+ const entries = orderedFiles.map((absolutePath) => {
99
+ const sourcePath = normalizePath(path.relative(projectRoot, absolutePath));
100
+ const importPath = toRelativeModulePath(outputDir, absolutePath);
101
+ return ` "${sourcePath}": async () => await import("${importPath}")`;
102
+ });
103
+
104
+ const output = [
105
+ "// Auto-generated by @navai/voice-frontend (navai-generate-web-loaders)",
106
+ `import type { NavaiFunctionModuleLoaders } from "${typeImport}";`,
107
+ "",
108
+ `export const ${exportName}: NavaiFunctionModuleLoaders = {`,
109
+ entries.join(",\n"),
110
+ "};",
111
+ ""
112
+ ].join("\n");
113
+
114
+ await mkdir(outputDir, { recursive: true });
115
+ await writeFile(outputFile, output, "utf8");
116
+
117
+ console.log(
118
+ `[navai] Generated ${entries.length} web module loader(s) at ${normalizePath(
119
+ path.relative(projectRoot, outputFile)
120
+ )}.`
121
+ );
122
+ }
123
+
124
+ function parseArgs(argv) {
125
+ const args = {};
126
+
127
+ for (let index = 0; index < argv.length; index += 1) {
128
+ const token = argv[index];
129
+ if (token === "--help" || token === "-h") {
130
+ args.help = true;
131
+ continue;
132
+ }
133
+
134
+ if (!token.startsWith("--")) {
135
+ throw new Error(`Unknown argument: "${token}"`);
136
+ }
137
+
138
+ const equalsIndex = token.indexOf("=");
139
+ if (equalsIndex >= 0) {
140
+ const key = token.slice(2, equalsIndex);
141
+ const value = token.slice(equalsIndex + 1);
142
+ setArgValue(args, key, value);
143
+ continue;
144
+ }
145
+
146
+ const key = token.slice(2);
147
+ const next = argv[index + 1];
148
+ if (!next || next.startsWith("--")) {
149
+ throw new Error(`Missing value for argument "--${key}"`);
150
+ }
151
+
152
+ setArgValue(args, key, next);
153
+ index += 1;
154
+ }
155
+
156
+ return args;
157
+ }
158
+
159
+ function setArgValue(args, key, value) {
160
+ const map = {
161
+ "project-root": "projectRoot",
162
+ "src-root": "srcRoot",
163
+ "output-file": "outputFile",
164
+ "env-file": "envFile",
165
+ "default-functions-folder": "defaultFunctionsFolder",
166
+ "default-routes-file": "defaultRoutesFile",
167
+ "type-import": "typeImport",
168
+ "export-name": "exportName"
169
+ };
170
+
171
+ const mapped = map[key];
172
+ if (!mapped) {
173
+ throw new Error(`Unknown argument: "--${key}"`);
174
+ }
175
+
176
+ args[mapped] = value;
177
+ }
178
+
179
+ function printHelp() {
180
+ const help = [
181
+ "navai-generate-web-loaders",
182
+ "",
183
+ "Generates src/ai/generated-module-loaders.ts using NAVAI_FUNCTIONS_FOLDERS and NAVAI_ROUTES_FILE.",
184
+ "",
185
+ "Usage:",
186
+ " navai-generate-web-loaders [--project-root <path>] [--env-file <path>]",
187
+ "",
188
+ "Options:",
189
+ " --project-root <path> Project root (default: current directory)",
190
+ " --src-root <path> Source root (default: src)",
191
+ " --output-file <path> Output file (default: src/ai/generated-module-loaders.ts)",
192
+ " --env-file <path> Env file relative to project root (default: .env)",
193
+ " --default-functions-folder <path> Fallback functions folder (default: src/ai/functions-modules)",
194
+ " --default-routes-file <path> Fallback routes file (default: src/ai/routes.ts)",
195
+ " --type-import <module> Type import path (default: @navai/voice-frontend)",
196
+ " --export-name <identifier> Export constant name (default: NAVAI_WEB_MODULE_LOADERS)",
197
+ " --help Show this help"
198
+ ].join("\n");
199
+
200
+ console.log(help);
201
+ }
202
+
203
+ async function readEnvFile(envPath) {
204
+ try {
205
+ const contents = await readFile(envPath, "utf8");
206
+ return parseEnv(contents);
207
+ } catch {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ function parseEnv(contents) {
213
+ const result = {};
214
+ const lines = contents.split(/\r?\n/);
215
+ for (const rawLine of lines) {
216
+ const line = rawLine.trim();
217
+ if (!line || line.startsWith("#")) {
218
+ continue;
219
+ }
220
+
221
+ const eqIndex = line.indexOf("=");
222
+ if (eqIndex <= 0) {
223
+ continue;
224
+ }
225
+
226
+ const key = line.slice(0, eqIndex).trim();
227
+ let value = line.slice(eqIndex + 1).trim();
228
+ if (
229
+ (value.startsWith("\"") && value.endsWith("\"")) ||
230
+ (value.startsWith("'") && value.endsWith("'"))
231
+ ) {
232
+ value = value.slice(1, -1);
233
+ }
234
+
235
+ result[key] = value;
236
+ }
237
+
238
+ return result;
239
+ }
240
+
241
+ function readOptional(input) {
242
+ if (typeof input !== "string") {
243
+ return undefined;
244
+ }
245
+
246
+ const trimmed = input.trim();
247
+ return trimmed.length > 0 ? trimmed : undefined;
248
+ }
249
+
250
+ function normalizePath(filePath) {
251
+ return filePath.replace(/\\/g, "/");
252
+ }
253
+
254
+ function normalizeSourcePath(input) {
255
+ return normalizePath(input)
256
+ .trim()
257
+ .replace(/^\/+/, "")
258
+ .replace(/^(\.\/)+/, "")
259
+ .replace(/^(\.\.\/)+/, "");
260
+ }
261
+
262
+ function ensureSrcPrefix(input) {
263
+ const normalized = normalizeSourcePath(input);
264
+ if (!normalized) {
265
+ return "";
266
+ }
267
+
268
+ return normalized.startsWith("src/") ? normalized : `src/${normalized}`;
269
+ }
270
+
271
+ function toRelativeModulePath(outputDir, absoluteFilePath) {
272
+ const relative = normalizePath(path.relative(outputDir, absoluteFilePath));
273
+ const withPrefix = relative.startsWith(".") ? relative : `./${relative}`;
274
+ return withPrefix.replace(/\.[cm]?[jt]sx?$/i, "");
275
+ }
276
+
277
+ function shouldIncludeFile(filename) {
278
+ if (!/\.[cm]?[jt]sx?$/i.test(filename)) {
279
+ return false;
280
+ }
281
+
282
+ return !/\.d\.ts$/i.test(filename);
283
+ }
284
+
285
+ function dedupeAbsolutePaths(absolutePaths) {
286
+ const deduped = new Map();
287
+ for (const absolutePath of absolutePaths) {
288
+ const resolved = path.resolve(absolutePath);
289
+ if (!deduped.has(resolved)) {
290
+ deduped.set(resolved, resolved);
291
+ }
292
+ }
293
+
294
+ return [...deduped.values()].sort((left, right) => left.localeCompare(right));
295
+ }
296
+
297
+ async function collectFilesForTokens(input) {
298
+ const files = [];
299
+ for (const token of input.tokens) {
300
+ files.push(...(await collectFilesForToken({ ...input, token })));
301
+ }
302
+
303
+ return files;
304
+ }
305
+
306
+ async function collectFilesForToken(input) {
307
+ const normalizedToken = ensureSrcPrefix(input.token);
308
+ if (!normalizedToken) {
309
+ return [];
310
+ }
311
+
312
+ const matcher = createPathMatcher(normalizedToken);
313
+
314
+ if (normalizedToken.endsWith("/...")) {
315
+ const base = normalizedToken.slice(0, -4).replace(/\/+$/, "");
316
+ const baseDirectory = path.resolve(input.projectRoot, base);
317
+ return await collectFilesFromDirectoryWithMatcher(baseDirectory, input, matcher);
318
+ }
319
+
320
+ if (normalizedToken.includes("*")) {
321
+ const wildcardBase = getWildcardSearchBase(normalizedToken);
322
+ const baseDirectory = path.resolve(input.projectRoot, wildcardBase);
323
+ return await collectFilesFromDirectoryWithMatcher(baseDirectory, input, matcher);
324
+ }
325
+
326
+ if (/\.[cm]?[jt]sx?$/i.test(normalizedToken)) {
327
+ const resolved = await resolveModulePathFromFs(normalizedToken, input.projectRoot);
328
+ if (!resolved || path.resolve(resolved) === path.resolve(input.outputFile)) {
329
+ return [];
330
+ }
331
+
332
+ return [resolved];
333
+ }
334
+
335
+ const possibleDirectory = path.resolve(input.projectRoot, normalizedToken);
336
+ const directoryStats = await safeStat(possibleDirectory);
337
+ if (directoryStats?.isDirectory()) {
338
+ return await walkDirectory(possibleDirectory, input.outputFile);
339
+ }
340
+
341
+ const resolved = await resolveModulePathFromFs(normalizedToken, input.projectRoot);
342
+ if (!resolved || path.resolve(resolved) === path.resolve(input.outputFile)) {
343
+ return [];
344
+ }
345
+
346
+ return [resolved];
347
+ }
348
+
349
+ async function collectFilesFromDirectoryWithMatcher(directoryPath, input, matcher) {
350
+ const files = await walkDirectoryIfExists(directoryPath, input.outputFile);
351
+ return files.filter((absolutePath) => matcher(sourcePathFromAbsolute(input.srcRoot, absolutePath)));
352
+ }
353
+
354
+ function getWildcardSearchBase(pattern) {
355
+ const wildcardIndex = pattern.indexOf("*");
356
+ if (wildcardIndex < 0) {
357
+ return pattern;
358
+ }
359
+
360
+ const prefix = pattern.slice(0, wildcardIndex);
361
+ if (!prefix) {
362
+ return "src";
363
+ }
364
+
365
+ const directoryPrefix = prefix.endsWith("/")
366
+ ? prefix
367
+ : prefix.slice(0, prefix.lastIndexOf("/") + 1);
368
+ const trimmed = directoryPrefix.replace(/\/+$/, "");
369
+ return trimmed || "src";
370
+ }
371
+
372
+ async function walkDirectoryIfExists(directoryPath, outputFile) {
373
+ const directoryStats = await safeStat(directoryPath);
374
+ if (!directoryStats?.isDirectory()) {
375
+ return [];
376
+ }
377
+
378
+ return await walkDirectory(directoryPath, outputFile);
379
+ }
380
+
381
+ async function safeStat(filePath) {
382
+ try {
383
+ return await stat(filePath);
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ async function walkDirectory(directoryPath, outputFile) {
390
+ const entries = await readdir(directoryPath, { withFileTypes: true });
391
+ const files = [];
392
+
393
+ for (const entry of entries) {
394
+ const absoluteEntryPath = path.join(directoryPath, entry.name);
395
+ if (entry.isDirectory()) {
396
+ files.push(...(await walkDirectory(absoluteEntryPath, outputFile)));
397
+ continue;
398
+ }
399
+
400
+ if (!entry.isFile() || !shouldIncludeFile(entry.name)) {
401
+ continue;
402
+ }
403
+
404
+ if (path.resolve(absoluteEntryPath) === path.resolve(outputFile)) {
405
+ continue;
406
+ }
407
+
408
+ files.push(path.resolve(absoluteEntryPath));
409
+ }
410
+
411
+ return files;
412
+ }
413
+
414
+ function sourcePathFromAbsolute(srcRoot, absoluteFilePath) {
415
+ return ensureSrcPrefix(path.relative(srcRoot, absoluteFilePath));
416
+ }
417
+
418
+ function globToRegExp(pattern) {
419
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
420
+ const wildcardSafe = escaped.replace(/\*\*/g, "___DOUBLE_STAR___");
421
+ const single = wildcardSafe.replace(/\*/g, "[^/]*");
422
+ const output = single.replace(/___DOUBLE_STAR___/g, ".*");
423
+ return new RegExp(`^${output}$`);
424
+ }
425
+
426
+ function createPathMatcher(input) {
427
+ const normalized = ensureSrcPrefix(input);
428
+ if (!normalized) {
429
+ return () => false;
430
+ }
431
+
432
+ if (normalized.endsWith("/...")) {
433
+ const base = normalized.slice(0, -4).replace(/\/+$/, "");
434
+ return (candidatePath) => candidatePath.startsWith(`${base}/`);
435
+ }
436
+
437
+ if (normalized.includes("*")) {
438
+ const regexp = globToRegExp(normalized);
439
+ return (candidatePath) => regexp.test(candidatePath);
440
+ }
441
+
442
+ if (/\.[cm]?[jt]sx?$/i.test(normalized)) {
443
+ return (candidatePath) => candidatePath === normalized;
444
+ }
445
+
446
+ const base = normalized.replace(/\/+$/, "");
447
+ return (candidatePath) => candidatePath === base || candidatePath.startsWith(`${base}/`);
448
+ }
449
+
450
+ function buildModuleCandidates(inputPath) {
451
+ const normalized = ensureSrcPrefix(inputPath);
452
+ if (!normalized) {
453
+ return [];
454
+ }
455
+
456
+ if (/\.[cm]?[jt]sx?$/.test(normalized)) {
457
+ return [normalized];
458
+ }
459
+
460
+ return [
461
+ ...DEFAULT_EXTENSIONS.map((extension) => `${normalized}${extension}`),
462
+ ...DEFAULT_EXTENSIONS.map((extension) => `${normalized}/index${extension}`)
463
+ ];
464
+ }
465
+
466
+ async function resolveModulePathFromFs(inputPath, projectRoot) {
467
+ const candidates = buildModuleCandidates(inputPath);
468
+ for (const candidate of candidates) {
469
+ const absoluteCandidate = path.resolve(projectRoot, candidate);
470
+ const candidateStats = await safeStat(absoluteCandidate);
471
+ if (candidateStats?.isFile() && shouldIncludeFile(path.basename(absoluteCandidate))) {
472
+ return absoluteCandidate;
473
+ }
474
+ }
475
+
476
+ return null;
477
+ }
478
+
479
+ main().catch((error) => {
480
+ const message = error instanceof Error ? error.message : String(error);
481
+ console.error(`[navai] Failed to generate web module loaders: ${message}`);
482
+ process.exitCode = 1;
483
+ });