create-aziz-app 3.1.2 → 3.1.3
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 +658 -207
- package/bin/create-aziz-app.js +302 -302
- package/package.json +1 -1
- package/template/README.md +402 -402
- package/template/_framework/api.js +219 -219
- package/template/_framework/boot.js +33 -33
- package/template/_framework/hooks.js +367 -291
- package/template/_framework/next-response.js +128 -128
- package/template/_framework/route-match.js +145 -145
- package/template/_framework/router.js +465 -259
- package/template/_framework/runtime.js +264 -264
- package/template/app/layout.js +18 -18
- package/template/app/page.js +33 -33
- package/template/docs/01-getting-started.md +151 -151
- package/template/docs/02-project-structure.md +109 -109
- package/template/docs/03-pages-and-layouts.md +140 -140
- package/template/docs/04-routing.md +120 -120
- package/template/docs/05-hooks.md +169 -169
- package/template/docs/06-api-routes.md +210 -210
- package/template/docs/07-api-client.md +151 -151
- package/template/docs/08-styling.md +94 -94
- package/template/docs/09-imports-and-modules.md +97 -97
- package/template/docs/10-database-and-models.md +154 -154
- package/template/docs/11-authentication.md +171 -171
- package/template/docs/12-production-and-deployment.md +121 -121
- package/template/docs/13-internals.md +179 -179
- package/template/docs/README.md +67 -67
- package/template/index.html +20 -20
- package/template/package.json +16 -16
- package/template/scripts/dev-server.js +493 -381
- package/template/scripts/lib/api-router.js +525 -525
- package/template/scripts/lib/js-transform.js +213 -195
- package/template/scripts/lib/module-resolver.js +132 -132
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// NextResponse — a small, Next.js App Router-compatible response
|
|
3
|
-
// object used inside api/** route handlers (server-side, Node).
|
|
4
|
-
//
|
|
5
|
-
// return NextResponse.json({ ok: true });
|
|
6
|
-
// return NextResponse.json({ error: 'nope' }, { status: 400 });
|
|
7
|
-
// return NextResponse.redirect('/login');
|
|
8
|
-
// const res = NextResponse.json({ ok: true });
|
|
9
|
-
// res.cookies.set('token', 'abc', { httpOnly: true });
|
|
10
|
-
// return res;
|
|
11
|
-
// ============================================================
|
|
12
|
-
|
|
13
|
-
class ResponseCookies {
|
|
14
|
-
constructor(headers) {
|
|
15
|
-
this.headers = headers;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
set(name, value, options = {}) {
|
|
19
|
-
let cookie = `${name}=${encodeURIComponent(value)}`;
|
|
20
|
-
|
|
21
|
-
if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}`;
|
|
22
|
-
if (options.expires) cookie += `; Expires=${new Date(options.expires).toUTCString()}`;
|
|
23
|
-
cookie += `; Path=${options.path || '/'}`;
|
|
24
|
-
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
25
|
-
if (options.httpOnly) cookie += '; HttpOnly';
|
|
26
|
-
if (options.secure) cookie += '; Secure';
|
|
27
|
-
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
28
|
-
|
|
29
|
-
const existing = this.headers.get('Set-Cookie');
|
|
30
|
-
if (!existing) this.headers.set('Set-Cookie', [cookie]);
|
|
31
|
-
else if (Array.isArray(existing)) existing.push(cookie);
|
|
32
|
-
else this.headers.set('Set-Cookie', [existing, cookie]);
|
|
33
|
-
|
|
34
|
-
return this;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
delete(name, options = {}) {
|
|
38
|
-
return this.set(name, '', { ...options, maxAge: 0 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
get(name) {
|
|
42
|
-
const existing = this.headers.get('Set-Cookie');
|
|
43
|
-
const list = Array.isArray(existing) ? existing : existing ? [existing] : [];
|
|
44
|
-
for (const cookie of list) {
|
|
45
|
-
if (cookie.startsWith(`${name}=`)) {
|
|
46
|
-
return { name, value: cookie.slice(name.length + 1).split(';')[0] };
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
class NextResponse {
|
|
54
|
-
constructor(body = null, init = {}) {
|
|
55
|
-
this.body = body;
|
|
56
|
-
this.status = init.status || 200;
|
|
57
|
-
this.statusText = init.statusText || '';
|
|
58
|
-
this.headers = new Map();
|
|
59
|
-
|
|
60
|
-
const headers = init.headers || {};
|
|
61
|
-
if (headers instanceof Map) {
|
|
62
|
-
for (const [k, v] of headers) this.headers.set(k, v);
|
|
63
|
-
} else if (typeof headers.forEach === 'function' && typeof headers.get === 'function') {
|
|
64
|
-
headers.forEach((v, k) => this.headers.set(k, v)); // Headers instance
|
|
65
|
-
} else if (typeof headers === 'object') {
|
|
66
|
-
for (const k of Object.keys(headers)) this.headers.set(k, headers[k]);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
this.cookies = new ResponseCookies(this.headers);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
get ok() {
|
|
73
|
-
return this.status >= 200 && this.status < 300;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
static json(data, init = {}) {
|
|
77
|
-
return new NextResponse(JSON.stringify(data), {
|
|
78
|
-
...init,
|
|
79
|
-
headers: {
|
|
80
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
81
|
-
...(init.headers || {}),
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
static text(text, init = {}) {
|
|
87
|
-
return new NextResponse(String(text), {
|
|
88
|
-
...init,
|
|
89
|
-
headers: {
|
|
90
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
91
|
-
...(init.headers || {}),
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
static html(html, init = {}) {
|
|
97
|
-
return new NextResponse(String(html), {
|
|
98
|
-
...init,
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
101
|
-
...(init.headers || {}),
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
static redirect(url, init = {}) {
|
|
107
|
-
const destination = typeof url === 'string' ? url : String(url);
|
|
108
|
-
return new NextResponse(null, {
|
|
109
|
-
...init,
|
|
110
|
-
status: init.status || 307,
|
|
111
|
-
headers: { 'Location': destination, ...(init.headers || {}) },
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
static error() {
|
|
116
|
-
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async json() {
|
|
120
|
-
return JSON.parse(this.body ?? 'null');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async text() {
|
|
124
|
-
return this.body == null ? '' : String(this.body);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
module.exports = { NextResponse };
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NextResponse — a small, Next.js App Router-compatible response
|
|
3
|
+
// object used inside api/** route handlers (server-side, Node).
|
|
4
|
+
//
|
|
5
|
+
// return NextResponse.json({ ok: true });
|
|
6
|
+
// return NextResponse.json({ error: 'nope' }, { status: 400 });
|
|
7
|
+
// return NextResponse.redirect('/login');
|
|
8
|
+
// const res = NextResponse.json({ ok: true });
|
|
9
|
+
// res.cookies.set('token', 'abc', { httpOnly: true });
|
|
10
|
+
// return res;
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
class ResponseCookies {
|
|
14
|
+
constructor(headers) {
|
|
15
|
+
this.headers = headers;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(name, value, options = {}) {
|
|
19
|
+
let cookie = `${name}=${encodeURIComponent(value)}`;
|
|
20
|
+
|
|
21
|
+
if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}`;
|
|
22
|
+
if (options.expires) cookie += `; Expires=${new Date(options.expires).toUTCString()}`;
|
|
23
|
+
cookie += `; Path=${options.path || '/'}`;
|
|
24
|
+
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
25
|
+
if (options.httpOnly) cookie += '; HttpOnly';
|
|
26
|
+
if (options.secure) cookie += '; Secure';
|
|
27
|
+
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
28
|
+
|
|
29
|
+
const existing = this.headers.get('Set-Cookie');
|
|
30
|
+
if (!existing) this.headers.set('Set-Cookie', [cookie]);
|
|
31
|
+
else if (Array.isArray(existing)) existing.push(cookie);
|
|
32
|
+
else this.headers.set('Set-Cookie', [existing, cookie]);
|
|
33
|
+
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
delete(name, options = {}) {
|
|
38
|
+
return this.set(name, '', { ...options, maxAge: 0 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get(name) {
|
|
42
|
+
const existing = this.headers.get('Set-Cookie');
|
|
43
|
+
const list = Array.isArray(existing) ? existing : existing ? [existing] : [];
|
|
44
|
+
for (const cookie of list) {
|
|
45
|
+
if (cookie.startsWith(`${name}=`)) {
|
|
46
|
+
return { name, value: cookie.slice(name.length + 1).split(';')[0] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class NextResponse {
|
|
54
|
+
constructor(body = null, init = {}) {
|
|
55
|
+
this.body = body;
|
|
56
|
+
this.status = init.status || 200;
|
|
57
|
+
this.statusText = init.statusText || '';
|
|
58
|
+
this.headers = new Map();
|
|
59
|
+
|
|
60
|
+
const headers = init.headers || {};
|
|
61
|
+
if (headers instanceof Map) {
|
|
62
|
+
for (const [k, v] of headers) this.headers.set(k, v);
|
|
63
|
+
} else if (typeof headers.forEach === 'function' && typeof headers.get === 'function') {
|
|
64
|
+
headers.forEach((v, k) => this.headers.set(k, v)); // Headers instance
|
|
65
|
+
} else if (typeof headers === 'object') {
|
|
66
|
+
for (const k of Object.keys(headers)) this.headers.set(k, headers[k]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.cookies = new ResponseCookies(this.headers);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get ok() {
|
|
73
|
+
return this.status >= 200 && this.status < 300;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static json(data, init = {}) {
|
|
77
|
+
return new NextResponse(JSON.stringify(data), {
|
|
78
|
+
...init,
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
81
|
+
...(init.headers || {}),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static text(text, init = {}) {
|
|
87
|
+
return new NextResponse(String(text), {
|
|
88
|
+
...init,
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
91
|
+
...(init.headers || {}),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static html(html, init = {}) {
|
|
97
|
+
return new NextResponse(String(html), {
|
|
98
|
+
...init,
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
101
|
+
...(init.headers || {}),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static redirect(url, init = {}) {
|
|
107
|
+
const destination = typeof url === 'string' ? url : String(url);
|
|
108
|
+
return new NextResponse(null, {
|
|
109
|
+
...init,
|
|
110
|
+
status: init.status || 307,
|
|
111
|
+
headers: { 'Location': destination, ...(init.headers || {}) },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static error() {
|
|
116
|
+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async json() {
|
|
120
|
+
return JSON.parse(this.body ?? 'null');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async text() {
|
|
124
|
+
return this.body == null ? '' : String(this.body);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { NextResponse };
|
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Aziz.js — Route matching (pure, dependency-free)
|
|
3
|
-
//
|
|
4
|
-
// Used by the client router. Kept side-effect free so it can
|
|
5
|
-
// be unit-tested in isolation.
|
|
6
|
-
//
|
|
7
|
-
// Supported patterns:
|
|
8
|
-
// /about → static
|
|
9
|
-
// /blog/[slug] → dynamic segment
|
|
10
|
-
// /files/[...path] → catch-all (one or more segments)
|
|
11
|
-
// ============================================================
|
|
12
|
-
|
|
13
|
-
// Strip query/hash, force leading slash, drop trailing slash (except root).
|
|
14
|
-
export function normalizePath(pathname) {
|
|
15
|
-
if (!pathname) return '/';
|
|
16
|
-
let p = String(pathname).split('?')[0].split('#')[0];
|
|
17
|
-
if (!p.startsWith('/')) p = '/' + p;
|
|
18
|
-
if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
|
|
19
|
-
return p || '/';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function splitPath(pathname) {
|
|
23
|
-
return normalizePath(pathname).split('/').filter(Boolean);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function isDynamic(part) {
|
|
27
|
-
return part.startsWith('[') && part.endsWith(']') && !part.startsWith('[...');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function isCatchAll(part) {
|
|
31
|
-
return part.startsWith('[...') && part.endsWith(']');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Match a route pattern against a pathname.
|
|
35
|
-
// Returns a params object, or null when there is no match.
|
|
36
|
-
export function matchRoute(pattern, pathname) {
|
|
37
|
-
const patternParts = splitPath(pattern);
|
|
38
|
-
const pathParts = splitPath(pathname);
|
|
39
|
-
const params = {};
|
|
40
|
-
|
|
41
|
-
const catchAll = patternParts.findIndex(isCatchAll);
|
|
42
|
-
|
|
43
|
-
if (catchAll !== -1) {
|
|
44
|
-
// Catch-all must be the final segment. It needs at least one
|
|
45
|
-
// path segment of its own (/blog/[...slug] does not match /blog).
|
|
46
|
-
if (pathParts.length < patternParts.length) return null;
|
|
47
|
-
|
|
48
|
-
for (let i = 0; i < catchAll; i++) {
|
|
49
|
-
const p = patternParts[i];
|
|
50
|
-
if (isDynamic(p)) {
|
|
51
|
-
params[p.slice(1, -1)] = decodeURIComponent(pathParts[i]);
|
|
52
|
-
} else if (p !== pathParts[i]) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
params[patternParts[catchAll].slice(4, -1)] = pathParts.slice(catchAll).map(decodeURIComponent);
|
|
57
|
-
return params;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (patternParts.length !== pathParts.length) return null;
|
|
61
|
-
|
|
62
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
63
|
-
const p = patternParts[i];
|
|
64
|
-
if (isDynamic(p)) {
|
|
65
|
-
params[p.slice(1, -1)] = decodeURIComponent(pathParts[i]);
|
|
66
|
-
} else if (p !== pathParts[i]) {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return params;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Specificity score — higher is more specific.
|
|
75
|
-
export function specificity(pattern) {
|
|
76
|
-
const parts = splitPath(pattern);
|
|
77
|
-
let statics = 0;
|
|
78
|
-
let dynamics = 0;
|
|
79
|
-
let catchAll = 0;
|
|
80
|
-
for (const p of parts) {
|
|
81
|
-
if (isCatchAll(p)) catchAll++;
|
|
82
|
-
else if (isDynamic(p)) dynamics++;
|
|
83
|
-
else statics++;
|
|
84
|
-
}
|
|
85
|
-
return { statics, dynamics, catchAll, depth: parts.length };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function compareSpecificity(a, b) {
|
|
89
|
-
if (a.catchAll !== b.catchAll) return a.catchAll - b.catchAll;
|
|
90
|
-
if (a.statics !== b.statics) return b.statics - a.statics;
|
|
91
|
-
if (a.dynamics !== b.dynamics) return b.dynamics - a.dynamics;
|
|
92
|
-
return b.depth - a.depth;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Resolve the best route for a pathname from a { pattern: value } map.
|
|
96
|
-
// Returns { pattern, params, value } or null.
|
|
97
|
-
export function resolveRoute(routes, pathname) {
|
|
98
|
-
const normalized = normalizePath(pathname);
|
|
99
|
-
|
|
100
|
-
// 1. Exact match wins outright
|
|
101
|
-
if (Object.prototype.hasOwnProperty.call(routes, normalized)) {
|
|
102
|
-
return { pattern: normalized, params: {}, value: routes[normalized] };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 2. Dynamic / catch-all patterns, ranked by specificity
|
|
106
|
-
const candidates = [];
|
|
107
|
-
for (const pattern of Object.keys(routes)) {
|
|
108
|
-
if (!pattern.includes('[')) continue;
|
|
109
|
-
const params = matchRoute(pattern, normalized);
|
|
110
|
-
if (params !== null) {
|
|
111
|
-
candidates.push({ pattern, params, value: routes[pattern], score: specificity(pattern) });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (candidates.length === 0) return null;
|
|
116
|
-
candidates.sort((a, b) => compareSpecificity(a.score, b.score));
|
|
117
|
-
const best = candidates[0];
|
|
118
|
-
return { pattern: best.pattern, params: best.params, value: best.value };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Does a layout at `pattern` apply to `pathname`?
|
|
122
|
-
// Layouts are prefix-scoped: app/about/layout.js applies to /about and /about/*.
|
|
123
|
-
export function matchesLayout(pattern, pathname) {
|
|
124
|
-
const patternParts = splitPath(pattern);
|
|
125
|
-
if (patternParts.length === 0) return true; // root layout
|
|
126
|
-
|
|
127
|
-
const pathParts = splitPath(pathname);
|
|
128
|
-
if (pathParts.length < patternParts.length) return false;
|
|
129
|
-
|
|
130
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
131
|
-
const p = patternParts[i];
|
|
132
|
-
if (isCatchAll(p)) return true;
|
|
133
|
-
if (isDynamic(p)) continue;
|
|
134
|
-
if (p !== pathParts[i]) return false;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Collect matching layouts, outermost (root) first.
|
|
141
|
-
export function collectLayouts(layouts, pathname) {
|
|
142
|
-
return layouts
|
|
143
|
-
.filter(l => matchesLayout(l.path, pathname))
|
|
144
|
-
.sort((a, b) => splitPath(a.path).length - splitPath(b.path).length);
|
|
145
|
-
}
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Aziz.js — Route matching (pure, dependency-free)
|
|
3
|
+
//
|
|
4
|
+
// Used by the client router. Kept side-effect free so it can
|
|
5
|
+
// be unit-tested in isolation.
|
|
6
|
+
//
|
|
7
|
+
// Supported patterns:
|
|
8
|
+
// /about → static
|
|
9
|
+
// /blog/[slug] → dynamic segment
|
|
10
|
+
// /files/[...path] → catch-all (one or more segments)
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
// Strip query/hash, force leading slash, drop trailing slash (except root).
|
|
14
|
+
export function normalizePath(pathname) {
|
|
15
|
+
if (!pathname) return '/';
|
|
16
|
+
let p = String(pathname).split('?')[0].split('#')[0];
|
|
17
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
18
|
+
if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
|
|
19
|
+
return p || '/';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function splitPath(pathname) {
|
|
23
|
+
return normalizePath(pathname).split('/').filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isDynamic(part) {
|
|
27
|
+
return part.startsWith('[') && part.endsWith(']') && !part.startsWith('[...');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isCatchAll(part) {
|
|
31
|
+
return part.startsWith('[...') && part.endsWith(']');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Match a route pattern against a pathname.
|
|
35
|
+
// Returns a params object, or null when there is no match.
|
|
36
|
+
export function matchRoute(pattern, pathname) {
|
|
37
|
+
const patternParts = splitPath(pattern);
|
|
38
|
+
const pathParts = splitPath(pathname);
|
|
39
|
+
const params = {};
|
|
40
|
+
|
|
41
|
+
const catchAll = patternParts.findIndex(isCatchAll);
|
|
42
|
+
|
|
43
|
+
if (catchAll !== -1) {
|
|
44
|
+
// Catch-all must be the final segment. It needs at least one
|
|
45
|
+
// path segment of its own (/blog/[...slug] does not match /blog).
|
|
46
|
+
if (pathParts.length < patternParts.length) return null;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < catchAll; i++) {
|
|
49
|
+
const p = patternParts[i];
|
|
50
|
+
if (isDynamic(p)) {
|
|
51
|
+
params[p.slice(1, -1)] = decodeURIComponent(pathParts[i]);
|
|
52
|
+
} else if (p !== pathParts[i]) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
params[patternParts[catchAll].slice(4, -1)] = pathParts.slice(catchAll).map(decodeURIComponent);
|
|
57
|
+
return params;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
63
|
+
const p = patternParts[i];
|
|
64
|
+
if (isDynamic(p)) {
|
|
65
|
+
params[p.slice(1, -1)] = decodeURIComponent(pathParts[i]);
|
|
66
|
+
} else if (p !== pathParts[i]) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return params;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Specificity score — higher is more specific.
|
|
75
|
+
export function specificity(pattern) {
|
|
76
|
+
const parts = splitPath(pattern);
|
|
77
|
+
let statics = 0;
|
|
78
|
+
let dynamics = 0;
|
|
79
|
+
let catchAll = 0;
|
|
80
|
+
for (const p of parts) {
|
|
81
|
+
if (isCatchAll(p)) catchAll++;
|
|
82
|
+
else if (isDynamic(p)) dynamics++;
|
|
83
|
+
else statics++;
|
|
84
|
+
}
|
|
85
|
+
return { statics, dynamics, catchAll, depth: parts.length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function compareSpecificity(a, b) {
|
|
89
|
+
if (a.catchAll !== b.catchAll) return a.catchAll - b.catchAll;
|
|
90
|
+
if (a.statics !== b.statics) return b.statics - a.statics;
|
|
91
|
+
if (a.dynamics !== b.dynamics) return b.dynamics - a.dynamics;
|
|
92
|
+
return b.depth - a.depth;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve the best route for a pathname from a { pattern: value } map.
|
|
96
|
+
// Returns { pattern, params, value } or null.
|
|
97
|
+
export function resolveRoute(routes, pathname) {
|
|
98
|
+
const normalized = normalizePath(pathname);
|
|
99
|
+
|
|
100
|
+
// 1. Exact match wins outright
|
|
101
|
+
if (Object.prototype.hasOwnProperty.call(routes, normalized)) {
|
|
102
|
+
return { pattern: normalized, params: {}, value: routes[normalized] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Dynamic / catch-all patterns, ranked by specificity
|
|
106
|
+
const candidates = [];
|
|
107
|
+
for (const pattern of Object.keys(routes)) {
|
|
108
|
+
if (!pattern.includes('[')) continue;
|
|
109
|
+
const params = matchRoute(pattern, normalized);
|
|
110
|
+
if (params !== null) {
|
|
111
|
+
candidates.push({ pattern, params, value: routes[pattern], score: specificity(pattern) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (candidates.length === 0) return null;
|
|
116
|
+
candidates.sort((a, b) => compareSpecificity(a.score, b.score));
|
|
117
|
+
const best = candidates[0];
|
|
118
|
+
return { pattern: best.pattern, params: best.params, value: best.value };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Does a layout at `pattern` apply to `pathname`?
|
|
122
|
+
// Layouts are prefix-scoped: app/about/layout.js applies to /about and /about/*.
|
|
123
|
+
export function matchesLayout(pattern, pathname) {
|
|
124
|
+
const patternParts = splitPath(pattern);
|
|
125
|
+
if (patternParts.length === 0) return true; // root layout
|
|
126
|
+
|
|
127
|
+
const pathParts = splitPath(pathname);
|
|
128
|
+
if (pathParts.length < patternParts.length) return false;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
131
|
+
const p = patternParts[i];
|
|
132
|
+
if (isCatchAll(p)) return true;
|
|
133
|
+
if (isDynamic(p)) continue;
|
|
134
|
+
if (p !== pathParts[i]) return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Collect matching layouts, outermost (root) first.
|
|
141
|
+
export function collectLayouts(layouts, pathname) {
|
|
142
|
+
return layouts
|
|
143
|
+
.filter(l => matchesLayout(l.path, pathname))
|
|
144
|
+
.sort((a, b) => splitPath(a.path).length - splitPath(b.path).length);
|
|
145
|
+
}
|