@africode/core 5.0.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/AFRICODE_FRAMEWORK_GUIDE.md +707 -0
- package/LICENSE +623 -0
- package/README.md +442 -0
- package/bin/africode.js +73 -0
- package/bin/africode.js.1758507140 +343 -0
- package/bin/cli.ts +83 -0
- package/bin/create-africode.js +158 -0
- package/bin/scaffold.ts +219 -0
- package/components/accordion.js +183 -0
- package/components/alert.js +131 -0
- package/components/auth.js +172 -0
- package/components/avatar.js +117 -0
- package/components/badge.js +104 -0
- package/components/base.d.ts +139 -0
- package/components/base.js +184 -0
- package/components/button.js +164 -0
- package/components/card.js +137 -0
- package/components/cultural-card.js +243 -0
- package/components/divider.js +83 -0
- package/components/dropdown.js +171 -0
- package/components/error-boundary.js +155 -0
- package/components/form.js +131 -0
- package/components/grid.js +273 -0
- package/components/hero.js +138 -0
- package/components/icon.js +36 -0
- package/components/index.js +57 -0
- package/components/input.js +256 -0
- package/components/kanga-card.js +185 -0
- package/components/language-switcher.js +108 -0
- package/components/loader.js +80 -0
- package/components/modal.js +262 -0
- package/components/motion.js +84 -0
- package/components/navbar.js +236 -0
- package/components/pattern-showcase.js +225 -0
- package/components/progress.js +134 -0
- package/components/react.js +111 -0
- package/components/section.js +54 -0
- package/components/select.js +322 -0
- package/components/sidebar.js +180 -0
- package/components/skeleton.js +85 -0
- package/components/table.js +181 -0
- package/components/tabs.js +202 -0
- package/components/theme-toggle.js +82 -0
- package/components/toast.js +139 -0
- package/components/tooltip.js +167 -0
- package/core/a2ui-schema-manager.js +344 -0
- package/core/a2ui.js +431 -0
- package/core/bun-runtime.js +799 -0
- package/core/cli/commands/add.js +23 -0
- package/core/cli/commands/audit.js +58 -0
- package/core/cli/commands/build.js +137 -0
- package/core/cli/commands/create-plugin.js +241 -0
- package/core/cli/commands/dev.js +228 -0
- package/core/cli/commands/lint.js +23 -0
- package/core/cli/commands/test.js +34 -0
- package/core/cli/migrator.js +71 -0
- package/core/cli/ui.js +46 -0
- package/core/compliance.js +628 -0
- package/core/config.js +263 -0
- package/core/db-advanced.js +481 -0
- package/core/db.js +284 -0
- package/core/enhanced-hmr.js +404 -0
- package/core/errors.js +222 -0
- package/core/file-router.js +290 -0
- package/core/heartbeat.js +64 -0
- package/core/hmr-client.js +204 -0
- package/core/hmr.js +196 -0
- package/core/html.d.ts +116 -0
- package/core/html.js +160 -0
- package/core/hydration.js +52 -0
- package/core/lipa-namba-journey.js +572 -0
- package/core/motion.js +106 -0
- package/core/nida-cig-middleware.js +455 -0
- package/core/patterns.d.ts +124 -0
- package/core/patterns.js +833 -0
- package/core/plugins/index.js +312 -0
- package/core/router.js +387 -0
- package/core/sdk-client.js +62 -0
- package/core/sdk.d.ts +133 -0
- package/core/sdk.js +123 -0
- package/core/seo.js +76 -0
- package/core/server/auth-endpoints.js +339 -0
- package/core/server/auth.js +180 -0
- package/core/server/csrf.js +206 -0
- package/core/server/db.js +39 -0
- package/core/server/middleware.js +324 -0
- package/core/server/rate-limit.js +238 -0
- package/core/server/render.js +69 -0
- package/core/server/router.js +120 -0
- package/core/shim.js +28 -0
- package/core/state.d.ts +86 -0
- package/core/state.js +242 -0
- package/core/store.d.ts +122 -0
- package/core/store.js +61 -0
- package/core/validation.d.ts +233 -0
- package/core/validation.js +590 -0
- package/core/websocket.js +639 -0
- package/dist/africode.js +2905 -0
- package/dist/africode.js.map +61 -0
- package/dist/build-info.json +23 -0
- package/dist/components.js +2888 -0
- package/dist/components.js.map +58 -0
- package/dist/styles/africanity.css +322 -0
- package/dist/styles/typography.css +141 -0
- package/docs/IDE-Guide.md +50 -0
- package/package.json +110 -0
- package/src/index.ts +196 -0
- package/styles/africanity.css +322 -0
- package/styles/typography.css +141 -0
- package/templates/starter/.env.example +15 -0
- package/templates/starter/africode.config.js +40 -0
- package/templates/starter/package.json +14 -0
- package/templates/starter/src/pages/index.html +46 -0
- package/templates/starter/src/pages/index.js +32 -0
- package/templates/starter/src/styles/main.css +4 -0
- package/templates/starter-3d/.env.example +7 -0
- package/templates/starter-3d/africode.config.js +29 -0
- package/templates/starter-3d/components/af-model-viewer.js +125 -0
- package/templates/starter-3d/package.json +15 -0
- package/templates/starter-3d/src/pages/index.html +46 -0
- package/templates/starter-3d/src/pages/index.js +50 -0
- package/templates/starter-3d/src/styles/main.css +4 -0
- package/templates/starter-react/.env.example +15 -0
- package/templates/starter-react/africode.config.js +40 -0
- package/templates/starter-react/package.json +16 -0
- package/templates/starter-react/src/pages/index.html +46 -0
- package/templates/starter-react/src/pages/index.js +68 -0
- package/templates/starter-react/src/styles/main.css +4 -0
- package/templates/starter-tailwind/.env.example +15 -0
- package/templates/starter-tailwind/africode.config.js +40 -0
- package/templates/starter-tailwind/package.json +20 -0
- package/templates/starter-tailwind/src/pages/index.html +46 -0
- package/templates/starter-tailwind/src/pages/index.js +37 -0
- package/templates/starter-tailwind/src/styles/main.css +4 -0
- package/templates/starter-tailwind/src/styles/tailwind.css +1 -0
- package/templates/starter-tailwind/src/tailwind-loader.js +30 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AfriCode Plugin Registry
|
|
3
|
+
*
|
|
4
|
+
* A minimal, strict, and predictable plug-in architecture for the AfriCode Framework.
|
|
5
|
+
* Employs deterministic execution, strong boundary isolation, and a stable context
|
|
6
|
+
* contract to prevent ecosystem bloat and ensure framework resilience.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Core framework version to check plugin compatibility
|
|
10
|
+
export const AFRICODE_VERSION = '4.0.0';
|
|
11
|
+
|
|
12
|
+
// Only these specific hooks are permitted in v1
|
|
13
|
+
export const ALLOWED_HOOKS = new Set([
|
|
14
|
+
'onConfigLoad',
|
|
15
|
+
'onComponentRegister',
|
|
16
|
+
'onRouteRegister',
|
|
17
|
+
'onStateInit',
|
|
18
|
+
'onServerStart',
|
|
19
|
+
'onRequest',
|
|
20
|
+
'onResponse',
|
|
21
|
+
'onError',
|
|
22
|
+
'onCliCommandRegister',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// Protected core CLI commands that plugins cannot overwrite
|
|
26
|
+
const PROTECTED_CLI_COMMANDS = new Set([
|
|
27
|
+
'dev',
|
|
28
|
+
'build',
|
|
29
|
+
'start',
|
|
30
|
+
'test',
|
|
31
|
+
'lint',
|
|
32
|
+
'audit',
|
|
33
|
+
'add',
|
|
34
|
+
'migrate',
|
|
35
|
+
'create',
|
|
36
|
+
'help',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export class AfriPluginRegistry {
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.plugins = new Map();
|
|
42
|
+
// Mode mapping: safe (swallow errors), strict (propagate crashes immediately)
|
|
43
|
+
this.mode = options.mode || 'safe';
|
|
44
|
+
|
|
45
|
+
// Plugin state tracking
|
|
46
|
+
this.disabledPlugins = new Set();
|
|
47
|
+
|
|
48
|
+
// Internal structured store for deterministic hook execution (arrays maintain registration order)
|
|
49
|
+
this.hooks = {
|
|
50
|
+
onConfigLoad: [],
|
|
51
|
+
onComponentRegister: [],
|
|
52
|
+
onRouteRegister: [],
|
|
53
|
+
onStateInit: [],
|
|
54
|
+
onServerStart: [],
|
|
55
|
+
onRequest: [],
|
|
56
|
+
onResponse: [],
|
|
57
|
+
onError: [],
|
|
58
|
+
onCliCommandRegister: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Store plugin-registered CLI commands
|
|
62
|
+
this.pluginCommands = new Map();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Registers a new plugin into the framework.
|
|
67
|
+
* @param {Object} manifest - The Plugin manifest
|
|
68
|
+
* @param {Object} hooks - The hooks implementation
|
|
69
|
+
*/
|
|
70
|
+
register(manifest, hooks) {
|
|
71
|
+
this._validateManifest(manifest);
|
|
72
|
+
this._validateCompatibility(manifest.compatibleWith);
|
|
73
|
+
this._validateHooks(manifest.name, hooks);
|
|
74
|
+
|
|
75
|
+
if (this.plugins.has(manifest.name)) {
|
|
76
|
+
console.warn(`[AfriCode Plugin] Plugin '${manifest.name}' is already registered. Skipping.`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.plugins.set(manifest.name, manifest);
|
|
81
|
+
|
|
82
|
+
// Bind hooks into the deterministic execution pipeline
|
|
83
|
+
for (const [hookName, handler] of Object.entries(hooks)) {
|
|
84
|
+
this.hooks[hookName].push({
|
|
85
|
+
pluginName: manifest.name,
|
|
86
|
+
handler,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Observability Hook
|
|
91
|
+
this._logObservability('onPluginLoad', manifest.name, `Registered v${manifest.version}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Disable a plugin at runtime. Safely ejects it from the pipeline.
|
|
96
|
+
*/
|
|
97
|
+
disable(pluginName) {
|
|
98
|
+
if (!this.plugins.has(pluginName)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.disabledPlugins.add(pluginName);
|
|
102
|
+
this._logObservability('onPluginDisable', pluginName, 'Plugin forcefully disabled.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Enable a previously disabled plugin.
|
|
107
|
+
*/
|
|
108
|
+
enable(pluginName) {
|
|
109
|
+
this.disabledPlugins.delete(pluginName);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Execute a specific hook safely across all registered plugins in registration order.
|
|
114
|
+
* @param {string} hookName - The name of the hook to trigger
|
|
115
|
+
* @param {Object} context - A stable execution context exposed to plugins
|
|
116
|
+
*/
|
|
117
|
+
async emit(hookName, context = {}) {
|
|
118
|
+
if (!ALLOWED_HOOKS.has(hookName)) {
|
|
119
|
+
throw new Error(`[AfriCode Plugin] Attempted to emit unknown hook: ${hookName}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handlers = this.hooks[hookName];
|
|
123
|
+
if (!handlers || handlers.length === 0) {
|
|
124
|
+
return context;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Clone context defensively to prevent deep framework mutation
|
|
128
|
+
// but allow mutations on specifically designed properties.
|
|
129
|
+
const stableContext = this._buildStableContext(hookName, context);
|
|
130
|
+
|
|
131
|
+
for (const { pluginName, handler } of handlers) {
|
|
132
|
+
if (this.disabledPlugins.has(pluginName)) {
|
|
133
|
+
continue;
|
|
134
|
+
} // Skip disabled plugins
|
|
135
|
+
|
|
136
|
+
const hookPromise = async () => {
|
|
137
|
+
const startTime = performance.now();
|
|
138
|
+
await handler(stableContext);
|
|
139
|
+
const latency = performance.now() - startTime;
|
|
140
|
+
|
|
141
|
+
// Deep trace for heavy hooks taking >50ms
|
|
142
|
+
if (latency > 50) {
|
|
143
|
+
this._logObservability(
|
|
144
|
+
'onHookComplete',
|
|
145
|
+
pluginName,
|
|
146
|
+
`Hook ${hookName} resolved in ${latency.toFixed(2)}ms`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
152
|
+
setTimeout(() => reject(new Error(`[Timeout] Plugin Execution exceeded 200ms`)), 200)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
// Execution Guarantee: Plugin must resolve within 200ms boundary
|
|
157
|
+
await Promise.race([hookPromise(), timeoutPromise]);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this._logObservability(
|
|
160
|
+
'onPluginError',
|
|
161
|
+
pluginName,
|
|
162
|
+
`Failed during '${hookName}': ${error.message}`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Isolation Policy: Bubble crash explicitly if registry runs in 'strict' mode
|
|
166
|
+
if (this.mode === 'strict') {
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Safe mode: Prevent crash mapping downstream
|
|
171
|
+
if (hookName !== 'onError') {
|
|
172
|
+
await this.emit('onError', {
|
|
173
|
+
error,
|
|
174
|
+
source: `plugin:${pluginName}`,
|
|
175
|
+
hook: hookName,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return stableContext;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Internal observability tracking logger to format structured output guarantees
|
|
186
|
+
*/
|
|
187
|
+
_logObservability(event, pluginName, metadata) {
|
|
188
|
+
const timestamp = new Date().toISOString();
|
|
189
|
+
console.log(
|
|
190
|
+
`[AfriCode Observability] ${timestamp} | EVENT: ${event} | PLUGIN: ${pluginName} | ${metadata}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validates the schema of a plugin manifest.
|
|
196
|
+
*/
|
|
197
|
+
_validateManifest(manifest) {
|
|
198
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
199
|
+
throw new Error('[AfriCode Plugin] Plugin manifest must be a valid object.');
|
|
200
|
+
}
|
|
201
|
+
if (!manifest.name || typeof manifest.name !== 'string') {
|
|
202
|
+
throw new Error("[AfriCode Plugin] Plugin manifest is missing a valid 'name' property.");
|
|
203
|
+
}
|
|
204
|
+
if (!manifest.version || typeof manifest.version !== 'string') {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`[AfriCode Plugin: ${manifest.name}] Manifest is missing a valid 'version' property.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (!manifest.compatibleWith) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`[AfriCode Plugin: ${manifest.name}] Manifest is missing 'compatibleWith' boundary property.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Optional lightweight boundary check against standard semantic mapping.
|
|
218
|
+
*/
|
|
219
|
+
_validateCompatibility(compatibleWith) {
|
|
220
|
+
// In a full production scenario, use a semantic versioning library.
|
|
221
|
+
// For v1, we provide a structured warning if versions mismatched wildly.
|
|
222
|
+
if (typeof compatibleWith !== 'string') {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Very basic extraction of major version target (e.g., ^2.0.0 or ~3.0.0)
|
|
227
|
+
const targetMajor = compatibleWith.match(/\d+/)?.[0];
|
|
228
|
+
const currentMajor = AFRICODE_VERSION.split('.')[0];
|
|
229
|
+
|
|
230
|
+
if (targetMajor && targetMajor !== currentMajor) {
|
|
231
|
+
console.warn(
|
|
232
|
+
`[AfriCode Plugin Warning] Plugin requires compatibility '${compatibleWith}' but framework is v${AFRICODE_VERSION}. Unexpected behavior may occur.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Ensures plugins only utilize globally permitted v1 hooks.
|
|
239
|
+
*/
|
|
240
|
+
_validateHooks(pluginName, hooks) {
|
|
241
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
242
|
+
throw new Error(`[AfriCode Plugin: ${pluginName}] Hooks object is missing or invalid.`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const hookName of Object.keys(hooks)) {
|
|
246
|
+
if (!ALLOWED_HOOKS.has(hookName)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[AfriCode Plugin: ${pluginName}] Invalid hook attempt: '${hookName}'. Authorized hooks are explicitly scoped.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (typeof hooks[hookName] !== 'function') {
|
|
252
|
+
throw new Error(`[AfriCode Plugin: ${pluginName}] Hook '${hookName}' must be a function.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Scopes the contextual payload based on the specific hook type.
|
|
259
|
+
* Prevents plugins from pulling down random core internals.
|
|
260
|
+
*/
|
|
261
|
+
_buildStableContext(hookName, payload = {}) {
|
|
262
|
+
switch (hookName) {
|
|
263
|
+
case 'onCliCommandRegister':
|
|
264
|
+
return {
|
|
265
|
+
// Safe command registration mechanism
|
|
266
|
+
registerCommand: (commandName, commandHandler) => {
|
|
267
|
+
if (PROTECTED_CLI_COMMANDS.has(commandName)) {
|
|
268
|
+
console.warn(
|
|
269
|
+
`[AfriCode Plugin] Refused to overwrite core framework CLI command: '${commandName}'.`
|
|
270
|
+
);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
this.pluginCommands.set(commandName, commandHandler);
|
|
274
|
+
return true;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
case 'onRouteRegister':
|
|
278
|
+
return {
|
|
279
|
+
addRoute: payload.addRoute, // The framework passes a strict routing interface
|
|
280
|
+
router: payload.router,
|
|
281
|
+
};
|
|
282
|
+
case 'onServerStart':
|
|
283
|
+
return { port: payload.port };
|
|
284
|
+
case 'onStateInit':
|
|
285
|
+
return { store: payload.store };
|
|
286
|
+
case 'onComponentRegister':
|
|
287
|
+
return { componentName: payload.name, componentClass: payload.component };
|
|
288
|
+
case 'onRequest':
|
|
289
|
+
case 'onResponse':
|
|
290
|
+
// Plugins receive specific restricted request/response maps
|
|
291
|
+
return { req: payload.req, res: payload.res, meta: payload.meta || {} };
|
|
292
|
+
case 'onError':
|
|
293
|
+
return { error: payload.error, source: payload.source };
|
|
294
|
+
case 'onConfigLoad': {
|
|
295
|
+
// Security Hardening: Never dump RAW process.env unconditionally
|
|
296
|
+
// Mask sensitive keys by delivering a tailored subset mapping.
|
|
297
|
+
const env = typeof process !== 'undefined' ? process.env : {};
|
|
298
|
+
const safeEnvSubset = {
|
|
299
|
+
NODE_ENV: env.NODE_ENV || 'development',
|
|
300
|
+
PORT: env.PORT || 3000,
|
|
301
|
+
// Blacklist database IPs, JWT secrets, Stripe Keys from rogue downstream plugins
|
|
302
|
+
};
|
|
303
|
+
return { config: payload.config, env: safeEnvSubset };
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
return payload;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Global static registry instance for core
|
|
312
|
+
export const registry = new AfriPluginRegistry();
|
package/core/router.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSystemRouter - Next.js style file-based routing for AfriCode
|
|
3
|
+
*
|
|
4
|
+
* Maps URL paths to files in the pages directory:
|
|
5
|
+
* - / → pages/index.html
|
|
6
|
+
* - /about → pages/about.html
|
|
7
|
+
* - /users/profile → pages/users/profile.html
|
|
8
|
+
* - /users/[id] → pages/users/[id].js (dynamic route)
|
|
9
|
+
* - /api/users → pages/api/users.js (API routes)
|
|
10
|
+
* - /api/[...slug] → pages/api/[...slug].js (catch-all routes)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdirSync, statSync } from 'fs';
|
|
14
|
+
import { join, relative, extname } from 'path';
|
|
15
|
+
|
|
16
|
+
export class FileSystemRouter {
|
|
17
|
+
constructor(pagesDir = './pages') {
|
|
18
|
+
this.pagesDir = pagesDir;
|
|
19
|
+
this.routes = []; // Ordered routes (dynamic routes last)
|
|
20
|
+
this.apiRoutes = []; // API routes
|
|
21
|
+
this.routeMap = new Map(); // Static routes for fast lookup
|
|
22
|
+
this.scanRoutes();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively scan the pages directory and build route mappings
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
scanRoutes() {
|
|
30
|
+
try {
|
|
31
|
+
this._scanDirectory('', this.pagesDir);
|
|
32
|
+
|
|
33
|
+
// Sort routes: exact matches first, then dynamic, then catch-all
|
|
34
|
+
this.routes.sort((a, b) => {
|
|
35
|
+
const aPriority = this._getRoutePriority(a.pattern);
|
|
36
|
+
const bPriority = this._getRoutePriority(b.pattern);
|
|
37
|
+
return aPriority - bPriority;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.apiRoutes.sort((a, b) => {
|
|
41
|
+
const aPriority = this._getRoutePriority(a.pattern);
|
|
42
|
+
const bPriority = this._getRoutePriority(b.pattern);
|
|
43
|
+
return aPriority - bPriority;
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn(`[AfriCode Router] Failed to scan pages directory: ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively scan a directory for route files
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
_scanDirectory(prefix, dirPath) {
|
|
55
|
+
try {
|
|
56
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
// Skip special files/dirs
|
|
60
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fullPath = join(dirPath, entry.name);
|
|
65
|
+
const routePath = prefix ? `${prefix}/${entry.name}` : `/${entry.name}`;
|
|
66
|
+
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
// Recursively scan subdirectories
|
|
69
|
+
this._scanDirectory(routePath, fullPath);
|
|
70
|
+
} else if (this._isRouteFile(entry.name)) {
|
|
71
|
+
this._addRoute(routePath, fullPath, entry.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.warn(`[AfriCode Router] Failed to scan directory ${dirPath}: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a file should be treated as a route
|
|
81
|
+
* @private
|
|
82
|
+
*/
|
|
83
|
+
_isRouteFile(filename) {
|
|
84
|
+
const validExtensions = ['.js', '.jsx', '.ts', '.tsx', '.html'];
|
|
85
|
+
return validExtensions.some(ext => filename.endsWith(ext)) &&
|
|
86
|
+
filename !== 'package.json';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Add a route from a file
|
|
91
|
+
* @private
|
|
92
|
+
*/
|
|
93
|
+
_addRoute(routePath, filePath, filename) {
|
|
94
|
+
// Remove file extension and trailing index
|
|
95
|
+
let pattern = routePath.replace(/\.(js|jsx|ts|tsx|html)$/, '');
|
|
96
|
+
|
|
97
|
+
// Handle index.js → /
|
|
98
|
+
if (pattern.endsWith('/index')) {
|
|
99
|
+
pattern = pattern.replace(/\/index$/, '') || '/';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Separate API routes from page routes
|
|
103
|
+
const isApi = pattern.startsWith('/api');
|
|
104
|
+
|
|
105
|
+
// Convert file path to route pattern with params
|
|
106
|
+
const route = {
|
|
107
|
+
pattern,
|
|
108
|
+
filePath,
|
|
109
|
+
isApi,
|
|
110
|
+
isDynamic: pattern.includes('['),
|
|
111
|
+
params: this._extractParams(pattern)
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Add to appropriate collection
|
|
115
|
+
if (isApi) {
|
|
116
|
+
this.apiRoutes.push(route);
|
|
117
|
+
} else {
|
|
118
|
+
this.routes.push(route);
|
|
119
|
+
// Add exact matches to fast lookup map
|
|
120
|
+
if (!route.isDynamic) {
|
|
121
|
+
this.routeMap.set(pattern, route);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract parameter names from route pattern
|
|
128
|
+
* /users/[id] → ['id']
|
|
129
|
+
* /posts/[...slug] → ['slug'] (with rest: true)
|
|
130
|
+
* @private
|
|
131
|
+
*/
|
|
132
|
+
_extractParams(pattern) {
|
|
133
|
+
const params = {};
|
|
134
|
+
const matches = pattern.match(/\[([^\]]+)\]/g) || [];
|
|
135
|
+
|
|
136
|
+
matches.forEach((match, index) => {
|
|
137
|
+
const name = match.slice(1, -1); // Remove [ and ]
|
|
138
|
+
if (name.startsWith('...')) {
|
|
139
|
+
params[name.slice(3)] = { rest: true, index };
|
|
140
|
+
} else {
|
|
141
|
+
params[name] = { rest: false, index };
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return params;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get route priority for sorting
|
|
150
|
+
* Lower number = higher priority
|
|
151
|
+
* @private
|
|
152
|
+
*/
|
|
153
|
+
_getRoutePriority(pattern) {
|
|
154
|
+
// Exact matches: priority 0
|
|
155
|
+
if (!pattern.includes('[')) {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Dynamic with rest params (catch-all): priority 2
|
|
160
|
+
if (pattern.includes('[...')) {
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Regular dynamic params: priority 1
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if a route matches a URL pathname
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
_matchRoute(route, pathname) {
|
|
173
|
+
if (!route.isDynamic) {
|
|
174
|
+
return route.pattern === pathname ? {} : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const patternParts = route.pattern.split('/').filter(Boolean);
|
|
178
|
+
const pathParts = pathname.split('/').filter(Boolean);
|
|
179
|
+
|
|
180
|
+
// Check if catch-all route
|
|
181
|
+
const hasCatchAll = Object.values(route.params).some(p => p.rest);
|
|
182
|
+
|
|
183
|
+
if (hasCatchAll) {
|
|
184
|
+
// Catch-all must match at least as many segments (minus 1 for the catch-all param)
|
|
185
|
+
if (pathParts.length < patternParts.length - 1) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Verify static segments match and capture catch-all
|
|
190
|
+
const params = {};
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
193
|
+
const part = patternParts[i];
|
|
194
|
+
|
|
195
|
+
if (part.startsWith('[...')) {
|
|
196
|
+
// Catch-all: capture remaining segments
|
|
197
|
+
// [... is 4 chars to remove from start, ] is 1 char from end
|
|
198
|
+
const paramName = part.slice(4, -1); // Remove [... and ]
|
|
199
|
+
params[paramName] = pathParts.slice(i).join('/');
|
|
200
|
+
return params; // IMPORTANT: Return immediately after matching catch-all
|
|
201
|
+
} else if (part !== pathParts[i]) {
|
|
202
|
+
// Static segment mismatch
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return params;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Not a catch-all route
|
|
211
|
+
if (patternParts.length !== pathParts.length) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Regular dynamic route matching (NOT catch-all)
|
|
216
|
+
const params = {};
|
|
217
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
218
|
+
const part = patternParts[i];
|
|
219
|
+
|
|
220
|
+
if (part.startsWith('[')) {
|
|
221
|
+
// Dynamic segment
|
|
222
|
+
const paramName = part.slice(1, -1);
|
|
223
|
+
params[paramName] = pathParts[i];
|
|
224
|
+
} else if (part !== pathParts[i]) {
|
|
225
|
+
// Static segment mismatch
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return params;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check which route should be used (prioritize based on specificity)
|
|
235
|
+
* @private
|
|
236
|
+
*/
|
|
237
|
+
_findBestMatch(pathname) {
|
|
238
|
+
const exactMatches = [];
|
|
239
|
+
const dynamicMatches = [];
|
|
240
|
+
const catchAllMatches = [];
|
|
241
|
+
|
|
242
|
+
// Try all routes
|
|
243
|
+
for (const route of this.routes) {
|
|
244
|
+
const params = this._matchRoute(route, pathname);
|
|
245
|
+
if (params !== null) {
|
|
246
|
+
if (!route.isDynamic) {
|
|
247
|
+
exactMatches.push({ route, params });
|
|
248
|
+
} else if (Object.values(route.params).some(p => p.rest)) {
|
|
249
|
+
catchAllMatches.push({ route, params });
|
|
250
|
+
} else {
|
|
251
|
+
dynamicMatches.push({ route, params });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Return best match: exact > dynamic > catch-all
|
|
257
|
+
if (exactMatches.length > 0) return exactMatches[0];
|
|
258
|
+
if (dynamicMatches.length > 0) return dynamicMatches[0];
|
|
259
|
+
if (catchAllMatches.length > 0) return catchAllMatches[0];
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check which API route should be used (prioritize based on specificity)
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
_findBestApiMatch(pathname) {
|
|
268
|
+
const exactMatches = [];
|
|
269
|
+
const dynamicMatches = [];
|
|
270
|
+
const catchAllMatches = [];
|
|
271
|
+
|
|
272
|
+
// Try all API routes
|
|
273
|
+
for (const route of this.apiRoutes) {
|
|
274
|
+
const params = this._matchRoute(route, pathname);
|
|
275
|
+
if (params !== null) {
|
|
276
|
+
if (!route.isDynamic) {
|
|
277
|
+
exactMatches.push({ route, params });
|
|
278
|
+
} else if (Object.values(route.params).some(p => p.rest)) {
|
|
279
|
+
catchAllMatches.push({ route, params });
|
|
280
|
+
} else {
|
|
281
|
+
dynamicMatches.push({ route, params });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Return best match: exact > dynamic > catch-all
|
|
287
|
+
if (exactMatches.length > 0) return exactMatches[0];
|
|
288
|
+
if (dynamicMatches.length > 0) return dynamicMatches[0];
|
|
289
|
+
if (catchAllMatches.length > 0) return catchAllMatches[0];
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a URL path to a file path and extract parameters
|
|
295
|
+
* @param {string} pathname - The URL pathname
|
|
296
|
+
* @returns {object|null} - { filePath, isApi, params, isDynamic } or null
|
|
297
|
+
*/
|
|
298
|
+
resolve(pathname) {
|
|
299
|
+
// Normalize pathname
|
|
300
|
+
pathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
301
|
+
if (pathname.endsWith('/') && pathname !== '/') {
|
|
302
|
+
pathname = pathname.slice(0, -1);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Fast path for exact matches
|
|
306
|
+
if (this.routeMap.has(pathname)) {
|
|
307
|
+
const route = this.routeMap.get(pathname);
|
|
308
|
+
return {
|
|
309
|
+
filePath: route.filePath,
|
|
310
|
+
isApi: route.isApi,
|
|
311
|
+
params: {},
|
|
312
|
+
isDynamic: false
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check API routes first
|
|
317
|
+
const apiMatch = this._findBestApiMatch(pathname);
|
|
318
|
+
if (apiMatch) {
|
|
319
|
+
return {
|
|
320
|
+
filePath: apiMatch.route.filePath,
|
|
321
|
+
isApi: true,
|
|
322
|
+
params: apiMatch.params,
|
|
323
|
+
isDynamic: apiMatch.route.isDynamic
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Check page routes
|
|
328
|
+
const pageMatch = this._findBestMatch(pathname);
|
|
329
|
+
if (pageMatch) {
|
|
330
|
+
return {
|
|
331
|
+
filePath: pageMatch.route.filePath,
|
|
332
|
+
isApi: false,
|
|
333
|
+
params: pageMatch.params,
|
|
334
|
+
isDynamic: pageMatch.route.isDynamic
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get all registered routes for debugging/manifest
|
|
343
|
+
*/
|
|
344
|
+
getRoutes() {
|
|
345
|
+
return {
|
|
346
|
+
pages: this.routes.map(r => ({
|
|
347
|
+
pattern: r.pattern,
|
|
348
|
+
file: r.filePath,
|
|
349
|
+
isDynamic: r.isDynamic
|
|
350
|
+
})),
|
|
351
|
+
apis: this.apiRoutes.map(r => ({
|
|
352
|
+
pattern: r.pattern,
|
|
353
|
+
file: r.filePath,
|
|
354
|
+
isDynamic: r.isDynamic
|
|
355
|
+
}))
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get route manifest for build/analysis
|
|
361
|
+
*/
|
|
362
|
+
getManifest() {
|
|
363
|
+
const manifest = {
|
|
364
|
+
timestamp: new Date().toISOString(),
|
|
365
|
+
pages: {},
|
|
366
|
+
apis: {}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
for (const route of this.routes) {
|
|
370
|
+
manifest.pages[route.pattern] = {
|
|
371
|
+
file: relative(this.pagesDir, route.filePath),
|
|
372
|
+
dynamic: route.isDynamic,
|
|
373
|
+
params: route.params
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const route of this.apiRoutes) {
|
|
378
|
+
manifest.apis[route.pattern] = {
|
|
379
|
+
file: relative(this.pagesDir, route.filePath),
|
|
380
|
+
dynamic: route.isDynamic,
|
|
381
|
+
params: route.params
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return manifest;
|
|
386
|
+
}
|
|
387
|
+
}
|