@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/src/virtual.js ADDED
@@ -0,0 +1,440 @@
1
+ export function sanitizeConfig(config) {
2
+ const seen = new WeakSet();
3
+ return JSON.parse(
4
+ JSON.stringify(config, (key, value) => {
5
+ if (typeof value === "object" && value !== null) {
6
+ if (seen.has(value)) {
7
+ return;
8
+ }
9
+ seen.add(value);
10
+ }
11
+ if (typeof value === "function") {
12
+ return undefined;
13
+ }
14
+ return value;
15
+ }),
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Generate a virtual module that wires the Standard OS runtime store.
21
+ *
22
+ * Module manifests declare `store:` entries as key → import path mappings:
23
+ *
24
+ * store: {
25
+ * user: "./models/Visitor.js#visitorStore",
26
+ * search: "./search.js#searchNotes",
27
+ * locale: "./i18n.js", // default export
28
+ * }
29
+ *
30
+ * The loader resolves the file paths and parses the `#exportName` suffix.
31
+ * This generator produces a module that imports each value and calls
32
+ * `provide(key, value)` from the Standard OS runtime store.
33
+ *
34
+ * @param {{ key: string, path: string, export: string|null }[]} storeEntries
35
+ * @returns {string} module source
36
+ */
37
+ export function generateStoreModule(storeEntries = []) {
38
+ if (!storeEntries || storeEntries.length === 0) {
39
+ return "// No store entries registered.\n";
40
+ }
41
+
42
+ let importStatements = 'import { provide } from "@stnd/store";\n';
43
+ let provideStatements = "";
44
+
45
+ storeEntries.forEach((entry, i) => {
46
+ const alias = `__store_${i}`;
47
+ if (entry.export) {
48
+ // Named export: import { visitorStore as __store_0 } from "...";
49
+ importStatements += `import { ${entry.export} as ${alias} } from ${JSON.stringify(entry.path)};\n`;
50
+ } else {
51
+ // Default export: import __store_0 from "...";
52
+ importStatements += `import ${alias} from ${JSON.stringify(entry.path)};\n`;
53
+ }
54
+ provideStatements += `provide(${JSON.stringify(entry.key)}, ${alias});\n`;
55
+ });
56
+
57
+ return importStatements + "\n" + provideStatements;
58
+ }
59
+
60
+ export function generateConfigModule(finalConfig) {
61
+ const cleanConfig = sanitizeConfig(finalConfig);
62
+ return `export default ${JSON.stringify(cleanConfig)}`;
63
+ }
64
+
65
+ export function generateModulesModule(runtimeModules) {
66
+ // Build a sanitized client payload with functions removed (safe for JSON serialization)
67
+ const sanitizedModules = runtimeModules.map((m) => {
68
+ const copy = JSON.parse(JSON.stringify(m));
69
+ // Remove internal import path from the public payload
70
+ delete copy.__importPath;
71
+ return copy;
72
+ });
73
+
74
+ // Generate import statements for the actual module modules so their functions
75
+ // are available at runtime (useful for SSR route handlers, etc.)
76
+ let importStatements = "";
77
+ const moduleNames = [];
78
+ runtimeModules.forEach((m, i) => {
79
+ if (m.__importPath) {
80
+ const name = `__module_${i}`;
81
+ importStatements += `import ${name} from ${JSON.stringify(m.__importPath)};\n`;
82
+ moduleNames.push(name);
83
+ } else {
84
+ moduleNames.push("undefined");
85
+ }
86
+ });
87
+
88
+ // Compose and return the virtual module source:
89
+ // - Import module modules (preserves functions)
90
+ // - Export `modules` (sanitized metadata) for client-side usage
91
+ // - Export `instances` as the actual imported modules for server/runtime use
92
+ return (
93
+ importStatements +
94
+ `\nexport const modules = ${JSON.stringify(sanitizedModules)};\n` +
95
+ `export const instances = [${moduleNames.join(", ")}];`
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Server-only virtual module carrying route patterns for domain-filtered
101
+ * modules (`filter: { domain }` in a manifest). Deliberately separate from
102
+ * `generateModulesModule`/`generateModulesMetadataModule`, whose payload is
103
+ * bundled to the client — route patterns have no reason to ship there.
104
+ * Consumed by the app's own request middleware to 404 a build-time-injected
105
+ * route that shouldn't answer on the current domain.
106
+ *
107
+ * @param {{id: string, filter: object, routes: Array<{path: string}>}[]} domainFilterModules
108
+ * @returns {string} module source
109
+ */
110
+ export function generateModuleDomainsModule(domainFilterModules = []) {
111
+ return `export const modules = ${JSON.stringify(domainFilterModules)};`;
112
+ }
113
+
114
+ export function generateModulesMetadataModule(runtimeModules) {
115
+ // Build a sanitized client payload with functions removed (safe for JSON serialization)
116
+ const sanitizedModules = runtimeModules.map((m) => {
117
+ const copy = JSON.parse(JSON.stringify(m));
118
+ // Remove internal import path from the public payload
119
+ delete copy.__importPath;
120
+ return copy;
121
+ });
122
+
123
+ return `export const modules = ${JSON.stringify(sanitizedModules)};`;
124
+ }
125
+
126
+ /**
127
+ * Generate a virtual module that statically imports all module styles.
128
+ * This guarantees styles are included at build time (works for both
129
+ * `output: "server"` and `output: "static"`) and avoids relying on
130
+ * injectScript runtime quirks.
131
+ *
132
+ * @param {string[]} styleExtensions
133
+ * @returns {string} module source
134
+ */
135
+ export function generateStylesModule(styleExtensions = []) {
136
+ const seen = new Set();
137
+ const imports = [];
138
+ for (const s of styleExtensions || []) {
139
+ if (!s) continue;
140
+ // Skip external URLs — handled separately via config._externalScripts/Base.astro
141
+ if (/^https?:\/\//.test(s) || /^\/\//.test(s)) continue;
142
+ if (seen.has(s)) continue;
143
+ seen.add(s);
144
+ imports.push(`import ${JSON.stringify(s)};`);
145
+ }
146
+ return imports.join("\n") + (imports.length ? "\n" : "");
147
+ }
148
+
149
+ /**
150
+ * Generate a unified virtual module for all module scripts.
151
+ *
152
+ * Manifests declare scripts as a mixed array of strings and descriptor objects:
153
+ *
154
+ * scripts: [
155
+ * "boot.js", // string → fire-and-forget, runs once
156
+ * "https://cdn.../lib.min.js", // string → external CDN, skipped here
157
+ * { src: "@stnd/client/copy-buttons", // object → lifecycle-managed
158
+ * init: "initCopyButtons", // called on first load
159
+ * refresh: "refreshCopyButtons" }, // called on astro:after-swap
160
+ * { src: "@stnd/client/image-zoom",
161
+ * init: "initImageZoom" }, // no refresh → init reruns on swap too
162
+ * ]
163
+ *
164
+ * Fire-and-forget strings are imported statically (side-effect only, once).
165
+ * Object entries with `init` are registered in a lifecycle runner that calls
166
+ * init on load and refresh (or init again) on every astro:after-swap.
167
+ *
168
+ * External CDN URLs in string form are excluded — handled via
169
+ * config._externalScripts and rendered as <script src="..."> by Base.astro.
170
+ *
171
+ * @param {(string | { src: string, init?: string, refresh?: string })[]} scriptEntries
172
+ * @returns {string} module source
173
+ */
174
+ export function generateScriptsModule(scriptEntries = []) {
175
+ const seenPaths = new Set();
176
+ const fireAndForget = [];
177
+ const lifecycle = [];
178
+
179
+ for (const entry of scriptEntries || []) {
180
+ if (!entry) continue;
181
+
182
+ if (typeof entry === "string") {
183
+ if (/^https?:\/\//.test(entry) || /^\/\//.test(entry)) continue;
184
+ if (seenPaths.has(entry)) continue;
185
+ seenPaths.add(entry);
186
+ fireAndForget.push(entry);
187
+ } else if (typeof entry === "object" && entry.src) {
188
+ if (seenPaths.has(entry.src)) continue;
189
+ seenPaths.add(entry.src);
190
+ lifecycle.push(entry);
191
+ }
192
+ }
193
+
194
+ if (fireAndForget.length === 0 && lifecycle.length === 0) {
195
+ return "// No scripts registered.\n";
196
+ }
197
+
198
+ let out = "";
199
+
200
+ // Fire-and-forget: static imports, side-effect only, run once
201
+ for (const p of fireAndForget) {
202
+ out += `import ${JSON.stringify(p)};\n`;
203
+ }
204
+
205
+ if (lifecycle.length === 0) return out;
206
+
207
+ // Lifecycle-managed: import as namespace, register in runner
208
+ out += "\n";
209
+ const registrations = [];
210
+ lifecycle.forEach((entry, i) => {
211
+ const alias = `__script_${i}`;
212
+ out += `import * as ${alias} from ${JSON.stringify(entry.src)};\n`;
213
+ registrations.push(
214
+ `{ src: ${JSON.stringify(entry.src)}, mod: ${alias}` +
215
+ (entry.init ? `, init: ${JSON.stringify(entry.init)}` : "") +
216
+ (entry.refresh ? `, refresh: ${JSON.stringify(entry.refresh)}` : "") +
217
+ ` }`,
218
+ );
219
+ });
220
+
221
+ out += `
222
+ var __scripts = [${registrations.join(", ")}];
223
+
224
+ function __runScripts(isSwap) {
225
+ __scripts.forEach(function(entry) {
226
+ try {
227
+ var fn = isSwap
228
+ ? (entry.refresh ? entry.mod[entry.refresh] : (entry.init ? entry.mod[entry.init] : null))
229
+ : (entry.init ? entry.mod[entry.init] : null);
230
+ if (typeof fn === "function") fn();
231
+ } catch (err) {
232
+ console.error("[stnd] script init failed: " + entry.src, err);
233
+ }
234
+ });
235
+ }
236
+
237
+ if (typeof window !== "undefined") {
238
+ __runScripts(false);
239
+ document.addEventListener("astro:after-swap", function() { __runScripts(true); });
240
+ }
241
+ `;
242
+
243
+ return out;
244
+ }
245
+
246
+ export function generateComponentsModule(componentExtensions) {
247
+ let importStatements = "";
248
+ let body = "export const extensions = {};\n";
249
+ let counter = 0;
250
+ Object.entries(componentExtensions || {}).forEach(([zone, entries]) => {
251
+ entries.forEach((entry) => {
252
+ const hasComponent = entry.component && entry.component.trim().length > 0;
253
+ const hasAction = entry.action && entry.action.trim().length > 0;
254
+
255
+ const componentName = hasComponent
256
+ ? `__ui_component_${counter}`
257
+ : "undefined";
258
+ const actionName = hasAction ? `__ui_action_${counter}` : "undefined";
259
+
260
+ if (hasComponent) {
261
+ importStatements += `import ${componentName} from ${JSON.stringify(entry.component)};\n`;
262
+ }
263
+
264
+ if (hasAction) {
265
+ importStatements += `import ${actionName} from ${JSON.stringify(entry.action)};\n`;
266
+ }
267
+
268
+ body += `extensions[${JSON.stringify(zone)}] = extensions[${JSON.stringify(zone)}] || [];\n`;
269
+ body += `extensions[${JSON.stringify(zone)}].push({ moduleId: ${JSON.stringify(entry.moduleId)}, trigger: ${entry.trigger ? JSON.stringify(entry.trigger) : "undefined"}, meta: ${JSON.stringify(entry.meta || {})}, component: ${componentName}, action: ${actionName} });\n`;
270
+
271
+ counter += 1;
272
+ });
273
+ });
274
+ return importStatements + "\n" + body;
275
+ }
276
+
277
+ export function generateMiddlewareModule(middlewareExtensions) {
278
+ let importStatements =
279
+ 'import { sequence, defineMiddleware } from "astro/middleware";\n';
280
+ let middlewareItems = [];
281
+ const sorted = [...(middlewareExtensions || [])].sort(
282
+ (a, b) => a.order - b.order,
283
+ );
284
+ sorted.forEach((entry, index) => {
285
+ const importName = `__mw_${index}`;
286
+ importStatements += `import * as ${importName} from ${JSON.stringify(entry.entrypoint)};\n`;
287
+ const handler = `${importName}.onRequest || ${importName}.default || ${importName}`;
288
+ middlewareItems.push(`defineMiddleware(async (context, next) => {
289
+ const h = ${handler};
290
+ return typeof h === "function" ? h(context, next) : next();
291
+ })`);
292
+ });
293
+
294
+ return (
295
+ importStatements +
296
+ `\nexport const onRequest = sequence(\n ${middlewareItems.join(",\n ")}\n);\n`
297
+ );
298
+ }
299
+
300
+ export function generateActionsModule(actionsExtensions) {
301
+ let importStatements = "";
302
+ let body = "export const actions = {};\n";
303
+ // Aggregate all actions from modules
304
+ (actionsExtensions || []).forEach((entry, index) => {
305
+ if (entry.entrypoint && entry.entrypoint.trim().length > 0) {
306
+ const importName = `__act_${index}`;
307
+ importStatements += `import * as ${importName} from ${JSON.stringify(entry.entrypoint)};\n`;
308
+ body += `Object.assign(actions, ${importName}.actions || ${importName}.default || ${importName});\n`;
309
+ }
310
+ });
311
+ return importStatements + "\n" + body;
312
+ }
313
+
314
+ export function generateHooksModule(customHooks = [], options = { ssr: true }) {
315
+ // Runtime Hooks Generator
316
+ // Filter hooks based on environment (SSR vs Client)
317
+ // If we are NOT in SSR, skip hooks marked as server-only
318
+ const filteredHooks = customHooks.filter((ext) => {
319
+ if (!options.ssr && ext.server) return false;
320
+ return true;
321
+ });
322
+
323
+ // Group custom hooks by hook name
324
+ const hooksMap = {};
325
+ filteredHooks.forEach((ext) => {
326
+ hooksMap[ext.hook] = hooksMap[ext.hook] || [];
327
+ hooksMap[ext.hook].push(ext);
328
+ });
329
+
330
+ let importStatements = "";
331
+ let body = "const hooks = {};\n";
332
+
333
+ let hookIndex = 0;
334
+ Object.entries(hooksMap).forEach(([hookName, listeners]) => {
335
+ body += `hooks[${JSON.stringify(hookName)}] = [];\n`;
336
+ listeners.forEach((listener) => {
337
+ if (listener.entrypoint && listener.entrypoint.trim().length > 0) {
338
+ const importName = `__hook_handler_${hookIndex++}`;
339
+ importStatements += `import ${importName} from ${JSON.stringify(listener.entrypoint)};\n`;
340
+ body += `hooks[${JSON.stringify(hookName)}].push({ moduleId: ${JSON.stringify(listener.moduleId)}, mod: ${importName} });\n`;
341
+ }
342
+ });
343
+ });
344
+
345
+ // Both runners isolate handler failures: one module's throwing handler
346
+ // must not blank out every other module's contribution to the same
347
+ // hook name, and (for runPipeline specifically) must not 500 a page
348
+ // that handler wasn't even trying to act on. A failure is logged and
349
+ // treated as "this handler contributed nothing" — the fan-out/pipeline
350
+ // continues with the remaining handlers.
351
+ const runnerHelper = `
352
+ export async function runHook(name, ...args) {
353
+ const entries = hooks[name] || [];
354
+ const results = [];
355
+ for (const entry of entries) {
356
+ const fn = typeof entry.mod === 'function' ? entry.mod : entry.mod?.default;
357
+ if (typeof fn !== 'function') continue;
358
+ try {
359
+ results.push(await fn(...args));
360
+ } catch (err) {
361
+ console.error('[stnd] hook "' + name + '" handler from module "' + entry.moduleId + '" threw:', err);
362
+ }
363
+ }
364
+ return results;
365
+ }
366
+
367
+ // Sequential transform: threads a single value through every handler
368
+ // registered for \`name\`, in order. Each handler receives the current value
369
+ // (plus any extra args) and must return the next value — there is no
370
+ // implicit "return nothing to pass through unchanged"; a handler that
371
+ // wants to leave the value untouched returns it explicitly. This is
372
+ // deliberate: a handler that forgets to return should fail loudly
373
+ // (the next handler/consumer chokes on undefined) rather than silently
374
+ // becoming a no-op. Unlike runHook (fan-out + collect), this is for "let
375
+ // each interested module inspect and optionally transform the same
376
+ // thing," e.g. a note-render descriptor that a gate/paywall module can
377
+ // override without the caller knowing which module (if any) acted.
378
+ export async function runPipeline(name, initialValue, ...args) {
379
+ const entries = hooks[name] || [];
380
+ let value = initialValue;
381
+ for (const entry of entries) {
382
+ const fn = typeof entry.mod === 'function' ? entry.mod : entry.mod?.default;
383
+ if (typeof fn !== 'function') continue;
384
+ try {
385
+ value = await fn(value, ...args);
386
+ } catch (err) {
387
+ console.error('[stnd] pipeline "' + name + '" handler from module "' + entry.moduleId + '" threw — value unchanged:', err);
388
+ }
389
+ }
390
+ return value;
391
+ }
392
+
393
+ export default hooks;
394
+ `;
395
+ return importStatements + "\n" + body + "\n" + runnerHelper;
396
+ }
397
+
398
+ export function generateContentModule(contentExtensions) {
399
+ let importStatements = "";
400
+ let body = "export const collections = {};\n";
401
+
402
+ (contentExtensions || []).forEach((entry, index) => {
403
+ const importName = `__content_${index}`;
404
+ // pathToFileURL is not needed here as JSON.stringify(path) works for import if it's absolute
405
+ // but 'entrypoint' is absolute path.
406
+ // On windows it needs file://, on mac/linux absolute path needs special handling in import?
407
+ // Vite handles absolute paths if they are in the allowed scope.
408
+ // Better use JSON.stringify(entry.entrypoint).
409
+
410
+ importStatements += `import * as ${importName} from ${JSON.stringify(entry.entrypoint)};\n`;
411
+
412
+ if (entry.type === "inline") {
413
+ // Inline: content is defined in module.default.content
414
+ body += `
415
+ if (${importName}.default && ${importName}.default.content && typeof ${importName}.default.content === 'object') {
416
+ Object.assign(collections, ${importName}.default.content);
417
+ }
418
+ `;
419
+ } else {
420
+ // External: file exports collections directly or as named exports
421
+ body += `
422
+ // Merge named exports as collections. Exclude default unless it's explicitly 'collections'
423
+ Object.keys(${importName}).forEach(key => {
424
+ if (key !== 'default') {
425
+ collections[key] = ${importName}[key];
426
+ }
427
+ });
428
+ // If default export exists and looks like a collection map (and is not the module def itself, which it shouldn't be for external content file)
429
+ // We can optionally support export default { home: ... }
430
+ if (${importName}.default && typeof ${importName}.default === 'object') {
431
+ // Verify it's not a module manifest by checking for 'id' + 'name'?
432
+ // Or just assume content file is pure content.
433
+ Object.assign(collections, ${importName}.default);
434
+ }
435
+ `;
436
+ }
437
+ });
438
+
439
+ return importStatements + "\n" + body;
440
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generateStylesModule } from "./virtual.js";
3
+
4
+ describe("generateStylesModule", () => {
5
+ it("returns an empty string when no styles provided", () => {
6
+ expect(generateStylesModule([])).toBe("");
7
+ expect(generateStylesModule()).toBe("");
8
+ });
9
+
10
+ it("generates import statements, dedupes and preserves order", () => {
11
+ const styles = [
12
+ "/abs/path/a.scss",
13
+ "./local/b.scss",
14
+ "/abs/path/a.scss", // duplicate
15
+ "@pkg/styles.css",
16
+ ];
17
+
18
+ const src = generateStylesModule(styles);
19
+
20
+ // Contains expected import lines
21
+ expect(src).toContain('import "/abs/path/a.scss";');
22
+ expect(src).toContain('import "./local/b.scss";');
23
+ expect(src).toContain('import "@pkg/styles.css";');
24
+
25
+ // Deduplication: only 3 unique imports
26
+ const importCount = (src.match(/^import\s/gm) || []).length;
27
+ expect(importCount).toBe(3);
28
+
29
+ // Order preserved as first-appearance order
30
+ const idxA = src.indexOf('"/abs/path/a.scss"');
31
+ const idxB = src.indexOf('"./local/b.scss"');
32
+ const idxC = src.indexOf('"@pkg/styles.css"');
33
+
34
+ expect(idxA).toBeLessThan(idxB);
35
+ expect(idxB).toBeLessThan(idxC);
36
+ });
37
+ });