@reidelsaltres/pureper 0.2.33 → 0.2.34

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.
Files changed (48) hide show
  1. package/out/foundation/CacheManager.d.ts +20 -0
  2. package/out/foundation/CacheManager.d.ts.map +1 -0
  3. package/out/foundation/CacheManager.js +129 -0
  4. package/out/foundation/CacheManager.js.map +1 -0
  5. package/out/foundation/Injection.d.ts +6 -0
  6. package/out/foundation/Injection.d.ts.map +1 -1
  7. package/out/foundation/Injection.js +23 -0
  8. package/out/foundation/Injection.js.map +1 -1
  9. package/out/foundation/Module.d.ts +41 -24
  10. package/out/foundation/Module.d.ts.map +1 -1
  11. package/out/foundation/Module.js +253 -152
  12. package/out/foundation/Module.js.map +1 -1
  13. package/out/foundation/Theme.d.ts +2 -0
  14. package/out/foundation/Theme.d.ts.map +1 -1
  15. package/out/foundation/Theme.js +9 -1
  16. package/out/foundation/Theme.js.map +1 -1
  17. package/out/foundation/Triplet.d.ts +10 -0
  18. package/out/foundation/Triplet.d.ts.map +1 -1
  19. package/out/foundation/Triplet.js +17 -0
  20. package/out/foundation/Triplet.js.map +1 -1
  21. package/out/foundation/TripletDecorator.d.ts.map +1 -1
  22. package/out/foundation/TripletDecorator.js +28 -1
  23. package/out/foundation/TripletDecorator.js.map +1 -1
  24. package/out/foundation/engine/Expression.d.ts.map +1 -1
  25. package/out/foundation/engine/Expression.js +11 -11
  26. package/out/foundation/engine/Expression.js.map +1 -1
  27. package/out/foundation/engine/TemplateEngine.d.ts.map +1 -1
  28. package/out/foundation/engine/TemplateEngine.js +6 -2
  29. package/out/foundation/engine/TemplateEngine.js.map +1 -1
  30. package/out/foundation/worker/ServiceWorker.d.ts +2 -25
  31. package/out/foundation/worker/ServiceWorker.d.ts.map +1 -1
  32. package/out/foundation/worker/ServiceWorker.js +2 -94
  33. package/out/foundation/worker/ServiceWorker.js.map +1 -1
  34. package/out/index.d.ts +4 -3
  35. package/out/index.d.ts.map +1 -1
  36. package/out/index.js +3 -2
  37. package/out/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/foundation/CacheManager.ts +151 -0
  40. package/src/foundation/Injection.ts +24 -0
  41. package/src/foundation/Module.ts +282 -174
  42. package/src/foundation/Theme.ts +10 -1
  43. package/src/foundation/Triplet.ts +21 -0
  44. package/src/foundation/TripletDecorator.ts +19 -1
  45. package/src/foundation/engine/Expression.ts +11 -12
  46. package/src/foundation/engine/TemplateEngine.ts +6 -3
  47. package/src/foundation/worker/ServiceWorker.ts +2 -101
  48. package/src/index.ts +4 -3
@@ -1,5 +1,14 @@
1
1
  import Observable from "./api/Observer.js";
2
- import { REGISTRY } from "./Triplet.js";
2
+ import { Placeholder } from "./Injection.js";
3
+ import { REGISTRY, RegistryCapture } from "./Triplet.js";
4
+ import CacheManager, { DownloadProgress } from "./CacheManager.js";
5
+
6
+ export type SubModuleStruct = {
7
+ name: string;
8
+ description?: string;
9
+ inbuilt?: boolean;
10
+ resources?: string[];
11
+ };
3
12
 
4
13
  export type ModuleStruct = {
5
14
  name: string;
@@ -8,51 +17,50 @@ export type ModuleStruct = {
8
17
  core?: boolean;
9
18
  enabled?: boolean;
10
19
  resources?: string[];
20
+ subModules?: SubModuleStruct[];
11
21
  };
12
22
 
13
- export type ModuleDownloadProgress = {
14
- /** Total number of resources */
15
- totalFiles: number;
16
- /** Number of resources downloaded so far */
17
- downloadedFiles: number;
18
- /** Current file being downloaded */
19
- currentFile: string;
20
- /** Total bytes across all resources (estimated) */
21
- totalBytes: number;
22
- /** Bytes downloaded so far */
23
- downloadedBytes: number;
24
- /** Download speed in bytes/sec */
25
- speed: number;
26
- /** Whether download is in progress */
27
- active: boolean;
28
- };
29
-
30
- export default class Module {
23
+ export default class Module extends Observable<boolean> {
31
24
  public readonly name: string;
32
25
  public readonly description?: string;
33
26
  public readonly icon?: string;
34
27
  public readonly core: boolean;
35
- public readonly enabled: Observable<boolean>;
36
28
  public readonly downloaded: Observable<boolean>;
37
29
  public readonly resources: string[];
38
30
  public readonly totalSize: Observable<number>;
39
- public readonly downloadProgress: Observable<ModuleDownloadProgress>;
31
+ public readonly downloadProgress: Observable<DownloadProgress>;
32
+ public readonly downloadError: Observable<string>;
40
33
 
41
34
  private _registrations: (() => Promise<void>)[] = [];
35
+ private _placeholderNames: string[] = [];
36
+ private _initialized: boolean = false;
37
+ private _subModules: SubModule[] = [];
38
+ private static _claimedPlaceholders = new Set<string>();
39
+
40
+ public get enabled(): Observable<boolean> {
41
+ return this;
42
+ }
42
43
 
43
44
  public constructor(struct: ModuleStruct) {
45
+ super(struct.core ? true : (struct.enabled ?? false));
44
46
  this.name = struct.name;
45
47
  this.description = struct.description;
46
48
  this.icon = struct.icon;
47
49
  this.core = struct.core ?? false;
48
- this.enabled = new Observable<boolean>(this.core ? true : (struct.enabled ?? false));
49
- this.downloaded = new Observable<boolean>(this.core ? true : false);
50
+ this.downloaded = new Observable<boolean>(false);
50
51
  this.resources = struct.resources ?? [];
51
52
  this.totalSize = new Observable<number>(0);
52
- this.downloadProgress = new Observable<ModuleDownloadProgress>({
53
- totalFiles: 0, downloadedFiles: 0, currentFile: '',
53
+ this.downloadProgress = new Observable<DownloadProgress>({
54
+ totalFiles: 0, completedFiles: 0, currentFile: '',
54
55
  totalBytes: 0, downloadedBytes: 0, speed: 0, active: false
55
56
  });
57
+ this.downloadError = new Observable<string>('');
58
+
59
+ if (struct.subModules) {
60
+ for (const sub of struct.subModules) {
61
+ this.addSubModule(sub);
62
+ }
63
+ }
56
64
  }
57
65
 
58
66
  public addRegistration(fn: () => Promise<void>): void {
@@ -73,12 +81,60 @@ export default class Module {
73
81
  public captureRegistrations(registry: (() => Promise<void>)[]): void {
74
82
  const captured = registry.splice(0, registry.length);
75
83
  this._registrations.push(...captured);
76
- console.log(`[Module]: "${this.name}" captured ${captured.length} registration(s)`);
84
+
85
+ // Discover placeholder names created by this module's imports
86
+ for (const name of Placeholder.getAllNames()) {
87
+ if (!Module._claimedPlaceholders.has(name)) {
88
+ Module._claimedPlaceholders.add(name);
89
+ this._placeholderNames.push(name);
90
+ }
91
+ }
92
+
93
+ console.log(`[Module]: "${this.name}" captured ${captured.length} registration(s), ${this._placeholderNames.length} placeholder(s)`);
94
+
95
+ const capturedResources = RegistryCapture.drain();
96
+ if (capturedResources.length > 0) {
97
+ this.resources.push(...capturedResources);
98
+ console.log(`[Module]: "${this.name}" auto-captured ${capturedResources.length} resource path(s)`);
99
+ }
100
+ }
101
+
102
+ public addPlaceholder(name: string): void {
103
+ this._placeholderNames.push(name);
104
+ }
105
+
106
+ public getPlaceholderNames(): ReadonlyArray<string> {
107
+ return this._placeholderNames;
77
108
  }
78
109
 
79
- public enable(): void {
80
- if (this.enabled.getObject() === true) return;
81
- this.enabled.setObject(true);
110
+ public markInitialized(): void {
111
+ this._initialized = true;
112
+ }
113
+
114
+ public async enable(): Promise<void> {
115
+ if (this.getObject() === true) return;
116
+ this.setObject(true);
117
+
118
+ if (!this._initialized) {
119
+ this._initialized = true;
120
+ // Snapshot existing placeholders before running registrations
121
+ const before = new Set(Placeholder.getAllNames());
122
+ for (const reg of this._registrations) {
123
+ await reg();
124
+ }
125
+ // Discover which placeholders were added by this module
126
+ for (const name of Placeholder.getAllNames()) {
127
+ if (!before.has(name)) {
128
+ this._placeholderNames.push(name);
129
+ }
130
+ }
131
+ } else {
132
+ // Re-activate previously registered placeholders
133
+ for (const name of this._placeholderNames) {
134
+ Placeholder.activate(name);
135
+ }
136
+ }
137
+
82
138
  console.log(`[Module]: "${this.name}" enabled`);
83
139
  }
84
140
 
@@ -86,13 +142,19 @@ export default class Module {
86
142
  if (this.core) {
87
143
  throw new Error(`[Module]: Cannot disable core module "${this.name}"`);
88
144
  }
89
- if (this.enabled.getObject() === false) return;
90
- this.enabled.setObject(false);
145
+ if (this.getObject() === false) return;
146
+ this.setObject(false);
147
+
148
+ // Deactivate placeholders
149
+ for (const name of this._placeholderNames) {
150
+ Placeholder.deactivate(name);
151
+ }
152
+
91
153
  console.log(`[Module]: "${this.name}" disabled`);
92
154
  }
93
155
 
94
156
  public isActive(): boolean {
95
- return this.enabled.getObject() === true;
157
+ return this.getObject() === true;
96
158
  }
97
159
 
98
160
  /** True if enabled but not downloaded — works only in the current session. */
@@ -100,172 +162,161 @@ export default class Module {
100
162
  return this.isActive() && !this.downloaded.getObject();
101
163
  }
102
164
 
103
- /**
104
- * Download all module resources with progress tracking.
105
- * Uses fetch to download each resource and tracks progress.
106
- */
107
165
  public async download(): Promise<void> {
108
166
  if (this.downloaded.getObject()) return;
109
- if (this.resources.length === 0) {
110
- // No resources to download — just mark as downloaded
167
+ this.downloadError.setObject('');
168
+
169
+ const hasInbuiltSubs = this._subModules.some(s => s.inbuilt && !s.downloaded.getObject() && s.resources.length > 0);
170
+ if (this.resources.length === 0 && !hasInbuiltSubs) {
111
171
  this.downloaded.setObject(true);
112
172
  console.log(`[Module]: "${this.name}" downloaded (no resources)`);
113
173
  return;
114
174
  }
115
175
 
116
- const resolveUrl = (url: string): string => {
117
- const trimmed = url.trim();
118
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
119
- if (trimmed.startsWith("//")) return `${window.location.protocol}${trimmed}`;
120
- return new URL(trimmed, document.baseURI).href;
121
- };
122
-
123
- const totalFiles = this.resources.length;
124
- let downloadedFiles = 0;
125
- let downloadedBytes = 0;
126
- let totalBytes = 0;
127
- let lastTime = performance.now();
128
- let lastBytes = 0;
129
- let speed = 0;
130
-
131
- // First, try to estimate total size with HEAD requests
132
- const headPromises = this.resources.map(async (url) => {
133
- try {
134
- const resolved = resolveUrl(url);
135
- const resp = await fetch(resolved, { method: 'HEAD' });
136
- const cl = resp.headers.get('content-length');
137
- return cl ? parseInt(cl, 10) : 0;
138
- } catch {
139
- return 0;
176
+ try {
177
+ // Download module's own resources
178
+ let totalBytes = await CacheManager.download(this.resources, this.downloadProgress);
179
+
180
+ // Also download all inbuilt sub-modules
181
+ for (const sub of this._subModules) {
182
+ if (sub.inbuilt && !sub.downloaded.getObject()) {
183
+ const subBytes = await CacheManager.download(sub.resources, this.downloadProgress);
184
+ sub.totalSize.setObject(subBytes);
185
+ sub.downloaded.setObject(true);
186
+ totalBytes += subBytes;
187
+ }
140
188
  }
141
- });
142
-
143
- const sizes = await Promise.all(headPromises);
144
- totalBytes = sizes.reduce((a, b) => a + b, 0);
145
- this.totalSize.setObject(totalBytes);
146
-
147
- this.downloadProgress.setObject({
148
- totalFiles, downloadedFiles: 0, currentFile: '',
149
- totalBytes, downloadedBytes: 0, speed: 0, active: true
150
- });
151
-
152
- console.log(`[Module]: Starting download of "${this.name}" (${totalFiles} files, ~${this.formatBytes(totalBytes)})`);
153
189
 
154
- for (let i = 0; i < this.resources.length; i++) {
155
- const url = this.resources[i];
156
- const resolved = resolveUrl(url);
157
- const fileName = url.split('/').pop() || url;
190
+ this.totalSize.setObject(totalBytes);
191
+ this.downloaded.setObject(true);
192
+ // Clear ephemeral flag if re-downloaded
193
+ ModuleManager.clearEphemeralCore(this.name);
194
+ console.log(`[Module]: "${this.name}" downloaded (${this.resources.length} files, ${CacheManager.formatBytes(totalBytes)})`);
195
+ } catch (e) {
196
+ const msg = e instanceof Error ? e.message : String(e);
197
+ this.downloadError.setObject(msg);
198
+ console.error(`[Module]: "${this.name}" download failed:`, msg);
199
+ }
200
+ }
158
201
 
159
- this.downloadProgress.setObject({
160
- totalFiles, downloadedFiles, currentFile: fileName,
161
- totalBytes, downloadedBytes, speed, active: true
162
- });
202
+ public formatBytes(bytes: number): string {
203
+ return CacheManager.formatBytes(bytes);
204
+ }
163
205
 
164
- try {
165
- const response = await fetch(resolved);
166
- if (!response.ok) {
167
- console.warn(`[Module]: Failed to download ${url}: ${response.status}`);
168
- downloadedFiles++;
169
- continue;
206
+ public async undownload(): Promise<void> {
207
+ this.downloadError.setObject('');
208
+ await CacheManager.delete(this.resources);
209
+
210
+ // Also undownload all sub-modules
211
+ for (const sub of this._subModules) {
212
+ if (sub.downloaded.getObject()) {
213
+ if (sub.inbuilt) {
214
+ // Inbuilt subs: clean up directly (bypass the guard)
215
+ await CacheManager.delete(sub.resources);
216
+ sub.downloaded.setObject(false);
217
+ sub.totalSize.setObject(0);
218
+ sub.downloadError.setObject('');
219
+ } else {
220
+ await sub.undownload();
170
221
  }
222
+ }
223
+ }
171
224
 
172
- // Read with progress tracking
173
- const reader = response.body?.getReader();
174
- const chunks: BlobPart[] = [];
175
-
176
- if (reader) {
177
- while (true) {
178
- const { done, value } = await reader.read();
179
- if (done) break;
180
- chunks.push(value);
181
- downloadedBytes += value.byteLength;
182
-
183
- // Calculate speed
184
- const now = performance.now();
185
- const elapsed = (now - lastTime) / 1000;
186
- if (elapsed >= 0.5) {
187
- speed = (downloadedBytes - lastBytes) / elapsed;
188
- lastTime = now;
189
- lastBytes = downloadedBytes;
190
- }
225
+ this.downloaded.setObject(false);
226
+ this.totalSize.setObject(0);
227
+ console.log(`[Module]: "${this.name}" removed from downloads`);
228
+ }
191
229
 
192
- this.downloadProgress.setObject({
193
- totalFiles, downloadedFiles, currentFile: fileName,
194
- totalBytes, downloadedBytes, speed, active: true
195
- });
196
- }
197
- }
230
+ public addSubModule(struct: SubModuleStruct): SubModule {
231
+ const sub = new SubModule(struct, this);
232
+ this._subModules.push(sub);
233
+ return sub;
234
+ }
198
235
 
199
- // Cache the resource
200
- if ('caches' in window) {
201
- try {
202
- const cache = await caches.open('purper-modules');
203
- const blob = new Blob(chunks);
204
- const cacheResponse = new Response(blob, {
205
- headers: response.headers
206
- });
207
- await cache.put(resolved, cacheResponse);
208
- } catch (e) {
209
- console.warn(`[Module]: Failed to cache ${url}`, e);
210
- }
211
- }
236
+ public getSubModules(): ReadonlyArray<SubModule> {
237
+ return this._subModules;
238
+ }
239
+ }
212
240
 
213
- downloadedFiles++;
214
- } catch (e) {
215
- console.warn(`[Module]: Error downloading ${url}`, e);
216
- downloadedFiles++;
217
- }
218
- }
241
+ export class SubModule {
242
+ public readonly name: string;
243
+ public readonly description?: string;
244
+ public readonly inbuilt: boolean;
245
+ public readonly resources: string[];
246
+ public readonly downloaded: Observable<boolean>;
247
+ public readonly parent: Module;
248
+ public readonly totalSize: Observable<number>;
249
+ public readonly downloadProgress: Observable<DownloadProgress>;
250
+ public readonly downloadError: Observable<string>;
219
251
 
220
- this.downloadProgress.setObject({
221
- totalFiles, downloadedFiles, currentFile: '',
222
- totalBytes, downloadedBytes, speed: 0, active: false
252
+ constructor(struct: SubModuleStruct, parent: Module) {
253
+ this.name = struct.name;
254
+ this.description = struct.description;
255
+ this.inbuilt = struct.inbuilt ?? true;
256
+ this.resources = struct.resources ?? [];
257
+ this.downloaded = new Observable<boolean>(false);
258
+ this.parent = parent;
259
+ this.totalSize = new Observable<number>(0);
260
+ this.downloadProgress = new Observable<DownloadProgress>({
261
+ totalFiles: 0, completedFiles: 0, currentFile: '',
262
+ totalBytes: 0, downloadedBytes: 0, speed: 0, active: false
223
263
  });
224
-
225
- this.downloaded.setObject(true);
226
- console.log(`[Module]: "${this.name}" downloaded (${downloadedFiles}/${totalFiles} files, ${this.formatBytes(downloadedBytes)})`);
264
+ this.downloadError = new Observable<string>('');
227
265
  }
228
266
 
229
- public formatBytes(bytes: number): string {
230
- if (bytes === 0) return '0 B';
231
- const k = 1024;
232
- const sizes = ['B', 'KB', 'MB', 'GB'];
233
- const i = Math.floor(Math.log(bytes) / Math.log(k));
234
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
267
+ private assertParentActive(): void {
268
+ if (!this.parent.isActive()) {
269
+ throw new Error(`[SubModule]: Cannot access "${this.name}" — parent module "${this.parent.name}" is disabled`);
270
+ }
271
+ }
272
+ private assertParentDownloaded(): void {
273
+ if (!this.parent.downloaded.getObject()) {
274
+ throw new Error(`[SubModule]: Cannot access "${this.name}" — parent module "${this.parent.name}" is not downloaded`);
275
+ }
235
276
  }
236
277
 
237
- public async undownload(): Promise<void> {
238
- if (this.core) {
239
- throw new Error(`[Module]: Cannot remove core module "${this.name}" from downloads`);
278
+ public async download(): Promise<void> {
279
+ if (this.inbuilt) {
280
+ throw new Error(`[SubModule]: "${this.name}" is inbuilt and cannot be downloaded separately`);
240
281
  }
282
+ this.assertParentDownloaded();
283
+ if (this.downloaded.getObject()) return;
284
+ this.downloadError.setObject('');
241
285
 
242
- // Remove cached resources
243
- if ('caches' in window && this.resources.length > 0) {
244
- try {
245
- const cache = await caches.open('purper-modules');
246
- const resolveUrl = (url: string): string => {
247
- const trimmed = url.trim();
248
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return trimmed;
249
- if (trimmed.startsWith("//")) return `${window.location.protocol}${trimmed}`;
250
- return new URL(trimmed, document.baseURI).href;
251
- };
252
- for (const url of this.resources) {
253
- await cache.delete(resolveUrl(url));
254
- }
255
- console.log(`[Module]: Cleared cache for "${this.name}"`);
256
- } catch (e) {
257
- console.warn(`[Module]: Failed to clear cache for "${this.name}"`, e);
258
- }
286
+ if (this.resources.length === 0) {
287
+ this.downloaded.setObject(true);
288
+ return;
259
289
  }
260
290
 
291
+ try {
292
+ const bytes = await CacheManager.download(this.resources, this.downloadProgress);
293
+ this.totalSize.setObject(bytes);
294
+ this.downloaded.setObject(true);
295
+ console.log(`[SubModule]: "${this.name}" of "${this.parent.name}" downloaded (${CacheManager.formatBytes(bytes)})`);
296
+ } catch (e) {
297
+ const msg = e instanceof Error ? e.message : String(e);
298
+ this.downloadError.setObject(msg);
299
+ console.error(`[SubModule]: "${this.name}" download failed:`, msg);
300
+ }
301
+ }
302
+
303
+ public async undownload(): Promise<void> {
304
+ if (this.inbuilt) {
305
+ throw new Error(`[SubModule]: "${this.name}" is inbuilt and cannot be removed separately`);
306
+ }
307
+ if (!this.downloaded.getObject()) return;
308
+ this.downloadError.setObject('');
309
+
310
+ await CacheManager.delete(this.resources);
261
311
  this.downloaded.setObject(false);
262
312
  this.totalSize.setObject(0);
263
- console.log(`[Module]: "${this.name}" removed from downloads`);
313
+ console.log(`[SubModule]: "${this.name}" of "${this.parent.name}" removed from downloads`);
264
314
  }
265
315
  }
266
316
 
267
317
  export class ModuleManager {
268
318
  private static _modules: Map<string, Module> = new Map();
319
+ private static _userEphemeralCores = new Set<string>();
269
320
  private static readonly STORAGE_KEY = "purper:modules";
270
321
  private static readonly SESSION_KEY = "purper:modules:session";
271
322
 
@@ -306,6 +357,7 @@ export class ModuleManager {
306
357
 
307
358
  for (const mod of this._modules.values()) {
308
359
  if (mod.isActive()) {
360
+ mod.markInitialized();
309
361
  console.log(`[Module]: Initializing module "${mod.name}" (${mod.getRegistrations().length} registration(s))`);
310
362
  for (const reg of mod.getRegistrations()) {
311
363
  promises.push(reg());
@@ -324,18 +376,50 @@ export class ModuleManager {
324
376
  }
325
377
  }
326
378
 
379
+ // Auto-download enabled modules that are not yet downloaded
380
+ // (fire-and-forget — does not block initialization)
381
+ this.autoDownload();
382
+
327
383
  return promises;
328
384
  }
329
385
 
386
+ private static async autoDownload(): Promise<void> {
387
+ for (const mod of this._modules.values()) {
388
+ if (mod.core && !mod.downloaded.getObject() && !this._userEphemeralCores.has(mod.name)) {
389
+ console.log(`[Module]: Auto-downloading "${mod.name}"...`);
390
+ mod.download().then(() => {
391
+ this.persistState();
392
+ }).catch(e => {
393
+ console.warn(`[Module]: Auto-download failed for "${mod.name}"`, e);
394
+ });
395
+ }
396
+ }
397
+ }
398
+
330
399
  static persistState(): void {
331
- const localState: Record<string, { enabled: boolean, downloaded: boolean }> = {};
400
+ const localState: Record<string, { enabled: boolean, downloaded: boolean, size: number, subModules?: Record<string, { downloaded: boolean, size: number }> }> = {};
332
401
  const sessionState: Record<string, { enabled: boolean }> = {};
333
402
 
334
403
  for (const mod of this._modules.values()) {
335
- if (mod.core) continue;
404
+ const subs = mod.getSubModules();
405
+ let subModules: Record<string, { downloaded: boolean, size: number }> | undefined;
406
+ if (subs.length > 0) {
407
+ subModules = {};
408
+ for (const sub of subs) {
409
+ subModules[sub.name] = {
410
+ downloaded: sub.downloaded.getObject() === true,
411
+ size: sub.totalSize.getObject() ?? 0
412
+ };
413
+ }
414
+ }
336
415
 
337
- if (mod.downloaded.getObject()) {
338
- localState[mod.name] = { enabled: mod.isActive(), downloaded: true };
416
+ if (mod.downloaded.getObject() || mod.core) {
417
+ localState[mod.name] = {
418
+ enabled: mod.isActive(),
419
+ downloaded: mod.downloaded.getObject() === true,
420
+ size: mod.totalSize.getObject() ?? 0,
421
+ ...(subModules ? { subModules } : {})
422
+ };
339
423
  } else if (mod.isActive()) {
340
424
  sessionState[mod.name] = { enabled: true };
341
425
  }
@@ -350,25 +434,49 @@ export class ModuleManager {
350
434
  }
351
435
  }
352
436
 
437
+ static clearEphemeralCore(name: string): void {
438
+ this._userEphemeralCores.delete(name);
439
+ }
440
+
353
441
  static restoreState(): void {
354
442
  // Restore from localStorage (downloaded modules)
355
443
  try {
356
444
  const raw = localStorage.getItem(this.STORAGE_KEY);
357
445
  if (raw) {
358
- const state: Record<string, { enabled: boolean, downloaded: boolean } | boolean> = JSON.parse(raw);
446
+ const state: Record<string, { enabled: boolean, downloaded: boolean, size?: number, subModules?: Record<string, { enabled?: boolean, downloaded: boolean, size?: number }> } | boolean> = JSON.parse(raw);
359
447
  for (const [name, data] of Object.entries(state)) {
360
448
  const mod = this._modules.get(name);
361
- if (mod && !mod.core) {
449
+ if (mod) {
362
450
  if (typeof data === 'boolean') {
363
451
  // Old format: value is just a boolean for enabled state
364
- mod.enabled.setObject(data);
452
+ if (!mod.core) mod.setObject(data);
365
453
  console.log(`[Module]: Migrated old format "${name}" → enabled=${data}`);
366
454
  continue;
367
455
  }
368
456
  if (data.downloaded) {
369
457
  mod.downloaded.setObject(true);
370
458
  }
371
- mod.enabled.setObject(data.enabled);
459
+ if (data.size) {
460
+ mod.totalSize.setObject(data.size);
461
+ }
462
+ if (!mod.core) {
463
+ mod.setObject(data.enabled);
464
+ }
465
+ // Track core modules explicitly made ephemeral by user
466
+ if (mod.core && !data.downloaded) {
467
+ this._userEphemeralCores.add(name);
468
+ }
469
+ if (data.subModules) {
470
+ for (const sub of mod.getSubModules()) {
471
+ const subData = data.subModules[sub.name];
472
+ if (subData) {
473
+ sub.downloaded.setObject(subData.downloaded);
474
+ if (subData.size) {
475
+ sub.totalSize.setObject(subData.size);
476
+ }
477
+ }
478
+ }
479
+ }
372
480
  console.log(`[Module]: Restored "${name}" → downloaded=${data.downloaded}, enabled=${data.enabled}`);
373
481
  }
374
482
  }
@@ -385,7 +493,7 @@ export class ModuleManager {
385
493
  for (const [name, data] of Object.entries(state)) {
386
494
  const mod = this._modules.get(name);
387
495
  if (mod && !mod.core && !mod.downloaded.getObject()) {
388
- mod.enabled.setObject(data.enabled);
496
+ mod.setObject(data.enabled);
389
497
  console.log(`[Module]: Restored ephemeral "${name}" → enabled=${data.enabled}`);
390
498
  }
391
499
  }
@@ -1,6 +1,7 @@
1
1
  import Fetcher from "./Fetcher.js";
2
2
  import Observable from "./api/Observer.js";
3
3
 
4
+ export const DEFAULT_THEME = "Blazor";
4
5
  export let ACTIVE_THEME_KEY = "Empty";
5
6
  let activeThemeSheet: CSSStyleSheet | null = null;
6
7
  let _internalThemeSwitch = false;
@@ -20,7 +21,7 @@ export async function init() {
20
21
  if (ACTIVE_THEME_KEY) {
21
22
  await setTheme(ACTIVE_THEME_KEY);
22
23
  } else {
23
- await setTheme("Blazor");
24
+ await setTheme(DEFAULT_THEME);
24
25
  }
25
26
  }
26
27
  export async function setTheme(name: string) {
@@ -168,3 +169,11 @@ export async function deactivateAppTheme(): Promise<void> {
168
169
  console.info(`[Theme]: AppTheme "${current.name}" deactivated`);
169
170
  }
170
171
 
172
+ export async function resetToDefault(): Promise<void> {
173
+ await deactivateAppTheme();
174
+ await setTheme(DEFAULT_THEME);
175
+ ACTIVE_THEME_KEY = DEFAULT_THEME;
176
+ localStorage.setItem("theme", DEFAULT_THEME);
177
+ console.info(`[Theme]: Reset to default theme "${DEFAULT_THEME}"`);
178
+ }
179
+
@@ -10,6 +10,27 @@ import { Implementation, ImplementationStruct, Placeholder } from "./Injection.j
10
10
 
11
11
  export const REGISTRY: (() => Promise<void>)[] = [];
12
12
 
13
+ export class RegistryCapture {
14
+ private static _unclaimed: string[] = [];
15
+ private static _classResources = new Map<Function, string[]>();
16
+
17
+ /** Called by decorators to record which file paths a class uses. */
18
+ static capture(cls: Function, paths: string[]): void {
19
+ this._classResources.set(cls, paths);
20
+ this._unclaimed.push(...paths);
21
+ }
22
+
23
+ /** Drain all unclaimed resource paths. Called by Module.captureRegistrations(). */
24
+ static drain(): string[] {
25
+ return this._unclaimed.splice(0);
26
+ }
27
+
28
+ /** Get resource paths for a specific class. */
29
+ static getResources(cls: Function): string[] {
30
+ return this._classResources.get(cls) ?? [];
31
+ }
32
+ }
33
+
13
34
  export enum AccessType {
14
35
  NONE = 0,
15
36
  OFFLINE = 1 << 0,