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