Skip to content

Commit 5263b53

Browse files
authored
Merge pull request #96 from Stackla/introduce-widget-handling-via-import
feat: add embed functionality with support for multiple widget versions
2 parents 0b5cc48 + 8555a39 commit 5263b53

File tree

10 files changed

+265
-6
lines changed

10 files changed

+265
-6
lines changed

.github/workflows/main.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- uses: actions/checkout@v2
3131
- uses: actions/setup-node@v1
3232
with:
33-
node-version: 20
33+
node-version: 23.6.0
3434
- name: Install
3535
run: npm install
3636
- name: Build

.nvmrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20
1+
23.6.0

esbuild.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const defaultConfig = {
99
jsx: "automatic",
1010
outdir: "dist/esm",
1111
sourcemap: false,
12-
treeShaking: true,
12+
treeShaking: true
1313
}
1414

1515
// Build ESM

package.json

+8-3
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@
6262
"types": "./dist/esm/events/index.d.ts",
6363
"require": "./dist/cjs/events/index.js"
6464
},
65-
"./icons": "./dist/styles/_icons.scss"
65+
"./embed": {
66+
"import": "./dist/esm/embed.js",
67+
"types": "./dist/esm/embed.d.ts",
68+
"require": "./dist/cjs/embed.js"
69+
}
6670
},
6771
"scripts": {
6872
"eslint": "eslint src/.",
@@ -105,15 +109,16 @@
105109
"identity-obj-proxy": "^3.0.0",
106110
"jest": "^29.7.0",
107111
"jest-environment-jsdom": "^29.7.0",
112+
"jest-fetch-mock": "^3.0.3",
108113
"sass": "^1.80.6",
109114
"stylelint": "^16.10.0",
110115
"stylelint-config-standard": "^36.0.1",
111116
"stylelint-config-standard-scss": "^13.1.0",
112117
"stylelint-prettier": "^5.0.2",
113118
"stylelint-scss": "^6.8.1",
119+
"swiper": "^11.1.14",
114120
"ts-jest": "^29.2.5",
115-
"typescript": "^5.6.3",
116-
"swiper": "^11.1.14"
121+
"typescript": "^5.7.3"
117122
},
118123
"files": [
119124
"dist"

src/constants.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const STAGING_LEGACY_WIDGET_URL = "assetscdn.teaser.stackla.com"
2+
export const PRODUCTION_LEGACY_WIDGET_URL = "assetscdn.stackla.com"
3+
4+
export const STAGING_DATA_URL = "https://widget-data.teaser.stackla.com"
5+
export const STAGING_UI_URL = "https://widget-ui.teaser.stackla.com"
6+
7+
export const PRODUCTION_DATA_URL = "https://widget-data.stackla.com"
8+
export const PRODUCTION_UI_URL = "https://widget-ui.stackla.com"

src/embed/embed.params.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const generateDataHTMLStringByParams = (params: Record<string, string | boolean | number>): string => {
2+
return Object.entries(params)
3+
.map(([key, value]) => ` data-${encodeURIComponent(key)}="${encodeURIComponent(value)}"`)
4+
.join("")
5+
}

src/embed/embed.spec.ts

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/* eslint-disable no-var */
2+
import { embed } from "."
3+
import fetchMock from "jest-fetch-mock"
4+
import { getWidgetV2EmbedCode } from "./v2"
5+
import { getWidgetV3EmbedCode } from "./v3"
6+
import { generateDataHTMLStringByParams } from "./embed.params"
7+
8+
fetchMock.enableMocks()
9+
10+
const REQUEST_URL = "https://widget-data.stackla.com/123/version"
11+
12+
describe("load embed code", () => {
13+
beforeEach(() => {
14+
fetchMock.resetMocks()
15+
})
16+
17+
it("should return the correct embed code for v2", async () => {
18+
fetchMock.mockIf(REQUEST_URL, async () => {
19+
return JSON.stringify({ version: "2" })
20+
})
21+
22+
const createdDiv = document.createElement("div")
23+
await embed({
24+
widgetId: "123",
25+
root: createdDiv,
26+
dataProperties: {
27+
foo: "bar",
28+
baz: 123
29+
},
30+
environment: "production"
31+
})
32+
33+
expect(createdDiv.innerHTML).toBe(getWidgetV2EmbedCode({ foo: "bar", baz: 123 }, "production"))
34+
})
35+
36+
it("should return the correct embed code for v3", async () => {
37+
fetchMock.mockIf(REQUEST_URL, async () => {
38+
return JSON.stringify({ version: "3" })
39+
})
40+
41+
const createdDiv = document.createElement("div")
42+
await embed({
43+
widgetId: "123",
44+
root: createdDiv,
45+
dataProperties: {
46+
foo: "bar",
47+
baz: 123
48+
},
49+
environment: "production"
50+
})
51+
52+
expect(createdDiv.innerHTML).toBe(getWidgetV3EmbedCode({ foo: "bar", baz: 123 }, "production"))
53+
})
54+
55+
it("should throw an error if the version is not supported", async () => {
56+
fetchMock.mockIf(REQUEST_URL, async () => {
57+
return JSON.stringify({ version: "4" })
58+
})
59+
60+
const createdDiv = document.createElement("div")
61+
try {
62+
await embed({
63+
widgetId: "123",
64+
root: createdDiv,
65+
dataProperties: {
66+
foo: "bar",
67+
baz: 123
68+
},
69+
environment: "production"
70+
})
71+
} catch (error) {
72+
expect(error).toBe("Failed to embed widget. No widget code accessible with version 4")
73+
}
74+
})
75+
76+
it("should skip the fetch call if the version is provided", async () => {
77+
const createdDiv = document.createElement("div")
78+
await embed({
79+
widgetId: "123",
80+
root: createdDiv,
81+
version: "3",
82+
dataProperties: {
83+
foo: "bar",
84+
baz: 123
85+
},
86+
environment: "production"
87+
})
88+
89+
expect(fetchMock).not.toHaveBeenCalled()
90+
expect(createdDiv.innerHTML).toBe(getWidgetV3EmbedCode({ foo: "bar", baz: 123 }, "production"))
91+
})
92+
93+
it("should test param string method", async () => {
94+
const params = generateDataHTMLStringByParams({ foo: "bar", baz: 123 })
95+
96+
expect(params).toBe(' data-foo="bar" data-baz="123"')
97+
})
98+
99+
it("should deal with malicious payloads", async () => {
100+
const createdDiv = document.createElement("div")
101+
await embed({
102+
widgetId: "123",
103+
root: createdDiv,
104+
version: "3",
105+
dataProperties: {
106+
foo: "bar",
107+
baz: 123,
108+
'><img src="x" onerror="alert(1)">': '"><img src="x" onerror="alert(1)">'
109+
},
110+
environment: "production"
111+
})
112+
113+
expect(fetchMock).not.toHaveBeenCalled()
114+
expect(createdDiv.innerHTML).toContain(
115+
`<div id="ugc-widget" data-foo="bar" data-baz="123" data-%3e%3cimg%20src%3d%22x%22%20onerror%3d%22alert(1)%22%3e="%22%3E%3Cimg%20src%3D%22x%22%20onerror%3D%22alert(1)%22%3E"></div>`
116+
)
117+
})
118+
})

src/embed/index.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { STAGING_DATA_URL, PRODUCTION_DATA_URL } from "../constants"
2+
import { getWidgetV2EmbedCode } from "./v2"
3+
import { getWidgetV3EmbedCode } from "./v3"
4+
5+
export type Environment = "staging" | "production"
6+
type Generation = "2" | "3"
7+
8+
interface EmbedOptions<T> {
9+
widgetId: string
10+
root: T
11+
environment: Environment
12+
version?: Generation
13+
dataProperties: Record<string, string | number | boolean>
14+
}
15+
16+
interface JSONSchema {
17+
version: string
18+
}
19+
20+
export function getWidgetDataUrl(env: Environment) {
21+
switch (env) {
22+
case "staging":
23+
return STAGING_DATA_URL
24+
case "production":
25+
return PRODUCTION_DATA_URL
26+
}
27+
}
28+
29+
function getRequestUrl(widgetId: string, environment: Environment) {
30+
return `${getWidgetDataUrl(environment)}/${widgetId}/version`
31+
}
32+
33+
async function retrieveWidgetVersionFromServer(widgetId: string, environment: Environment): Promise<string> {
34+
const response = await fetch(getRequestUrl(widgetId, environment))
35+
const json: JSONSchema = await response.json()
36+
37+
return json.version
38+
}
39+
40+
export async function embed<T extends ShadowRoot | HTMLElement>(options: EmbedOptions<T>) {
41+
const { environment = "production", widgetId, root, version, dataProperties } = options
42+
43+
try {
44+
const widgetVersion = version ?? (await retrieveWidgetVersionFromServer(widgetId, environment))
45+
46+
switch (widgetVersion) {
47+
case "2":
48+
root.innerHTML += getWidgetV2EmbedCode(dataProperties, environment)
49+
break
50+
case "3":
51+
root.innerHTML += getWidgetV3EmbedCode(dataProperties, environment)
52+
break
53+
default:
54+
throw new Error(`No widget code accessible with version ${widgetVersion}`)
55+
}
56+
} catch (error) {
57+
console.error(`Failed to embed widget. ${error}`)
58+
}
59+
}

src/embed/v2.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { STAGING_LEGACY_WIDGET_URL, PRODUCTION_LEGACY_WIDGET_URL } from "../constants"
2+
import { Environment } from "."
3+
import { generateDataHTMLStringByParams } from "./embed.params"
4+
5+
const getUrlByEnv = (environment: Environment) => {
6+
switch (environment) {
7+
case "staging":
8+
return STAGING_LEGACY_WIDGET_URL
9+
case "production":
10+
default:
11+
return PRODUCTION_LEGACY_WIDGET_URL
12+
}
13+
}
14+
15+
const getWidgetV2EmbedCode = (data: Record<string, string | boolean | number>, environment: Environment) => {
16+
const dataParams = generateDataHTMLStringByParams(data)
17+
18+
return `
19+
<!-- Nosto Widget Embed Code (start) -->
20+
<div class="stackla-widget" style="width: 100%; overflow: hidden;"${dataParams}></div>
21+
<script type="text/javascript">
22+
(function (d, id) {
23+
var t, el = d.scripts[d.scripts.length - 1].previousElementSibling;
24+
if (el) el.dataset.initTimestamp = (new Date()).getTime();
25+
if (d.getElementById(id)) return;
26+
t = d.createElement('script');
27+
t.src = '//${getUrlByEnv(environment)}/media/js/widget/fluid-embed.min.js';
28+
t.id = id;
29+
(d.getElementsByTagName('head')[0] || d.getElementsByTagName('body')[0]).appendChild(t);
30+
}(document, 'stackla-widget-js'));
31+
</script>
32+
<!-- Nosto Widget Embed Code (end) -->
33+
`
34+
}
35+
36+
export { getWidgetV2EmbedCode }

src/embed/v3.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { STAGING_UI_URL, PRODUCTION_UI_URL } from "../constants"
2+
import { Environment } from "."
3+
import { generateDataHTMLStringByParams } from "./embed.params"
4+
5+
const getUrlByEnv = (environment: Environment) => {
6+
switch (environment) {
7+
case "staging":
8+
return STAGING_UI_URL
9+
case "production":
10+
default:
11+
return PRODUCTION_UI_URL
12+
}
13+
}
14+
15+
const getWidgetV3EmbedCode = (data: Record<string, string | boolean | number>, environment: Environment) => {
16+
const dataParams = generateDataHTMLStringByParams(data)
17+
18+
return `
19+
<div id="ugc-widget"${dataParams}></div>
20+
<script type="module">
21+
(async () => {
22+
const widget = await import('https://${getUrlByEnv(environment)}/core.esm.js');
23+
widget.init();
24+
})();
25+
</script>`
26+
}
27+
28+
export { getWidgetV3EmbedCode }

0 commit comments

Comments
 (0)