@readium/navigator 2.2.7 → 2.2.8

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 (46) hide show
  1. package/dist/ar-DyHX_uy2-DyHX_uy2-DyHX_uy2.js +7 -0
  2. package/dist/ar-DyHX_uy2-DyHX_uy2.js +7 -0
  3. package/dist/da-Dct0PS3E-Dct0PS3E-Dct0PS3E.js +7 -0
  4. package/dist/da-Dct0PS3E-Dct0PS3E.js +7 -0
  5. package/dist/fr-C5HEel98-C5HEel98-C5HEel98.js +7 -0
  6. package/dist/fr-C5HEel98-C5HEel98.js +7 -0
  7. package/dist/index.js +4330 -2342
  8. package/dist/index.umd.cjs +1571 -39
  9. package/dist/it-DFOBoXGy-DFOBoXGy-DFOBoXGy.js +7 -0
  10. package/dist/it-DFOBoXGy-DFOBoXGy.js +7 -0
  11. package/dist/pt_PT-Di3sVjze-Di3sVjze-Di3sVjze.js +7 -0
  12. package/dist/pt_PT-Di3sVjze-Di3sVjze.js +7 -0
  13. package/dist/sv-BfzAFsVN-BfzAFsVN-BfzAFsVN.js +7 -0
  14. package/dist/sv-BfzAFsVN-BfzAFsVN.js +7 -0
  15. package/package.json +1 -1
  16. package/src/dom/_readium_executionCleanup.js +13 -0
  17. package/src/dom/_readium_executionPrevention.js +65 -0
  18. package/src/dom/_readium_webpubExecution.js +4 -0
  19. package/src/epub/EpubNavigator.ts +26 -2
  20. package/src/epub/frame/FrameBlobBuilder.ts +37 -131
  21. package/src/epub/frame/FramePoolManager.ts +34 -5
  22. package/src/epub/fxl/FXLFramePoolManager.ts +20 -2
  23. package/src/helpers/minify.ts +14 -0
  24. package/src/index.ts +2 -1
  25. package/src/injection/Injectable.ts +85 -0
  26. package/src/injection/Injector.ts +356 -0
  27. package/src/injection/epubInjectables.ts +90 -0
  28. package/src/injection/index.ts +2 -0
  29. package/src/injection/webpubInjectables.ts +59 -0
  30. package/src/webpub/WebPubBlobBuilder.ts +19 -80
  31. package/src/webpub/WebPubFramePoolManager.ts +29 -4
  32. package/src/webpub/WebPubNavigator.ts +15 -1
  33. package/types/src/epub/EpubNavigator.d.ts +3 -0
  34. package/types/src/epub/frame/FrameBlobBuilder.d.ts +7 -4
  35. package/types/src/epub/frame/FramePoolManager.d.ts +3 -1
  36. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +3 -1
  37. package/types/src/helpers/minify.d.ts +12 -0
  38. package/types/src/index.d.ts +1 -0
  39. package/types/src/injection/Injectable.d.ts +68 -0
  40. package/types/src/injection/Injector.d.ts +22 -0
  41. package/types/src/injection/epubInjectables.d.ts +6 -0
  42. package/types/src/injection/index.d.ts +2 -0
  43. package/types/src/injection/webpubInjectables.d.ts +5 -0
  44. package/types/src/webpub/WebPubBlobBuilder.d.ts +7 -3
  45. package/types/src/webpub/WebPubFramePoolManager.d.ts +3 -1
  46. package/types/src/webpub/WebPubNavigator.d.ts +3 -0
@@ -2,6 +2,7 @@ import { ModuleName } from "@readium/navigator-html-injectables";
2
2
  import { Locator, Publication } from "@readium/shared";
3
3
  import FrameBlobBuider from "./FrameBlobBuilder";
4
4
  import { FrameManager } from "./FrameManager";
5
+ import { Injector } from "../../injection/Injector";
5
6
 
6
7
  const UPPER_BOUNDARY = 5;
7
8
  const LOWER_BOUNDARY = 3;
@@ -16,11 +17,18 @@ export class FramePoolManager {
16
17
  private readonly inprogress: Map<string, Promise<void>> = new Map();
17
18
  private pendingUpdates: Map<string, { inPool: boolean }> = new Map();
18
19
  private currentBaseURL: string | undefined;
20
+ private readonly injector: Injector | null = null;
19
21
 
20
- constructor(container: HTMLElement, positions: Locator[], cssProperties?: { [key: string]: string }) {
22
+ constructor(
23
+ container: HTMLElement,
24
+ positions: Locator[],
25
+ cssProperties?: { [key: string]: string },
26
+ injector?: Injector | null
27
+ ) {
21
28
  this.container = container;
22
29
  this.positions = positions;
23
30
  this.currentCssProperties = cssProperties;
31
+ this.injector = injector ?? null;
24
32
  }
25
33
 
26
34
  async destroy() {
@@ -47,7 +55,13 @@ export class FramePoolManager {
47
55
  this.pool.clear();
48
56
 
49
57
  // Revoke all blobs
50
- this.blobs.forEach(v => URL.revokeObjectURL(v));
58
+ this.blobs.forEach(v => {
59
+ this.injector?.releaseBlobUrl?.(v);
60
+ URL.revokeObjectURL(v);
61
+ });
62
+
63
+ // Clean up injector if it exists
64
+ this.injector?.dispose();
51
65
 
52
66
  // Empty container of elements
53
67
  this.container.childNodes.forEach(v => {
@@ -90,7 +104,10 @@ export class FramePoolManager {
90
104
  // Check if base URL of publication has changed
91
105
  if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
92
106
  // Revoke all blobs
93
- this.blobs.forEach(v => URL.revokeObjectURL(v));
107
+ this.blobs.forEach(v => {
108
+ this.injector?.releaseBlobUrl?.(v);
109
+ URL.revokeObjectURL(v);
110
+ });
94
111
  this.blobs.clear();
95
112
  }
96
113
  this.currentBaseURL = pub.baseURL;
@@ -103,13 +120,17 @@ export class FramePoolManager {
103
120
  // when navigating backwards, where paginated will go the
104
121
  // start of the resource instead of the end due to the
105
122
  // corrupted width ColumnSnapper (injectables) gets on init
106
- this.blobs.forEach(v => URL.revokeObjectURL(v));
123
+ this.blobs.forEach(v => {
124
+ this.injector?.releaseBlobUrl?.(v);
125
+ URL.revokeObjectURL(v);
126
+ });
107
127
  this.blobs.clear();
108
128
  this.pendingUpdates.clear();
109
129
  }
110
130
  if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) {
111
131
  const url = this.blobs.get(href);
112
132
  if(url) {
133
+ this.injector?.releaseBlobUrl?.(url);
113
134
  URL.revokeObjectURL(url);
114
135
  this.blobs.delete(href);
115
136
  this.pendingUpdates.delete(href);
@@ -129,7 +150,15 @@ export class FramePoolManager {
129
150
  const itm = pub.readingOrder.findWithHref(href);
130
151
  if(!itm) return; // TODO throw?
131
152
  if(!this.blobs.has(href)) {
132
- const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm, this.currentCssProperties);
153
+ const blobBuilder = new FrameBlobBuider(
154
+ pub,
155
+ this.currentBaseURL || "",
156
+ itm,
157
+ {
158
+ cssProperties: this.currentCssProperties,
159
+ injector: this.injector
160
+ }
161
+ );
133
162
  const blobURL = await blobBuilder.build();
134
163
  this.blobs.set(href, blobURL);
135
164
  }
@@ -6,6 +6,7 @@ import { FXLFrameManager } from "./FXLFrameManager";
6
6
  import { FXLPeripherals } from "./FXLPeripherals";
7
7
  import { FXLSpreader, Orientation, Spread } from "./FXLSpreader";
8
8
  import { VisualNavigatorViewport } from "../../Navigator";
9
+ import { Injector } from "../../injection/Injector";
9
10
 
10
11
  const UPPER_BOUNDARY = 8;
11
12
  const LOWER_BOUNDARY = 5;
@@ -26,6 +27,7 @@ export class FXLFramePoolManager {
26
27
  private readonly delayedTimeout: Map<string, number> = new Map();
27
28
  private currentBaseURL: string | undefined;
28
29
  private previousFrames: FXLFrameManager[] = [];
30
+ private readonly injector: Injector | null = null;
29
31
 
30
32
  // NEW
31
33
  private readonly bookElement: HTMLDivElement;
@@ -44,10 +46,16 @@ export class FXLFramePoolManager {
44
46
  // private readonly pages: FXLFrameManager[] = [];
45
47
  public readonly peripherals: FXLPeripherals;
46
48
 
47
- constructor(container: HTMLElement, positions: Locator[], pub: Publication) {
49
+ constructor(
50
+ container: HTMLElement,
51
+ positions: Locator[],
52
+ pub: Publication,
53
+ injector?: Injector | null
54
+ ) {
48
55
  this.container = container;
49
56
  this.positions = positions;
50
57
  this.pub = pub;
58
+ this.injector = injector ?? null;
51
59
  this.spreadPresentation = pub.metadata.otherMetadata?.spread || Spread.auto;
52
60
 
53
61
  if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr)
@@ -393,6 +401,9 @@ export class FXLFramePoolManager {
393
401
  // Revoke all blobs
394
402
  this.blobs.forEach(v => URL.revokeObjectURL(v));
395
403
 
404
+ // Clean up injector if it exists
405
+ this.injector?.dispose();
406
+
396
407
  // Empty container of elements
397
408
  this.container.childNodes.forEach(v => {
398
409
  if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove();
@@ -495,7 +506,14 @@ export class FXLFramePoolManager {
495
506
  const itm = pub.readingOrder.items[index];
496
507
  if(!itm) return; // TODO throw?
497
508
  if(!this.blobs.has(href)) {
498
- const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm);
509
+ const blobBuilder = new FrameBlobBuider(
510
+ pub,
511
+ this.currentBaseURL || "",
512
+ itm,
513
+ {
514
+ injector: this.injector
515
+ }
516
+ );
499
517
  const blobURL = await blobBuilder.build(true);
500
518
  this.blobs.set(href, blobURL);
501
519
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Utilities for processing CSS and JavaScript content
3
+ */
4
+
5
+ /**
6
+ * Minifies JavaScript by removing comments and normalizing whitespace
7
+ */
8
+ export const stripJS = (source: string) => source.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, "").replace(/\s+/g, " ");
9
+
10
+ /**
11
+ * Minifies CSS by removing comments and normalizing whitespace
12
+ * Note: URL resolution should be handled by the caller with correct context
13
+ */
14
+ export const stripCSS = (source: string) => source.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, "").replace(/ {2,}/g, " ");
package/src/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from './epub';
4
4
  export * from './audio';
5
5
  export * from './helpers';
6
6
  export * from './preferences';
7
- export * from './css';
7
+ export * from './css';
8
+ export * from './injection';
@@ -0,0 +1,85 @@
1
+ import { Link } from "@readium/shared";
2
+
3
+ type ForbiddenAttributes = "type" | "rel" | "href" | "src";
4
+ type AllowedAttributes = {
5
+ [K in string]: K extends ForbiddenAttributes
6
+ ? never
7
+ : (string | boolean | undefined);
8
+ } & {
9
+ [K in ForbiddenAttributes]?: never;
10
+ };
11
+
12
+ export interface IBaseInjectable {
13
+ id?: string;
14
+ target?: "head" | "body";
15
+ type?: string;
16
+ condition?: (doc: Document) => boolean;
17
+
18
+ // Extra attributes - type and rel are forbidden here since they are at root
19
+ attributes?: AllowedAttributes;
20
+ }
21
+
22
+ export interface IScriptInjectable extends IBaseInjectable {
23
+ as: "script";
24
+ rel?: never; // Scripts don't have rel
25
+ }
26
+
27
+ export interface ILinkInjectable extends IBaseInjectable {
28
+ as: "link";
29
+ rel: string;
30
+ }
31
+
32
+ export interface IUrlInjectable {
33
+ url: string;
34
+ }
35
+
36
+ export interface IBlobInjectable {
37
+ blob: Blob;
38
+ }
39
+
40
+ export type IInjectable = (IScriptInjectable | ILinkInjectable) & (IUrlInjectable | IBlobInjectable);
41
+
42
+ /**
43
+ * Defines a rule for resource injection, specifying which resources to inject into which documents.
44
+ */
45
+ export interface IInjectableRule {
46
+ /**
47
+ * List of resource URLs or patterns that this rule applies to.
48
+ * Can be exact URLs or patterns with wildcards.
49
+ */
50
+ resources: Array<string | RegExp>;
51
+
52
+ /**
53
+ * Resources to inject at the beginning of the target (in array order)
54
+ */
55
+ prepend?: IInjectable[];
56
+
57
+ /**
58
+ * Resources to inject at the end of the target (in array order)
59
+ */
60
+ append?: IInjectable[];
61
+ }
62
+
63
+ export interface IInjectablesConfig {
64
+ rules: IInjectableRule[];
65
+ allowedDomains?: string[];
66
+ }
67
+
68
+ export interface IInjector {
69
+ /**
70
+ * Injects resources into a document based on matching rules
71
+ * @param doc The document to inject resources into
72
+ * @param link The link being loaded, used to match against injection rules
73
+ */
74
+ injectForDocument(doc: Document, link: Link): Promise<void>;
75
+
76
+ /**
77
+ * Cleans up any resources used by the injector
78
+ */
79
+ dispose(): void;
80
+
81
+ /**
82
+ * Get the list of allowed domains
83
+ */
84
+ getAllowedDomains(): string[]
85
+ }
@@ -0,0 +1,356 @@
1
+ import { IInjectableRule, IInjectable, IInjector, IInjectablesConfig } from "./Injectable";
2
+ import { Link } from "@readium/shared";
3
+
4
+ const inferTypeFromResource = (resource: IInjectable): string | undefined => {
5
+ // If blob has a type, use it
6
+ if ("blob" in resource && resource.blob.type) {
7
+ return resource.blob.type;
8
+ }
9
+
10
+ // For scripts, default to text/javascript
11
+ if (resource.as === "script") {
12
+ return "text/javascript";
13
+ }
14
+
15
+ // For links, try to infer from URL extension
16
+ if (resource.as === "link" && "url" in resource) {
17
+ const url = resource.url.toLowerCase();
18
+ if (url.endsWith(".css")) return "text/css";
19
+ if ([".js", ".mjs", ".cjs"].some(ext => url.endsWith(ext))) return "text/javascript";
20
+ }
21
+
22
+ return undefined;
23
+ };
24
+
25
+ const applyAttributes = (element: HTMLElement, resource: IInjectable): void => {
26
+ // Apply extra attributes, filtering out root-level properties
27
+ if (resource.attributes) {
28
+ Object.entries(resource.attributes).forEach(([key, value]) => {
29
+ // Skip root-level properties to prevent conflicts
30
+ if (key === "type" || key === "rel" || key === "href" || key === "src") {
31
+ return;
32
+ }
33
+
34
+ if (value !== undefined && value !== null) {
35
+ // Convert boolean attributes to proper HTML format
36
+ if (typeof value === "boolean") {
37
+ if (value) {
38
+ element.setAttribute(key, "");
39
+ }
40
+ } else {
41
+ element.setAttribute(key, value);
42
+ }
43
+ }
44
+ });
45
+ }
46
+ };
47
+
48
+ const scriptify = (doc: Document, resource: IInjectable, source: string): HTMLScriptElement => {
49
+ const s = doc.createElement("script");
50
+ s.dataset.readium = "true";
51
+
52
+ // Set the injectable ID if provided
53
+ if (resource.id) {
54
+ s.id = resource.id;
55
+ }
56
+
57
+ // Apply root-level type if provided
58
+ const finalType = resource.type || inferTypeFromResource(resource);
59
+ if (finalType) {
60
+ s.type = finalType;
61
+ }
62
+
63
+ // Apply extra attributes
64
+ applyAttributes(s, resource);
65
+
66
+ // Always set src from the processed URL
67
+ s.src = source;
68
+
69
+ return s;
70
+ };
71
+
72
+ const linkify = (doc: Document, resource: IInjectable, source: string): HTMLLinkElement => {
73
+ const s = doc.createElement("link");
74
+ s.dataset.readium = "true";
75
+
76
+ // Set the injectable ID if provided
77
+ if (resource.id) {
78
+ s.id = resource.id;
79
+ }
80
+
81
+ // Apply root-level rel if provided
82
+ if (resource.rel) {
83
+ s.rel = resource.rel;
84
+ }
85
+
86
+ const finalType = resource.type || inferTypeFromResource(resource);
87
+ if (finalType) {
88
+ s.type = finalType;
89
+ }
90
+
91
+ // Apply extra attributes
92
+ applyAttributes(s, resource);
93
+
94
+ // Always set href from the processed URL
95
+ s.href = source;
96
+
97
+ return s;
98
+ };
99
+
100
+ export class Injector implements IInjector {
101
+ private readonly blobStore: Map<string, { url: string; refCount: number }> = new Map();
102
+ private readonly createdBlobUrls: Set<string> = new Set();
103
+ private readonly rules: IInjectableRule[];
104
+ private readonly allowedDomains: string[] = [];
105
+ private injectableIdCounter = 0;
106
+
107
+ constructor(config: IInjectablesConfig) {
108
+ // Validate allowed domains - they should be proper URLs for external resources
109
+ this.allowedDomains = (config.allowedDomains || []).map(domain => {
110
+ try {
111
+ new URL(domain);
112
+ return domain;
113
+ } catch {
114
+ throw new Error(`Invalid allowed domain: "${domain}". Must be a valid URL (e.g., "https://fonts.googleapis.com").`);
115
+ }
116
+ });
117
+
118
+ // Assign IDs to injectables that don't have them
119
+ this.rules = config.rules.map(rule => {
120
+ const processedRule: IInjectableRule = { ...rule };
121
+
122
+ // Process prepend injectables (reverse to preserve order when prepending)
123
+ if (rule.prepend) {
124
+ processedRule.prepend = rule.prepend.map(injectable => ({
125
+ ...injectable,
126
+ id: injectable.id || `injectable-${this.injectableIdCounter++}`
127
+ })).reverse(); // Reverse here so we can process normally later
128
+ }
129
+
130
+ // Process append injectables (keep original order)
131
+ if (rule.append) {
132
+ processedRule.append = rule.append.map(injectable => ({
133
+ ...injectable,
134
+ id: injectable.id || `injectable-${this.injectableIdCounter++}`
135
+ }));
136
+ }
137
+
138
+ return processedRule;
139
+ });
140
+ }
141
+
142
+ public dispose(): void {
143
+ // Cleanup any created blob URLs
144
+ for (const url of this.createdBlobUrls) {
145
+ try {
146
+ URL.revokeObjectURL(url);
147
+ } catch (error) {
148
+ console.warn("Failed to revoke blob URL:", url, error);
149
+ }
150
+ }
151
+ this.createdBlobUrls.clear();
152
+ }
153
+
154
+ public getAllowedDomains(): string[] {
155
+ return [...this.allowedDomains]; // Return a copy to prevent external modification
156
+ }
157
+
158
+ public async injectForDocument(doc: Document, link: Link): Promise<void> {
159
+ for (const rule of this.rules) {
160
+ if (this.matchesRule(rule, link)) {
161
+ await this.applyRule(doc, rule);
162
+ }
163
+ }
164
+ }
165
+
166
+ private matchesRule(rule: IInjectableRule, link: Link): boolean {
167
+ // Use the original href from the publication, not the resolved blob URL
168
+ const originalHref = link.href;
169
+
170
+ return rule.resources.some(pattern => {
171
+ if (pattern instanceof RegExp) {
172
+ return pattern.test(originalHref);
173
+ }
174
+ return originalHref === pattern;
175
+ });
176
+ }
177
+
178
+ private async getOrCreateBlobUrl(resource: IInjectable): Promise<string> {
179
+ // Use the injectable ID as the cache key
180
+ const cacheKey = resource.id!; // ID is guaranteed to exist after constructor
181
+
182
+ if (this.blobStore.has(cacheKey)) {
183
+ const entry = this.blobStore.get(cacheKey)!;
184
+ entry.refCount++;
185
+ return entry.url;
186
+ }
187
+
188
+ if ("blob" in resource) {
189
+ const url = URL.createObjectURL(resource.blob);
190
+ this.blobStore.set(cacheKey, { url, refCount: 1 });
191
+ this.createdBlobUrls.add(url);
192
+ return url;
193
+ }
194
+
195
+ throw new Error("Resource must have a blob property");
196
+ }
197
+
198
+ public async releaseBlobUrl(url: string): Promise<void> {
199
+ if (!this.createdBlobUrls.has(url)) return;
200
+
201
+ const entry = Array.from(this.blobStore.values())
202
+ .find(entry => entry.url === url);
203
+
204
+ if (entry) {
205
+ entry.refCount--;
206
+ if (entry.refCount <= 0) {
207
+ URL.revokeObjectURL(url);
208
+ this.createdBlobUrls.delete(url);
209
+ // Remove from blobStore
210
+ for (const [key, value] of this.blobStore.entries()) {
211
+ if (value.url === url) {
212
+ this.blobStore.delete(key);
213
+ break;
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ private async getResourceUrl(resource: IInjectable, doc: Document): Promise<string> {
221
+ if ("url" in resource) {
222
+ const resolvedUrl = new URL(resource.url, doc.baseURI).toString();
223
+ if (!this.isValidUrl(resolvedUrl, doc)) {
224
+ throw new Error(`Invalid URL: Only HTTPS, data:, blob:, or localhost HTTP URLs are allowed. Got: ${resource.url}`);
225
+ }
226
+ return resolvedUrl;
227
+ } else {
228
+ return this.getOrCreateBlobUrl(resource);
229
+ }
230
+ }
231
+
232
+ private createPreloadLink(doc: Document, resource: IInjectable, url: string): void {
233
+ if (resource.as !== "link" || resource.rel !== "preload") return;
234
+
235
+ // Create a new resource object with preload attributes
236
+ const preloadResource: IInjectable = {
237
+ ...resource,
238
+ rel: "preload",
239
+ attributes: {
240
+ ...resource.attributes,
241
+ as: resource.as
242
+ }
243
+ };
244
+
245
+ const preloadLink = linkify(doc, preloadResource, url);
246
+ doc.head.appendChild(preloadLink);
247
+ }
248
+
249
+ private createElement(doc: Document, resource: IInjectable, source: string): HTMLElement {
250
+ if (resource.as === "script") {
251
+ return scriptify(doc, resource, source);
252
+ }
253
+ if (resource.as === "link") {
254
+ return linkify(doc, resource, source);
255
+ }
256
+ throw new Error(`Unsupported element type: ${(resource as any).as}`);
257
+ }
258
+
259
+ private async applyRule(doc: Document, rule: IInjectableRule): Promise<void> {
260
+ const createdElements: { element: HTMLElement; url: string }[] = [];
261
+
262
+ // Collect all injectables that pass their conditions before modifying the document
263
+ const prependInjectables = rule.prepend ? rule.prepend.filter(resource =>
264
+ !resource.condition || resource.condition(doc)
265
+ ) : [];
266
+
267
+ const appendInjectables = rule.append ? rule.append.filter(resource =>
268
+ !resource.condition || resource.condition(doc)
269
+ ) : [];
270
+
271
+ try {
272
+ // Process prepend injectables first (already reversed in constructor)
273
+ for (const resource of prependInjectables) {
274
+ await this.processInjectable(resource, doc, createdElements, "prepend");
275
+ }
276
+
277
+ // Process append injectables next (in order)
278
+ for (const resource of appendInjectables) {
279
+ await this.processInjectable(resource, doc, createdElements, "append");
280
+ }
281
+ } catch (error) {
282
+ // Clean up any created elements on error
283
+ for (const { element, url } of createdElements) {
284
+ try {
285
+ element.remove();
286
+ await this.releaseBlobUrl(url);
287
+ } catch (cleanupError) {
288
+ console.error("Error during cleanup:", cleanupError);
289
+ }
290
+ }
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ private async processInjectable(
296
+ resource: IInjectable,
297
+ doc: Document,
298
+ createdElements: { element: HTMLElement; url: string }[],
299
+ position: "prepend" | "append"
300
+ ): Promise<void> {
301
+ const target = resource.target === "body" ? doc.body : doc.head;
302
+ if (!target) return;
303
+
304
+ let url: string | null = null;
305
+ try {
306
+ url = await this.getResourceUrl(resource, doc);
307
+
308
+ if (resource.rel === "preload" && "url" in resource) {
309
+ this.createPreloadLink(doc, resource, url);
310
+ } else {
311
+ const element = this.createElement(doc, resource, url);
312
+ createdElements.push({ element, url });
313
+
314
+ if (position === "prepend") {
315
+ target.prepend(element);
316
+ } else {
317
+ target.append(element);
318
+ }
319
+ }
320
+ } catch (error) {
321
+ console.error("Failed to process resource:", error);
322
+ if (url && "blob" in resource) {
323
+ await this.releaseBlobUrl(url);
324
+ }
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ private isValidUrl(url: string, doc: Document): boolean {
330
+ try {
331
+ const parsed = new URL(url, doc.baseURI);
332
+
333
+ // Allow data URLs
334
+ if (parsed.protocol === "data:") return true;
335
+
336
+ // Allow blob URLs that we created
337
+ if (parsed.protocol === "blob:" && this.createdBlobUrls.has(url)) {
338
+ return true;
339
+ }
340
+
341
+ // Check against allowed domains if any are specified
342
+ if (this.allowedDomains.length > 0) {
343
+ const origin = parsed.origin;
344
+ return this.allowedDomains.some(allowed => {
345
+ const allowedOrigin = new URL(allowed).origin;
346
+ return origin === allowedOrigin;
347
+ });
348
+ }
349
+
350
+ // No allowed domains specified - deny external URLs
351
+ return false;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
356
+ }