@stnd/launcher 1.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/launcher.ts ADDED
@@ -0,0 +1,577 @@
1
+ /**
2
+ * @stnd/launcher - The Universal Command Palette Engine
3
+ *
4
+ * Aggregates actions and views from the @stnd module system.
5
+ * Uses virtual:stnd/components to discover extensions.
6
+ * Reads app-specific services (user store, search) from the
7
+ * Standard OS runtime store via provide/inject.
8
+ */
9
+ import { writable, derived, get } from "svelte/store";
10
+ import { inject, provide } from "@stnd/store";
11
+ import Log from "@stnd/log";
12
+
13
+ // @ts-ignore - virtual module system
14
+ import { modules } from "virtual:stnd/modules-metadata";
15
+ // @ts-ignore
16
+ import { extensions as componentExtensions } from "virtual:stnd/components";
17
+ // @ts-ignore
18
+ import { runHook } from "virtual:stnd/hooks";
19
+
20
+ const log = Log({ scope: "Launcher" }) as any;
21
+
22
+ // =============================================================================
23
+ // TYPES
24
+ // =============================================================================
25
+
26
+ export type ViewTrigger = string;
27
+ export type PromptText = string;
28
+
29
+ export interface CommandMeta {
30
+ title: string;
31
+ desc?: string;
32
+ type?: "action" | "view";
33
+ icon?: string;
34
+ context?: string | string[];
35
+ requireRole?: string | string[];
36
+ requireAuth?: boolean;
37
+ requireGuest?: boolean;
38
+ size?: { width: string; height: string };
39
+ hidden?: boolean;
40
+ override?: boolean;
41
+ }
42
+
43
+ export interface Command {
44
+ trigger: string;
45
+ title: string;
46
+ desc?: string;
47
+ type: "action" | "view";
48
+ icon?: string;
49
+ context?: string | string[];
50
+ requireRole?: string | string[];
51
+ requireAuth?: boolean;
52
+ requireGuest?: boolean;
53
+ action?: () => void;
54
+ component?: any;
55
+ size?: { width: string; height: string };
56
+ moduleId?: string;
57
+ hidden?: boolean;
58
+ }
59
+
60
+ export interface RegistryState {
61
+ commands: Command[];
62
+ views: Record<string, any>;
63
+ }
64
+
65
+ export interface LauncherResult {
66
+ title: string;
67
+ desc?: string;
68
+ icon?: string;
69
+ type: "action" | "view" | "search-result" | "command";
70
+ action?: () => void;
71
+ url?: string;
72
+ trigger?: string;
73
+ id?: string;
74
+ username?: string;
75
+ owner_id?: number | null;
76
+ }
77
+
78
+ export interface PaletteState {
79
+ open: boolean;
80
+ view: string;
81
+ returnTo: string | null;
82
+ query: string;
83
+ contexts: string[];
84
+ props?: any;
85
+ dynamicSize?: { width: string; height: string } | null;
86
+ }
87
+
88
+ // =============================================================================
89
+ // STORES
90
+ // =============================================================================
91
+
92
+ export const registry = writable<RegistryState>({
93
+ commands: [],
94
+ views: {},
95
+ });
96
+
97
+ export const palette = writable<PaletteState>({
98
+ open: false,
99
+ view: "search",
100
+ returnTo: null,
101
+ query: "",
102
+ contexts: ["global"],
103
+ });
104
+
105
+ // App-specific filter (still module-scoped — not worth a store key)
106
+ let appFilter: ((trigger: string, meta: CommandMeta) => boolean) | null = null;
107
+
108
+ /**
109
+ * Configure app-specific launcher options without booting.
110
+ *
111
+ * Prefer the Standard OS runtime store (provide/inject) for userStore
112
+ * and searchNotes. This function remains for the filter option and
113
+ * backward compatibility.
114
+ */
115
+ export function configureLauncher(
116
+ options: {
117
+ searchNotes?: (query: string) => Promise<LauncherResult[]>;
118
+ userStore?: any;
119
+ filter?: (trigger: string, meta: CommandMeta) => boolean;
120
+ } = {},
121
+ ) {
122
+ // Legacy compat: if callers still pass these, push them into the runtime store
123
+ if (options.searchNotes) provide("search", options.searchNotes);
124
+ if (options.userStore) provide("user", options.userStore);
125
+ if (options.filter) appFilter = options.filter;
126
+ }
127
+
128
+ /**
129
+ * Initialize the Launcher with app-specific logic and boot immediately.
130
+ *
131
+ * Shorthand for `configureLauncher(options)` + `bootLauncher()`.
132
+ */
133
+ export function initLauncher(
134
+ options: {
135
+ searchNotes?: (query: string) => Promise<LauncherResult[]>;
136
+ userStore?: any;
137
+ filter?: (trigger: string, meta: CommandMeta) => boolean;
138
+ } = {},
139
+ ) {
140
+ configureLauncher(options);
141
+
142
+ // Initial registry boot
143
+ bootLauncher();
144
+ }
145
+
146
+ // =============================================================================
147
+ // CORE ENGINE
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Register a command or view in the launcher
152
+ */
153
+ export function register(
154
+ trigger: string,
155
+ meta: CommandMeta & { moduleId?: string },
156
+ handler: (() => void) | any,
157
+ ) {
158
+ // Apply global filter if set
159
+ if (appFilter && !appFilter(trigger, meta)) {
160
+ return;
161
+ }
162
+
163
+ registry.update((r) => {
164
+ // Check for collisions in existing commands
165
+ const existingIndex = r.commands.findIndex((c) => c.trigger === trigger);
166
+ if (existingIndex !== -1 && !meta.override) {
167
+ const errorMsg =
168
+ `[Launcher] Fatal Collision: Trigger "${trigger}" already registered by "${r.commands[existingIndex].moduleId || "core"}". ` +
169
+ `Attempting to register again from "${meta.moduleId || "unknown"}". ` +
170
+ `See: https://stnd.build/manual/5-reference/launcher-trigger-collision`;
171
+ log.error(errorMsg);
172
+ throw new Error(errorMsg);
173
+ }
174
+
175
+ const m = meta as any;
176
+ let updates: any = { ...r };
177
+
178
+ // Register as view if type is "view"
179
+ if (m.type === "view") {
180
+ updates.views = { ...r.views, [trigger]: handler };
181
+ }
182
+
183
+ // Create a command (either for actions or for views as launcher shortcuts)
184
+ const cmd: Command = {
185
+ trigger,
186
+ title: m.title,
187
+ desc: m.desc,
188
+ type: m.type === "view" ? "view" : "action",
189
+ icon: m.icon,
190
+ context: m.context,
191
+ requireRole: m.requireRole,
192
+ requireAuth: m.requireAuth,
193
+ requireGuest: m.requireGuest,
194
+ size: m.size,
195
+ action: m.type === "view" ? () => launcherView(trigger) : handler,
196
+ component: m.type === "view" ? handler : undefined,
197
+ moduleId: meta.moduleId || "core",
198
+ hidden: m.hidden,
199
+ };
200
+
201
+ if (existingIndex !== -1 && meta.override) {
202
+ updates.commands = [...r.commands];
203
+ updates.commands[existingIndex] = cmd;
204
+ } else {
205
+ updates.commands = [...r.commands, cmd];
206
+ }
207
+
208
+ return updates;
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Boot the Launcher system by collecting extensions from the module system
214
+ */
215
+ export async function bootLauncher() {
216
+ // 1. Clear registry
217
+ registry.set({ commands: [], views: {} });
218
+
219
+ // 2. Load Actions from extensions
220
+ const actions = componentExtensions?.["launcher:action"] || [];
221
+ actions.forEach((entry: any) => {
222
+ if (!entry) return;
223
+
224
+ // Support both single action and bundled actions
225
+ let actionSource = entry.actions || entry.action?.actions || entry.action;
226
+
227
+ // Handle Module Namespace (default export)
228
+ if (
229
+ actionSource &&
230
+ typeof actionSource === "object" &&
231
+ "default" in actionSource
232
+ ) {
233
+ actionSource = actionSource.default;
234
+ }
235
+
236
+ const bundledActions = Array.isArray(actionSource) ? actionSource : null;
237
+
238
+ if (Array.isArray(bundledActions)) {
239
+ bundledActions.forEach((item: any) => {
240
+ if (!item?.trigger || typeof item.action !== "function") return;
241
+ register(
242
+ item.trigger,
243
+ {
244
+ ...(entry.meta || {}),
245
+ ...(item.meta || {}),
246
+ type: "action",
247
+ moduleId: entry.moduleId,
248
+ },
249
+ item.action,
250
+ );
251
+ });
252
+ } else if (entry.trigger && typeof entry.action === "function") {
253
+ register(
254
+ entry.trigger,
255
+ { ...(entry.meta || {}), type: "action", moduleId: entry.moduleId },
256
+ entry.action,
257
+ );
258
+ }
259
+ });
260
+
261
+ // 3. Load Views from extensions
262
+ const views = componentExtensions?.["launcher:view"] || [];
263
+ views.forEach((entry: any) => {
264
+ if (entry.trigger && entry.component) {
265
+ register(
266
+ entry.trigger,
267
+ { ...entry.meta, type: "view", moduleId: entry.moduleId },
268
+ entry.component,
269
+ );
270
+ }
271
+ });
272
+
273
+ // 4. Load Themes from modules metadata
274
+ const themeModules = (modules || []).filter(
275
+ (m: any) => m?.meta?.type === "theme",
276
+ );
277
+ themeModules.forEach((m: any) => {
278
+ const themeId = m.meta.themeId || m.id.replace("theme-", "");
279
+ register(
280
+ `::theme ${themeId}`,
281
+ {
282
+ title: m.meta.label || m.name || themeId,
283
+ type: "action",
284
+ icon: "palette",
285
+ desc: m.description || "A custom theme.",
286
+ moduleId: m.id,
287
+ },
288
+ async () => {
289
+ document.documentElement.setAttribute("data-theme", themeId);
290
+ if (typeof localStorage !== "undefined")
291
+ localStorage.setItem("std_theme", themeId);
292
+
293
+ // Persistence Tier: Save to preferences (Session/DB)
294
+ try {
295
+ const save = inject("savePreference");
296
+ if (typeof save === "function") {
297
+ await save({ theme: themeId });
298
+ }
299
+ } catch (e) {
300
+ log.debug("No preference saver available, theme saved locally only");
301
+ }
302
+ },
303
+ );
304
+ });
305
+
306
+ // 5. Load Content entries (via hook)
307
+ try {
308
+ const contentResults = await runHook("stnd:launcher:content:register");
309
+ const allEntries = contentResults?.flat().filter(Boolean) || [];
310
+ // Deduplicate by id (runHook may call the same handler multiple times)
311
+ const seen = new Set();
312
+ const entries = allEntries.filter((entry: any) => {
313
+ if (!entry.id || seen.has(entry.id)) return false;
314
+ seen.add(entry.id);
315
+ return true;
316
+ });
317
+ entries.forEach((entry: any) => {
318
+ if (!entry.url) return;
319
+ const contentType = entry.type || "page";
320
+ register(
321
+ `::go ${contentType} ${entry.id}`,
322
+ {
323
+ title: entry.title,
324
+ type: "action",
325
+ icon: entry.icon || "note",
326
+ desc: entry.desc || "",
327
+ moduleId: entry.moduleId || "core:content",
328
+ },
329
+ () => {
330
+ import("astro:transitions/client").then((m) => m.navigate(entry.url));
331
+ },
332
+ );
333
+ });
334
+ if (entries.length)
335
+ log.debug(`Registered ${entries.length} content entries`);
336
+ } catch (e) {
337
+ log.debug("No content entries discovered via hook");
338
+ }
339
+
340
+ log.debug("Initialized");
341
+ }
342
+
343
+ // =============================================================================
344
+ // SEARCH & MATCHING
345
+ // =============================================================================
346
+
347
+ export async function search(query: string): Promise<LauncherResult[]> {
348
+ const searchFn = inject("search", async () => []);
349
+ return searchFn(query);
350
+ }
351
+
352
+ export function computeMatches(
353
+ query: string,
354
+ registryCommands: Command[],
355
+ searchResults: LauncherResult[],
356
+ currentUser: any,
357
+ ): LauncherResult[] {
358
+ const isCommand = query.startsWith("::");
359
+ const contextualCommands = registryCommands.filter((cmd) => {
360
+ // 0. Filtrage de l'état d'authentification (l'utilisateur anonyme n'est pas authentifié)
361
+ const isAuthenticated =
362
+ currentUser?.isAuthenticated === true ||
363
+ (!!currentUser?.id &&
364
+ currentUser?.id !== "anonymous-system-user" &&
365
+ !currentUser?.isAnonymous);
366
+ if (cmd.requireAuth && !isAuthenticated) return false;
367
+ if (cmd.requireGuest && isAuthenticated) return false;
368
+
369
+ // 1. Role filtering
370
+ if (cmd.requireRole) {
371
+ if (!isAuthenticated) return false;
372
+ const roles = Array.isArray(cmd.requireRole)
373
+ ? cmd.requireRole
374
+ : [cmd.requireRole];
375
+ if (!roles.includes(currentUser.role)) return false;
376
+ }
377
+
378
+ // 2. Context filtering
379
+ // If command defines a context, the palette must have it.
380
+ // context: "note" means palette.contexts must contain "note"
381
+ // context: ["note", "owner"] means palette.contexts must contain BOTH "note" and "owner"
382
+ if (cmd.context) {
383
+ const activeContexts = get(palette).contexts || [];
384
+ const requiredContexts = Array.isArray(cmd.context)
385
+ ? cmd.context
386
+ : [cmd.context];
387
+
388
+ // Every required context must be present in active contexts
389
+ const hasRequiredContexts = requiredContexts.every((c) =>
390
+ activeContexts.includes(c),
391
+ );
392
+ if (!hasRequiredContexts) return false;
393
+ }
394
+
395
+ return true;
396
+ });
397
+
398
+ if (isCommand) {
399
+ const searchTerm = query.replace(/^::/, "").toLowerCase();
400
+ return contextualCommands
401
+ .filter((c) => {
402
+ if (c.hidden && !searchTerm) return false;
403
+ return c.trigger.replace(/^::/, "").toLowerCase().includes(searchTerm);
404
+ })
405
+ .map((c) => ({ ...c }));
406
+ }
407
+
408
+ // Regular search
409
+ const matches = contextualCommands
410
+ .filter((c) => {
411
+ if (c.hidden) return false;
412
+ return c.title.toLowerCase().includes(query.toLowerCase());
413
+ })
414
+ .map((c) => ({ ...c }));
415
+
416
+ const finalMatches = [...matches, ...searchResults];
417
+
418
+ // Si une requête de recherche textuelle est saisie, injecter l'option de poser une question à l'IA du jardin
419
+ if (query.trim() && !isCommand) {
420
+ const aiPromptItem: LauncherResult = {
421
+ title: "Demander à l'IA du Jardin",
422
+ desc: `Obtenir une réponse basée sur vos notes pour : "${query}"`,
423
+ icon: "ph:sparkle",
424
+ type: "view",
425
+ trigger: "::ask",
426
+ action: () => launcherView("::ask", { question: query }),
427
+ };
428
+ // Placer l'option IA en premier
429
+ finalMatches.unshift(aiPromptItem);
430
+ }
431
+
432
+ return finalMatches;
433
+ }
434
+
435
+ export const activeView = derived(
436
+ [registry, palette],
437
+ ([$registry, $palette]) => {
438
+ return $registry.views[$palette.view] || null;
439
+ },
440
+ );
441
+
442
+ export const windowSize = derived(
443
+ [registry, palette, activeView],
444
+ ([$registry, $palette, $activeView]) => {
445
+ // 1. Dynamic Override
446
+ if ($palette.dynamicSize) return $palette.dynamicSize;
447
+
448
+ // 2. Registry Size
449
+ if ($activeView) {
450
+ const cmd = $registry.commands.find(
451
+ (c) => c.action === $activeView || c.trigger === $palette.view,
452
+ );
453
+ // Note: The registry commands might store the view component in a different property depending on how it was registered
454
+ // In register(): if type=view, it goes to r.views. We don't necessarily keep a ref in r.commands to the component function easily?
455
+ // Let's check register()...
456
+ // It creates a command with 'action: handler'. handler IS the component for view types.
457
+ // So checking c.action === $activeView should work.
458
+ if (cmd) {
459
+ return (
460
+ cmd.size ||
461
+ (cmd as any).meta?.size || { width: "standard", height: "standard" }
462
+ );
463
+ }
464
+ }
465
+
466
+ // 3. Default
467
+ return { width: "standard", height: "standard" };
468
+ },
469
+ );
470
+
471
+ // =============================================================================
472
+ // API
473
+ // =============================================================================
474
+
475
+ export function launcherToggle(query: string = "") {
476
+ palette.update((p) => {
477
+ const willBeOpen = !p.open;
478
+ return {
479
+ ...p,
480
+ open: willBeOpen,
481
+ query,
482
+ view: willBeOpen ? "search" : p.view,
483
+ };
484
+ });
485
+ }
486
+
487
+ export function launcherView(view: string, props: any = {}) {
488
+ palette.update((p) => ({
489
+ ...p,
490
+ open: true,
491
+ view,
492
+ query: "",
493
+ props,
494
+ dynamicSize: null,
495
+ }));
496
+ }
497
+
498
+ export function setSize(size: { width: string; height: string } | null) {
499
+ palette.update((p) => ({ ...p, dynamicSize: size }));
500
+ }
501
+
502
+ export function setContexts(contexts: string[]) {
503
+ palette.update((p) => ({ ...p, contexts }));
504
+ }
505
+
506
+ export function launcherClose() {
507
+ palette.update((p) => ({ ...p, open: false }));
508
+ }
509
+
510
+ export function execute(item: LauncherResult) {
511
+ if (item.action) {
512
+ item.action();
513
+ // Don't close launcher for view commands - they handle their own state
514
+ if (item.type !== "view") {
515
+ launcherClose();
516
+ }
517
+ } else if (item.url) {
518
+ import("astro:transitions/client").then((m) =>
519
+ m.navigate(item.url as string),
520
+ );
521
+ launcherClose();
522
+ } else if (item.trigger) {
523
+ if (item.trigger.startsWith("::")) {
524
+ launcherView(item.trigger);
525
+ }
526
+ }
527
+ }
528
+ export function getPageState(user: any = null): any {
529
+ if (typeof window === "undefined") {
530
+ return { contexts: ["global"] };
531
+ }
532
+
533
+ const path = window.location.pathname;
534
+ const state: any = {
535
+ contexts: ["global"],
536
+ noteSlug: null,
537
+ };
538
+
539
+ if (path.startsWith("/writer") || path.startsWith("/editor")) {
540
+ state.contexts.push("editor");
541
+ return state;
542
+ }
543
+
544
+ // Common pattern for notes
545
+ const noteMatch = path.match(/^\/([^/]+)$/);
546
+ if (noteMatch) {
547
+ state.contexts.push("note");
548
+ state.noteSlug = noteMatch[1];
549
+ }
550
+
551
+ if (path === "/") {
552
+ state.contexts.push("home");
553
+ }
554
+ return state;
555
+ }
556
+
557
+ /**
558
+ * Svelte Action: openLauncher
559
+ * usage: <button use:openLauncher={"::trigger"}>Open</button>
560
+ */
561
+ export function openLauncher(node: HTMLElement, trigger: string = "") {
562
+ function handleClick(e: MouseEvent) {
563
+ e.preventDefault();
564
+ if (trigger.startsWith("::")) {
565
+ launcherView(trigger);
566
+ } else {
567
+ launcherToggle(trigger);
568
+ }
569
+ }
570
+
571
+ node.addEventListener("click", handleClick);
572
+ return {
573
+ destroy() {
574
+ node.removeEventListener("click", handleClick);
575
+ },
576
+ };
577
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@stnd/launcher",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "svelte": "./Launcher.svelte",
6
+ "main": "launcher.ts",
7
+ "exports": {
8
+ ".": {
9
+ "svelte": "./Launcher.svelte",
10
+ "default": "./launcher.ts"
11
+ },
12
+ "./launcher": "./launcher.ts",
13
+ "./Launcher.svelte": "./Launcher.svelte",
14
+ "./transitions": "./transitions.js",
15
+ "./*": "./*"
16
+ },
17
+ "dependencies": {
18
+ "svelte": "5.56.4",
19
+ "wrangler": "^4.105.0",
20
+ "@stnd/icon": "0.2.0",
21
+ "@stnd/log": "0.5.0",
22
+ "@stnd/store": "0.5.0"
23
+ },
24
+ "peerDependencies": {
25
+ "astro": ">=5.0.0"
26
+ }
27
+ }