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 +1 -1
- package/src/build/routeOutPath.js +1 -1
- package/src/commands/build.js +57 -36
- package/src/commands/dev.js +45 -31
- package/src/config/defaults.js +3 -0
- package/src/config/listIndex.js +88 -0
- package/src/config/loadConfig.js +5 -0
- package/src/config/validate.js +9 -0
- package/src/loader/loadRouteConfig.js +47 -0
- package/src/router/routeHelpers.js +38 -0
- package/ui/assets/index-DQxY5SSL.js +146 -0
- package/ui/assets/index-DQxY5SSL.js.map +1 -0
- package/ui/assets/index-dGdl_f78.css +1 -0
- package/ui/index.html +4 -4
- package/ui/assets/index-BU1U7AZy.css +0 -1
- package/ui/assets/index-BevI1ZS7.js +0 -142
- package/ui/assets/index-BevI1ZS7.js.map +0 -1
package/package.json
CHANGED
|
@@ -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).
|
|
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';
|
package/src/commands/build.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/src/commands/dev.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
+
}
|
package/src/config/loadConfig.js
CHANGED
|
@@ -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 } };
|
package/src/config/validate.js
CHANGED
|
@@ -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
|
+
}
|