Repository: duckduckgo/content-scope-scripts
Pull request #1545: [ContentFeature] ConfigFeature class: de-decouple build specific code from content-features for re-usability
Author: login: dbajpeyi
Created: 2025-03-05T15:14:52Z | Merged: 2025-03-13T09:43:56Z

**Asana Task/Github Issue:** <!-- Link to Asana Task/Github Issue --> https://app.asana.com/0/72649045549333/1209507178584805

## Description

<!--
  Provide a terse summary of what the change is, detailed descriptions should belong in ship reviews or tech designs
-->
1. Move out feature setting parsing logic to a `ConfigFeature` class, which allows outside packages to re-use functionality like `getFeatureSetting`, without having to implement the whole `ContentFeature`
2. Move out all `import.meta` code from the core code, and pass it as `importConfig` in the `content-scope-features.js` class


## Testing Steps


## Checklist

<!--
  These questions are a friendly reminder to shipping code, if you're uncertain ask the AoR owners.
  It's also totally appropriate to not check some of these boxes, if they don't apply to your change.
-->
*Please tick all that apply:*

- [ ] I have tested this change locally
- [ ] I have tested this change locally in all supported browsers
- [ ] This change will be visible to users
- [ ] I have added automated tests that cover this change
- [ ] I have ensured the change is gated by config
- [ ] This change was covered by a ship review
- [x] This change was covered by a tech design
- [ ] Any dependent config has been merged




Diff:
diff --git a/injected/entry-points/android.js b/injected/entry-points/android.js
index 6e54c96d93d..89b525c6b5e 100644
--- a/injected/entry-points/android.js
+++ b/injected/entry-points/android.js
@@ -3,7 +3,6 @@
  */
 import { load, init } from '../src/content-scope-features.js';
 import { processConfig, isGloballyDisabled } from './../src/utils';
-import { isTrackerOrigin } from '../src/trackers';
 import { AndroidMessagingConfig } from '../../messaging/index.js';
 
 function initCode() {
@@ -33,8 +32,6 @@ function initCode() {
 
     load({
         platform: processedConfig.platform,
-        trackerLookup: processedConfig.trackerLookup,
-        documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup),
         site: processedConfig.site,
         bundledConfig: processedConfig.bundledConfig,
         messagingConfig: processedConfig.messagingConfig,
diff --git a/injected/entry-points/apple.js b/injected/entry-points/apple.js
index 556b0480a17..efd3df274a4 100644
--- a/injected/entry-points/apple.js
+++ b/injected/entry-points/apple.js
@@ -3,7 +3,6 @@
  */
 import { load, init } from '../src/content-scope-features.js';
 import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils';
-import { isTrackerOrigin } from '../src/trackers';
 import { WebkitMessagingConfig, TestTransportConfig } from '../../messaging/index.js';
 
 function initCode() {
@@ -44,8 +43,6 @@ function initCode() {
 
     load({
         platform: processedConfig.platform,
-        trackerLookup: processedConfig.trackerLookup,
-        documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup),
         site: processedConfig.site,
         bundledConfig: processedConfig.bundledConfig,
         messagingConfig: processedConfig.messagingConfig,
diff --git a/injected/entry-points/chrome.js b/injected/entry-points/chrome.js
index ebae03cd2a9..8225c90199b 100644
--- a/injected/entry-points/chrome.js
+++ b/injected/entry-points/chrome.js
@@ -1,7 +1,6 @@
 /**
  * @module Chrome integration
  */
-import { isTrackerOrigin } from '../src/trackers';
 import { computeLimitedSiteObject } from '../src/utils';
 
 /**
@@ -39,8 +38,6 @@ function randomString() {
 }
 
 function init() {
-    const trackerLookup = import.meta.trackerLookup;
-    const documentOriginIsTracker = isTrackerOrigin(trackerLookup);
     // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
     const bundledConfig = $BUNDLED_CONFIG$;
     const randomMethodName = '_d' + randomString();
@@ -54,9 +51,7 @@ function init() {
           platform: {
               name: 'extension'
           },
-          trackerLookup: ${JSON.stringify(trackerLookup)},
           site: ${JSON.stringify(siteObject)},
-          documentOriginIsTracker: ${documentOriginIsTracker},
           bundledConfig: ${JSON.stringify(bundledConfig)}
       })
       // Define a random function we call later.
diff --git a/injected/entry-points/extension-mv3.js b/injected/entry-points/extension-mv3.js
index 57d17c47377..89b68a26a84 100644
--- a/injected/entry-points/extension-mv3.js
+++ b/injected/entry-points/extension-mv3.js
@@ -2,19 +2,14 @@
  * @module main world integration for Chrome MV3 and Firefox (enhanced) MV2
  */
 import { load, init, update } from '../src/content-scope-features.js';
-import { isTrackerOrigin } from '../src/trackers.js';
 import { computeLimitedSiteObject } from '../src/utils.js';
 
 const secret = (crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32).toString().replace('0.', '');
 
-const trackerLookup = import.meta.trackerLookup;
-
 load({
     platform: {
         name: 'extension',
     },
-    trackerLookup,
-    documentOriginIsTracker: isTrackerOrigin(trackerLookup),
     site: computeLimitedSiteObject(),
     // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
     bundledConfig: $BUNDLED_CONFIG$,
diff --git a/injected/entry-points/integration.js b/injected/entry-points/integration.js
index fe43e49e7bc..833b6863c80 100644
--- a/injected/entry-points/integration.js
+++ b/injected/entry-points/integration.js
@@ -1,5 +1,4 @@
 import { load, init } from '../src/content-scope-features.js';
-import { isTrackerOrigin } from '../src/trackers';
 import { TestTransportConfig } from '../../messaging/index.js';
 function getTopLevelURL() {
     try {
@@ -17,7 +16,6 @@ function getTopLevelURL() {
 
 function generateConfig() {
     const topLevelUrl = getTopLevelURL();
-    const trackerLookup = import.meta.trackerLookup;
     return {
         debug: false,
         sessionKey: 'randomVal',
@@ -30,7 +28,6 @@ function generateConfig() {
             allowlisted: false,
             enabledFeatures: ['fingerprintingCanvas', 'fingerprintingScreenSize', 'navigatorInterface', 'cookie'],
         },
-        trackerLookup,
     };
 }
 
@@ -84,12 +81,12 @@ async function initCode() {
             };
         },
     });
+
     load({
         // @ts-expect-error Types of property 'name' are incompatible.
         platform: processedConfig.platform,
-        trackerLookup: processedConfig.trackerLookup,
-        documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup),
         site: processedConfig.site,
+        bundledConfig: processedConfig.bundledConfig,
         messagingConfig: processedConfig.messagingConfig,
     });
 
diff --git a/injected/entry-points/windows.js b/injected/entry-points/windows.js
index 213066ee6da..04fa9dc20e6 100644
--- a/injected/entry-points/windows.js
+++ b/injected/entry-points/windows.js
@@ -3,7 +3,6 @@
  */
 import { load, init } from '../src/content-scope-features.js';
 import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils';
-import { isTrackerOrigin } from '../src/trackers';
 import { WindowsMessagingConfig } from '../../messaging/index.js';
 
 function initCode() {
@@ -31,8 +30,6 @@ function initCode() {
 
     load({
         platform: processedConfig.platform,
-        trackerLookup: processedConfig.trackerLookup,
-        documentOriginIsTracker: isTrackerOrigin(processedConfig.trackerLookup),
         site: processedConfig.site,
         bundledConfig: processedConfig.bundledConfig,
         messagingConfig: processedConfig.messagingConfig,
diff --git a/injected/src/config-feature.js b/injected/src/config-feature.js
new file mode 100644
index 00000000000..10a65cd406f
--- /dev/null
+++ b/injected/src/config-feature.js
@@ -0,0 +1,159 @@
+import { immutableJSONPatch } from 'immutable-json-patch';
+import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
+
+export default class ConfigFeature {
+    /** @type {import('./utils.js').RemoteConfig | undefined} */
+    #bundledConfig;
+
+    /** @type {string} */
+    name;
+
+    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: import('./content-feature.js').AssetConfig | undefined, site: import('./content-feature.js').Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
+    #args;
+
+    /**
+     * @param {string} name
+     * @param {import('./content-scope-features.js').LoadArgs} args
+     */
+    constructor(name, args) {
+        this.name = name;
+        const { bundledConfig, site, platform } = args;
+        this.#bundledConfig = bundledConfig;
+        this.#args = args;
+        // If we have a bundled config, treat it as a regular config
+        // This will be overriden by the remote config if it is available
+        if (this.#bundledConfig && this.#args) {
+            const enabledFeatures = computeEnabledFeatures(bundledConfig, site.domain, platform.version);
+            this.#args.featureSettings = parseFeatureSettings(bundledConfig, enabledFeatures);
+        }
+    }
+
+    get args() {
+        return this.#args;
+    }
+
+    set args(args) {
+        this.#args = args;
+    }
+
+    get featureSettings() {
+        return this.#args?.featureSettings;
+    }
+
+    /**
+     * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
+     * Consider using patchSettings instead as per `getFeatureSetting`.
+     * @param {string} featureKeyName
+     * @return {any[]}
+     * @protected
+     */
+    matchDomainFeatureSetting(featureKeyName) {
+        const domain = this.args?.site.domain;
+        if (!domain) return [];
+        const domains = this._getFeatureSettings()?.[featureKeyName] || [];
+        return domains.filter((rule) => {
+            if (Array.isArray(rule.domain)) {
+                return rule.domain.some((domainRule) => {
+                    return matchHostname(domain, domainRule);
+                });
+            }
+            return matchHostname(domain, rule.domain);
+        });
+    }
+
+    /**
+     * Return the settings object for a feature
+     * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
+     * @returns {any}
+     */
+    _getFeatureSettings(featureName) {
+        const camelFeatureName = featureName || camelcase(this.name);
+        return this.featureSettings?.[camelFeatureName];
+    }
+
+    /**
+     * For simple boolean settings, return true if the setting is 'enabled'
+     * For objects, verify the 'state' field is 'enabled'.
+     * This allows for future forwards compatibility with more complex settings if required.
+     * For example:
+     * ```json
+     * {
+     *    "toggle": "enabled"
+     * }
+     * ```
+     * Could become later (without breaking changes):
+     * ```json
+     * {
+     *   "toggle": {
+     *       "state": "enabled",
+     *       "someOtherKey": 1
+     *   }
+     * }
+     * ```
+     * This also supports domain overrides as per `getFeatureSetting`.
+     * @param {string} featureKeyName
+     * @param {string} [featureName]
+     * @returns {boolean}
+     */
+    getFeatureSettingEnabled(featureKeyName, featureName) {
+        const result = this.getFeatureSetting(featureKeyName, featureName);
+        if (typeof result === 'object') {
+            return result.state === 'enabled';
+        }
+        return result === 'enabled';
+    }
+
+    /**
+      * Return a specific setting from the feature settings
+      * If the "settings" key within the config has a "domains" key, it will be used to override the settings.
+      * This uses JSONPatch to apply the patches to settings before getting the setting value.
+      * For example.com getFeatureSettings('val') will return 1:
+      * ```json
+      *  {
+      *      "settings": {
+      *         "domains": [
+      *             {
+      *                "domain": "example.com",
+      *                "patchSettings": [
+      *                    { "op": "replace", "path": "/val", "value": 1 }
+      *                ]
+      *             }
+      *         ]
+      *      }
+      *  }
+      * ```
+      * "domain" can either be a string or an array of strings.
+    
+      * For boolean states you should consider using getFeatureSettingEnabled.
+      * @param {string} featureKeyName
+      * @param {string} [featureName]
+      * @returns {any}
+    */
+    getFeatureSetting(featureKeyName, featureName) {
+        let result = this._getFeatureSettings(featureName);
+        if (featureKeyName === 'domains') {
+            throw new Error('domains is a reserved feature setting key name');
+        }
+        const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
+            return a.domain.length - b.domain.length;
+        });
+        for (const match of domainMatch) {
+            if (match.patchSettings === undefined) {
+                continue;
+            }
+            try {
+                result = immutableJSONPatch(result, match.patchSettings);
+            } catch (e) {
+                console.error('Error applying patch settings', e);
+            }
+        }
+        return result?.[featureKeyName];
+    }
+
+    /**
+     * @returns {import('./utils.js').RemoteConfig | undefined}
+     **/
+    get bundledConfig() {
+        return this.#bundledConfig;
+    }
+}
diff --git a/injected/src/content-feature.js b/injected/src/content-feature.js
index 9cec5ffb7b5..02137065ace 100644
--- a/injected/src/content-feature.js
+++ b/injected/src/content-feature.js
@@ -1,11 +1,12 @@
-import { camelcase, matchHostname, processAttr, computeEnabledFeatures, parseFeatureSettings } from './utils.js';
-import { immutableJSONPatch } from 'immutable-json-patch';
+import { processAttr } from './utils.js';
 import { PerformanceMonitor } from './performance.js';
 import { defineProperty, shimInterface, shimProperty, wrapMethod, wrapProperty, wrapToString } from './wrapper-utils.js';
 // eslint-disable-next-line no-redeclare
 import { Proxy, Reflect } from './captured-globals.js';
 import { Messaging, MessagingContext } from '../../messaging/index.js';
 import { extensionConstructMessagingConfig } from './sendmessage-transport.js';
+import { isTrackerOrigin } from './trackers.js';
+import ConfigFeature from './config-feature.js';
 
 /**
  * @typedef {object} AssetConfig
@@ -21,41 +22,34 @@ import { extensionConstructMessagingConfig } from './sendmessage-transport.js';
  * @property {string[]} [enabledFeatures]
  */
 
-export default class ContentFeature {
+export default class ContentFeature extends ConfigFeature {
     /** @type {import('./utils.js').RemoteConfig | undefined} */
-    #bundledConfig;
-    /** @type {object | undefined} */
-    #trackerLookup;
-    /** @type {boolean | undefined} */
-    #documentOriginIsTracker;
-    /** @type {Record<string, unknown> | undefined} */
-    // eslint-disable-next-line no-unused-private-class-members
-    #bundledfeatureSettings;
     /** @type {import('../../messaging').Messaging} */
     // eslint-disable-next-line no-unused-private-class-members
     #messaging;
     /** @type {boolean} */
     #isDebugFlagSet = false;
 
-    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
-    #args;
+    /** @type {ImportMeta} */
+    #importConfig;
 
-    constructor(featureName) {
-        this.name = featureName;
-        this.#args = null;
+    constructor(featureName, importConfig, args) {
+        super(featureName, args);
+        this.setArgs(this.args);
         this.monitor = new PerformanceMonitor();
+        this.#importConfig = importConfig;
     }
 
     get isDebug() {
-        return this.#args?.debug || false;
+        return this.args?.debug || false;
     }
 
     get desktopModeEnabled() {
-        return this.#args?.desktopModeEnabled || false;
+        return this.args?.desktopModeEnabled || false;
     }
 
     get forcedZoomEnabled() {
-        return this.#args?.forcedZoomEnabled || false;
+        return this.args?.forcedZoomEnabled || false;
     }
 
     /**
@@ -74,28 +68,28 @@ export default class ContentFeature {
      * @type {AssetConfig | undefined}
      */
     get assetConfig() {
-        return this.#args?.assets;
+        return this.args?.assets;
     }
 
     /**
-     * @returns {boolean}
-     */
-    get documentOriginIsTracker() {
-        return !!this.#documentOriginIsTracker;
+     * @returns {ImportMeta['trackerLookup']}
+     **/
+    get trackerLookup() {
+        return this.#importConfig.trackerLookup || {};
     }
 
     /**
-     * @returns {object}
-     **/
-    get trackerLookup() {
-        return this.#trackerLookup || {};
+     * @returns {ImportMeta['injectName']}
+     */
+    get injectName() {
+        return this.#importConfig.injectName;
     }
 
     /**
-     * @returns {import('./utils.js').RemoteConfig | undefined}
-     **/
-    get bundledConfig() {
-        return this.#bundledConfig;
+     * @returns {boolean}
+     */
+    get documentOriginIsTracker() {
+        return isTrackerOrigin(this.trackerLookup);
     }
 
     /**
@@ -103,8 +97,7 @@ export default class ContentFeature {
      * @return {MessagingContext}
      */
     _createMessagingContext() {
-        const injectName = import.meta.injectName;
-        const contextName = injectName === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts';
+        const contextName = this.injectName === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts';
 
         return new MessagingContext({
             context: contextName,
@@ -121,7 +114,7 @@ export default class ContentFeature {
     get messaging() {
         if (this._messaging) return this._messaging;
         const messagingContext = this._createMessagingContext();
-        let messagingConfig = this.#args?.messagingConfig;
+        let messagingConfig = this.args?.messagingConfig;
         if (!messagingConfig) {
             if (this.platform?.name !== 'extension') throw new Error('Only extension messaging supported, all others should be passed in');
             messagingConfig = extensionConstructMessagingConfig();
@@ -144,127 +137,22 @@ export default class ContentFeature {
         return processAttr(configSetting, defaultValue);
     }
 
-    /**
-     * Return a specific setting from the feature settings
-     * If the "settings" key within the config has a "domains" key, it will be used to override the settings.
-     * This uses JSONPatch to apply the patches to settings before getting the setting value.
-     * For example.com getFeatureSettings('val') will return 1:
-     * ```json
-     *  {
-     *      "settings": {
-     *         "domains": [
-     *             {
-     *                "domain": "example.com",
-     *                "patchSettings": [
-     *                    { "op": "replace", "path": "/val", "value": 1 }
-     *                ]
-     *             }
-     *         ]
-     *      }
-     *  }
-     * ```
-     * "domain" can either be a string or an array of strings.
-
-     * For boolean states you should consider using getFeatureSettingEnabled.
-     * @param {string} featureKeyName
-     * @param {string} [featureName]
-     * @returns {any}
-     */
-    getFeatureSetting(featureKeyName, featureName) {
-        let result = this._getFeatureSettings(featureName);
-        if (featureKeyName === 'domains') {
-            throw new Error('domains is a reserved feature setting key name');
-        }
-        const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
-            return a.domain.length - b.domain.length;
-        });
-        for (const match of domainMatch) {
-            if (match.patchSettings === undefined) {
-                continue;
-            }
-            try {
-                result = immutableJSONPatch(result, match.patchSettings);
-            } catch (e) {
-                console.error('Error applying patch settings', e);
-            }
-        }
-        return result?.[featureKeyName];
-    }
-
-    /**
-     * Return the settings object for a feature
-     * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
-     * @returns {any}
-     */
-    _getFeatureSettings(featureName) {
-        const camelFeatureName = featureName || camelcase(this.name);
-        return this.#args?.featureSettings?.[camelFeatureName];
-    }
-
-    /**
-     * For simple boolean settings, return true if the setting is 'enabled'
-     * For objects, verify the 'state' field is 'enabled'.
-     * This allows for future forwards compatibility with more complex settings if required.
-     * For example:
-     * ```json
-     * {
-     *    "toggle": "enabled"
-     * }
-     * ```
-     * Could become later (without breaking changes):
-     * ```json
-     * {
-     *   "toggle": {
-     *       "state": "enabled",
-     *       "someOtherKey": 1
-     *   }
-     * }
-     * ```
-     * This also supports domain overrides as per `getFeatureSetting`.
-     * @param {string} featureKeyName
-     * @param {string} [featureName]
-     * @returns {boolean}
-     */
-    getFeatureSettingEnabled(featureKeyName, featureName) {
-        const result = this.getFeatureSetting(featureKeyName, featureName);
-        if (typeof result === 'object') {
-            return result.state === 'enabled';
-        }
-        return result === 'enabled';
-    }
-
-    /**
-     * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
-     * Consider using patchSettings instead as per `getFeatureSetting`.
-     * @param {string} featureKeyName
-     * @return {any[]}
-     * @private
-     */
-    matchDomainFeatureSetting(featureKeyName) {
-        const domain = this.#args?.site.domain;
-        if (!domain) return [];
-        const domains = this._getFeatureSettings()?.[featureKeyName] || [];
-        return domains.filter((rule) => {
-            if (Array.isArray(rule.domain)) {
-                return rule.domain.some((domainRule) => {
-                    return matchHostname(domain, domainRule);
-                });
-            }
-            return matchHostname(domain, rule.domain);
-        });
-    }
-
     init(args) {}
 
     callInit(args) {
         const mark = this.monitor.mark(this.name + 'CallInit');
-        this.#args = args;
-        this.platform = args.platform;
-        this.init(args);
+        this.setArgs(args);
+        // Passing this.args is legacy here and features should use this.args or other properties directly
+        this.init(this.args);
         mark.end();
         this.measure();
     }
 
+    setArgs(args) {
+        this.args = args;
+        this.platform = args.platform;
+    }
+
     load(args) {}
 
     /**
@@ -306,32 +194,21 @@ export default class ContentFeature {
         return this.messaging.subscribe(name, cb);
     }
 
-    /**
-     * @param {import('./content-scope-features.js').LoadArgs} args
-     */
-    callLoad(args) {
+    callLoad() {
         const mark = this.monitor.mark(this.name + 'CallLoad');
-        this.#args = args;
-        this.platform = args.platform;
-        this.#bundledConfig = args.bundledConfig;
-        // If we have a bundled config, treat it as a regular config
-        // This will be overriden by the remote config if it is available
-        if (this.#bundledConfig && this.#args) {
-            const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version);
-            this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures);
-        }
-        this.#trackerLookup = args.trackerLookup;
-        this.#documentOriginIsTracker = args.documentOriginIsTracker;
-        this.load(args);
+        this.load(this.args);
         mark.end();
     }
 
     measure() {
-        if (this.#args?.debug) {
+        if (this.isDebug) {
             this.monitor.measureAll();
         }
     }
 
+    /**
+     * @deprecated - use messaging instead.
+     */
     update() {}
 
     /**
@@ -401,7 +278,7 @@ export default class ContentFeature {
      * @param {import('./wrapper-utils').DefineInterfaceOptions} options
      */
     shimInterface(interfaceName, ImplClass, options) {
-        return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this));
+        return shimInterface(interfaceName, ImplClass, options, this.defineProperty.bind(this), this.injectName);
     }
 
     /**
@@ -416,6 +293,6 @@ export default class ContentFeature {
      * @param {boolean} [readOnly] - whether the property should be read-only (default: false)
      */
     shimProperty(instanceHost, instanceProp, implInstance, readOnly = false) {
-        return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this));
+        return shimProperty(instanceHost, instanceProp, implInstance, readOnly, this.defineProperty.bind(this), this.injectName);
     }
 }
diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js
index ee8db2fcc6f..57dc1433c00 100644
--- a/injected/src/content-scope-features.js
+++ b/injected/src/content-scope-features.js
@@ -22,10 +22,7 @@ const isHTMLDocument =
  * @typedef {object} LoadArgs
  * @property {import('./content-feature').Site} site
  * @property {import('./utils.js').Platform} platform
- * @property {boolean} documentOriginIsTracker
  * @property {import('./utils.js').RemoteConfig} bundledConfig
- * @property {string} [injectName]
- * @property {object} trackerLookup - provided currently only by the extension
  * @property {import('@duckduckgo/messaging').MessagingConfig} [messagingConfig]
  * @property {string} [messageSecret] - optional, used in the messageBridge creation
  */
@@ -39,12 +36,17 @@ export function load(args) {
         return;
     }
 
-    const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : [];
+    const importConfig = {
+        trackerLookup: import.meta.trackerLookup,
+        injectName: import.meta.injectName,
+    };
+
+    const featureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : [];
 
     for (const featureName of featureNames) {
         const ContentFeature = platformFeatures['ddg_feature_' + featureName];
-        const featureInstance = new ContentFeature(featureName);
-        featureInstance.callLoad(args);
+        const featureInstance = new ContentFeature(featureName, importConfig, args);
+        featureInstance.callLoad();
         features.push({ featureName, featureInstance });
     }
     mark.end();
diff --git a/injected/src/features/duck-player.js b/injected/src/features/duck-player.js
index 4c973971313..14998fce6c1 100644
--- a/injected/src/features/duck-player.js
+++ b/injected/src/features/duck-player.js
@@ -94,7 +94,7 @@ export default class DuckPlayerFeature extends ContentFeature {
 
         const locale = args?.locale || args?.language || 'en';
         const env = new Environment({
-            debug: args.debug,
+            debug: this.isDebug,
             injectName: import.meta.injectName,
             platform: this.platform,
             locale,
@@ -107,10 +107,6 @@ export default class DuckPlayerFeature extends ContentFeature {
             comms.serpProxy();
         }
     }
-
-    load(args) {
-        super.load(args);
-    }
 }
 
 /**
diff --git a/injected/src/features/element-hiding.js b/injected/src/features/element-hiding.js
index 84974ff627e..ded7c38a2d9 100644
--- a/injected/src/features/element-hiding.js
+++ b/injected/src/features/element-hiding.js
@@ -320,12 +320,10 @@ export default class ElementHiding extends ContentFeature {
 
         // determine whether strict hide rules should be injected as a style tag
         if (shouldInjectStyleTag) {
-            // @ts-expect-error: Accessing private method
             shouldInjectStyleTag = this.matchDomainFeatureSetting('styleTagExceptions').length === 0;
         }
 
         // collect all matching rules for domain
-        // @ts-expect-error: Accessing private method
         const activeDomainRules = this.matchDomainFeatureSetting('domains').flatMap((item) => item.rules);
 
         const overrideRules = activeDomainRules.filter((rule) => {
diff --git a/injected/src/features/navigator-interface.js b/injected/src/features/navigator-interface.js
index 9591a391654..57843d3abd0 100644
--- a/injected/src/features/navigator-interface.js
+++ b/injected/src/features/navigator-interface.js
@@ -4,7 +4,6 @@ import { createPageWorldBridge } from './message-bridge/create-page-world-bridge
 
 export default class NavigatorInterface extends ContentFeature {
     load(args) {
-        // @ts-expect-error: Accessing private method
         if (this.matchDomainFeatureSetting('privilegedDomains').length) {
             this.injectNavigatorInterface(args);
         }
diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js
index d7615641026..c3c66825231 100644
--- a/injected/src/features/web-compat.js
+++ b/injected/src/features/web-compat.js
@@ -463,7 +463,7 @@ export class WebCompat extends ContentFeature {
 
     mediaSessionFix() {
         try {
-            if (window.navigator.mediaSession && import.meta.injectName !== 'integration') {
+            if (window.navigator.mediaSession && this.injectName !== 'integration') {
                 return;
             }
 
@@ -509,7 +509,7 @@ export class WebCompat extends ContentFeature {
     presentationFix() {
         try {
             // @ts-expect-error due to: Property 'presentation' does not exist on type 'Navigator'
-            if (window.navigator.presentation && import.meta.injectName !== 'integration') {
+            if (window.navigator.presentation && this.injectName !== 'integration') {
                 return;
             }
 
diff --git a/injected/src/globals.d.ts b/injected/src/globals.d.ts
index cb14b677af3..e0e65302b06 100644
--- a/injected/src/globals.d.ts
+++ b/injected/src/globals.d.ts
@@ -44,6 +44,9 @@ declare module '*.riv' {
 }
 
 declare module 'ddg:platformFeatures' {
-    const output: Record<string, new (featureName: string) => import('./content-feature').default>;
+    const output: Record<
+        string,
+        new (featureName: string, importConfig: ImportConfig, args: LoadArgs) => import('./content-feature').default
+    >;
     export default output;
 }
diff --git a/injected/src/utils.js b/injected/src/utils.js
index 2f7800d240b..d046d27b9ac 100644
--- a/injected/src/utils.js
+++ b/injected/src/utils.js
@@ -642,7 +642,6 @@ export function processConfig(data, userList, preferences, platformSpecificFeatu
 
     // Copy feature settings from remote config to preferences object
     output.featureSettings = parseFeatureSettings(data, enabledFeatures);
-    output.trackerLookup = import.meta.trackerLookup;
     output.bundledConfig = data;
 
     return output;
diff --git a/injected/src/wrapper-utils.js b/injected/src/wrapper-utils.js
index 9d8bdf27803..ae33f697cea 100644
--- a/injected/src/wrapper-utils.js
+++ b/injected/src/wrapper-utils.js
@@ -193,9 +193,10 @@ export function wrapMethod(object, propertyName, wrapperFn, definePropertyFn) {
  * @param {typeof globalThis[StandardInterfaceName]} ImplClass - the class to use as the shim implementation
  * @param {DefineInterfaceOptions} options - options for defining the interface
  * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property
+ * @param {ImportMeta['injectName']} [injectName] - the name of the inject to use for the shim
  */
-export function shimInterface(interfaceName, ImplClass, options, definePropertyFn) {
-    if (import.meta.injectName === 'integration') {
+export function shimInterface(interfaceName, ImplClass, options, definePropertyFn, injectName) {
+    if (injectName === 'integration') {
         if (!globalThis.origInterfaceDescriptors) globalThis.origInterfaceDescriptors = {};
         const descriptor = Object.getOwnPropertyDescriptor(globalThis, interfaceName);
         globalThis.origInterfaceDescriptors[interfaceName] = descriptor;
@@ -270,7 +271,7 @@ export function shimInterface(interfaceName, ImplClass, options, definePropertyF
         }
     }
 
-    if (import.meta.injectName === 'integration') {
+    if (injectName === 'integration') {
         // mark the class as a shimmed class
         // we do it only in test mode, to avoid potential side effects in production. See the playwright test in integration-test/test-pages/webcompat/pages/shims.html
         definePropertyFn(ImplClass, ddgShimMark, {
@@ -304,12 +305,13 @@ export function shimInterface(interfaceName, ImplClass, options, definePropertyF
  * @param {Base[K]} implInstance - instance to use as the shim (e.g. new MyMediaSession())
  * @param {boolean} readOnly - whether the property should be read-only
  * @param {DefinePropertyFn} definePropertyFn - function to use for defining the property
+ * @param {ImportMeta['injectName']} [injectName] - the name of the inject to use for the shim
  */
-export function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn) {
+export function shimProperty(baseObject, propertyName, implInstance, readOnly, definePropertyFn, injectName) {
     // @ts-expect-error - implInstance is a class instance
     const ImplClass = implInstance.constructor;
 
-    if (import.meta.injectName === 'integration') {
+    if (injectName === 'integration') {
         if (!globalThis.origPropDescriptors) globalThis.origPropDescriptors = [];
         const descriptor = Object.getOwnPropertyDescriptor(baseObject, propertyName);
         globalThis.origPropDescriptors.push([baseObject, propertyName, descriptor]);
diff --git a/injected/unit-test/content-feature.js b/injected/unit-test/content-feature.js
index f9e28737ea2..88d5cf67f55 100644
--- a/injected/unit-test/content-feature.js
+++ b/injected/unit-test/content-feature.js
@@ -12,8 +12,8 @@ describe('ContentFeature class', () => {
                 didRun = true;
             }
         }
-        const me = new MyTestFeature('test');
-        me.callInit({
+
+        const args = {
             site: {
                 domain: 'beep.example.com',
             },
@@ -42,7 +42,9 @@ describe('ContentFeature class', () => {
                     ],
                 },
             },
-        });
+        };
+        const me = new MyTestFeature('test', {}, args);
+        me.callInit(args);
         expect(didRun).withContext('Should run').toBeTrue();
     });
 
@@ -56,7 +58,7 @@ describe('ContentFeature class', () => {
         }
         let feature;
         beforeEach(() => {
-            feature = new MyTestFeature('someFeatureName');
+            feature = new MyTestFeature('someFeatureName', {}, {});
         });
 
         it('should not send duplicate flags', () => {
@@ -85,7 +87,7 @@ describe('ContentFeature class', () => {
         }
         let feature;
         beforeEach(() => {
-            feature = new MyTestFeature('someFeatureName');
+            feature = new MyTestFeature('someFeatureName', {}, {});
         });
 
         it('should add debug flag to value descriptors', () => {
diff --git a/injected/unit-test/utils.js b/injected/unit-test/utils.js
index 68acaf7a70a..7c0ba602f1a 100644
--- a/injected/unit-test/utils.js
+++ b/injected/unit-test/utils.js
@@ -89,8 +89,6 @@ describe('Helpers checks', () => {
             },
             versionNumber: 99,
             sessionKey: 'testSessionKey',
-            // import.meta.trackerLookup is undefined because we've not overloaded it
-            trackerLookup: undefined,
             bundledConfig: configIn,
         });
     });
@@ -169,7 +167,6 @@ describe('Helpers checks', () => {
             },
             versionString: '0.9.9',
             sessionKey: 'testSessionKey',
-            trackerLookup: undefined,
             bundledConfig: configIn,
         });
     });
diff --git a/injected/unit-test/verify-artifacts.js b/injected/unit-test/verify-artifacts.js
index 144d12970eb..ce4e1697d01 100644
--- a/injected/unit-test/verify-artifacts.js
+++ b/injected/unit-test/verify-artifacts.js
@@ -17,10 +17,7 @@ if (process.platform === 'win32') {
 const checks = {
     android: {
         file: join(BUILD, 'android/contentScope.js'),
-        tests: [
-            { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE },
-            { kind: 'containsString', text: 'output.trackerLookup = {', includes: true },
-        ],
+        tests: [{ kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }],
     },
     chrome: {
         file: join(BUILD, 'chrome/inject.js'),
@@ -45,20 +42,16 @@ const checks = {
     },
     integration: {
         file: join(BUILD, 'integration/contentScope.js'),
-        tests: [{ kind: 'containsString', text: 'const trackerLookup = {', includes: true }],
+        tests: [{ kind: 'containsString', text: 'trackerLookup: {', includes: true }],
     },
     windows: {
         file: join(BUILD, 'windows/contentScope.js'),
-        tests: [
-            { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE },
-            { kind: 'containsString', text: 'output.trackerLookup = {', includes: true },
-        ],
+        tests: [{ kind: 'maxFileSize', value: CSS_OUTPUT_SIZE }],
     },
     apple: {
         file: join(APPLE_BUILD, 'contentScope.js'),
         tests: [
             { kind: 'maxFileSize', value: CSS_OUTPUT_SIZE },
-            { kind: 'containsString', text: 'output.trackerLookup = {', includes: true },
             { kind: 'containsString', text: '#bundledConfig', includes: false },
         ],
     },
@@ -77,7 +70,6 @@ describe('checks', () => {
             if (check.kind === 'containsString') {
                 it(`${platformName}: '${localPath}' contains ${check.text}`, () => {
                     const fileContents = readFileSync(platformChecks.file).toString();
-                    // @ts-expect-error - can't infer that value is a number without adding types
                     const includes = fileContents.includes(check.text);
                     if (check.includes) {
                         expect(includes).toBeTrue();


Reviews:
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:34:05Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:36:00Z
user: login: dbajpeyi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:37:56Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:39:52Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:29:33Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:53:43Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:54:47Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:03:14Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:04:29Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:05:14Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:05:25Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:07:43Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:08:43Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:09:34Z
user: login: jonathanKingston
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:10:23Z
user: login: dbajpeyi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T12:04:37Z
user: login: dbajpeyi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T12:05:45Z
user: login: dbajpeyi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T12:25:34Z
user: login: shakyShane
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T08:32:07Z

Review comments:
diff_hunk: @@ -31,10 +30,14 @@ function initCode() {
         debug: processedConfig.debug,
     });
 
+    const importConfig = {
path: injected/entry-points/android.js
user: login: jonathanKingston
body: Maybe move this into content-scope-features to remove the need for it being in all files?
created_at: 2025-03-05T17:34:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -463,7 +463,7 @@ export class WebCompat extends ContentFeature {
 
     mediaSessionFix() {
         try {
-            if (window.navigator.mediaSession && import.meta.injectName !== 'integration') {
+            if (window.navigator.mediaSession && this.injectName !== 'integration') {
path: injected/src/features/web-compat.js
user: login: jonathanKingston
body: Should this be `this.importConfig.injectName`?
created_at: 2025-03-05T17:36:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -463,7 +463,7 @@ export class WebCompat extends ContentFeature {
 
     mediaSessionFix() {
         try {
-            if (window.navigator.mediaSession && import.meta.injectName !== 'integration') {
+            if (window.navigator.mediaSession && this.injectName !== 'integration') {
path: injected/src/features/web-compat.js
user: login: dbajpeyi
body: There's a getter in the base class.
created_at: 2025-03-05T17:37:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,7 +37,7 @@ export function load(args) {
         return;
     }
 
-    const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : [];
path: injected/src/content-scope-features.js
user: login: jonathanKingston
body: In the for loop below I think you'll need to pass in importConfig into the ContentFeature, probably to the constructor.
created_at: 2025-03-05T17:39:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,41 +22,196 @@ import { extensionConstructMessagingConfig } from './sendmessage-transport.js';
  * @property {string[]} [enabledFeatures]
  */
 
-export default class ContentFeature {
+class ConfigParser {
     /** @type {import('./utils.js').RemoteConfig | undefined} */
     #bundledConfig;
-    /** @type {object | undefined} */
-    #trackerLookup;
-    /** @type {boolean | undefined} */
-    #documentOriginIsTracker;
-    /** @type {Record<string, unknown> | undefined} */
-    // eslint-disable-next-line no-unused-private-class-members
-    #bundledfeatureSettings;
+
+    /** @type {any} */
+    name;
+
+    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
+    #args;
+
+    /**
+     * @param {any} name
+     */
+    constructor(name) {
+        this.name = name;
+    }
+
+    get args() {
+        return this.#args;
+    }
+
+    set args(args) {
+        this.#args = args;
+    }
+
+    get featureSettings() {
+        return this.#args?.featureSettings;
+    }
+
+    /**
+     * @param {import('./content-scope-features.js').LoadArgs} loadArgs
+     */
+    initLoadArgs(loadArgs) {
+        const { bundledConfig, site, platform } = loadArgs;
+        this.#bundledConfig = bundledConfig;
+        this.#args = loadArgs;
+        // If we have a bundled config, treat it as a regular config
+        // This will be overriden by the remote config if it is available
+        if (this.#bundledConfig && this.#args) {
+            const enabledFeatures = computeEnabledFeatures(bundledConfig, site.domain, platform.version);
+            this.#args.featureSettings = parseFeatureSettings(bundledConfig, enabledFeatures);
+        }
+    }
+
+    /**
+     * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
+     * Consider using patchSettings instead as per `getFeatureSetting`.
+     * @param {string} featureKeyName
+     * @return {any[]}
+     * @protected
+     */
+    matchDomainFeatureSetting(featureKeyName) {
+        const domain = this.args?.site.domain;
+        if (!domain) return [];
+        const domains = this._getFeatureSettings()?.[featureKeyName] || [];
+        return domains.filter((rule) => {
+            if (Array.isArray(rule.domain)) {
+                return rule.domain.some((domainRule) => {
+                    return matchHostname(domain, domainRule);
+                });
+            }
+            return matchHostname(domain, rule.domain);
+        });
+    }
+
+    /**
+     * Return the settings object for a feature
+     * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
+     * @returns {any}
+     */
+    _getFeatureSettings(featureName) {
+        const camelFeatureName = featureName || camelcase(this.name);
+        return this.featureSettings?.[camelFeatureName];
+    }
+
+    /**
+     * For simple boolean settings, return true if the setting is 'enabled'
+     * For objects, verify the 'state' field is 'enabled'.
+     * This allows for future forwards compatibility with more complex settings if required.
+     * For example:
+     * ```json
+     * {
+     *    "toggle": "enabled"
+     * }
+     * ```
+     * Could become later (without breaking changes):
+     * ```json
+     * {
+     *   "toggle": {
+     *       "state": "enabled",
+     *       "someOtherKey": 1
+     *   }
+     * }
+     * ```
+     * This also supports domain overrides as per `getFeatureSetting`.
+     * @param {string} featureKeyName
+     * @param {string} [featureName]
+     * @returns {boolean}
+     */
+    getFeatureSettingEnabled(featureKeyName, featureName) {
+        const result = this.getFeatureSetting(featureKeyName, featureName);
+        if (typeof result === 'object') {
+            return result.state === 'enabled';
+        }
+        return result === 'enabled';
+    }
+
+    /**
+      * Return a specific setting from the feature settings
+      * If the "settings" key within the config has a "domains" key, it will be used to override the settings.
+      * This uses JSONPatch to apply the patches to settings before getting the setting value.
+      * For example.com getFeatureSettings('val') will return 1:
+      * ```json
+      *  {
+      *      "settings": {
+      *         "domains": [
+      *             {
+      *                "domain": "example.com",
+      *                "patchSettings": [
+      *                    { "op": "replace", "path": "/val", "value": 1 }
+      *                ]
+      *             }
+      *         ]
+      *      }
+      *  }
+      * ```
+      * "domain" can either be a string or an array of strings.
+    
+      * For boolean states you should consider using getFeatureSettingEnabled.
+      * @param {string} featureKeyName
+      * @param {string} [featureName]
+      * @returns {any}
+    */
+    getFeatureSetting(featureKeyName, featureName) {
+        let result = this._getFeatureSettings(featureName);
+        if (featureKeyName === 'domains') {
+            throw new Error('domains is a reserved feature setting key name');
+        }
+        const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
+            return a.domain.length - b.domain.length;
+        });
+        for (const match of domainMatch) {
+            if (match.patchSettings === undefined) {
+                continue;
+            }
+            try {
+                result = immutableJSONPatch(result, match.patchSettings);
+            } catch (e) {
+                console.error('Error applying patch settings', e);
+            }
+        }
+        return result?.[featureKeyName];
+    }
+
+    /**
+     * @returns {import('./utils.js').RemoteConfig | undefined}
+     **/
+    get bundledConfig() {
+        return this.#bundledConfig;
+    }
+}
+
+export default class ContentFeature extends ConfigParser {
+    /** @type {import('./utils.js').RemoteConfig | undefined} */
     /** @type {import('../../messaging').Messaging} */
     // eslint-disable-next-line no-unused-private-class-members
     #messaging;
     /** @type {boolean} */
     #isDebugFlagSet = false;
 
-    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
-    #args;
+    /** @type {ImportMeta} */
+    #importConfig;
 
-    constructor(featureName) {
-        this.name = featureName;
-        this.#args = null;
+    constructor(featureName, importConfig) {
+        super(featureName);
+        this.args = null;
path: injected/src/content-feature.js
user: login: jonathanKingston
body: ```suggestion
        this.#args = null;
```
created_at: 2025-03-06T14:29:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,11 +36,16 @@ export function load(args) {
         return;
     }
 
-    const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : [];
+    const importConfig = {
+        trackerLookup: import.meta.trackerLookup,
+        injectName: import.meta.injectName,
+    };
+
+    const featureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : [];
 
     for (const featureName of featureNames) {
         const ContentFeature = platformFeatures['ddg_feature_' + featureName];
-        const featureInstance = new ContentFeature(featureName);
+        const featureInstance = new ContentFeature(featureName, importConfig);
         featureInstance.callLoad(args);
path: injected/src/content-scope-features.js
user: login: jonathanKingston
body: ```suggestion
        const featureInstance = new ContentFeature(featureName, importConfig, args);
        featureInstance.callLoad();
```


Later on:
```
featureInstance.callInit(args);
```
created_at: 2025-03-06T14:53:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,11 +36,16 @@ export function load(args) {
         return;
     }
 
-    const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : [];
+    const importConfig = {
+        trackerLookup: import.meta.trackerLookup,
+        injectName: import.meta.injectName,
+    };
+
+    const featureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : [];
 
     for (const featureName of featureNames) {
         const ContentFeature = platformFeatures['ddg_feature_' + featureName];
-        const featureInstance = new ContentFeature(featureName);
+        const featureInstance = new ContentFeature(featureName, importConfig);
         featureInstance.callLoad(args);
path: injected/src/content-scope-features.js
user: login: jonathanKingston
body: This change would allow us to move the initLoadArgs it super constructor.
created_at: 2025-03-06T14:54:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -54,7 +54,6 @@ function init() {
           platform: {
               name: 'extension'
           },
-          trackerLookup: ${JSON.stringify(trackerLookup)},
           site: ${JSON.stringify(siteObject)},
           documentOriginIsTracker: ${documentOriginIsTracker},
path: injected/entry-points/chrome.js
user: login: jonathanKingston
body: ```suggestion
```
created_at: 2025-03-06T15:03:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,41 +22,34 @@ import { extensionConstructMessagingConfig } from './sendmessage-transport.js';
  * @property {string[]} [enabledFeatures]
  */
 
-export default class ContentFeature {
+export default class ContentFeature extends ConfigFeature {
     /** @type {import('./utils.js').RemoteConfig | undefined} */
-    #bundledConfig;
-    /** @type {object | undefined} */
-    #trackerLookup;
-    /** @type {boolean | undefined} */
-    #documentOriginIsTracker;
-    /** @type {Record<string, unknown> | undefined} */
-    // eslint-disable-next-line no-unused-private-class-members
-    #bundledfeatureSettings;
     /** @type {import('../../messaging').Messaging} */
     // eslint-disable-next-line no-unused-private-class-members
     #messaging;
     /** @type {boolean} */
     #isDebugFlagSet = false;
 
-    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: AssetConfig | undefined, site: Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
-    #args;
+    /** @type {ImportMeta} */
+    #importConfig;
 
-    constructor(featureName) {
-        this.name = featureName;
-        this.#args = null;
+    constructor(featureName, importConfig) {
+        super(featureName);
path: injected/src/content-feature.js
user: login: jonathanKingston
body: ```suggestion
    constructor(featureName, importConfig, args) {
        super(featureName, args);
```
created_at: 2025-03-06T15:04:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,164 @@
+import { immutableJSONPatch } from 'immutable-json-patch';
+import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
+
+export default class ConfigFeature {
+    /** @type {import('./utils.js').RemoteConfig | undefined} */
+    #bundledConfig;
+
+    /** @type {any} */
+    name;
+
+    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: import('./content-feature.js').AssetConfig | undefined, site: import('./content-feature.js').Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
+    #args;
+
+    /**
+     * @param {any} name
+     */
+    constructor(name) {
+        this.name = name;
+    }
+
+    get args() {
+        return this.#args;
+    }
+
+    set args(args) {
+        this.#args = args;
+    }
+
+    get featureSettings() {
+        return this.#args?.featureSettings;
+    }
+
+    /**
+     * @param {import('./content-scope-features.js').LoadArgs} loadArgs
+     */
+    initLoadArgs(loadArgs) {
+        const { bundledConfig, site, platform } = loadArgs;
+        this.#bundledConfig = bundledConfig;
+        this.#args = loadArgs;
+        // If we have a bundled config, treat it as a regular config
+        // This will be overriden by the remote config if it is available
+        if (this.#bundledConfig && this.#args) {
+            const enabledFeatures = computeEnabledFeatures(bundledConfig, site.domain, platform.version);
+            this.#args.featureSettings = parseFeatureSettings(bundledConfig, enabledFeatures);
+        }
+    }
path: injected/src/config-feature.js
user: login: jonathanKingston
body: All this moves into the constructor.
created_at: 2025-03-06T15:05:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,164 @@
+import { immutableJSONPatch } from 'immutable-json-patch';
+import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
+
+export default class ConfigFeature {
+    /** @type {import('./utils.js').RemoteConfig | undefined} */
+    #bundledConfig;
+
+    /** @type {any} */
+    name;
+
+    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: import('./content-feature.js').AssetConfig | undefined, site: import('./content-feature.js').Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
+    #args;
+
+    /**
+     * @param {any} name
+     */
+    constructor(name) {
path: injected/src/config-feature.js
user: login: jonathanKingston
body: ```suggestion
    constructor(name, args) {
```
created_at: 2025-03-06T15:05:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,121 +137,11 @@ export default class ContentFeature {
         return processAttr(configSetting, defaultValue);
     }
 
-    /**
-     * Return a specific setting from the feature settings
-     * If the "settings" key within the config has a "domains" key, it will be used to override the settings.
-     * This uses JSONPatch to apply the patches to settings before getting the setting value.
-     * For example.com getFeatureSettings('val') will return 1:
-     * ```json
-     *  {
-     *      "settings": {
-     *         "domains": [
-     *             {
-     *                "domain": "example.com",
-     *                "patchSettings": [
-     *                    { "op": "replace", "path": "/val", "value": 1 }
-     *                ]
-     *             }
-     *         ]
-     *      }
-     *  }
-     * ```
-     * "domain" can either be a string or an array of strings.
-
-     * For boolean states you should consider using getFeatureSettingEnabled.
-     * @param {string} featureKeyName
-     * @param {string} [featureName]
-     * @returns {any}
-     */
-    getFeatureSetting(featureKeyName, featureName) {
-        let result = this._getFeatureSettings(featureName);
-        if (featureKeyName === 'domains') {
-            throw new Error('domains is a reserved feature setting key name');
-        }
-        const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
-            return a.domain.length - b.domain.length;
-        });
-        for (const match of domainMatch) {
-            if (match.patchSettings === undefined) {
-                continue;
-            }
-            try {
-                result = immutableJSONPatch(result, match.patchSettings);
-            } catch (e) {
-                console.error('Error applying patch settings', e);
-            }
-        }
-        return result?.[featureKeyName];
-    }
-
-    /**
-     * Return the settings object for a feature
-     * @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
-     * @returns {any}
-     */
-    _getFeatureSettings(featureName) {
-        const camelFeatureName = featureName || camelcase(this.name);
-        return this.#args?.featureSettings?.[camelFeatureName];
-    }
-
-    /**
-     * For simple boolean settings, return true if the setting is 'enabled'
-     * For objects, verify the 'state' field is 'enabled'.
-     * This allows for future forwards compatibility with more complex settings if required.
-     * For example:
-     * ```json
-     * {
-     *    "toggle": "enabled"
-     * }
-     * ```
-     * Could become later (without breaking changes):
-     * ```json
-     * {
-     *   "toggle": {
-     *       "state": "enabled",
-     *       "someOtherKey": 1
-     *   }
-     * }
-     * ```
-     * This also supports domain overrides as per `getFeatureSetting`.
-     * @param {string} featureKeyName
-     * @param {string} [featureName]
-     * @returns {boolean}
-     */
-    getFeatureSettingEnabled(featureKeyName, featureName) {
-        const result = this.getFeatureSetting(featureKeyName, featureName);
-        if (typeof result === 'object') {
-            return result.state === 'enabled';
-        }
-        return result === 'enabled';
-    }
-
-    /**
-     * Given a config key, interpret the value as a list of domain overrides, and return the elements that match the current page
-     * Consider using patchSettings instead as per `getFeatureSetting`.
-     * @param {string} featureKeyName
-     * @return {any[]}
-     * @private
-     */
-    matchDomainFeatureSetting(featureKeyName) {
-        const domain = this.#args?.site.domain;
-        if (!domain) return [];
-        const domains = this._getFeatureSettings()?.[featureKeyName] || [];
-        return domains.filter((rule) => {
-            if (Array.isArray(rule.domain)) {
-                return rule.domain.some((domainRule) => {
-                    return matchHostname(domain, domainRule);
-                });
-            }
-            return matchHostname(domain, rule.domain);
-        });
-    }
-
     init(args) {}
 
     callInit(args) {
         const mark = this.monitor.mark(this.name + 'CallInit');
-        this.#args = args;
+        this.args = args;
path: injected/src/content-feature.js
user: login: jonathanKingston
body: This becomes a method called `setArgs`. That we call from the constructor and `callInit`. We remove these two lines from `callLoad`.
created_at: 2025-03-06T15:07:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,23 +194,15 @@ export default class ContentFeature {
      */
     callLoad(args) {
         const mark = this.monitor.mark(this.name + 'CallLoad');
-        this.#args = args;
+        this.args = args;
         this.platform = args.platform;
-        this.#bundledConfig = args.bundledConfig;
-        // If we have a bundled config, treat it as a regular config
-        // This will be overriden by the remote config if it is available
-        if (this.#bundledConfig && this.#args) {
-            const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version);
-            this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures);
-        }
-        this.#trackerLookup = args.trackerLookup;
-        this.#documentOriginIsTracker = args.documentOriginIsTracker;
+        this.initLoadArgs(args);
path: injected/src/content-feature.js
user: login: jonathanKingston
body: This initLoadArgs becomes part of the constructor (just calling super with the args).
created_at: 2025-03-06T15:08:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,23 +194,15 @@ export default class ContentFeature {
      */
     callLoad(args) {
         const mark = this.monitor.mark(this.name + 'CallLoad');
-        this.#args = args;
+        this.args = args;
         this.platform = args.platform;
-        this.#bundledConfig = args.bundledConfig;
-        // If we have a bundled config, treat it as a regular config
-        // This will be overriden by the remote config if it is available
-        if (this.#bundledConfig && this.#args) {
-            const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version);
-            this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures);
-        }
-        this.#trackerLookup = args.trackerLookup;
-        this.#documentOriginIsTracker = args.documentOriginIsTracker;
+        this.initLoadArgs(args);
         this.load(args);
path: injected/src/content-feature.js
user: login: jonathanKingston
body: ```suggestion
        this.load(this.#args);
```
created_at: 2025-03-06T15:09:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -311,23 +194,15 @@ export default class ContentFeature {
      */
     callLoad(args) {
         const mark = this.monitor.mark(this.name + 'CallLoad');
-        this.#args = args;
+        this.args = args;
         this.platform = args.platform;
-        this.#bundledConfig = args.bundledConfig;
-        // If we have a bundled config, treat it as a regular config
-        // This will be overriden by the remote config if it is available
-        if (this.#bundledConfig && this.#args) {
-            const enabledFeatures = computeEnabledFeatures(args.bundledConfig, args.site.domain, this.platform.version);
-            this.#args.featureSettings = parseFeatureSettings(args.bundledConfig, enabledFeatures);
-        }
-        this.#trackerLookup = args.trackerLookup;
-        this.#documentOriginIsTracker = args.documentOriginIsTracker;
+        this.initLoadArgs(args);
         this.load(args);
path: injected/src/content-feature.js
user: login: jonathanKingston
body: (this passing in is legacy btw, I think there's very few uses of it).
created_at: 2025-03-06T15:10:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,10 +30,14 @@ function initCode() {
         debug: processedConfig.debug,
     });
 
+    const importConfig = {
path: injected/entry-points/android.js
user: login: dbajpeyi
body: Done!
created_at: 2025-03-10T12:04:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,7 +37,7 @@ export function load(args) {
         return;
     }
 
-    const featureNames = typeof import.meta.injectName === 'string' ? platformSupport[import.meta.injectName] : [];
path: injected/src/content-scope-features.js
user: login: dbajpeyi
body: Done! 
created_at: 2025-03-10T12:05:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,164 @@
+import { immutableJSONPatch } from 'immutable-json-patch';
+import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
+
+export default class ConfigFeature {
+    /** @type {import('./utils.js').RemoteConfig | undefined} */
+    #bundledConfig;
+
+    /** @type {any} */
+    name;
+
+    /** @type {{ debug?: boolean, desktopModeEnabled?: boolean, forcedZoomEnabled?: boolean, featureSettings?: Record<string, unknown>, assets?: import('./content-feature.js').AssetConfig | undefined, site: import('./content-feature.js').Site, messagingConfig?: import('@duckduckgo/messaging').MessagingConfig } | null} */
+    #args;
+
+    /**
+     * @param {any} name
+     */
+    constructor(name) {
+        this.name = name;
+    }
+
+    get args() {
+        return this.#args;
+    }
+
+    set args(args) {
+        this.#args = args;
+    }
+
+    get featureSettings() {
+        return this.#args?.featureSettings;
+    }
+
+    /**
+     * @param {import('./content-scope-features.js').LoadArgs} loadArgs
+     */
+    initLoadArgs(loadArgs) {
+        const { bundledConfig, site, platform } = loadArgs;
+        this.#bundledConfig = bundledConfig;
+        this.#args = loadArgs;
+        // If we have a bundled config, treat it as a regular config
+        // This will be overriden by the remote config if it is available
+        if (this.#bundledConfig && this.#args) {
+            const enabledFeatures = computeEnabledFeatures(bundledConfig, site.domain, platform.version);
+            this.#args.featureSettings = parseFeatureSettings(bundledConfig, enabledFeatures);
+        }
+    }
path: injected/src/config-feature.js
user: login: dbajpeyi
body: Moved to config feature constructor!
created_at: 2025-03-10T12:25:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: netlify[bot]
created_at: 2025-03-05T15:15:10Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *content-scope-scripts* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | c3d8d698a004d106906ec91bdc6436d9fddaddaf |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/content-scope-scripts/deploys/67d297051b095e00085434d8 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-1545--content-scope-scripts.netlify.app](https://deploy-preview-1545--content-scope-scripts.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTE1NDUtLWNvbnRlbnQtc2NvcGUtc2NyaXB0cy5uZXRsaWZ5LmFwcCJ9.wuRH4Zb6NwO7BNowoO0-C1to4caXuz8cEpZe0GsbNig)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [content-scope-scripts Preview](https://deploy-preview-1545--content-scope-scripts.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/content-scope-scripts/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-05T15:15:37Z
body: ### Temporary Branch Update

The temporary branch has been updated with the latest changes. Below are the details:

  - **Branch Name**: [pr-releases/pr-1545](https://github.com/duckduckgo/content-scope-scripts/tree/pr-releases/pr-1545)
  - **Commit Hash**: [d36d17d80a1bdc4603a724c1f643e0f6fd7ebb22](https://github.com/duckduckgo/content-scope-scripts/commit/d36d17d80a1bdc4603a724c1f643e0f6fd7ebb22)
  - **Install Command**: `npm i github:duckduckgo/content-scope-scripts#d36d17d80a1bdc4603a724c1f643e0f6fd7ebb22`

  Please use the above install command to update to the latest version.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-05T15:15:54Z
body: ### *[Beta]* Generated file diff
*Time updated:* Thu, 13 Mar 2025 08:28:45 GMT

<details open>
<summary>Android</summary>

        - android/autofillPasswordImport.js
- android/brokerProtection.js
- android/contentScope.js


File has changed
</details>
<details open>
<summary>Chrome</summary>

        - chrome/inject.js


File has changed
</details>
<details open>
<summary>Chrome-mv3</summary>

        - chrome-mv3/inject.js


File has changed
</details>
<details open>
<summary>Firefox</summary>

        - firefox/inject.js


File has changed
</details>
<details >
<summary>Integration</summary>

        - integration/contentScope.js


File has changed
</details>
<details open>
<summary>Windows</summary>

        - windows/contentScope.js


File has changed
</details>
<details open>
<summary>Apple</summary>

        - dist/contentScope.js
- dist/contentScopeIsolated.js


File has changed
</details>
author_association: CONTRIBUTOR
user: login: dbajpeyi
created_at: 2025-03-10T12:54:32Z
body: @jonathanKingston I've addressed the comments. Note that the references are `this.args` are unique now, because the base class has a setter. Please let me know if it makes sense.

PS: Integration test seems to be broken, but not sure why. I am investigating.
author_association: CONTRIBUTOR
user: login: jonathanKingston
created_at: 2025-03-13T00:10:05Z
body: @shakyShane this reshuffle starts simplifying some of the application structure. It breaks content-feature into two classes, one which autofill can then depend on or use in isolation.

I suggest when Deep gets back we add more testing on this interface so we can add it as a testable interface rather than just letting this barrier to autofill to rot.

This largely is a transitional step, I don't think we want to reside with args wiring still being so complex (i'd like to break the dependency of features heavily relying on them and instead use methods to access that data).

This also simplifies the load and init arguments mostly as a requirement to remove the use of import.meta which I think we agree now was a bit of a mistake. We just then use it at the periphery and don't require autofill to need custom transpiling. importConfig is the transport object for anything that's import.meta related through into the features.

I've reviewed most of this, I'd appreciate a quick look over in case I missed anything. The 4 additional small tiny commits I made just got the integration tests working from a missed setting of args in a method.
author_association: CONTRIBUTOR
user: login: shakyShane
created_at: 2025-03-13T08:31:47Z
body: @jonathanKingston I pushed a commit to fix a type, as mentioned in asana I tested this with complex integrations like duck.ai etc.
author_association: CONTRIBUTOR
user: login: shakyShane
created_at: 2025-03-13T08:49:29Z
body: * **#1565** <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1565?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1521** <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1521?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1545** <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1545?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/duckduckgo/content-scope-scripts/1545?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR