@reidelsaltres/pureper 0.2.32 → 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 +47 -5
  10. package/out/foundation/Module.d.ts.map +1 -1
  11. package/out/foundation/Module.js +277 -26
  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 +312 -27
  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
@@ -0,0 +1,151 @@
1
+ import Observable from "./api/Observer.js";
2
+ import Fetcher from "./Fetcher.js";
3
+
4
+ export type DownloadProgress = {
5
+ totalFiles: number;
6
+ completedFiles: number;
7
+ currentFile: string;
8
+ totalBytes: number;
9
+ downloadedBytes: number;
10
+ speed: number;
11
+ active: boolean;
12
+ };
13
+
14
+ export default class CacheManager {
15
+ private static readonly CACHE_NAME = 'purper-modules';
16
+
17
+ static resolveUrl(url: string): string {
18
+ return Fetcher.resolveUrl(url);
19
+ }
20
+
21
+ static async download(urls: string[], progress?: Observable<DownloadProgress>): Promise<number> {
22
+ if (urls.length === 0) return 0;
23
+
24
+ const totalFiles = urls.length;
25
+ let completedFiles = 0;
26
+ let downloadedBytes = 0;
27
+ let totalBytes = 0;
28
+ let lastTime = performance.now();
29
+ let lastBytes = 0;
30
+ let speed = 0;
31
+
32
+ const update = (currentFile: string, active: boolean) => {
33
+ progress?.setObject({ totalFiles, completedFiles, currentFile, totalBytes, downloadedBytes, speed, active });
34
+ };
35
+
36
+ // Estimate total size via HEAD
37
+ const sizes = await Promise.all(urls.map(async (url) => {
38
+ try {
39
+ const resp = await fetch(this.resolveUrl(url), { method: 'HEAD' });
40
+ const cl = resp.headers.get('content-length');
41
+ return cl ? parseInt(cl, 10) : 0;
42
+ } catch { return 0; }
43
+ }));
44
+ totalBytes = sizes.reduce((a, b) => a + b, 0);
45
+
46
+ update('', true);
47
+
48
+ const failedFiles: string[] = [];
49
+
50
+ for (const url of urls) {
51
+ const resolved = this.resolveUrl(url);
52
+ const fileName = url.split('/').pop() || url;
53
+ update(fileName, true);
54
+
55
+ try {
56
+ const response = await fetch(resolved);
57
+ if (!response.ok) {
58
+ console.warn(`[CacheManager]: Failed to download ${url}: ${response.status}`);
59
+ failedFiles.push(url);
60
+ completedFiles++;
61
+ continue;
62
+ }
63
+
64
+ const reader = response.body?.getReader();
65
+ const chunks: BlobPart[] = [];
66
+
67
+ if (reader) {
68
+ while (true) {
69
+ const { done, value } = await reader.read();
70
+ if (done) break;
71
+ chunks.push(value);
72
+ downloadedBytes += value.byteLength;
73
+
74
+ const now = performance.now();
75
+ const elapsed = (now - lastTime) / 1000;
76
+ if (elapsed >= 0.5) {
77
+ speed = (downloadedBytes - lastBytes) / elapsed;
78
+ lastTime = now;
79
+ lastBytes = downloadedBytes;
80
+ }
81
+
82
+ update(fileName, true);
83
+ }
84
+ }
85
+
86
+ if ('caches' in window) {
87
+ try {
88
+ const cache = await caches.open(this.CACHE_NAME);
89
+ const blob = new Blob(chunks);
90
+ const cacheResponse = new Response(blob, { headers: response.headers });
91
+ await cache.put(resolved, cacheResponse);
92
+ } catch (e) {
93
+ console.warn(`[CacheManager]: Failed to cache ${url}`, e);
94
+ }
95
+ }
96
+
97
+ completedFiles++;
98
+ } catch (e) {
99
+ console.warn(`[CacheManager]: Error downloading ${url}`, e);
100
+ failedFiles.push(url);
101
+ completedFiles++;
102
+ }
103
+ }
104
+
105
+ speed = 0;
106
+ update('', false);
107
+
108
+ if (failedFiles.length > 0) {
109
+ throw new Error(`[CacheManager]: ${failedFiles.length} file(s) failed to download: ${failedFiles.join(', ')}`);
110
+ }
111
+
112
+ return downloadedBytes;
113
+ }
114
+
115
+ static async delete(urls: string[]): Promise<void> {
116
+ if (!('caches' in window) || urls.length === 0) return;
117
+ const cache = await caches.open(this.CACHE_NAME);
118
+ for (const url of urls) {
119
+ await cache.delete(this.resolveUrl(url));
120
+ }
121
+ }
122
+
123
+ static async isCached(url: string): Promise<boolean> {
124
+ if (!('caches' in window)) return false;
125
+ const cache = await caches.open(this.CACHE_NAME);
126
+ const match = await cache.match(this.resolveUrl(url));
127
+ return match !== undefined;
128
+ }
129
+
130
+ static async getCachedSize(urls: string[]): Promise<number> {
131
+ if (!('caches' in window) || urls.length === 0) return 0;
132
+ const cache = await caches.open(this.CACHE_NAME);
133
+ let total = 0;
134
+ for (const url of urls) {
135
+ const match = await cache.match(this.resolveUrl(url));
136
+ if (match) {
137
+ const blob = await match.blob();
138
+ total += blob.size;
139
+ }
140
+ }
141
+ return total;
142
+ }
143
+
144
+ static formatBytes(bytes: number): string {
145
+ if (bytes === 0) return '0 B';
146
+ const k = 1024;
147
+ const sizes = ['B', 'KB', 'MB', 'GB'];
148
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
149
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
150
+ }
151
+ }
@@ -165,6 +165,30 @@ export class Placeholder {
165
165
  console.info(`[Placeholder:${placeholderName}]: Reloaded ${reloads.length} instance(s)`);
166
166
  }
167
167
 
168
+ /** Return all registered placeholder names. */
169
+ public static getAllNames(): string[] {
170
+ return Array.from(this._all.keys());
171
+ }
172
+
173
+ /** Deactivate a placeholder — hides all instances. */
174
+ public static deactivate(name: string): void {
175
+ const p = this._all.get(name);
176
+ if (!p) return;
177
+ p.activeImpl.setObject(null);
178
+ console.info(`[Placeholder:${name}]: Deactivated`);
179
+ }
180
+
181
+ /** Activate a placeholder — restores the default implementation. */
182
+ public static activate(name: string): void {
183
+ const p = this._all.get(name);
184
+ if (!p) return;
185
+ const first = p.implementations.values().next().value;
186
+ if (first && !p.activeImpl.getObject()) {
187
+ p.activeImpl.setObject(first);
188
+ console.info(`[Placeholder:${name}]: Activated with "${first.name}"`);
189
+ }
190
+ }
191
+
168
192
  /**
169
193
  * Switch a single instance to a different implementation and reload it.
170
194
  * Does NOT change the global active implementation.
@@ -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;
@@ -7,25 +16,51 @@ export type ModuleStruct = {
7
16
  icon?: string;
8
17
  core?: boolean;
9
18
  enabled?: boolean;
19
+ resources?: string[];
20
+ subModules?: SubModuleStruct[];
10
21
  };
11
22
 
12
- export default class Module {
23
+ export default class Module extends Observable<boolean> {
13
24
  public readonly name: string;
14
25
  public readonly description?: string;
15
26
  public readonly icon?: string;
16
27
  public readonly core: boolean;
17
- public readonly enabled: Observable<boolean>;
18
28
  public readonly downloaded: Observable<boolean>;
29
+ public readonly resources: string[];
30
+ public readonly totalSize: Observable<number>;
31
+ public readonly downloadProgress: Observable<DownloadProgress>;
32
+ public readonly downloadError: Observable<string>;
19
33
 
20
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
+ }
21
43
 
22
44
  public constructor(struct: ModuleStruct) {
45
+ super(struct.core ? true : (struct.enabled ?? false));
23
46
  this.name = struct.name;
24
47
  this.description = struct.description;
25
48
  this.icon = struct.icon;
26
49
  this.core = struct.core ?? false;
27
- this.enabled = new Observable<boolean>(this.core ? true : (struct.enabled ?? false));
28
- this.downloaded = new Observable<boolean>(this.core ? true : false);
50
+ this.downloaded = new Observable<boolean>(false);
51
+ this.resources = struct.resources ?? [];
52
+ this.totalSize = new Observable<number>(0);
53
+ this.downloadProgress = new Observable<DownloadProgress>({
54
+ totalFiles: 0, completedFiles: 0, currentFile: '',
55
+ totalBytes: 0, downloadedBytes: 0, speed: 0, active: false
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
+ }
29
64
  }
30
65
 
31
66
  public addRegistration(fn: () => Promise<void>): void {
@@ -46,12 +81,60 @@ export default class Module {
46
81
  public captureRegistrations(registry: (() => Promise<void>)[]): void {
47
82
  const captured = registry.splice(0, registry.length);
48
83
  this._registrations.push(...captured);
49
- 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;
108
+ }
109
+
110
+ public markInitialized(): void {
111
+ this._initialized = true;
50
112
  }
51
113
 
52
- public enable(): void {
53
- if (this.enabled.getObject() === true) return;
54
- this.enabled.setObject(true);
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
+
55
138
  console.log(`[Module]: "${this.name}" enabled`);
56
139
  }
57
140
 
@@ -59,13 +142,19 @@ export default class Module {
59
142
  if (this.core) {
60
143
  throw new Error(`[Module]: Cannot disable core module "${this.name}"`);
61
144
  }
62
- if (this.enabled.getObject() === false) return;
63
- 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
+
64
153
  console.log(`[Module]: "${this.name}" disabled`);
65
154
  }
66
155
 
67
156
  public isActive(): boolean {
68
- return this.enabled.getObject() === true;
157
+ return this.getObject() === true;
69
158
  }
70
159
 
71
160
  /** True if enabled but not downloaded — works only in the current session. */
@@ -73,22 +162,161 @@ export default class Module {
73
162
  return this.isActive() && !this.downloaded.getObject();
74
163
  }
75
164
 
76
- public download(): void {
77
- this.downloaded.setObject(true);
78
- console.log(`[Module]: "${this.name}" downloaded`);
165
+ public async download(): Promise<void> {
166
+ if (this.downloaded.getObject()) return;
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) {
171
+ this.downloaded.setObject(true);
172
+ console.log(`[Module]: "${this.name}" downloaded (no resources)`);
173
+ return;
174
+ }
175
+
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
+ }
188
+ }
189
+
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
+ }
79
200
  }
80
201
 
81
- public undownload(): void {
82
- if (this.core) {
83
- throw new Error(`[Module]: Cannot remove core module "${this.name}" from downloads`);
202
+ public formatBytes(bytes: number): string {
203
+ return CacheManager.formatBytes(bytes);
204
+ }
205
+
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();
221
+ }
222
+ }
84
223
  }
224
+
85
225
  this.downloaded.setObject(false);
226
+ this.totalSize.setObject(0);
86
227
  console.log(`[Module]: "${this.name}" removed from downloads`);
87
228
  }
229
+
230
+ public addSubModule(struct: SubModuleStruct): SubModule {
231
+ const sub = new SubModule(struct, this);
232
+ this._subModules.push(sub);
233
+ return sub;
234
+ }
235
+
236
+ public getSubModules(): ReadonlyArray<SubModule> {
237
+ return this._subModules;
238
+ }
239
+ }
240
+
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>;
251
+
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
263
+ });
264
+ this.downloadError = new Observable<string>('');
265
+ }
266
+
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
+ }
276
+ }
277
+
278
+ public async download(): Promise<void> {
279
+ if (this.inbuilt) {
280
+ throw new Error(`[SubModule]: "${this.name}" is inbuilt and cannot be downloaded separately`);
281
+ }
282
+ this.assertParentDownloaded();
283
+ if (this.downloaded.getObject()) return;
284
+ this.downloadError.setObject('');
285
+
286
+ if (this.resources.length === 0) {
287
+ this.downloaded.setObject(true);
288
+ return;
289
+ }
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);
311
+ this.downloaded.setObject(false);
312
+ this.totalSize.setObject(0);
313
+ console.log(`[SubModule]: "${this.name}" of "${this.parent.name}" removed from downloads`);
314
+ }
88
315
  }
89
316
 
90
317
  export class ModuleManager {
91
318
  private static _modules: Map<string, Module> = new Map();
319
+ private static _userEphemeralCores = new Set<string>();
92
320
  private static readonly STORAGE_KEY = "purper:modules";
93
321
  private static readonly SESSION_KEY = "purper:modules:session";
94
322
 
@@ -129,6 +357,7 @@ export class ModuleManager {
129
357
 
130
358
  for (const mod of this._modules.values()) {
131
359
  if (mod.isActive()) {
360
+ mod.markInitialized();
132
361
  console.log(`[Module]: Initializing module "${mod.name}" (${mod.getRegistrations().length} registration(s))`);
133
362
  for (const reg of mod.getRegistrations()) {
134
363
  promises.push(reg());
@@ -147,18 +376,50 @@ export class ModuleManager {
147
376
  }
148
377
  }
149
378
 
379
+ // Auto-download enabled modules that are not yet downloaded
380
+ // (fire-and-forget — does not block initialization)
381
+ this.autoDownload();
382
+
150
383
  return promises;
151
384
  }
152
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
+
153
399
  static persistState(): void {
154
- 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 }> }> = {};
155
401
  const sessionState: Record<string, { enabled: boolean }> = {};
156
402
 
157
403
  for (const mod of this._modules.values()) {
158
- 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
+ }
159
415
 
160
- if (mod.downloaded.getObject()) {
161
- 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
+ };
162
423
  } else if (mod.isActive()) {
163
424
  sessionState[mod.name] = { enabled: true };
164
425
  }
@@ -173,25 +434,49 @@ export class ModuleManager {
173
434
  }
174
435
  }
175
436
 
437
+ static clearEphemeralCore(name: string): void {
438
+ this._userEphemeralCores.delete(name);
439
+ }
440
+
176
441
  static restoreState(): void {
177
442
  // Restore from localStorage (downloaded modules)
178
443
  try {
179
444
  const raw = localStorage.getItem(this.STORAGE_KEY);
180
445
  if (raw) {
181
- 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);
182
447
  for (const [name, data] of Object.entries(state)) {
183
448
  const mod = this._modules.get(name);
184
- if (mod && !mod.core) {
449
+ if (mod) {
185
450
  if (typeof data === 'boolean') {
186
451
  // Old format: value is just a boolean for enabled state
187
- mod.enabled.setObject(data);
452
+ if (!mod.core) mod.setObject(data);
188
453
  console.log(`[Module]: Migrated old format "${name}" → enabled=${data}`);
189
454
  continue;
190
455
  }
191
456
  if (data.downloaded) {
192
457
  mod.downloaded.setObject(true);
193
458
  }
194
- 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
+ }
195
480
  console.log(`[Module]: Restored "${name}" → downloaded=${data.downloaded}, enabled=${data.enabled}`);
196
481
  }
197
482
  }
@@ -208,7 +493,7 @@ export class ModuleManager {
208
493
  for (const [name, data] of Object.entries(state)) {
209
494
  const mod = this._modules.get(name);
210
495
  if (mod && !mod.core && !mod.downloaded.getObject()) {
211
- mod.enabled.setObject(data.enabled);
496
+ mod.setObject(data.enabled);
212
497
  console.log(`[Module]: Restored ephemeral "${name}" → enabled=${data.enabled}`);
213
498
  }
214
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,