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.
- package/README.md +160 -0
- package/bin/statikapi.js +8 -0
- package/package.json +27 -0
- package/src/build/routeOutPath.js +8 -0
- package/src/commands/build.js +191 -0
- package/src/commands/dev.js +249 -0
- package/src/commands/init.js +10 -0
- package/src/commands/preview.js +320 -0
- package/src/config/defaults.js +4 -0
- package/src/config/loadConfig.js +46 -0
- package/src/config/validate.js +39 -0
- package/src/help.js +21 -0
- package/src/index.js +38 -0
- package/src/loader/errors.js +15 -0
- package/src/loader/loadModuleValue.js +60 -0
- package/src/loader/loadPaths.js +102 -0
- package/src/loader/serializeGuard.js +51 -0
- package/src/router/mapRoutes.js +114 -0
- package/src/util/bytes.js +10 -0
- package/src/util/fsx.js +22 -0
- package/src/util/readFlags.js +28 -0
- package/ui/assets/index-C7lyR6dJ.js +57 -0
- package/ui/assets/index-C7lyR6dJ.js.map +1 -0
- package/ui/assets/index-CnyB4RRg.css +1 -0
- package/ui/index.html +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# statikapi — Static API generator (CLI)
|
|
2
|
+
|
|
3
|
+
Build a folder of JSON endpoints from simple files, then preview them in a lightweight UI.
|
|
4
|
+
|
|
5
|
+
> Requires **Node 18+**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Use without installing (recommended):
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npx statikapi --help
|
|
13
|
+
|
|
14
|
+
Or add to a project
|
|
15
|
+
pnpm add -D statikapi
|
|
16
|
+
# npm i -D statikapi
|
|
17
|
+
# yarn add -D statikapi
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
statikapi <command> [options]
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
init Scaffold a new StatikAPI project
|
|
27
|
+
build Build static JSON endpoints
|
|
28
|
+
dev Watch & rebuild on changes
|
|
29
|
+
preview Serve the built JSON files + UI
|
|
30
|
+
|
|
31
|
+
Global:
|
|
32
|
+
-h, --help Show help
|
|
33
|
+
-v, --version Show version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
##Quick start
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
# 1) Create a folder with API sources
|
|
40
|
+
|
|
41
|
+
mkdir src-api
|
|
42
|
+
echo "export default { hello: 'world' }" > src-api/index.js
|
|
43
|
+
|
|
44
|
+
# 2) Build
|
|
45
|
+
|
|
46
|
+
npx statikapi build --pretty
|
|
47
|
+
|
|
48
|
+
# 3) Preview (opens http://127.0.0.1:8788/_ui)
|
|
49
|
+
|
|
50
|
+
npx statikapi preview
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Project layout
|
|
54
|
+
|
|
55
|
+
- src-api/: your source files (default; configurable)
|
|
56
|
+
- api-out/: generated JSON, one folder per route (default)
|
|
57
|
+
|
|
58
|
+
Examples of file → route mapping:
|
|
59
|
+
|
|
60
|
+
File Route Output file
|
|
61
|
+
src-api/index.js / api-out/index.json
|
|
62
|
+
src-api/blog/archive.js /blog/archive api-out/blog/archive/index.json
|
|
63
|
+
src-api/users/[id].js /users/:id dynamic (see below)
|
|
64
|
+
src-api/docs/[...slug].js /docs/\*slug catch-all (see below)
|
|
65
|
+
|
|
66
|
+
## Dynamic routes
|
|
67
|
+
|
|
68
|
+
For src-api/users/[id].js, export a paths() function that returns the concrete IDs to prebuild:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
// src-api/users/[id].js
|
|
72
|
+
export async function paths() {
|
|
73
|
+
return ['1', '2', '3']; // builds /users/1, /users/2, /users/3
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function data({ params }) {
|
|
77
|
+
return { id: params.id };
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Catch-all works similarly:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
// src-api/docs/[...slug].js
|
|
85
|
+
export async function paths() {
|
|
86
|
+
return [['a', 'b'], ['guide']]; // → /docs/a/b and /docs/guide
|
|
87
|
+
}
|
|
88
|
+
export async function data({ params }) {
|
|
89
|
+
return { slug: params.slug, path: params.slug.join('/') };
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Producing data
|
|
94
|
+
|
|
95
|
+
Each module can export either:
|
|
96
|
+
|
|
97
|
+
- `export async function data(ctx) { ... }` → its return value is serialized, or
|
|
98
|
+
- `export default <value|function>` → if a function, it’s called and awaited.
|
|
99
|
+
|
|
100
|
+
Returned data must be JSON-serializable (plain objects/arrays, finite numbers, no functions, etc.).
|
|
101
|
+
|
|
102
|
+
## Config
|
|
103
|
+
|
|
104
|
+
You can optionally add statikapi.config.js in your project root:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
export default {
|
|
108
|
+
srcDir: 'src-api',
|
|
109
|
+
outDir: 'api-out',
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
You can override via flags: `--srcDir <dir>`, `--outDir <dir>`.
|
|
114
|
+
|
|
115
|
+
## Flags (per command)
|
|
116
|
+
|
|
117
|
+
`build`
|
|
118
|
+
|
|
119
|
+
- `--pretty` (or `--minify=false`) — pretty-print JSON.
|
|
120
|
+
- `--srcDir`, `--outDir` — override config paths.
|
|
121
|
+
|
|
122
|
+
`dev`
|
|
123
|
+
|
|
124
|
+
- Rebuilds on changes, updates the preview UI via SSE.
|
|
125
|
+
- `--previewHost`, `--previewPort` — where to notify the preview server.
|
|
126
|
+
- `--srcDir`, `--outDir` — override config paths.
|
|
127
|
+
|
|
128
|
+
`preview`
|
|
129
|
+
|
|
130
|
+
- Serves `api-out/` and the UI at `/\_ui`.
|
|
131
|
+
- `--host` (default 127.0.0.1)
|
|
132
|
+
- `--port` (default 8788)
|
|
133
|
+
- `--open` — try to open the browser
|
|
134
|
+
- UI source:
|
|
135
|
+
- `--uiDir <path>` — serve a built UI from this directory
|
|
136
|
+
- Otherwise, uses the embedded UI bundled with the CLI
|
|
137
|
+
- If missing, proxies to a dev UI at `http://127.0.0.1:5173` (override with `--uiDevHost`, `--uiDevPort`)
|
|
138
|
+
|
|
139
|
+
## Examples
|
|
140
|
+
|
|
141
|
+
There are two example projects in this repo under `example/`:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
# from repo root
|
|
145
|
+
|
|
146
|
+
pnpm -C example/basic dev
|
|
147
|
+
pnpm -C example/basic preview
|
|
148
|
+
|
|
149
|
+
pnpm -C example/dynamic dev
|
|
150
|
+
pnpm -C example/dynamic preview
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Troubleshooting
|
|
154
|
+
|
|
155
|
+
- UI doesn’t load: ensure `preview` is running; if you’re developing the UI separately, start Vite on port 5173 or pass `--uiDir` to serve a built UI.
|
|
156
|
+
- Dynamic routes not emitted: make sure the file exports a valid `paths()` function returning strings (or arrays of strings for catch-all).
|
|
157
|
+
|
|
158
|
+
License
|
|
159
|
+
|
|
160
|
+
ISC – see LICENSE
|
package/bin/statikapi.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "statikapi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/zonayedpca/statikapi",
|
|
7
|
+
"directory": "packages/cli"
|
|
8
|
+
},
|
|
9
|
+
"bugs": { "url": "https://github.com/zonayedpca/statikapi/issues" },
|
|
10
|
+
"homepage": "https://github.com/zonayedpca/statikapi#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "statikapi": "bin/statikapi.js" },
|
|
13
|
+
"files": ["bin", "dist", "ui", "src", "README.md", "LICENSE"],
|
|
14
|
+
"exports": { ".": { "import": "./src/index.js" } },
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": ["static", "json", "api", "cli", "ssg"],
|
|
18
|
+
"publishConfig": { "access": "public", "provenance": true },
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"chokidar": "^3.6.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node ./scripts/build.js || true",
|
|
24
|
+
"sync-ui": "rm -rf ui && mkdir -p ui && cp -R ../ui/dist/* ui/",
|
|
25
|
+
"prepack": "pnpm -w --filter @statikapi/ui build && pnpm run sync-ui"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** Map a route to an output JSON file (index.json style). Static routes only. */
|
|
4
|
+
export function routeToOutPath({ outAbs, route }) {
|
|
5
|
+
// '/' -> index.json, '/users' -> users/index.json, '/blog/archive' -> blog/archive/index.json
|
|
6
|
+
const rel = route === '/' ? 'index.json' : route.slice(1) + '/index.json';
|
|
7
|
+
return path.join(outAbs, rel);
|
|
8
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readFlags } from '../util/readFlags.js'; // from Task 3
|
|
2
|
+
import { loadConfig } from '../config/loadConfig.js'; // from Task 3
|
|
3
|
+
import { ConfigError } from '../config/validate.js'; // from Task 3
|
|
4
|
+
import { mapRoutes } from '../router/mapRoutes.js'; // from Task 4
|
|
5
|
+
import { loadModuleValue } from '../loader/loadModuleValue.js'; // from Task 5
|
|
6
|
+
import { loadPaths } from '../loader/loadPaths.js';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
import { emptyDir, writeFileEnsured } from '../util/fsx.js';
|
|
12
|
+
import { routeToOutPath } from '../build/routeOutPath.js';
|
|
13
|
+
import { formatBytes } from '../util/bytes.js';
|
|
14
|
+
|
|
15
|
+
function toConcrete(routePattern, segTokens, segs) {
|
|
16
|
+
// segTokens: ['users', ':id'] or ['docs','*slug']
|
|
17
|
+
// segs: ['1'] or ['a','b']
|
|
18
|
+
let idx = 0;
|
|
19
|
+
const parts = routePattern.split('/').map((p) => {
|
|
20
|
+
if (p.startsWith(':')) return segs[idx++] ?? '';
|
|
21
|
+
if (p.startsWith('*')) return segs.slice(idx).join('/');
|
|
22
|
+
return p;
|
|
23
|
+
});
|
|
24
|
+
const concrete = parts.join('/').replace(/\/+/g, '/');
|
|
25
|
+
return concrete;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toParams(segTokens, concreteRoute) {
|
|
29
|
+
const concreteSegs = concreteRoute.split('/').filter(Boolean);
|
|
30
|
+
const params = {};
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < segTokens.length; i++) {
|
|
33
|
+
const tok = segTokens[i];
|
|
34
|
+
if (tok.startsWith(':')) {
|
|
35
|
+
params[tok.slice(1)] = concreteSegs[i] ?? '';
|
|
36
|
+
} else if (tok.startsWith('*')) {
|
|
37
|
+
params[tok.slice(1)] = concreteSegs.slice(i);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return params;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default async function buildCmd(argv) {
|
|
45
|
+
const t0 = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const flags = readFlags(argv);
|
|
48
|
+
const { config } = await loadConfig({ flags });
|
|
49
|
+
|
|
50
|
+
const pretty = flags.pretty === true || flags.minify === false;
|
|
51
|
+
const space = pretty ? 2 : 0;
|
|
52
|
+
|
|
53
|
+
// keep legacy-friendly stub line so the old test passes
|
|
54
|
+
console.log('statikapi build → building JSON endpoints (MVP)');
|
|
55
|
+
|
|
56
|
+
// discover routes
|
|
57
|
+
const routes = await mapRoutes({ srcAbs: config.paths.srcAbs });
|
|
58
|
+
|
|
59
|
+
// MVP: only handle static routes (dynamic/catch-all in next task)
|
|
60
|
+
const staticRoutes = routes.filter((r) => r.type === 'static');
|
|
61
|
+
const dynRoutes = routes.filter((r) => r.type === 'dynamic');
|
|
62
|
+
const catRoutes = routes.filter((r) => r.type === 'catchall');
|
|
63
|
+
|
|
64
|
+
// prepare outDir (clean, then write)
|
|
65
|
+
await emptyDir(config.paths.outAbs);
|
|
66
|
+
|
|
67
|
+
let fileCount = 0;
|
|
68
|
+
let byteCount = 0;
|
|
69
|
+
let skippedDynamic = 0;
|
|
70
|
+
const manifest = []; // array of unified entries
|
|
71
|
+
|
|
72
|
+
const digest = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
73
|
+
const relSrc = (abs) => {
|
|
74
|
+
try {
|
|
75
|
+
return path.relative(process.cwd(), abs) || abs;
|
|
76
|
+
} catch {
|
|
77
|
+
return abs;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const relOut = (abs) => {
|
|
82
|
+
try {
|
|
83
|
+
return path.relative(process.cwd(), abs).replaceAll(path.sep, '/') || abs;
|
|
84
|
+
} catch {
|
|
85
|
+
return abs;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function pushManifest({ route, srcFile, outFile, json }) {
|
|
90
|
+
const st = await fs.stat(outFile).catch(() => null);
|
|
91
|
+
const entry = {
|
|
92
|
+
// stable field order
|
|
93
|
+
route,
|
|
94
|
+
outFile: relOut(outFile),
|
|
95
|
+
srcFile: relSrc(srcFile),
|
|
96
|
+
// backward-compat (old tests read filePath → output path)
|
|
97
|
+
filePath: relOut(outFile),
|
|
98
|
+
bytes: Buffer.byteLength(json),
|
|
99
|
+
mtime: st ? st.mtimeMs : Date.now(),
|
|
100
|
+
hash: digest(json),
|
|
101
|
+
revalidate: null,
|
|
102
|
+
};
|
|
103
|
+
manifest.push(entry);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const r of staticRoutes) {
|
|
107
|
+
const val = await loadModuleValue(r.file);
|
|
108
|
+
const json = JSON.stringify(val, null, space) + (pretty ? '\n' : '');
|
|
109
|
+
const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: r.route });
|
|
110
|
+
await writeFileEnsured(outFile, json);
|
|
111
|
+
fileCount++;
|
|
112
|
+
byteCount += Buffer.byteLength(json);
|
|
113
|
+
|
|
114
|
+
await pushManifest({ route: r.route, srcFile: r.file, outFile, json });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// helper: materialize a concrete route from tokens + param segments
|
|
118
|
+
async function emitConcreteRoute(r, segs) {
|
|
119
|
+
const concreteRoute = toConcrete(r.route, r.segments, segs);
|
|
120
|
+
const params = toParams(r.segments, concreteRoute);
|
|
121
|
+
const val = await loadModuleValue(r.file, { params });
|
|
122
|
+
const json = JSON.stringify(val, null, space) + (pretty ? '\n' : '');
|
|
123
|
+
const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: concreteRoute });
|
|
124
|
+
await writeFileEnsured(outFile, json);
|
|
125
|
+
fileCount++;
|
|
126
|
+
byteCount += Buffer.byteLength(json);
|
|
127
|
+
|
|
128
|
+
await pushManifest({ route: concreteRoute, srcFile: r.file, outFile, json });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// dynamic: expect [['val'], ...] from loadPaths()
|
|
132
|
+
for (const r of dynRoutes) {
|
|
133
|
+
const list = await loadPaths(r.file, r);
|
|
134
|
+
if (!list) {
|
|
135
|
+
skippedDynamic++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for (const segs of list) {
|
|
140
|
+
const concrete = toConcrete(r.route, r.segments, segs);
|
|
141
|
+
if (seen.has(concrete)) continue;
|
|
142
|
+
seen.add(concrete);
|
|
143
|
+
await emitConcreteRoute(r, segs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// catch-all: expect [['a','b'], ['guide'], ...]
|
|
148
|
+
for (const r of catRoutes) {
|
|
149
|
+
const list = await loadPaths(r.file, r);
|
|
150
|
+
if (!list) {
|
|
151
|
+
skippedDynamic++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const segs of list) {
|
|
156
|
+
const concrete = toConcrete(r.route, r.segments, segs);
|
|
157
|
+
if (seen.has(concrete)) continue;
|
|
158
|
+
seen.add(concrete);
|
|
159
|
+
await emitConcreteRoute(r, segs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write manifest once (sorted for determinism)
|
|
164
|
+
const manifestPath = path.join(config.paths.outAbs, '.statikapi', 'manifest.json');
|
|
165
|
+
const sorted = manifest.sort((a, b) => a.route.localeCompare(b.route));
|
|
166
|
+
const manifestJson = JSON.stringify(sorted, null, pretty ? 2 : 0) + (pretty ? '\n' : '');
|
|
167
|
+
await writeFileEnsured(manifestPath, manifestJson);
|
|
168
|
+
byteCount += Buffer.byteLength(manifestJson);
|
|
169
|
+
fileCount++;
|
|
170
|
+
|
|
171
|
+
const elapsed = Date.now() - t0;
|
|
172
|
+
|
|
173
|
+
const extra = skippedDynamic ? `, skipped ${skippedDynamic} dynamic route(s)` : '';
|
|
174
|
+
console.log(
|
|
175
|
+
`[statikapi] wrote ${fileCount} file(s), ${formatBytes(byteCount)} in ${elapsed} ms${extra}`
|
|
176
|
+
);
|
|
177
|
+
return 0;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof ConfigError) {
|
|
180
|
+
console.error(`[statikapi] Config error: ${err.message}`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
// LoaderError already includes file path; show as-is
|
|
184
|
+
if (err && err.name === 'LoaderError') {
|
|
185
|
+
console.error(`[statikapi] ${err.message}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
console.error('[statikapi] Build failed:', err?.stack || err?.message || err);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { readFlags } from '../util/readFlags.js';
|
|
6
|
+
import { loadConfig } from '../config/loadConfig.js';
|
|
7
|
+
import { mapRoutes, fileToRoute } from '../router/mapRoutes.js';
|
|
8
|
+
import { routeToOutPath } from '../build/routeOutPath.js';
|
|
9
|
+
import { writeFileEnsured } from '../util/fsx.js';
|
|
10
|
+
import { loadModuleValue } from '../loader/loadModuleValue.js';
|
|
11
|
+
import { loadPaths } from '../loader/loadPaths.js';
|
|
12
|
+
|
|
13
|
+
function clearScreen() {
|
|
14
|
+
process.stdout.write('\x1Bc'); // ANSI "clear screen"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toConcrete(routePattern, segTokens, segs) {
|
|
18
|
+
let idx = 0;
|
|
19
|
+
const parts = routePattern.split('/').map((p) => {
|
|
20
|
+
if (p.startsWith(':')) return segs[idx++] ?? '';
|
|
21
|
+
if (p.startsWith('*')) return segs.slice(idx).join('/');
|
|
22
|
+
return p;
|
|
23
|
+
});
|
|
24
|
+
return parts.join('/').replace(/\/+/g, '/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toParams(segTokens, concreteRoute) {
|
|
28
|
+
const concreteSegs = concreteRoute.split('/').filter(Boolean);
|
|
29
|
+
const params = {};
|
|
30
|
+
for (let i = 0; i < segTokens.length; i++) {
|
|
31
|
+
const tok = segTokens[i];
|
|
32
|
+
if (tok.startsWith(':')) params[tok.slice(1)] = concreteSegs[i] ?? '';
|
|
33
|
+
else if (tok.startsWith('*')) {
|
|
34
|
+
params[tok.slice(1)] = concreteSegs.slice(i);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return params;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default async function devCmd(argv) {
|
|
42
|
+
// In non-TTY (like node --test), behave like the old stub so tests don't hang.
|
|
43
|
+
if (!process.stdout.isTTY) {
|
|
44
|
+
console.log('statikapi dev → starting dev server (stub)');
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const flags = readFlags(argv);
|
|
49
|
+
const { config } = await loadConfig({ flags });
|
|
50
|
+
|
|
51
|
+
// Where to notify preview
|
|
52
|
+
const previewHost = String(flags.previewHost ?? '127.0.0.1');
|
|
53
|
+
const previewPort = Number.isFinite(flags.previewPort) ? Number(flags.previewPort) : 8788;
|
|
54
|
+
const notifyOrigin = `http://${previewHost}:${previewPort}`;
|
|
55
|
+
|
|
56
|
+
async function notifyChanged(route) {
|
|
57
|
+
try {
|
|
58
|
+
// Node 18+ has global fetch
|
|
59
|
+
const u = `${notifyOrigin}/_ui/changed?route=${encodeURIComponent(route)}`;
|
|
60
|
+
await fetch(u, { method: 'POST' }).catch(() => {});
|
|
61
|
+
} catch {
|
|
62
|
+
// Ignore if preview isn't running
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cache of outputs per source file (for deletions on subsequent rebuilds)
|
|
67
|
+
const lastEmitted = new Map(); // fileAbs -> Set<concreteRoute>
|
|
68
|
+
|
|
69
|
+
// Manifest state
|
|
70
|
+
const manifestByRoute = new Map(); // route -> entry
|
|
71
|
+
const digest = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
72
|
+
const relSrc = (abs) => {
|
|
73
|
+
try {
|
|
74
|
+
return path.relative(process.cwd(), abs) || abs;
|
|
75
|
+
} catch {
|
|
76
|
+
return abs;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const relOut = (abs) => {
|
|
80
|
+
try {
|
|
81
|
+
return path.relative(process.cwd(), abs).replaceAll(path.sep, '/') || abs;
|
|
82
|
+
} catch {
|
|
83
|
+
return abs;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
async function writeManifest() {
|
|
87
|
+
const list = Array.from(manifestByRoute.values()).sort((a, b) =>
|
|
88
|
+
a.route.localeCompare(b.route)
|
|
89
|
+
);
|
|
90
|
+
const json = JSON.stringify(list, null, 2) + '\n';
|
|
91
|
+
await writeFileEnsured(path.join(config.paths.outAbs, '.statikapi', 'manifest.json'), json);
|
|
92
|
+
}
|
|
93
|
+
async function upsertManifest({ route, srcFile, outFile, json }) {
|
|
94
|
+
const st = await fs.stat(outFile).catch(() => null);
|
|
95
|
+
|
|
96
|
+
const entry = {
|
|
97
|
+
route,
|
|
98
|
+
outFile: relOut(outFile),
|
|
99
|
+
srcFile: relSrc(srcFile),
|
|
100
|
+
filePath: relOut(outFile), // backward-compat alias
|
|
101
|
+
bytes: Buffer.byteLength(json),
|
|
102
|
+
mtime: st ? st.mtimeMs : Date.now(),
|
|
103
|
+
hash: digest(json),
|
|
104
|
+
revalidate: null,
|
|
105
|
+
};
|
|
106
|
+
manifestByRoute.set(route, entry);
|
|
107
|
+
}
|
|
108
|
+
function deleteFromManifest(route) {
|
|
109
|
+
manifestByRoute.delete(route);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function emitStatic(r, { fresh = false } = {}) {
|
|
113
|
+
const val = await loadModuleValue(r.file, { __fresh: fresh });
|
|
114
|
+
const json = JSON.stringify(val, null, 2) + '\n';
|
|
115
|
+
const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: r.route });
|
|
116
|
+
await writeFileEnsured(outFile, json);
|
|
117
|
+
lastEmitted.set(r.file, new Set([r.route]));
|
|
118
|
+
await upsertManifest({ route: r.route, srcFile: r.file, outFile, json });
|
|
119
|
+
// notify UI
|
|
120
|
+
await notifyChanged(r.route);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function emitDynamic(r, { fresh = false } = {}) {
|
|
125
|
+
const list = await loadPaths(r.file, r, { fresh });
|
|
126
|
+
if (!list) {
|
|
127
|
+
lastEmitted.set(r.file, new Set());
|
|
128
|
+
return { written: 0, skipped: 1 };
|
|
129
|
+
}
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const emittedRoutes = new Set();
|
|
132
|
+
let written = 0;
|
|
133
|
+
for (const segs of list) {
|
|
134
|
+
const concrete = toConcrete(r.route, r.segments, segs);
|
|
135
|
+
if (seen.has(concrete)) continue;
|
|
136
|
+
seen.add(concrete);
|
|
137
|
+
const params = toParams(r.segments, concrete);
|
|
138
|
+
const val = await loadModuleValue(r.file, { params, __fresh: fresh });
|
|
139
|
+
const json = JSON.stringify(val, null, 2) + '\n';
|
|
140
|
+
const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: concrete });
|
|
141
|
+
await writeFileEnsured(outFile, json);
|
|
142
|
+
emittedRoutes.add(concrete);
|
|
143
|
+
await upsertManifest({ route: concrete, srcFile: r.file, outFile, json });
|
|
144
|
+
written++;
|
|
145
|
+
}
|
|
146
|
+
// Delete stale outputs from this file (not present anymore)
|
|
147
|
+
const prev = lastEmitted.get(r.file) || new Set();
|
|
148
|
+
for (const oldRoute of prev) {
|
|
149
|
+
if (!emittedRoutes.has(oldRoute)) {
|
|
150
|
+
const p = routeToOutPath({ outAbs: config.paths.outAbs, route: oldRoute });
|
|
151
|
+
try {
|
|
152
|
+
await fs.rm(p, { force: true });
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
deleteFromManifest(oldRoute);
|
|
157
|
+
await notifyChanged(oldRoute);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
lastEmitted.set(r.file, emittedRoutes);
|
|
161
|
+
|
|
162
|
+
// notify UI for all emitted routes
|
|
163
|
+
for (const route of emittedRoutes) {
|
|
164
|
+
await notifyChanged(route);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { written, skipped: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function shouldHandle(fileAbs) {
|
|
171
|
+
const rel = path.posix.normalize(
|
|
172
|
+
fileAbs.replaceAll(path.sep, '/').slice(config.paths.srcAbs.length + 1)
|
|
173
|
+
);
|
|
174
|
+
if (!rel) return false;
|
|
175
|
+
if (rel.startsWith('_')) return false;
|
|
176
|
+
const ext = path.extname(rel);
|
|
177
|
+
return ext === '.js' || ext === '.mjs' || ext === '.cjs';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function buildOne(fileAbs, kind) {
|
|
181
|
+
if (!shouldHandle(fileAbs)) return;
|
|
182
|
+
const info = fileToRoute({ srcAbs: config.paths.srcAbs, fileAbs });
|
|
183
|
+
clearScreen();
|
|
184
|
+
console.log(`statikapi dev → ${kind}: ${path.relative(process.cwd(), fileAbs)}`);
|
|
185
|
+
|
|
186
|
+
if (!info) {
|
|
187
|
+
// File is ignored or no longer maps; delete prior outputs if any
|
|
188
|
+
const prev = lastEmitted.get(fileAbs);
|
|
189
|
+
if (prev) {
|
|
190
|
+
for (const route of prev) {
|
|
191
|
+
const p = routeToOutPath({ outAbs: config.paths.outAbs, route });
|
|
192
|
+
try {
|
|
193
|
+
await fs.rm(p, { force: true });
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
deleteFromManifest(route);
|
|
198
|
+
await notifyChanged(route);
|
|
199
|
+
}
|
|
200
|
+
lastEmitted.delete(fileAbs);
|
|
201
|
+
}
|
|
202
|
+
console.log(`[statikapi] (ignored or unmapped)`);
|
|
203
|
+
await writeManifest();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const r = { file: fileAbs, route: info.route, type: info.type, segments: info.normSegments };
|
|
208
|
+
try {
|
|
209
|
+
if (r.type === 'static') {
|
|
210
|
+
const files = await emitStatic(r, { fresh: true });
|
|
211
|
+
console.log(`[statikapi] wrote ${files} file(s) for ${r.route}`);
|
|
212
|
+
} else {
|
|
213
|
+
const { written, skipped } = await emitDynamic(r, { fresh: true });
|
|
214
|
+
const extra = skipped ? `, skipped ${skipped}` : '';
|
|
215
|
+
console.log(`[statikapi] wrote ${written} file(s) for ${r.route}${extra}`);
|
|
216
|
+
}
|
|
217
|
+
await writeManifest();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`[statikapi] ${err?.message || err}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Initial full build
|
|
224
|
+
clearScreen();
|
|
225
|
+
console.log('statikapi dev → initial build…');
|
|
226
|
+
const routes = await mapRoutes({ srcAbs: config.paths.srcAbs });
|
|
227
|
+
for (const r of routes) {
|
|
228
|
+
if (r.type === 'static') await emitStatic(r);
|
|
229
|
+
else await emitDynamic(r);
|
|
230
|
+
}
|
|
231
|
+
await writeManifest();
|
|
232
|
+
console.log(`[statikapi] ready. Watching ${path.relative(process.cwd(), config.paths.srcAbs)}/`);
|
|
233
|
+
|
|
234
|
+
const watcher = chokidar.watch(config.paths.srcAbs, {
|
|
235
|
+
ignoreInitial: true,
|
|
236
|
+
ignored: (p) => path.basename(p).startsWith('_'),
|
|
237
|
+
});
|
|
238
|
+
watcher.on('add', (p) => buildOne(p, 'add'));
|
|
239
|
+
watcher.on('change', (p) => buildOne(p, 'change'));
|
|
240
|
+
watcher.on('unlink', (p) => buildOne(p, 'unlink'));
|
|
241
|
+
|
|
242
|
+
// Keep process alive until SIGINT
|
|
243
|
+
await new Promise((resolve) => {
|
|
244
|
+
const stop = () => watcher.close().then(resolve).catch(resolve);
|
|
245
|
+
process.on('SIGINT', stop);
|
|
246
|
+
process.on('SIGTERM', stop);
|
|
247
|
+
});
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export default async function initCmd() {
|
|
2
|
+
console.log('statikapi init → use `npx create-statikapi <name>` to scaffold a new project.');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// after publishing, replace the above with the below:
|
|
6
|
+
// export default async function initCmd(argv) {
|
|
7
|
+
// // Delegate to create-statikapi programmatically
|
|
8
|
+
// const { main } = await import('create-statikapi/src/index.js');
|
|
9
|
+
// return (await main(argv), 0);
|
|
10
|
+
// }
|