diff --git a/README.md b/README.md index 49f26c7a..fa709ca4 100644 --- a/README.md +++ b/README.md @@ -1285,6 +1285,21 @@ window.optable.cmd = new OptableCommands(window.optable.cmd || []); For the page-side stub and behaviour details, see the [command queue addon README](lib/addons/commands.md). +## Static mappings + +The static mappings addon applies a wrapper's customer configuration defaults onto `window.optable`, so values a publisher sets on the page before the wrapper script loads always win. Plain-object config sections merge recursively; any other default only fills in a value the publisher left `null` or `undefined`. + +```typescript +import { setStaticMappings } from "@optable/web-sdk/lib/dist/addons/staticMappings"; + +setStaticMappings({ + defaultSite: "customer-sdk", + analytics: { tenant: "customer", sample: 0.1 }, +}); +``` + +For the merge rules and a full wrapper example, see the [static mappings addon README](lib/addons/staticMappings.md). + ## Demo Pages The demo pages are working examples of both `identify` and `targeting` APIs, as well as an integration with the [Google Ad Manager 360](https://admanager.google.com/home/) ad server, enabling the targeting of ads served by GAM360 to audiences activated in the [Optable](https://optable.co/) DCN. diff --git a/lib/addons/staticMappings.md b/lib/addons/staticMappings.md new file mode 100644 index 00000000..4a9e8fdb --- /dev/null +++ b/lib/addons/staticMappings.md @@ -0,0 +1,40 @@ +# Static Mappings Addon + +Applies a wrapper's customer configuration defaults onto `window.optable`, so values a publisher sets on the page before the wrapper script loads always win. + +## Usage + +The wrapper passes its customer defaults; the addon fills in whatever the publisher left unset: + +```js +import { setStaticMappings } from "@optable/web-sdk/lib/dist/addons/staticMappings"; + +setStaticMappings({ + defaultSite: "customer-sdk", + node: "customer-node", + analytics: { + tenant: "customer", + sample: 0.1, + pbjsObjectName: "pbjs", + }, + withID5: true, +}); + +// Values that are unconditional or derived from other keys stay in the wrapper: +window.optable.wrapperVersion = SDK_WRAPPER_VERSION; +window.optable.site = window.optable.site ?? window.optable.defaultSite; +window.optable.analytics.pbjsObject = + window.optable.analytics.pbjsObject ?? window[window.optable.analytics.pbjsObjectName]; +``` + +`window.optable` is created if the page did not define it. + +## Merge rules + +- When the existing value and the default are both plain objects, they merge recursively, so nested config like `analytics.*` defaults key by key. +- Everything else is a leaf: the default is assigned as-is, and only when the current value is `null` or `undefined`. An absent object default is therefore assigned by reference, not cloned. +- A publisher value that is not a plain object is never recursed into or replaced by an object default. + +Object values that are not config sections — a prebid global, for instance — should be assigned caller-side (like `pbjsObject` above) rather than passed as defaults, so a publisher-supplied object is never merged with the default one. + +A default therefore never overrides an explicit publisher value. A publisher setting `sample = 0` or an empty string keeps it, and `0`, `""` and `false` all survive. diff --git a/lib/addons/staticMappings.test.ts b/lib/addons/staticMappings.test.ts new file mode 100644 index 00000000..762f3c38 --- /dev/null +++ b/lib/addons/staticMappings.test.ts @@ -0,0 +1,65 @@ +import { setStaticMappings } from "./staticMappings"; + +const w = window as unknown as { optable?: any }; + +beforeEach(() => { + delete w.optable; +}); + +describe("setStaticMappings", () => { + it("creates w.optable and fills top-level and nested defaults", () => { + setStaticMappings({ + site: "customer-sdk", + analytics: { tenant: "customer", sample: 0.1 }, + }); + expect(w.optable.site).toBe("customer-sdk"); + expect(w.optable.analytics).toEqual({ tenant: "customer", sample: 0.1 }); + }); + + it("leaves publisher-set values alone, including falsy ones", () => { + w.optable = { + site: "publisher-site", + withID5: false, + analytics: { sample: 0, tenant: "" }, + }; + setStaticMappings({ + site: "customer-sdk", + withID5: true, + analytics: { sample: 0.1, tenant: "customer", pbjsObjectName: "pbjs" }, + }); + expect(w.optable.site).toBe("publisher-site"); + expect(w.optable.withID5).toBe(false); + expect(w.optable.analytics.sample).toBe(0); + expect(w.optable.analytics.tenant).toBe(""); + expect(w.optable.analytics.pbjsObjectName).toBe("pbjs"); + }); + + it("replaces null values with defaults", () => { + w.optable = { node: null }; + setStaticMappings({ node: "a" }); + expect(w.optable.node).toBe("a"); + }); + + it("treats arrays as leaves and assigns absent object defaults by reference", () => { + const pbjs = { que: [] }; + w.optable = { prebidInstances: ["oajs"] }; + setStaticMappings({ + prebidInstances: ["pbjs"], + analytics: { pbjsObject: pbjs }, + }); + expect(w.optable.prebidInstances).toEqual(["oajs"]); + expect(w.optable.analytics.pbjsObject).toBe(pbjs); + }); + + it("does not recurse into a publisher value that is not a plain object", () => { + w.optable = { analytics: "off" }; + setStaticMappings({ analytics: { tenant: "customer" } }); + expect(w.optable.analytics).toBe("off"); + }); + + it("replaces a non-object window.optable instead of throwing", () => { + w.optable = "clobbered"; + setStaticMappings({ site: "customer-sdk" }); + expect(w.optable).toEqual({ site: "customer-sdk" }); + }); +}); diff --git a/lib/addons/staticMappings.ts b/lib/addons/staticMappings.ts new file mode 100644 index 00000000..ed1733a4 --- /dev/null +++ b/lib/addons/staticMappings.ts @@ -0,0 +1,30 @@ +// Applies a wrapper's config defaults onto window.optable, so publisher-set +// values always win. When both sides are plain objects they merge recursively, +// covering nested config like analytics.*. Anything else is assigned as-is, +// and only when the current value is null or undefined, so publisher overrides +// of 0, "" and false survive. +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype; +} + +function applyDefaults(target: Record, defaults: Record): void { + for (const key of Object.keys(defaults)) { + const def = defaults[key]; + const cur = target[key]; + if (isPlainObject(def) && isPlainObject(cur)) { + applyDefaults(cur, def); + } else if (cur === null || cur === undefined) { + target[key] = def; + } + } +} + +function setStaticMappings(defaults: Record): void { + const w = window as unknown as { optable?: Record }; + if (typeof w.optable !== "object" || w.optable === null) { + w.optable = {}; + } + applyDefaults(w.optable, defaults); +} + +export { setStaticMappings };