@stnd/core 0.1.0 → 0.22.2
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/LICENSE +21 -0
- package/README.md +239 -0
- package/package.json +90 -12
- package/src/dev-toolbar/debug-toggle.js +21 -0
- package/src/dev-toolbar/lab-toggle.js +153 -0
- package/src/loader.js +681 -0
- package/src/virtual.js +440 -0
- package/src/virtual.test.js +37 -0
- package/standard.js +545 -0
- package/CHANGELOG.md +0 -13
- package/src/StandardModel.ts +0 -55
- package/src/StandardRoot.ts +0 -73
- package/src/Visitor.ts +0 -50
- package/src/index.ts +0 -4
- package/tsconfig.json +0 -20
package/src/loader.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module Auto-Discovery and Loading System
|
|
3
|
+
*
|
|
4
|
+
* This module handles the discovery, loading, and processing of modules (vertical slices).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
8
|
+
import { globSync } from "glob";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
import Log from "@stnd/log";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
const RouteSchema = z.object({
|
|
15
|
+
path: z.string(),
|
|
16
|
+
entrypoint: z.string(),
|
|
17
|
+
methods: z.array(z.string()).optional(),
|
|
18
|
+
priority: z.number().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const HookSchema = z.union([
|
|
22
|
+
z.string(),
|
|
23
|
+
z
|
|
24
|
+
.object({
|
|
25
|
+
ui: z.string().optional(),
|
|
26
|
+
action: z.string().optional(),
|
|
27
|
+
server: z.boolean().optional(),
|
|
28
|
+
meta: z.record(z.any()).optional(),
|
|
29
|
+
})
|
|
30
|
+
.passthrough(),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const ScriptSchema = z.union([
|
|
34
|
+
z.string(),
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
src: z.string(),
|
|
38
|
+
init: z.string().optional(),
|
|
39
|
+
inline: z.boolean().optional(),
|
|
40
|
+
})
|
|
41
|
+
.passthrough(),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const MiddlewareSchema = z.union([
|
|
45
|
+
z.string(),
|
|
46
|
+
z
|
|
47
|
+
.object({
|
|
48
|
+
entrypoint: z.string(),
|
|
49
|
+
order: z.number().optional(),
|
|
50
|
+
})
|
|
51
|
+
.passthrough(),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const ModuleManifestSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
id: z.string().min(1, "A module must have an 'id'"),
|
|
57
|
+
name: z.string().optional(),
|
|
58
|
+
description: z.string().optional(),
|
|
59
|
+
icon: z.string().optional(),
|
|
60
|
+
dependencies: z.array(z.string()).optional(),
|
|
61
|
+
config: z.record(z.any()).optional(),
|
|
62
|
+
routes: z.array(RouteSchema).optional(),
|
|
63
|
+
// Where this module is allowed to exist. `domain` lists the canonical
|
|
64
|
+
// domain(s) it belongs to; the module's routes and hooks disappear
|
|
65
|
+
// everywhere else. The framework never resolves a hostname itself — the
|
|
66
|
+
// app tells the guard which canonical domain the current request is on,
|
|
67
|
+
// so "standard.garden" still matches while developing on localhost.
|
|
68
|
+
// Absent = the module exists everywhere (the default for every module
|
|
69
|
+
// that doesn't opt in).
|
|
70
|
+
filter: z
|
|
71
|
+
.object({
|
|
72
|
+
domain: z.union([z.string(), z.array(z.string())]).optional(),
|
|
73
|
+
})
|
|
74
|
+
.passthrough()
|
|
75
|
+
.optional(),
|
|
76
|
+
hooks: z.record(z.union([HookSchema, z.array(HookSchema)])).optional(),
|
|
77
|
+
styles: z.array(z.string()).optional(),
|
|
78
|
+
scripts: z.array(ScriptSchema).optional(),
|
|
79
|
+
middleware: z.array(MiddlewareSchema).optional(),
|
|
80
|
+
integrations: z
|
|
81
|
+
.union([z.array(z.any()), z.custom((val) => typeof val === "function")])
|
|
82
|
+
.optional(),
|
|
83
|
+
aliases: z.record(z.string()).optional(),
|
|
84
|
+
actions: z.union([z.string(), z.record(z.string())]).optional(),
|
|
85
|
+
store: z.record(z.string()).optional(),
|
|
86
|
+
content: z.union([z.string(), z.record(z.string())]).optional(),
|
|
87
|
+
head: z.array(z.any()).optional(),
|
|
88
|
+
vite: z.any().optional(), // Escape hatch for vite config
|
|
89
|
+
})
|
|
90
|
+
.passthrough();
|
|
91
|
+
|
|
92
|
+
const DEFAULT_MODULE_PATTERNS = ["./modules/**/*.module.{ts,js}"];
|
|
93
|
+
|
|
94
|
+
export function buildModulePatterns(config) {
|
|
95
|
+
const folders = Array.isArray(config?.moduleFolder)
|
|
96
|
+
? config.moduleFolder
|
|
97
|
+
: config?.moduleFolder
|
|
98
|
+
? [config.moduleFolder]
|
|
99
|
+
: [];
|
|
100
|
+
|
|
101
|
+
const cleaned = folders
|
|
102
|
+
.map((f) => (f || "").replace(/^\/*|\/*$/g, ""))
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
|
|
105
|
+
if (cleaned.length > 0) {
|
|
106
|
+
return cleaned.map((folder) => `${folder}/**/*.module.{ts,js}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return DEFAULT_MODULE_PATTERNS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function discoverModuleFiles(appRoot, patterns) {
|
|
113
|
+
return globSync(patterns, {
|
|
114
|
+
cwd: appRoot,
|
|
115
|
+
absolute: true,
|
|
116
|
+
ignore: ["**/_*/**", "**/.*/**"],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function loadModules({
|
|
121
|
+
command,
|
|
122
|
+
config,
|
|
123
|
+
finalConfig,
|
|
124
|
+
injectRoute,
|
|
125
|
+
injectScript,
|
|
126
|
+
}) {
|
|
127
|
+
const log = Log({ scope: "Modules" });
|
|
128
|
+
const appRoot = fileURLToPath(config.root);
|
|
129
|
+
log.success("Initializing module engine...");
|
|
130
|
+
|
|
131
|
+
const modulePatterns = buildModulePatterns(finalConfig);
|
|
132
|
+
const require = createRequire(import.meta.url);
|
|
133
|
+
|
|
134
|
+
const discoveredFiles = discoverModuleFiles(appRoot, modulePatterns);
|
|
135
|
+
|
|
136
|
+
const resolveModulePath = (idOrPath) => {
|
|
137
|
+
if (path.isAbsolute(idOrPath)) return idOrPath;
|
|
138
|
+
|
|
139
|
+
const tryResolve = (id) => {
|
|
140
|
+
try {
|
|
141
|
+
return require.resolve(id);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const isBare =
|
|
148
|
+
typeof idOrPath === "string" &&
|
|
149
|
+
!idOrPath.startsWith("@") &&
|
|
150
|
+
!idOrPath.startsWith(".") &&
|
|
151
|
+
!idOrPath.startsWith("/");
|
|
152
|
+
const prefixed = isBare ? `@stnd/${idOrPath}` : null;
|
|
153
|
+
const resolved =
|
|
154
|
+
tryResolve(idOrPath) || (prefixed ? tryResolve(prefixed) : null);
|
|
155
|
+
|
|
156
|
+
if (!resolved && !idOrPath.startsWith(".") && !idOrPath.startsWith("/")) {
|
|
157
|
+
// Quiet warning for local dependencies
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const moduleMap = new Map();
|
|
163
|
+
const idToPath = new Map();
|
|
164
|
+
const explicitLoadList = Array.isArray(finalConfig.moduleLoad)
|
|
165
|
+
? finalConfig.moduleLoad
|
|
166
|
+
: [];
|
|
167
|
+
|
|
168
|
+
const loadQueue = [
|
|
169
|
+
...explicitLoadList.map((rawId) => ({ rawId, origin: "explicit" })),
|
|
170
|
+
...discoveredFiles.map((rawId) => ({ rawId, origin: "discovered" })),
|
|
171
|
+
];
|
|
172
|
+
const processedPaths = new Set();
|
|
173
|
+
const moduleOrigins = new Map();
|
|
174
|
+
|
|
175
|
+
while (loadQueue.length > 0) {
|
|
176
|
+
const batch = loadQueue.splice(0, loadQueue.length);
|
|
177
|
+
const tasks = batch
|
|
178
|
+
.map(({ rawId, origin, sourceModule }) => {
|
|
179
|
+
const resolvedPath = resolveModulePath(rawId);
|
|
180
|
+
if (!resolvedPath || processedPaths.has(resolvedPath)) return null;
|
|
181
|
+
processedPaths.add(resolvedPath);
|
|
182
|
+
return { rawId, origin, sourceModule, resolvedPath };
|
|
183
|
+
})
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
|
|
186
|
+
if (tasks.length === 0) continue;
|
|
187
|
+
|
|
188
|
+
const results = await Promise.allSettled(
|
|
189
|
+
tasks.map(async ({ rawId, origin, resolvedPath }) => {
|
|
190
|
+
const moduleDef = await import(pathToFileURL(resolvedPath).href);
|
|
191
|
+
return { rawId, origin, resolvedPath, moduleDef };
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
results.forEach((result, index) => {
|
|
196
|
+
const task = tasks[index];
|
|
197
|
+
if (result.status === "rejected") {
|
|
198
|
+
log.fatal(
|
|
199
|
+
`Failed to load module: ${task.resolvedPath}\nSee: https://stnd.build/manual/diagnostic/module-load-failure`,
|
|
200
|
+
result.reason,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { moduleDef, resolvedPath, origin } = result.value;
|
|
205
|
+
const rawDef = moduleDef.default;
|
|
206
|
+
|
|
207
|
+
if (task.rawId === "core" || task.rawId === "@stnd/core") {
|
|
208
|
+
let errorMsg = `Invalid dependency: "@stnd/core" is the orchestration engine, not a module.`;
|
|
209
|
+
if (task.sourceModule) {
|
|
210
|
+
errorMsg += `\n Required by: "${task.sourceModule}" module`;
|
|
211
|
+
}
|
|
212
|
+
errorMsg += `\nSee: https://stnd.build/manual/diagnostic/core-is-not-a-module`;
|
|
213
|
+
log.fatal(errorMsg);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// STRICT MANIFEST VALIDATION
|
|
217
|
+
const parsedDef = ModuleManifestSchema.safeParse(rawDef);
|
|
218
|
+
if (!parsedDef.success) {
|
|
219
|
+
let errorMsg = `Invalid module manifest format: ${resolvedPath}\n`;
|
|
220
|
+
parsedDef.error.issues.forEach((err) => {
|
|
221
|
+
errorMsg += ` - [${err.path.join(".")}] ${err.message}\n`;
|
|
222
|
+
});
|
|
223
|
+
if (task.sourceModule) {
|
|
224
|
+
errorMsg += ` Required by: "${task.sourceModule}" module\n`;
|
|
225
|
+
}
|
|
226
|
+
errorMsg += `See: https://stnd.build/manual/diagnostic/content-validation`;
|
|
227
|
+
log.fatal(errorMsg);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const def = parsedDef.data;
|
|
231
|
+
|
|
232
|
+
// STRICT ID CHECKING
|
|
233
|
+
if (idToPath.has(def.id)) {
|
|
234
|
+
const existingPath = idToPath.get(def.id);
|
|
235
|
+
if (existingPath !== resolvedPath) {
|
|
236
|
+
log.fatal(
|
|
237
|
+
`Duplicate module ID "${def.id}" detected.` +
|
|
238
|
+
`\n Path 1: ${existingPath}` +
|
|
239
|
+
`\n Path 2: ${resolvedPath}` +
|
|
240
|
+
`\n Every module must have a unique ID.` +
|
|
241
|
+
`\n See: https://stnd.build/manual/diagnostic/module-duplicate-id`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
moduleMap.set(resolvedPath, { def, filePath: resolvedPath });
|
|
247
|
+
idToPath.set(def.id, resolvedPath);
|
|
248
|
+
moduleOrigins.set(def.id, origin);
|
|
249
|
+
|
|
250
|
+
if (Array.isArray(def.dependencies)) {
|
|
251
|
+
for (const depId of def.dependencies) {
|
|
252
|
+
if (!idToPath.has(depId)) {
|
|
253
|
+
loadQueue.push({
|
|
254
|
+
rawId: depId,
|
|
255
|
+
origin: "dependency",
|
|
256
|
+
sourceModule: def.id,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2. Build Dependency Graph
|
|
265
|
+
const sortedIds = [];
|
|
266
|
+
const adjacency = new Map();
|
|
267
|
+
const indegree = new Map();
|
|
268
|
+
const allIds = Array.from(idToPath.keys());
|
|
269
|
+
|
|
270
|
+
allIds.forEach((id) => {
|
|
271
|
+
adjacency.set(id, new Set());
|
|
272
|
+
indegree.set(id, 0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
for (const moduleId of allIds) {
|
|
276
|
+
const pathStr = idToPath.get(moduleId);
|
|
277
|
+
if (!pathStr) continue;
|
|
278
|
+
const { def } = moduleMap.get(pathStr);
|
|
279
|
+
if (!def?.dependencies) continue;
|
|
280
|
+
|
|
281
|
+
for (const rawDep of def.dependencies) {
|
|
282
|
+
let depId = rawDep;
|
|
283
|
+
if (!idToPath.has(depId)) {
|
|
284
|
+
const resolvedDepPath = resolveModulePath(rawDep);
|
|
285
|
+
if (resolvedDepPath && moduleMap.has(resolvedDepPath)) {
|
|
286
|
+
depId = moduleMap.get(resolvedDepPath).def.id;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!idToPath.has(depId)) {
|
|
291
|
+
log.fatal(
|
|
292
|
+
`Module "${moduleId}" has unresolved dependency: "${rawDep}"\nSee: https://stnd.build/manual/diagnostic/module-dependency-unresolved`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!adjacency.has(depId)) adjacency.set(depId, new Set());
|
|
297
|
+
adjacency.get(depId).add(moduleId);
|
|
298
|
+
indegree.set(moduleId, (indegree.get(moduleId) || 0) + 1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const ready = allIds.filter((id) => (indegree.get(id) || 0) === 0);
|
|
303
|
+
while (ready.length > 0) {
|
|
304
|
+
ready.sort();
|
|
305
|
+
const nextId = ready.shift();
|
|
306
|
+
sortedIds.push(nextId);
|
|
307
|
+
const dependents = adjacency.get(nextId);
|
|
308
|
+
if (!dependents) continue;
|
|
309
|
+
for (const dependent of dependents) {
|
|
310
|
+
const nextCount = (indegree.get(dependent) || 0) - 1;
|
|
311
|
+
indegree.set(dependent, nextCount);
|
|
312
|
+
if (nextCount === 0) ready.push(dependent);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (sortedIds.length !== allIds.length) {
|
|
317
|
+
log.fatal(
|
|
318
|
+
"Circular dependency detected in module graph.\nSee: https://stnd.build/manual/diagnostic/module-dependency-circular",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 3. Process
|
|
323
|
+
const modules = {
|
|
324
|
+
runtime: [],
|
|
325
|
+
// Server-only: {id, filter, routes} for modules declaring `filter.domain`.
|
|
326
|
+
// See the comment at the injectRoute() call site for why this exists
|
|
327
|
+
// separately from `runtime` (which is the client-bundled payload).
|
|
328
|
+
domainFilters: [],
|
|
329
|
+
config: [],
|
|
330
|
+
aliases: {},
|
|
331
|
+
ui: {},
|
|
332
|
+
middleware: [],
|
|
333
|
+
integrations: [],
|
|
334
|
+
actions: [],
|
|
335
|
+
hooks: [], // formerly lifecycleExtensions
|
|
336
|
+
content: [],
|
|
337
|
+
store: [],
|
|
338
|
+
styles: [],
|
|
339
|
+
scripts: [],
|
|
340
|
+
head: [],
|
|
341
|
+
optimizeDeps: [],
|
|
342
|
+
icon: { tokens: {}, library: null }, // merged token contributions from modules
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Tracks whether each hook name has been classified as a UI/action-zone
|
|
346
|
+
// contribution (routed to virtual:stnd/components, no server filtering)
|
|
347
|
+
// or a logic hook (routed to virtual:stnd/hooks, supports `server: true`).
|
|
348
|
+
// A hook name must mean one thing consistently — see the fatal error
|
|
349
|
+
// thrown in the def.hooks block below.
|
|
350
|
+
const hookKindByName = new Map();
|
|
351
|
+
|
|
352
|
+
for (const moduleId of sortedIds) {
|
|
353
|
+
const pathStr = idToPath.get(moduleId);
|
|
354
|
+
const { def, filePath } = moduleMap.get(pathStr);
|
|
355
|
+
const moduleDir = path.dirname(filePath);
|
|
356
|
+
|
|
357
|
+
if (def.status === "disabled") continue;
|
|
358
|
+
|
|
359
|
+
// Environment-gated modules: skip if command doesn't match.
|
|
360
|
+
// Supports string ("dev") or array (["dev", "preview"]).
|
|
361
|
+
if (def.environment) {
|
|
362
|
+
const envs = Array.isArray(def.environment)
|
|
363
|
+
? def.environment
|
|
364
|
+
: [def.environment];
|
|
365
|
+
if (!envs.includes(command)) {
|
|
366
|
+
log.debug(
|
|
367
|
+
`[${def.name}] skipped (environment: ${def.environment}, command: ${command})`,
|
|
368
|
+
);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
Log.success(def.name, "");
|
|
374
|
+
|
|
375
|
+
if (def.config) {
|
|
376
|
+
modules.config.push({ moduleId: def.id, value: def.config });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── def.store ──────────────────────────────────────────────
|
|
380
|
+
// Declarative provide/inject for the Standard OS runtime store.
|
|
381
|
+
//
|
|
382
|
+
// store: {
|
|
383
|
+
// user: "./models/Visitor.js#visitorStore",
|
|
384
|
+
// search: "./search.js#searchNotes",
|
|
385
|
+
// locale: "./i18n.js", // default export
|
|
386
|
+
// }
|
|
387
|
+
//
|
|
388
|
+
// The loader resolves file paths and parses the #exportName suffix.
|
|
389
|
+
// The virtual module system generates the import + provide() calls.
|
|
390
|
+
if (def.store) {
|
|
391
|
+
Object.entries(def.store).forEach(([key, value]) => {
|
|
392
|
+
let filePath, exportName;
|
|
393
|
+
if (typeof value === "string") {
|
|
394
|
+
const hashIndex = value.lastIndexOf("#");
|
|
395
|
+
if (hashIndex > 0) {
|
|
396
|
+
filePath = value.slice(0, hashIndex);
|
|
397
|
+
exportName = value.slice(hashIndex + 1);
|
|
398
|
+
} else {
|
|
399
|
+
filePath = value;
|
|
400
|
+
exportName = null;
|
|
401
|
+
}
|
|
402
|
+
} else if (value && typeof value === "object") {
|
|
403
|
+
filePath = value.from || value.path;
|
|
404
|
+
exportName = value.export || null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (filePath) {
|
|
408
|
+
const resolved = filePath.startsWith("@")
|
|
409
|
+
? filePath
|
|
410
|
+
: path.resolve(moduleDir, filePath);
|
|
411
|
+
modules.store.push({ key, path: resolved, export: exportName });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (def.routes) {
|
|
417
|
+
def.routes.forEach((route) => {
|
|
418
|
+
const entrypoint = pathToFileURL(
|
|
419
|
+
path.resolve(moduleDir, route.entrypoint),
|
|
420
|
+
).href;
|
|
421
|
+
injectRoute({ pattern: route.path, entrypoint });
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// A module declaring `filter: { domain }` needs its route PATTERNS at
|
|
426
|
+
// request time, server-side, to turn "hidden in the UI" into "the URL
|
|
427
|
+
// itself answers 404" (routes are injected once here, at build time, so
|
|
428
|
+
// hiding a button never stops the URL from working). This is collected
|
|
429
|
+
// separately from `modules.runtime` because that array is the CLIENT
|
|
430
|
+
// payload and deliberately strips `routes` (see the destructuring a few
|
|
431
|
+
// hundred lines down) — bundling every route pattern to the browser for
|
|
432
|
+
// a check only the server needs would be pure waste.
|
|
433
|
+
if (def.filter) {
|
|
434
|
+
modules.domainFilters.push({
|
|
435
|
+
id: def.id,
|
|
436
|
+
filter: def.filter,
|
|
437
|
+
routes: def.routes || [],
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (def.styles) {
|
|
442
|
+
def.styles.forEach((style) => {
|
|
443
|
+
// Preserve external URLs (https:// or protocol-relative //) as-is so they don't get path-resolved into local files.
|
|
444
|
+
if (/^https?:\/\//.test(style) || /^\/\//.test(style)) {
|
|
445
|
+
modules.styles.push(style);
|
|
446
|
+
} else {
|
|
447
|
+
const pathRef = style.startsWith("@")
|
|
448
|
+
? style
|
|
449
|
+
: path.resolve(moduleDir, style);
|
|
450
|
+
modules.styles.push(pathRef);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (def.scripts) {
|
|
456
|
+
def.scripts.forEach((script) => {
|
|
457
|
+
if (typeof script === "string") {
|
|
458
|
+
if (/^https?:\/\//.test(script)) {
|
|
459
|
+
// External CDN URLs are kept as-is.
|
|
460
|
+
// standard.js handles them with a <script src> tag.
|
|
461
|
+
modules.scripts.push(script);
|
|
462
|
+
} else {
|
|
463
|
+
const pathRef = script.startsWith("@")
|
|
464
|
+
? script
|
|
465
|
+
: path.resolve(moduleDir, script);
|
|
466
|
+
modules.scripts.push(pathRef);
|
|
467
|
+
}
|
|
468
|
+
} else if (typeof script === "object" && script.src) {
|
|
469
|
+
// Lifecycle-managed: { src, init?, refresh? }
|
|
470
|
+
const resolvedSrc = script.src.startsWith("@")
|
|
471
|
+
? script.src
|
|
472
|
+
: path.resolve(moduleDir, script.src);
|
|
473
|
+
modules.scripts.push({ ...script, src: resolvedSrc });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (def.middleware) {
|
|
479
|
+
def.middleware.forEach((entry) => {
|
|
480
|
+
const entrypoint = path.resolve(
|
|
481
|
+
moduleDir,
|
|
482
|
+
typeof entry === "string" ? entry : entry.entrypoint,
|
|
483
|
+
);
|
|
484
|
+
const order = typeof entry.order === "number" ? entry.order : 0;
|
|
485
|
+
modules.middleware.push({ moduleId: def.id, order, entrypoint });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (def.actions) {
|
|
490
|
+
modules.actions.push({
|
|
491
|
+
moduleId: def.id,
|
|
492
|
+
entrypoint: path.resolve(moduleDir, def.actions),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── def.hooks ───────────────────────────────────────────────
|
|
497
|
+
// The single unified API for all module contributions:
|
|
498
|
+
//
|
|
499
|
+
// hooks: {
|
|
500
|
+
// // UI into zones (use `ui` key)
|
|
501
|
+
// "stnd:base": ["./Toast.astro"],
|
|
502
|
+
// "stnd:client": [{ ui: "@stnd/lab/Lab.svelte", meta: { "client:load": true } }],
|
|
503
|
+
//
|
|
504
|
+
// // Lifecycle hooks and JS action handlers
|
|
505
|
+
// "astro:build:done": [{ action: "./on-build.js" }],
|
|
506
|
+
// "launcher:action": [{ action: "./actions/nav.js" }],
|
|
507
|
+
//
|
|
508
|
+
// // Launcher views (ui entries in custom hook names)
|
|
509
|
+
// "launcher:view": [{ ui: "./views/ShareView.svelte", trigger: "::share", meta: {...} }],
|
|
510
|
+
// }
|
|
511
|
+
//
|
|
512
|
+
// Resolution rules:
|
|
513
|
+
// - astro:* hooks OR .js/.ts entries → runtime lifecycle hooks
|
|
514
|
+
// - Everything else → UI zone contributions (rendered by Hook.astro)
|
|
515
|
+
// - Paths starting with "@" are kept as Vite aliases
|
|
516
|
+
// - Plain strings are shorthand for { ui: "path" }
|
|
517
|
+
// - `ui` is preferred, `component` accepted for backward compat
|
|
518
|
+
if (def.hooks) {
|
|
519
|
+
const resolvePath = (p) =>
|
|
520
|
+
p && p.startsWith("@") ? p : p ? path.resolve(moduleDir, p) : null;
|
|
521
|
+
|
|
522
|
+
Object.entries(def.hooks).forEach(([hookName, value]) => {
|
|
523
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
524
|
+
entries.forEach((entry) => {
|
|
525
|
+
if (!entry) return;
|
|
526
|
+
|
|
527
|
+
// Normalize: plain string → { ui: "path" }
|
|
528
|
+
const normalized = typeof entry === "string" ? { ui: entry } : entry;
|
|
529
|
+
|
|
530
|
+
const entryPath =
|
|
531
|
+
normalized.ui || normalized.component || normalized.action;
|
|
532
|
+
if (!entryPath) return;
|
|
533
|
+
|
|
534
|
+
const isLifecycleOrAction =
|
|
535
|
+
hookName.startsWith("astro:") ||
|
|
536
|
+
(!hookName.startsWith("launcher:") &&
|
|
537
|
+
!hookName.endsWith(":action") &&
|
|
538
|
+
entryPath.match(/\.(js|ts|mjs|cjs)$/i));
|
|
539
|
+
|
|
540
|
+
// A hook name must mean one thing consistently: either a logic
|
|
541
|
+
// hook (virtual:stnd/hooks, runHook/runPipeline) or a UI/action
|
|
542
|
+
// zone (virtual:stnd/components, <Hook zone="..."> + direct
|
|
543
|
+
// extensions[zone] reads). These are two disconnected virtual
|
|
544
|
+
// modules — silently splitting one name across both means half
|
|
545
|
+
// the registered handlers are invisible to whichever consumer
|
|
546
|
+
// asked for the other kind. Fail loud instead, same as duplicate
|
|
547
|
+
// module IDs and launcher trigger collisions.
|
|
548
|
+
const kind = isLifecycleOrAction ? "logic" : "ui";
|
|
549
|
+
const previousKind = hookKindByName.get(hookName);
|
|
550
|
+
if (previousKind && previousKind !== kind) {
|
|
551
|
+
log.fatal(
|
|
552
|
+
`Hook "${hookName}" is registered as both a logic hook and a UI/action zone contribution.` +
|
|
553
|
+
`\n Module "${def.id}" registered a ${kind} entry (${entryPath}), but "${hookName}" was already classified as ${previousKind} by an earlier module.` +
|
|
554
|
+
`\n A hook name must consistently resolve to one kind: logic hooks are ".js/.ts" entries not prefixed "launcher:" or suffixed ":action"; everything else is a UI/action zone.` +
|
|
555
|
+
`\n Rename this hook or make its entry type consistent with the other registrations.` +
|
|
556
|
+
`\n See: https://stnd.build/manual/diagnostic/hook-kind-mismatch`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
hookKindByName.set(hookName, kind);
|
|
560
|
+
|
|
561
|
+
if (isLifecycleOrAction) {
|
|
562
|
+
modules.hooks.push({
|
|
563
|
+
moduleId: def.id,
|
|
564
|
+
hook: hookName,
|
|
565
|
+
entrypoint: path.resolve(moduleDir, entryPath),
|
|
566
|
+
server: !!normalized.server,
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
// UI zone contribution. `server` is meaningless here: this
|
|
570
|
+
// pool compiles into virtual:stnd/components, which — unlike
|
|
571
|
+
// virtual:stnd/hooks — has no SSR/client split (see
|
|
572
|
+
// generateComponentsModule), so a `server: true` flag can
|
|
573
|
+
// never be honored. Fail loud rather than silently ignoring
|
|
574
|
+
// it and giving false confidence that server-only code stays
|
|
575
|
+
// out of the client bundle.
|
|
576
|
+
if (normalized.server) {
|
|
577
|
+
log.fatal(
|
|
578
|
+
`Hook "${hookName}" (module "${def.id}") sets "server: true", but this hook classifies as a UI/action zone contribution, which has no server/client split.` +
|
|
579
|
+
`\n "server: true" only has an effect on logic hooks (".js/.ts" entries not prefixed "launcher:" or suffixed ":action").` +
|
|
580
|
+
`\n Remove "server: true", or rename this hook off the "launcher:"/":action" pattern if it must stay out of the client bundle.` +
|
|
581
|
+
`\n See: https://stnd.build/manual/diagnostic/hook-server-flag-ignored`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (!modules.ui[hookName]) modules.ui[hookName] = [];
|
|
585
|
+
modules.ui[hookName].push({
|
|
586
|
+
moduleId: def.id,
|
|
587
|
+
component: resolvePath(normalized.ui || normalized.component),
|
|
588
|
+
action: resolvePath(normalized.action),
|
|
589
|
+
meta: normalized.meta || {},
|
|
590
|
+
trigger: normalized.trigger,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (def.aliases) {
|
|
598
|
+
Object.entries(def.aliases).forEach(([alias, target]) => {
|
|
599
|
+
modules.aliases[alias] = target.startsWith(".")
|
|
600
|
+
? path.resolve(moduleDir, target)
|
|
601
|
+
: target;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (def.content) {
|
|
606
|
+
modules.content.push({
|
|
607
|
+
type: typeof def.content === "string" ? "external" : "inline",
|
|
608
|
+
entrypoint:
|
|
609
|
+
typeof def.content === "string"
|
|
610
|
+
? path.resolve(moduleDir, def.content)
|
|
611
|
+
: filePath,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (def.optimizeDeps) {
|
|
616
|
+
modules.optimizeDeps.push(...def.optimizeDeps);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── def.icon ────────────────────────────────────────────────
|
|
620
|
+
// Module-level icon token contributions.
|
|
621
|
+
//
|
|
622
|
+
// icon: {
|
|
623
|
+
// library: "lucide",
|
|
624
|
+
// tokens: {
|
|
625
|
+
// search: "lucide:search",
|
|
626
|
+
// home: "lucide:house",
|
|
627
|
+
// },
|
|
628
|
+
// }
|
|
629
|
+
//
|
|
630
|
+
// Tokens are merged in module resolution order (later modules win).
|
|
631
|
+
// Apps can ship a *.module.js that sets their preferred icon library
|
|
632
|
+
// without touching astro.config.mjs.
|
|
633
|
+
if (def.icon) {
|
|
634
|
+
if (def.icon.tokens && typeof def.icon.tokens === "object") {
|
|
635
|
+
Object.assign(modules.icon.tokens, def.icon.tokens);
|
|
636
|
+
}
|
|
637
|
+
if (def.icon.library) {
|
|
638
|
+
modules.icon.library = def.icon.library;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Collect integrations: supports an array of Astro integrations or a
|
|
643
|
+
// factory function (config) => AstroIntegration[]. Factory functions are
|
|
644
|
+
// resolved in standard.js after finalConfig is fully merged.
|
|
645
|
+
if (def.integrations) {
|
|
646
|
+
if (typeof def.integrations === "function") {
|
|
647
|
+
modules.integrations.push(def.integrations);
|
|
648
|
+
} else if (Array.isArray(def.integrations)) {
|
|
649
|
+
modules.integrations.push(...def.integrations);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const {
|
|
654
|
+
routes,
|
|
655
|
+
styles,
|
|
656
|
+
scripts,
|
|
657
|
+
head,
|
|
658
|
+
middleware,
|
|
659
|
+
integrations,
|
|
660
|
+
hooks,
|
|
661
|
+
dependencies,
|
|
662
|
+
aliases,
|
|
663
|
+
content,
|
|
664
|
+
icon,
|
|
665
|
+
...clientPayload
|
|
666
|
+
} = def;
|
|
667
|
+
// Only include in runtime if the module has client-usable properties beyond pure metadata.
|
|
668
|
+
// Pure metadata keys (id, name, description, status) don't justify importing the module file,
|
|
669
|
+
// which would pull server-only deps (e.g. @astrojs/sitemap → Node streams) into the bundle.
|
|
670
|
+
const METADATA_ONLY_KEYS = new Set(["id", "name", "description", "status"]);
|
|
671
|
+
if (
|
|
672
|
+
Object.keys(clientPayload).filter((k) => !METADATA_ONLY_KEYS.has(k))
|
|
673
|
+
.length > 0
|
|
674
|
+
) {
|
|
675
|
+
clientPayload.__importPath = pathToFileURL(filePath).href;
|
|
676
|
+
modules.runtime.push(clientPayload);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return { modules, loadedPaths: Array.from(moduleMap.keys()) };
|
|
681
|
+
}
|