statikapi 0.1.0

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,320 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs/promises';
3
+ import fss from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { URL, fileURLToPath } from 'node:url';
7
+ import { readFlags } from '../util/readFlags.js';
8
+ import { loadConfig } from '../config/loadConfig.js';
9
+ import { routeToOutPath } from '../build/routeOutPath.js';
10
+
11
+ export default async function previewCmd(argv) {
12
+ // Keep old tests green: in non-TTY (node --test), behave like stub and exit.
13
+ if (!process.stdout.isTTY) {
14
+ console.log('statikapi preview → previewing built JSON (stub)');
15
+ return 0;
16
+ }
17
+
18
+ const flags = readFlags(argv || []);
19
+ const host = String(flags.host ?? '127.0.0.1');
20
+ const port = Number.isFinite(flags.port) ? Number(flags.port) : 8788;
21
+ const autoOpen = flags.open === true;
22
+
23
+ const { config } = await loadConfig({ flags });
24
+
25
+ // --- React UI defaults ---
26
+ // Prefer --uiDir; else use embedded UI inside this package; else proxy to Vite dev.
27
+ const here = path.dirname(fileURLToPath(import.meta.url)); // .../packages/cli/src/commands
28
+ const embeddedUi = path.resolve(here, '../../ui'); // .../packages/cli/ui
29
+ const uiDir = flags.uiDir ? path.resolve(String(flags.uiDir)) : embeddedUi;
30
+ const hasUi = uiDir && fss.existsSync(uiDir);
31
+
32
+ const uiDevHost = String(flags.uiDevHost ?? '127.0.0.1');
33
+ const uiDevPort = Number.isFinite(flags.uiDevPort) ? Number(flags.uiDevPort) : 5173;
34
+
35
+ const MIME = {
36
+ '.html': 'text/html; charset=utf-8',
37
+ '.js': 'application/javascript; charset=utf-8',
38
+ '.css': 'text/css; charset=utf-8',
39
+ '.json': 'application/json; charset=utf-8',
40
+ '.svg': 'image/svg+xml',
41
+ '.png': 'image/png',
42
+ '.jpg': 'image/jpeg',
43
+ '.jpeg': 'image/jpeg',
44
+ '.ico': 'image/x-icon',
45
+ '.map': 'application/json',
46
+ };
47
+
48
+ const outDir = config.paths.outAbs;
49
+ const manifestPath = path.join(outDir, '.statikapi', 'manifest.json');
50
+
51
+ const send = (res, code, body, headers = {}) => {
52
+ const h = {
53
+ 'Cache-Control': 'no-store',
54
+ ...headers,
55
+ };
56
+ res.writeHead(code, h);
57
+ if (body && (typeof body === 'string' || Buffer.isBuffer(body))) res.end(body);
58
+ else res.end();
59
+ };
60
+
61
+ const notFound = (res, msg = 'Not found') =>
62
+ send(res, 404, JSON.stringify({ error: msg }) + '\n', {
63
+ 'Content-Type': 'application/json; charset=utf-8',
64
+ });
65
+
66
+ const badReq = (res, msg) =>
67
+ send(res, 400, JSON.stringify({ error: msg }) + '\n', {
68
+ 'Content-Type': 'application/json; charset=utf-8',
69
+ });
70
+
71
+ const etag = (buf) => `"sha1-${crypto.createHash('sha1').update(buf).digest('hex')}"`;
72
+
73
+ async function readManifest() {
74
+ try {
75
+ const raw = await fs.readFile(manifestPath);
76
+ return raw;
77
+ } catch {
78
+ return Buffer.from('[]', 'utf8');
79
+ }
80
+ }
81
+
82
+ // --- SSE: subscribers/broadcast ---
83
+ const clients = new Set(); // Set<http.ServerResponse>
84
+ function sseSend(res, data) {
85
+ // default "message" event with one data line
86
+ res.write(`data: ${data}\n\n`);
87
+ }
88
+ function broadcast(data) {
89
+ for (const res of clients) {
90
+ try {
91
+ sseSend(res, data);
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ }
96
+ }
97
+
98
+ // Simple proxy to Vite dev server (only used if no built UI is found)
99
+ async function proxyUi(req, res, uiPathname) {
100
+ const httpMod = uiDevHost.startsWith('https')
101
+ ? await import('node:https')
102
+ : await import('node:http');
103
+ const client = uiDevHost.startsWith('https') ? httpMod.default : httpMod.default;
104
+ const targetPath =
105
+ uiPathname + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
106
+ const opts = {
107
+ hostname: uiDevHost,
108
+ port: uiDevPort,
109
+ method: req.method || 'GET',
110
+ path: targetPath,
111
+ headers: req.headers,
112
+ };
113
+ const p = client.request(opts, (up) => {
114
+ const headers = { ...up.headers };
115
+ // Always no-store for UI assets
116
+ headers['cache-control'] = 'no-store';
117
+ res.writeHead(up.statusCode || 502, headers);
118
+ up.pipe(res);
119
+ });
120
+ p.on('error', () => {
121
+ const msg = `StatikAPI UI dev server not found at http://${uiDevHost}:${uiDevPort}. Start it with: pnpm -w --filter packages/ui dev`;
122
+ res.writeHead(502, {
123
+ 'Content-Type': 'text/plain; charset=utf-8',
124
+ 'Cache-Control': 'no-store',
125
+ });
126
+ res.end(msg);
127
+ });
128
+ if (req.readable) req.pipe(p);
129
+ else p.end();
130
+ }
131
+
132
+ async function tryServeFrom(rootDir, reqPath, { spaFallback = null } = {}) {
133
+ const target = path.normalize(path.join(rootDir, reqPath.replace(/^\/+/, '')));
134
+ if (!target.startsWith(rootDir)) return null; // path traversal guard
135
+ try {
136
+ const st = await fs.stat(target);
137
+ if (st.isDirectory()) {
138
+ const idx = path.join(target, 'index.html');
139
+ const buf = await fs.readFile(idx);
140
+ return { buf, ctype: MIME['.html'] };
141
+ }
142
+ const buf = await fs.readFile(target);
143
+ const ext = path.extname(target).toLowerCase();
144
+ return { buf, ctype: MIME[ext] || 'application/octet-stream' };
145
+ } catch {
146
+ if (spaFallback) {
147
+ try {
148
+ const fallback = path.join(rootDir, spaFallback);
149
+ const buf = await fs.readFile(fallback);
150
+ return { buf, ctype: MIME['.html'] };
151
+ } catch {
152
+ /* ignore */
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+ }
158
+
159
+ const server = http.createServer(async (req, res) => {
160
+ const base = `http://${host}:${port}`;
161
+ let url;
162
+ try {
163
+ url = new URL(req.url || '/', base);
164
+ } catch {
165
+ return notFound(res, 'Invalid URL');
166
+ }
167
+ const pathname = url.pathname;
168
+
169
+ // --- SSE subscription ---
170
+ if (pathname === '/_ui/events') {
171
+ res.writeHead(200, {
172
+ 'Content-Type': 'text/event-stream; charset=utf-8',
173
+ 'Cache-Control': 'no-store',
174
+ Connection: 'keep-alive',
175
+ });
176
+ res.write(': connected\n\n'); // comment line
177
+ clients.add(res);
178
+
179
+ // keepalive pings
180
+ const ping = setInterval(() => {
181
+ try {
182
+ res.write(': ping\n\n');
183
+ } catch {
184
+ // ignore write errors
185
+ }
186
+ }, 30000);
187
+
188
+ req.on('close', () => {
189
+ clearInterval(ping);
190
+ clients.delete(res);
191
+ });
192
+ return;
193
+ }
194
+
195
+ // Internal notify hook (used by dev watcher)
196
+ if (pathname === '/_ui/changed') {
197
+ const route = url.searchParams.get('route') || '';
198
+ broadcast(`changed:${route}`);
199
+ return send(res, 204, '');
200
+ }
201
+
202
+ // UI root always React: serve built dist if present; otherwise proxy to Vite dev
203
+ if (pathname === '/_ui' || pathname === '/ui' || pathname === '/ui/') {
204
+ if (hasUi) {
205
+ const served = await tryServeFrom(uiDir, 'index.html', { spaFallback: null });
206
+ if (served) {
207
+ return send(res, 200, served.buf, {
208
+ 'Content-Type': served.ctype,
209
+ 'Cache-Control': 'no-store',
210
+ });
211
+ }
212
+ }
213
+ return proxyUi(req, res, '/_ui/');
214
+ }
215
+
216
+ // Helper: manifest passthrough
217
+ if (pathname === '/_ui/index' || pathname === '/ui/index') {
218
+ const raw = await readManifest();
219
+ const tag = etag(raw);
220
+ if (req.headers['if-none-match'] === tag) {
221
+ res.writeHead(304, { ETag: tag, 'Cache-Control': 'no-store' });
222
+ return res.end();
223
+ }
224
+ return send(res, 200, raw, {
225
+ 'Content-Type': 'application/json; charset=utf-8',
226
+ ETag: tag,
227
+ });
228
+ }
229
+
230
+ // Helper: stream a built JSON by route
231
+ if (pathname === '/_ui/file' || pathname === '/ui/file') {
232
+ const route = url.searchParams.get('route');
233
+ if (!route || !route.startsWith('/')) {
234
+ return badReq(res, 'query parameter "route" is required and must start with "/"');
235
+ }
236
+ const fileAbs = routeToOutPath({ outAbs: outDir, route });
237
+ if (!fss.existsSync(fileAbs)) return notFound(res, `No file for route: ${route}`);
238
+ res.writeHead(200, {
239
+ 'Content-Type': 'application/json; charset=utf-8',
240
+ 'X-StatikAPI-Route': route,
241
+ 'X-StatikAPI-File': path.relative(process.cwd(), fileAbs).replaceAll(path.sep, '/'),
242
+ 'Cache-Control': 'no-store',
243
+ });
244
+ fss.createReadStream(fileAbs).pipe(res);
245
+ return;
246
+ }
247
+
248
+ // Serve UI assets: built if present, else proxy to Vite dev
249
+ if (pathname.startsWith('/_ui') || pathname.startsWith('/ui')) {
250
+ if (hasUi) {
251
+ const rel = pathname.replace(/^\/_?ui\/?/, '');
252
+ const reqPath = rel === '' ? 'index.html' : rel;
253
+ const served = await tryServeFrom(uiDir, reqPath, { spaFallback: 'index.html' });
254
+ if (served) {
255
+ return send(res, 200, served.buf, {
256
+ 'Content-Type': served.ctype,
257
+ 'Cache-Control': 'no-store',
258
+ });
259
+ }
260
+ return notFound(res);
261
+ }
262
+ return proxyUi(req, res, pathname);
263
+ }
264
+
265
+ // Static serve from api-out (best-effort)
266
+ const safe = path.normalize(path.join(outDir, pathname));
267
+ if (!safe.startsWith(outDir)) return notFound(res);
268
+ try {
269
+ const stat = await fs.stat(safe);
270
+ if (stat.isDirectory()) {
271
+ const idx = path.join(safe, 'index.json');
272
+ const s2 = await fs.readFile(idx);
273
+ return send(res, 200, s2, { 'Content-Type': 'application/json; charset=utf-8' });
274
+ } else {
275
+ const buf = await fs.readFile(safe);
276
+ const ctype = safe.endsWith('.json')
277
+ ? 'application/json; charset=utf-8'
278
+ : 'text/plain; charset=utf-8';
279
+ return send(res, 200, buf, { 'Content-Type': ctype });
280
+ }
281
+ } catch {
282
+ return notFound(res);
283
+ }
284
+ });
285
+
286
+ await new Promise((resolve, reject) => {
287
+ server.once('error', reject);
288
+ server.listen(port, host, resolve);
289
+ });
290
+
291
+ const url = `http://${host}:${port}/_ui`;
292
+ console.log(`statikapi preview → serving ${path.relative(process.cwd(), outDir) || outDir}`);
293
+ console.log(`open ${url}`);
294
+
295
+ if (autoOpen) {
296
+ openBrowser(url).catch(() => {});
297
+ }
298
+
299
+ // Graceful shutdown
300
+ await new Promise((resolve) => {
301
+ const stop = () => server.close(() => resolve());
302
+ process.on('SIGINT', stop);
303
+ process.on('SIGTERM', stop);
304
+ });
305
+ return 0;
306
+ }
307
+
308
+ async function openBrowser(url) {
309
+ const { exec } = await import('node:child_process');
310
+ const plat = process.platform;
311
+ return new Promise((resolve) => {
312
+ const cmd =
313
+ plat === 'darwin'
314
+ ? `open "${url}"`
315
+ : plat === 'win32'
316
+ ? `start "" "${url}"`
317
+ : `xdg-open "${url}"`;
318
+ exec(cmd, () => resolve());
319
+ });
320
+ }
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_CONFIG = {
2
+ srcDir: 'src-api',
3
+ outDir: 'api-out',
4
+ };
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { DEFAULT_CONFIG } from './defaults.js';
5
+ import { validateAndNormalize, ConfigError } from './validate.js';
6
+
7
+ export async function loadConfig({ cwd = process.cwd(), flags = {} } = {}) {
8
+ const file = path.join(cwd, 'statikapi.config.js');
9
+ let fromFile = false;
10
+ let fileCfg = {};
11
+
12
+ try {
13
+ await fs.access(file);
14
+ fromFile = true;
15
+ const mod = await import(pathToFileURL(file).href);
16
+ fileCfg = (mod?.default ?? mod?.config ?? mod) || {};
17
+ if (typeof fileCfg !== 'object' || fileCfg == null || Array.isArray(fileCfg)) {
18
+ throw new ConfigError('Config file must export an object (default or named "config")');
19
+ }
20
+ } catch (err) {
21
+ if (err?.code !== 'ENOENT') {
22
+ const e = err instanceof Error ? err : new Error(String(err));
23
+ e.message = `Failed to load "statikapi.config.js": ${e.message}`;
24
+ throw e;
25
+ }
26
+ // no config file → fine
27
+ }
28
+
29
+ // Merge: defaults <- file <- flags
30
+ const merged = {
31
+ ...DEFAULT_CONFIG,
32
+ ...fileCfg,
33
+ ...pickFlags(flags),
34
+ };
35
+
36
+ // Validate & expand absolute paths
37
+ const finalCfg = validateAndNormalize(merged, { cwd });
38
+ return { config: finalCfg, source: { fromFile, filePath: file } };
39
+ }
40
+
41
+ function pickFlags(flags) {
42
+ const o = {};
43
+ if (flags.srcDir != null) o.srcDir = String(flags.srcDir);
44
+ if (flags.outDir != null) o.outDir = String(flags.outDir);
45
+ return o;
46
+ }
@@ -0,0 +1,39 @@
1
+ import path from 'node:path';
2
+
3
+ export class ConfigError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'ConfigError';
7
+ }
8
+ }
9
+
10
+ export function validateAndNormalize(userCfg, { cwd = process.cwd() } = {}) {
11
+ const cfg = { ...userCfg };
12
+
13
+ for (const key of ['srcDir', 'outDir']) {
14
+ if (typeof cfg[key] !== 'string' || !cfg[key].trim()) {
15
+ throw new ConfigError(`"${key}" must be a non-empty string`);
16
+ }
17
+ if (path.isAbsolute(cfg[key])) {
18
+ throw new ConfigError(`"${key}" must be a relative path. Got absolute: ${cfg[key]}`);
19
+ }
20
+ // normalize separators & remove ./ and a/../b
21
+ const normalized = path.posix.normalize(cfg[key].replaceAll(path.sep, '/'));
22
+ if (normalized.startsWith('..')) {
23
+ throw new ConfigError(`"${key}" cannot traverse outside the project: ${normalized}`);
24
+ }
25
+ cfg[key] = normalized;
26
+ }
27
+
28
+ if (cfg.srcDir === cfg.outDir) {
29
+ throw new ConfigError(`"srcDir" and "outDir" must differ (both "${cfg.srcDir}")`);
30
+ }
31
+
32
+ return {
33
+ ...cfg,
34
+ paths: {
35
+ srcAbs: path.join(cwd, cfg.srcDir),
36
+ outAbs: path.join(cwd, cfg.outDir),
37
+ },
38
+ };
39
+ }
package/src/help.js ADDED
@@ -0,0 +1,21 @@
1
+ export const HELP = `statikapi — Static API generator
2
+
3
+ Usage:
4
+ statikapi <command> [options]
5
+
6
+ Commands:
7
+ init Scaffold a new StatikAPI project
8
+ build Build static JSON endpoints
9
+ dev Start dev mode (watch & rebuild)
10
+ preview Serve the built JSON files
11
+
12
+ Global options:
13
+ -h, --help Show help
14
+ -v, --version Show version
15
+
16
+ Examples:
17
+ statikapi init
18
+ statikapi build
19
+ statikapi dev
20
+ statikapi preview
21
+ `;
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createRequire } from 'node:module';
2
+ import { HELP } from './help.js';
3
+ import initCmd from './commands/init.js';
4
+ import buildCmd from './commands/build.js';
5
+ import devCmd from './commands/dev.js';
6
+ import previewCmd from './commands/preview.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const { version } = require('../package.json');
10
+
11
+ export async function run(argv = process.argv.slice(2)) {
12
+ const [cmd, ...rest] = argv;
13
+
14
+ if (!cmd || cmd === '-h' || cmd === '--help') {
15
+ console.log(HELP);
16
+ return 0;
17
+ }
18
+
19
+ if (cmd === '-v' || cmd === '--version') {
20
+ console.log(`statikapi v${version}`);
21
+ return 0;
22
+ }
23
+
24
+ switch (cmd) {
25
+ case 'init':
26
+ return await initCmd(rest);
27
+ case 'build':
28
+ return await buildCmd(rest);
29
+ case 'dev':
30
+ return await devCmd(rest);
31
+ case 'preview':
32
+ return await previewCmd(rest);
33
+ default:
34
+ console.error(`Unknown command: ${cmd}\n`);
35
+ console.log(HELP);
36
+ return 1;
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ export class LoaderError extends Error {
2
+ constructor(file, message) {
3
+ super(`[module:${file}] ${message}`);
4
+ this.name = 'LoaderError';
5
+ this.file = file;
6
+ }
7
+ }
8
+
9
+ export class SerializationError extends Error {
10
+ constructor(message, atPath) {
11
+ super(`${message}${atPath ? ` at ${atPath}` : ''}`);
12
+ this.name = 'SerializationError';
13
+ this.atPath = atPath || '$';
14
+ }
15
+ }
@@ -0,0 +1,60 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { LoaderError } from './errors.js';
4
+ import { assertSerializable } from './serializeGuard.js';
5
+
6
+ /**
7
+ * Load a module file (ESM or CJS), resolve its value:
8
+ * - if it exports `async function data()`, call it (no args) and await.
9
+ * - else if default export is a function, call and await.
10
+ * - else if default export is a value, use it.
11
+ * Then verify JSON-serializability.
12
+ */
13
+ export async function loadModuleValue(fileAbs, args = {}) {
14
+ const fresh = args?.__fresh === true; // dev watcher sets this
15
+
16
+ const fileInfo = short(fileAbs);
17
+ let mod;
18
+ try {
19
+ const u = new URL(pathToFileURL(fileAbs).href);
20
+ if (fresh) u.search = `v=${Date.now()}-${Math.random()}`;
21
+ mod = await import(u.href);
22
+ } catch (e) {
23
+ throw new LoaderError(fileInfo, `Failed to import: ${e.message}`);
24
+ }
25
+
26
+ let producer = null;
27
+ if (typeof mod?.data === 'function') producer = mod.data;
28
+ else if (typeof mod?.default === 'function') producer = mod.default;
29
+
30
+ let value;
31
+ try {
32
+ if (producer) value = await producer(args);
33
+ else if ('default' in (mod || {})) value = mod.default;
34
+ else
35
+ throw new LoaderError(
36
+ fileInfo,
37
+ `No export found. Use 'export async function data()' or 'export default <value|function>'.`
38
+ );
39
+ } catch (e) {
40
+ if (e instanceof LoaderError) throw e;
41
+ throw new LoaderError(fileInfo, `Error executing module: ${e.message}`);
42
+ }
43
+
44
+ try {
45
+ assertSerializable(value, '$');
46
+ } catch (e) {
47
+ throw new LoaderError(fileInfo, `Not JSON-serializable: ${e.message}`);
48
+ }
49
+
50
+ return value;
51
+ }
52
+
53
+ function short(p) {
54
+ // Show path relative to repo root for nicer messages
55
+ try {
56
+ return path.relative(process.cwd(), p) || p;
57
+ } catch {
58
+ return p;
59
+ }
60
+ }
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { LoaderError } from './errors.js';
4
+
5
+ export async function loadPaths(fileAbs, { route, type, segments }, { fresh = false } = {}) {
6
+ const fileInfo = short(fileAbs);
7
+ let mod;
8
+ try {
9
+ const u = new URL(pathToFileURL(fileAbs).href);
10
+ if (fresh) u.search = `v=${Date.now()}-${Math.random()}`;
11
+ mod = await import(u.href);
12
+ } catch (e) {
13
+ throw new LoaderError(fileInfo, `Failed to import for paths(): ${e.message}`);
14
+ }
15
+ if (typeof mod?.paths !== 'function') return null;
16
+
17
+ let res;
18
+ try {
19
+ res = await mod.paths();
20
+ } catch (e) {
21
+ throw new LoaderError(fileInfo, `paths() threw: ${e.message}`);
22
+ }
23
+ if (!Array.isArray(res)) {
24
+ throw new LoaderError(fileInfo, `paths() must return an array`);
25
+ }
26
+
27
+ if (type === 'dynamic') {
28
+ // /users/:id → string[]
29
+ for (const v of res) {
30
+ if (typeof v !== 'string')
31
+ throw new LoaderError(fileInfo, `paths() for ${route} must be string[]`);
32
+ if (!v)
33
+ throw new LoaderError(
34
+ fileInfo,
35
+ `paths() entry for :${paramName(segments)} cannot be empty`
36
+ );
37
+ if (v.includes('/'))
38
+ throw new LoaderError(
39
+ fileInfo,
40
+ `paths() entry for :${paramName(segments)} must not contain '/'`
41
+ );
42
+ }
43
+ return res.map((v) => [v]); // normalize to array-of-segments
44
+ }
45
+
46
+ if (type === 'catchall') {
47
+ // /docs/*slug → (string | string[])[]
48
+ const out = [];
49
+ for (const v of res) {
50
+ if (typeof v === 'string') {
51
+ if (!v)
52
+ throw new LoaderError(
53
+ fileInfo,
54
+ `paths() entry for *${paramName(segments)} must be non-empty`
55
+ );
56
+ out.push([v]);
57
+ } else if (Array.isArray(v)) {
58
+ if (v.length === 0) {
59
+ throw new LoaderError(
60
+ fileInfo,
61
+ `paths() entry for *${paramName(segments)} must be non-empty`
62
+ );
63
+ }
64
+ for (const s of v) {
65
+ if (typeof s !== 'string' || !s) {
66
+ throw new LoaderError(
67
+ fileInfo,
68
+ `paths() entry for *${paramName(segments)} must contain non-empty strings`
69
+ );
70
+ }
71
+ if (s.includes('/')) {
72
+ throw new LoaderError(
73
+ fileInfo,
74
+ `paths() entry segment for *${paramName(segments)} must not contain '/'`
75
+ );
76
+ }
77
+ }
78
+ out.push(v);
79
+ } else {
80
+ throw new LoaderError(fileInfo, `paths() for ${route} must be (string | string[])[]`);
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // static shouldn't have paths()
87
+ return null;
88
+ }
89
+
90
+ function paramName(segTokens) {
91
+ // segTokens like ['users', ':id'] or ['docs','*slug']
92
+ const tok = segTokens.find((t) => t.startsWith(':') || t.startsWith('*'));
93
+ return tok ? tok.slice(1) : 'param';
94
+ }
95
+
96
+ function short(p) {
97
+ try {
98
+ return path.relative(process.cwd(), p) || p;
99
+ } catch {
100
+ return p;
101
+ }
102
+ }