statikapi 0.6.4 → 1.0.0-rc.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statikapi",
3
- "version": "0.6.4",
3
+ "version": "1.0.0-rc.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zonayedpca/statikapi",
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
 
3
- /** Map a route to an output JSON file (index.json style). Static routes only. */
3
+ /** Map a route to an output JSON file (index.json style). */
4
4
  export function routeToOutPath({ outAbs, route }) {
5
5
  // '/' -> index.json, '/users' -> users/index.json, '/blog/archive' -> blog/archive/index.json
6
6
  const rel = route === '/' ? 'index.json' : route.slice(1) + '/index.json';
@@ -5,44 +5,15 @@ import crypto from 'node:crypto';
5
5
  import { loadConfig } from '../config/loadConfig.js';
6
6
  import { ConfigError } from '../config/validate.js';
7
7
  import { loadPaths } from '../loader/loadPaths.js';
8
+ import { loadRouteConfig } from '../loader/loadRouteConfig.js';
8
9
  import { loadModuleValue } from '../loader/loadModuleValue.js';
9
10
  import { mapRoutes } from '../router/mapRoutes.js';
11
+ import { collectionRouteForSegments, toConcreteRoute, toParams } from '../router/routeHelpers.js';
10
12
  import { readFlags } from '../util/readFlags.js';
11
13
  import { emptyDir, writeFileEnsured } from '../util/fsx.js';
12
14
  import { formatBytes } from '../util/bytes.js';
13
15
  import { routeToOutPath } from '../build/routeOutPath.js';
14
16
 
15
- function toConcrete(routePattern, segTokens, segs) {
16
- // segTokens: ['users', ':id'] or ['docs','*slug']
17
- // segs: ['1'] or ['a','b']
18
- let idx = 0;
19
-
20
- const parts = routePattern.split('/').map((p) => {
21
- if (p.startsWith(':')) return segs[idx++] ?? '';
22
- if (p.startsWith('*')) return segs.slice(idx).join('/');
23
- return p;
24
- });
25
- const concrete = parts.join('/').replace(/\/+/g, '/');
26
-
27
- return concrete;
28
- }
29
-
30
- function toParams(segTokens, concreteRoute) {
31
- const concreteSegs = concreteRoute.split('/').filter(Boolean);
32
- const params = {};
33
-
34
- for (let i = 0; i < segTokens.length; i++) {
35
- const tok = segTokens[i];
36
- if (tok.startsWith(':')) {
37
- params[tok.slice(1)] = concreteSegs[i] ?? '';
38
- } else if (tok.startsWith('*')) {
39
- params[tok.slice(1)] = concreteSegs.slice(i);
40
- break;
41
- }
42
- }
43
- return params;
44
- }
45
-
46
17
  export default async function buildCmd(argv) {
47
18
  const t0 = Date.now();
48
19
  try {
@@ -69,6 +40,7 @@ export default async function buildCmd(argv) {
69
40
  let byteCount = 0;
70
41
  let skippedDynamic = 0;
71
42
  const manifest = []; // array of unified entries
43
+ const emittedByRoute = new Map(); // route -> srcFile
72
44
 
73
45
  const digest = (s) => crypto.createHash('sha1').update(s).digest('hex');
74
46
  const relSrc = (abs) => {
@@ -88,6 +60,14 @@ export default async function buildCmd(argv) {
88
60
  };
89
61
 
90
62
  async function pushManifest({ route, srcFile, outFile, json }) {
63
+ const owner = emittedByRoute.get(route);
64
+ if (owner && owner !== srcFile) {
65
+ throw new Error(
66
+ `Route collision for ${route}: ${relSrc(srcFile)} conflicts with ${relSrc(owner)}`
67
+ );
68
+ }
69
+ emittedByRoute.set(route, srcFile);
70
+
91
71
  const st = await fs.stat(outFile).catch(() => null);
92
72
  const entry = {
93
73
  // stable field order
@@ -116,7 +96,7 @@ export default async function buildCmd(argv) {
116
96
 
117
97
  // helper: materialize a concrete route from tokens + param segments
118
98
  async function emitConcreteRoute(r, segs) {
119
- const concreteRoute = toConcrete(r.route, r.segments, segs);
99
+ const concreteRoute = toConcreteRoute(r.route, segs);
120
100
  const params = toParams(r.segments, concreteRoute);
121
101
  const val = await loadModuleValue(r.file, { params });
122
102
  const json = JSON.stringify(val, null, space) + (pretty ? '\n' : '');
@@ -126,6 +106,26 @@ export default async function buildCmd(argv) {
126
106
  byteCount += Buffer.byteLength(json);
127
107
 
128
108
  await pushManifest({ route: concreteRoute, srcFile: r.file, outFile, json });
109
+
110
+ return val;
111
+ }
112
+
113
+ async function emitListIndexRoute(r, listIndexCfg, items) {
114
+ if (!listIndexCfg.enabled) return;
115
+
116
+ const collectionRoute = collectionRouteForSegments(r.segments);
117
+ if (!collectionRoute) {
118
+ throw new Error(`config.listIndex requires a static parent route for ${r.route}`);
119
+ }
120
+
121
+ const payload = items.map((item) => pickItemFields(item, listIndexCfg.pick, r.route));
122
+ const json = JSON.stringify(payload, null, space) + (pretty ? '\n' : '');
123
+ const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: collectionRoute });
124
+ await writeFileEnsured(outFile, json);
125
+ fileCount++;
126
+ byteCount += Buffer.byteLength(json);
127
+
128
+ await pushManifest({ route: collectionRoute, srcFile: r.file, outFile, json });
129
129
  }
130
130
 
131
131
  // dynamic: expect [['val'], ...] from loadPaths()
@@ -135,13 +135,17 @@ export default async function buildCmd(argv) {
135
135
  skippedDynamic++;
136
136
  continue;
137
137
  }
138
+ const routeConfig = await loadRouteConfig(r.file, { fallback: config });
138
139
  const seen = new Set();
140
+ const listItems = [];
139
141
  for (const segs of list) {
140
- const concrete = toConcrete(r.route, r.segments, segs);
142
+ const concrete = toConcreteRoute(r.route, segs);
141
143
  if (seen.has(concrete)) continue;
142
144
  seen.add(concrete);
143
- await emitConcreteRoute(r, segs);
145
+ const item = await emitConcreteRoute(r, segs);
146
+ listItems.push(item);
144
147
  }
148
+ await emitListIndexRoute(r, routeConfig.listIndex, listItems);
145
149
  }
146
150
 
147
151
  // catch-all: expect [['a','b'], ['guide'], ...]
@@ -151,13 +155,17 @@ export default async function buildCmd(argv) {
151
155
  skippedDynamic++;
152
156
  continue;
153
157
  }
158
+ const routeConfig = await loadRouteConfig(r.file, { fallback: config });
154
159
  const seen = new Set();
160
+ const listItems = [];
155
161
  for (const segs of list) {
156
- const concrete = toConcrete(r.route, r.segments, segs);
162
+ const concrete = toConcreteRoute(r.route, segs);
157
163
  if (seen.has(concrete)) continue;
158
164
  seen.add(concrete);
159
- await emitConcreteRoute(r, segs);
165
+ const item = await emitConcreteRoute(r, segs);
166
+ listItems.push(item);
160
167
  }
168
+ await emitListIndexRoute(r, routeConfig.listIndex, listItems);
161
169
  }
162
170
 
163
171
  // Write manifest once (sorted for determinism)
@@ -189,3 +197,16 @@ export default async function buildCmd(argv) {
189
197
  return 1;
190
198
  }
191
199
  }
200
+
201
+ function pickItemFields(item, pick, route) {
202
+ if (!pick) return item;
203
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
204
+ throw new Error(`config.listIndex.pick requires plain-object items for ${route}`);
205
+ }
206
+
207
+ const out = {};
208
+ for (const key of pick) {
209
+ if (Object.hasOwn(item, key)) out[key] = item[key];
210
+ }
211
+ return out;
212
+ }
@@ -7,9 +7,11 @@ import http from 'node:http';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
9
  import { loadConfig } from '../config/loadConfig.js';
10
+ import { loadRouteConfig } from '../loader/loadRouteConfig.js';
10
11
  import { loadModuleValue } from '../loader/loadModuleValue.js';
11
12
  import { loadPaths } from '../loader/loadPaths.js';
12
13
  import { mapRoutes, fileToRoute } from '../router/mapRoutes.js';
14
+ import { collectionRouteForSegments, toConcreteRoute, toParams } from '../router/routeHelpers.js';
13
15
  import { readFlags } from '../util/readFlags.js';
14
16
  import { writeFileEnsured } from '../util/fsx.js';
15
17
  import { routeToOutPath } from '../build/routeOutPath.js';
@@ -24,36 +26,6 @@ function clearScreen() {
24
26
  process.stdout.write('\x1Bc'); // ANSI "clear screen"
25
27
  }
26
28
 
27
- function toConcrete(routePattern, segTokens, segs) {
28
- let idx = 0;
29
-
30
- const parts = routePattern.split('/').map((p) => {
31
- if (p.startsWith(':')) return segs[idx++] ?? '';
32
- if (p.startsWith('*')) return segs.slice(idx).join('/');
33
- return p;
34
- });
35
-
36
- return parts.join('/').replace(/\/+/g, '/');
37
- }
38
-
39
- function toParams(segTokens, concreteRoute) {
40
- const concreteSegs = concreteRoute.split('/').filter(Boolean);
41
- const params = {};
42
-
43
- for (let i = 0; i < segTokens.length; i++) {
44
- const tok = segTokens[i];
45
-
46
- if (tok.startsWith(':')) params[tok.slice(1)] = concreteSegs[i] ?? '';
47
- else if (tok.startsWith('*')) {
48
- params[tok.slice(1)] = concreteSegs.slice(i);
49
-
50
- break;
51
- }
52
- }
53
-
54
- return params;
55
- }
56
-
57
29
  export default async function devCmd(argv) {
58
30
  const flags = readFlags(argv);
59
31
 
@@ -98,6 +70,7 @@ export default async function devCmd(argv) {
98
70
 
99
71
  // Manifest state
100
72
  const manifestByRoute = new Map(); // route -> entry
73
+ const routeOwners = new Map(); // route -> srcFile
101
74
 
102
75
  const digest = (s) => crypto.createHash('sha1').update(s).digest('hex');
103
76
  const relSrc = (abs) => {
@@ -122,6 +95,14 @@ export default async function devCmd(argv) {
122
95
  await writeFileEnsured(path.join(config.paths.outAbs, '.statikapi', 'manifest.json'), json);
123
96
  }
124
97
  async function upsertManifest({ route, srcFile, outFile, json }) {
98
+ const owner = routeOwners.get(route);
99
+ if (owner && owner !== srcFile) {
100
+ throw new Error(
101
+ `Route collision for ${route}: ${relSrc(srcFile)} conflicts with ${relSrc(owner)}`
102
+ );
103
+ }
104
+ routeOwners.set(route, srcFile);
105
+
125
106
  const st = await fs.stat(outFile).catch(() => null);
126
107
 
127
108
  const entry = {
@@ -138,6 +119,7 @@ export default async function devCmd(argv) {
138
119
  }
139
120
  function deleteFromManifest(route) {
140
121
  manifestByRoute.delete(route);
122
+ routeOwners.delete(route);
141
123
  }
142
124
 
143
125
  async function emitStatic(r, { fresh = false } = {}) {
@@ -158,11 +140,13 @@ export default async function devCmd(argv) {
158
140
  lastEmitted.set(r.file, new Set());
159
141
  return { written: 0, skipped: 1 };
160
142
  }
143
+ const routeConfig = await loadRouteConfig(r.file, { fresh, fallback: config });
161
144
  const seen = new Set();
162
145
  const emittedRoutes = new Set();
146
+ const listItems = [];
163
147
  let written = 0;
164
148
  for (const segs of list) {
165
- const concrete = toConcrete(r.route, r.segments, segs);
149
+ const concrete = toConcreteRoute(r.route, segs);
166
150
  if (seen.has(concrete)) continue;
167
151
  seen.add(concrete);
168
152
  const params = toParams(r.segments, concrete);
@@ -171,10 +155,27 @@ export default async function devCmd(argv) {
171
155
  const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: concrete });
172
156
  await writeFileEnsured(outFile, json);
173
157
  emittedRoutes.add(concrete);
158
+ listItems.push(val);
174
159
  await upsertManifest({ route: concrete, srcFile: r.file, outFile, json });
175
160
  written++;
176
161
  }
177
162
 
163
+ if (routeConfig.listIndex.enabled) {
164
+ const collectionRoute = collectionRouteForSegments(r.segments);
165
+ if (!collectionRoute) {
166
+ throw new Error(`config.listIndex requires a static parent route for ${r.route}`);
167
+ }
168
+ const payload = listItems.map((item) =>
169
+ pickItemFields(item, routeConfig.listIndex.pick, r.route)
170
+ );
171
+ const json = JSON.stringify(payload, null, 2) + '\n';
172
+ const outFile = routeToOutPath({ outAbs: config.paths.outAbs, route: collectionRoute });
173
+ await writeFileEnsured(outFile, json);
174
+ emittedRoutes.add(collectionRoute);
175
+ await upsertManifest({ route: collectionRoute, srcFile: r.file, outFile, json });
176
+ written++;
177
+ }
178
+
178
179
  // Delete stale outputs from this file (not present anymore)
179
180
  const prev = lastEmitted.get(r.file) || new Set();
180
181
  for (const oldRoute of prev) {
@@ -479,3 +480,16 @@ async function openInBrowser(url) {
479
480
  process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
480
481
  exec(`${cmd} "${url}"`);
481
482
  }
483
+
484
+ function pickItemFields(item, pick, route) {
485
+ if (!pick) return item;
486
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
487
+ throw new Error(`config.listIndex.pick requires plain-object items for ${route}`);
488
+ }
489
+
490
+ const out = {};
491
+ for (const key of pick) {
492
+ if (Object.hasOwn(item, key)) out[key] = item[key];
493
+ }
494
+ return out;
495
+ }
@@ -1,4 +1,7 @@
1
+ import { cloneListIndexConfig } from './listIndex.js';
2
+
1
3
  export const DEFAULT_CONFIG = {
2
4
  srcDir: 'src-api',
3
5
  outDir: 'api-out',
6
+ listIndex: cloneListIndexConfig(),
4
7
  };
@@ -0,0 +1,88 @@
1
+ export const DEFAULT_LIST_INDEX_CONFIG = Object.freeze({
2
+ enabled: false,
3
+ pick: null,
4
+ });
5
+
6
+ export function cloneListIndexConfig(cfg = DEFAULT_LIST_INDEX_CONFIG) {
7
+ return {
8
+ enabled: cfg.enabled,
9
+ pick: cfg.pick ? [...cfg.pick] : null,
10
+ };
11
+ }
12
+
13
+ export function normalizeListIndexValue(raw, { label = 'listIndex' } = {}) {
14
+ const base = cloneListIndexConfig();
15
+
16
+ if (raw == null || raw === false) return base;
17
+ if (raw === true) {
18
+ base.enabled = true;
19
+ return base;
20
+ }
21
+
22
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
23
+ throw new Error(`${label} must be true, false, or an object`);
24
+ }
25
+
26
+ const enabled = raw.enabled == null ? true : raw.enabled;
27
+ if (typeof enabled !== 'boolean') {
28
+ throw new Error(`${label}.enabled must be a boolean`);
29
+ }
30
+
31
+ let pick = null;
32
+ if ('pick' in raw) {
33
+ if (raw.pick != null) {
34
+ pick = normalizePick(raw.pick, { label: `${label}.pick` });
35
+ }
36
+ }
37
+
38
+ return { enabled, pick };
39
+ }
40
+
41
+ export function applyListIndexFlagOverrides(baseCfg, flags = {}) {
42
+ const next = cloneListIndexConfig(baseCfg);
43
+ const hasEnabled = flags.listIndex != null;
44
+ const hasPick = flags.listIndexPick != null;
45
+
46
+ if (!hasEnabled && !hasPick) return next;
47
+
48
+ if (hasEnabled) {
49
+ const raw = flags.listIndex;
50
+ if (typeof raw !== 'boolean') {
51
+ throw new Error(`listIndex flag must be true or false`);
52
+ }
53
+ next.enabled = raw;
54
+ if (raw === false && !hasPick) next.pick = null;
55
+ }
56
+
57
+ if (hasPick) {
58
+ next.pick = normalizePick(flags.listIndexPick, { label: 'listIndexPick flag' });
59
+ if (!hasEnabled) next.enabled = true;
60
+ }
61
+
62
+ return next;
63
+ }
64
+
65
+ function normalizePick(raw, { label }) {
66
+ if (Array.isArray(raw)) {
67
+ return dedupePick(raw, { label });
68
+ }
69
+ if (typeof raw === 'string') {
70
+ return dedupePick(
71
+ raw
72
+ .split(',')
73
+ .map((s) => s.trim())
74
+ .filter(Boolean),
75
+ { label }
76
+ );
77
+ }
78
+ throw new Error(`${label} must be an array of strings`);
79
+ }
80
+
81
+ function dedupePick(list, { label }) {
82
+ for (const key of list) {
83
+ if (typeof key !== 'string' || !key) {
84
+ throw new Error(`${label} must contain non-empty strings`);
85
+ }
86
+ }
87
+ return Array.from(new Set(list));
88
+ }
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import { DEFAULT_CONFIG } from './defaults.js';
6
6
  import { validateAndNormalize, ConfigError } from './validate.js';
7
+ import { applyListIndexFlagOverrides } from './listIndex.js';
7
8
 
8
9
  export async function loadConfig({ cwd = process.cwd(), flags = {} } = {}) {
9
10
  const file = path.join(cwd, 'statikapi.config.js');
@@ -34,6 +35,10 @@ export async function loadConfig({ cwd = process.cwd(), flags = {} } = {}) {
34
35
  ...pickFlags(flags),
35
36
  };
36
37
 
38
+ if (flags.listIndex != null || flags.listIndexPick != null) {
39
+ merged.listIndex = applyListIndexFlagOverrides(merged.listIndex, flags);
40
+ }
41
+
37
42
  // Validate & expand absolute paths
38
43
  const finalCfg = validateAndNormalize(merged, { cwd });
39
44
  return { config: finalCfg, source: { fromFile, filePath: file } };
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
+ import { cloneListIndexConfig, normalizeListIndexValue } from './listIndex.js';
4
+
3
5
  export class ConfigError extends Error {
4
6
  constructor(message) {
5
7
  super(message);
@@ -29,8 +31,15 @@ export function validateAndNormalize(userCfg, { cwd = process.cwd() } = {}) {
29
31
  throw new ConfigError(`"srcDir" and "outDir" must differ (both "${cfg.srcDir}")`);
30
32
  }
31
33
 
34
+ try {
35
+ cfg.listIndex = normalizeListIndexValue(cfg.listIndex, { label: 'listIndex' });
36
+ } catch (err) {
37
+ throw new ConfigError(err.message);
38
+ }
39
+
32
40
  return {
33
41
  ...cfg,
42
+ listIndex: cloneListIndexConfig(cfg.listIndex),
34
43
  paths: {
35
44
  srcAbs: path.join(cwd, cfg.srcDir),
36
45
  outAbs: path.join(cwd, cfg.outDir),
@@ -0,0 +1,47 @@
1
+ import path from 'node:path';
2
+
3
+ import { cloneListIndexConfig, normalizeListIndexValue } from '../config/listIndex.js';
4
+ import { LoaderError } from './errors.js';
5
+ import { importModule } from './importModule.js';
6
+
7
+ export async function loadRouteConfig(fileAbs, { fresh = false, fallback = null } = {}) {
8
+ const fileInfo = short(fileAbs);
9
+ let mod;
10
+
11
+ try {
12
+ mod = await importModule(fileAbs, { fresh });
13
+ } catch (e) {
14
+ throw new LoaderError(fileInfo, `Failed to import for config: ${e.message}`);
15
+ }
16
+
17
+ return normalizeConfig(mod?.config, fileInfo, fallback);
18
+ }
19
+
20
+ function normalizeConfig(raw, fileInfo, fallback) {
21
+ const base = { listIndex: cloneListIndexConfig(fallback?.listIndex) };
22
+
23
+ if (raw == null) return base;
24
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
25
+ throw new LoaderError(fileInfo, `config must be an object when exported`);
26
+ }
27
+
28
+ if (!Object.hasOwn(raw, 'listIndex')) return base;
29
+
30
+ const listIndex = raw.listIndex;
31
+ try {
32
+ return {
33
+ listIndex: normalizeListIndexValue(listIndex, { label: 'config.listIndex' }),
34
+ };
35
+ } catch (err) {
36
+ if (err instanceof LoaderError) throw err;
37
+ throw new LoaderError(fileInfo, err.message);
38
+ }
39
+ }
40
+
41
+ function short(p) {
42
+ try {
43
+ return path.relative(process.cwd(), p) || p;
44
+ } catch {
45
+ return p;
46
+ }
47
+ }
@@ -0,0 +1,38 @@
1
+ export function toConcreteRoute(routePattern, segs) {
2
+ let idx = 0;
3
+
4
+ const parts = routePattern.split('/').map((p) => {
5
+ if (p.startsWith(':')) return segs[idx++] ?? '';
6
+ if (p.startsWith('*')) return segs.slice(idx).join('/');
7
+ return p;
8
+ });
9
+
10
+ return parts.join('/').replace(/\/+/g, '/');
11
+ }
12
+
13
+ export function toParams(segTokens, concreteRoute) {
14
+ const concreteSegs = concreteRoute.split('/').filter(Boolean);
15
+ const params = {};
16
+
17
+ for (let i = 0; i < segTokens.length; i++) {
18
+ const tok = segTokens[i];
19
+ if (tok.startsWith(':')) {
20
+ params[tok.slice(1)] = concreteSegs[i] ?? '';
21
+ } else if (tok.startsWith('*')) {
22
+ params[tok.slice(1)] = concreteSegs.slice(i);
23
+ break;
24
+ }
25
+ }
26
+
27
+ return params;
28
+ }
29
+
30
+ export function collectionRouteForSegments(segTokens) {
31
+ const last = segTokens[segTokens.length - 1];
32
+ if (!last || (!last.startsWith(':') && !last.startsWith('*'))) return null;
33
+
34
+ const parent = segTokens.slice(0, -1);
35
+ if (parent.some((tok) => tok.startsWith(':') || tok.startsWith('*'))) return null;
36
+
37
+ return parent.length ? '/' + parent.join('/') : '/';
38
+ }