@financial-times/custom-code-component 0.0.1-THIS-IS-UPDATED-BY-CI

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.
package/src/errors.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * This is the base CCC component error class. These are raised when the component being loaded errors
3
+ */
4
+
5
+ import { ComponentPath, DetailType, isValidComponentPathObject } from "./path";
6
+
7
+ export class CCCError extends Error {
8
+ component: ComponentPath | null;
9
+ source?: string;
10
+ constructor(message: string | null, detail?: DetailType) {
11
+ if (!detail && message) {
12
+ super(message);
13
+ this.component = null;
14
+ } else if (typeof detail?.component === "string") {
15
+ super(
16
+ message ??
17
+ `${detail.cause ?? "Unknown error"} in ${detail.component} imported from ${detail.source ?? "an undefined source"}.`
18
+ );
19
+
20
+ this.component = ComponentPath.fromString(detail.component);
21
+ } else if (isValidComponentPathObject(detail?.component)) {
22
+ super(
23
+ message ??
24
+ `${detail.cause ?? "Unknown error"} in ${detail.component.org}/${detail.component.repo}/${detail.component.component}@${detail.component.versionRange} imported from ${detail.source ?? "an undefined source"}.`
25
+ );
26
+ this.component = new ComponentPath(detail.component);
27
+ } else {
28
+ super(
29
+ `${detail?.cause ?? "Unknown error"} in unknown component imported from ${detail?.source ?? "unknown source"}.`
30
+ );
31
+ this.component = null;
32
+ }
33
+
34
+ this.source = detail?.source;
35
+
36
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
37
+ if (Error.captureStackTrace) {
38
+ Error.captureStackTrace(this, CCCError);
39
+ }
40
+
41
+ this.name = "CCCError";
42
+ }
43
+ }
44
+
45
+ /**
46
+ * This class is used to raise errors that occur during the import of the CCC component app
47
+ */
48
+ export class CCCImportError extends CCCError {
49
+ constructor(message: string, detail: DetailType) {
50
+ super(message, { ...detail, cause: "Import error" });
51
+ this.name = "CCCImportError";
52
+ }
53
+ }
54
+
55
+ /**
56
+ * This class is used to raise errors that occur during the rendering of the CCC component app
57
+ */
58
+ export class CCCRenderError extends CCCError {
59
+ constructor(message: string, detail: DetailType) {
60
+ super(message, { ...detail, cause: "Render error" });
61
+ this.name = "CCCRenderError";
62
+ }
63
+ }
64
+
65
+ /**
66
+ * This class is used to raise errors that occur as a result of the CCC component app timing out
67
+ */
68
+ export class CCCTimeoutError extends CCCError {
69
+ constructor(detail: DetailType) {
70
+ super(null, { ...detail, cause: "Timeout error" });
71
+ this.name = "CCCTimeoutError";
72
+ }
73
+ }
package/src/events.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { ComponentPath } from "./path";
2
+
3
+ export class CCCConnectedEvent extends Event {
4
+ static eventType = "ccc:ConnectedEvent";
5
+
6
+ component: ComponentPath;
7
+ source?: string;
8
+
9
+ constructor(
10
+ detail: { component: ComponentPath; source?: string },
11
+ opts?: EventInit
12
+ ) {
13
+ super(CCCConnectedEvent.eventType, {
14
+ bubbles: true,
15
+ cancelable: false,
16
+ composed: true,
17
+ ...opts,
18
+ });
19
+ this.component = detail.component;
20
+ this.source = detail.source;
21
+ }
22
+ }
@@ -0,0 +1,144 @@
1
+ // Trace the element and all of its parents, collecting properties as we go
2
+ import {
3
+ sanitise,
4
+ assignIfUndefined,
5
+ } from "@financial-times/o-tracking/src/javascript/utils";
6
+
7
+ type PropMap = { [k: string]: any };
8
+ // For a given container element, get the number of elements that match the
9
+ // original element (siblings); and the index of the original element (position).
10
+ const getSiblingsAndPosition = (
11
+ el: Element | null,
12
+ originalEl: Element,
13
+ selector: string
14
+ ) => {
15
+ const siblings = Array.from(el?.querySelectorAll(selector) ?? []);
16
+ const position = siblings.findIndex((item) => item === originalEl);
17
+ if (position === -1) {
18
+ return;
19
+ }
20
+ return {
21
+ siblings: siblings.length,
22
+ position,
23
+ };
24
+ };
25
+
26
+ const elementPropertiesToCollect = [
27
+ "nodeName",
28
+ "className",
29
+ "id",
30
+ "href",
31
+ "text",
32
+ "role",
33
+ ];
34
+ // Get all (sanitised) properties of a given element.
35
+ const getAllElementProperties = (element: any) => {
36
+ const properties: PropMap = {};
37
+ for (const property of elementPropertiesToCollect) {
38
+ const value =
39
+ element[property] ||
40
+ element.getAttribute(property) ||
41
+ element.hasAttribute(property);
42
+ if (value !== undefined) {
43
+ if (typeof value === "boolean") {
44
+ properties[property] = value;
45
+ } else {
46
+ properties[property] = sanitise(value);
47
+ }
48
+ }
49
+ }
50
+
51
+ return properties;
52
+ };
53
+
54
+ const parseRawValue = (rawValue: string) => {
55
+ try {
56
+ const parsedValue = JSON.parse(rawValue);
57
+ const type = Object.prototype.toString.call(parsedValue);
58
+ const isJSON = type === "[object Object]" || type === "[object Array]";
59
+
60
+ return [isJSON, parsedValue];
61
+ } catch (error) {
62
+ return [false, null];
63
+ }
64
+ };
65
+
66
+ const getAttributeValue = (rawValue: string) => {
67
+ const [isJSON, value] = parseRawValue(rawValue);
68
+
69
+ return isJSON ? value : rawValue;
70
+ };
71
+
72
+ // Get some properties of a given element.
73
+ const getDomPathProps = (attrs: Attr[], props: PropMap) => {
74
+ // Collect any attribute that matches given strings.
75
+ attrs
76
+ .filter((attribute) =>
77
+ attribute.name.match(/^data-trackable|^data-o-|^aria-/i)
78
+ )
79
+ .forEach((attribute) => {
80
+ props[attribute.name] = attribute.value;
81
+ });
82
+
83
+ return props;
84
+ };
85
+
86
+ // Get only the custom data-trackable-context-? properties of a given element
87
+ const getContextProps = (
88
+ attrs: Attr[],
89
+ props: PropMap,
90
+ isOriginalEl: boolean
91
+ ) => {
92
+ const customProps: { [k: string]: any } = {};
93
+
94
+ // for the original element collect properties like className, nodeName
95
+ if (isOriginalEl) {
96
+ elementPropertiesToCollect.forEach((name) => {
97
+ if (typeof props[name] !== "undefined" && name !== "id") {
98
+ customProps[name] = props[name];
99
+ }
100
+ });
101
+ }
102
+
103
+ // Collect any attribute that matches given strings.
104
+ attrs
105
+ .filter((attribute) => attribute.name.match(/^data-trackable-context-/i))
106
+ .forEach((attribute) => {
107
+ customProps[attribute.name.replace("data-trackable-context-", "")] =
108
+ getAttributeValue(attribute.value);
109
+ });
110
+
111
+ return customProps;
112
+ };
113
+
114
+ export function getTrace(el: Element, rootEl: Element) {
115
+ const originalEl = el;
116
+ const selector = originalEl?.getAttribute("data-trackable")
117
+ ? `[data-trackable="${originalEl.getAttribute("data-trackable")}"]`
118
+ : originalEl?.nodeName;
119
+ const trace = [];
120
+ const customContext = {};
121
+ while (el && el !== rootEl) {
122
+ const props = getAllElementProperties(el);
123
+ const attrs = Array.from(el.attributes);
124
+ let domPathProps = getDomPathProps(attrs, props);
125
+
126
+ // If the element happens to have a data-trackable attribute, get the siblings
127
+ // and position of the element (relative to the current element).
128
+ if (domPathProps["data-trackable"]) {
129
+ domPathProps = Object.assign(
130
+ domPathProps,
131
+ getSiblingsAndPosition(el, originalEl, selector)
132
+ );
133
+ }
134
+
135
+ trace.push(domPathProps);
136
+
137
+ const contextProps = getContextProps(attrs, props, el === originalEl);
138
+
139
+ assignIfUndefined(contextProps, customContext);
140
+
141
+ el = el.parentNode as Element;
142
+ }
143
+ return { trace, customContext };
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { FTCustomCodeComponent } from "./custom-code-component";
2
+
3
+ // Register the custom element
4
+ export const init = () =>
5
+ customElements.define("custom-code-component", FTCustomCodeComponent);
6
+
7
+ // Allows module to be imported multiple times but custom element only registered once
8
+ if (customElements && !customElements.get("custom-code-component")) init();
package/src/logger.ts ADDED
@@ -0,0 +1,71 @@
1
+ export const LogLevel = Object.freeze({
2
+ DEBUG: 0,
3
+ INFO: 1,
4
+ WARN: 2,
5
+ ERROR: 3,
6
+ TEST: 4,
7
+ DEFAULT: 2,
8
+ });
9
+
10
+ export function convertStringLogLevel(value: string | null) {
11
+ const level = value?.toLowerCase();
12
+
13
+ if (level === "debug") {
14
+ return LogLevel.DEBUG;
15
+ }
16
+
17
+ if (level === "info") {
18
+ return LogLevel.INFO;
19
+ }
20
+
21
+ if (level === "warn") {
22
+ return LogLevel.WARN;
23
+ }
24
+
25
+ if (level === "error") {
26
+ return LogLevel.ERROR;
27
+ }
28
+
29
+ if (level === "test") {
30
+ return LogLevel.TEST;
31
+ }
32
+
33
+ return LogLevel.DEFAULT;
34
+ }
35
+
36
+ export class Logger {
37
+ level: number;
38
+ constructor(
39
+ { level = LogLevel.DEFAULT }: { level: number } = {
40
+ level: LogLevel.DEFAULT,
41
+ }
42
+ ) {
43
+ this.level = level;
44
+ }
45
+
46
+ debug(...args: any[]) {
47
+ if (this.level <= LogLevel.DEBUG) {
48
+ console.info(...args);
49
+ }
50
+ }
51
+
52
+ log = this.debug;
53
+
54
+ info(...args: any[]) {
55
+ if (this.level <= LogLevel.INFO) {
56
+ console.info(...args);
57
+ }
58
+ }
59
+
60
+ warn(...args: any[]) {
61
+ if (this.level <= LogLevel.WARN) {
62
+ console.warn(...args);
63
+ }
64
+ }
65
+
66
+ error(...args: any[]) {
67
+ if (this.level <= LogLevel.ERROR) {
68
+ console.error(...args);
69
+ }
70
+ }
71
+ }
package/src/path.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { CCCError } from "./errors";
2
+
3
+ export type ComponentPathType = {
4
+ org: string;
5
+ repo: string;
6
+ component: string;
7
+ versionRange: string;
8
+ };
9
+ export class ComponentPath {
10
+ org: string;
11
+ repo: string;
12
+ component: string;
13
+ versionRange: string;
14
+
15
+ constructor(path: ComponentPathType | string) {
16
+ const { org, repo, component, versionRange } = isValidComponentPathObject(
17
+ path
18
+ )
19
+ ? path
20
+ : ComponentPath.fromString(path);
21
+ this.org = org;
22
+ this.repo = repo;
23
+ this.component = component;
24
+ this.versionRange = versionRange;
25
+ }
26
+
27
+ set path(path: ComponentPathType | string) {
28
+ const { org, repo, component, versionRange } = isValidComponentPathObject(
29
+ path
30
+ )
31
+ ? path
32
+ : ComponentPath.fromString(path);
33
+ this.org = org;
34
+ this.repo = repo;
35
+ this.component = component;
36
+ this.versionRange = versionRange;
37
+ }
38
+
39
+ get path(): string {
40
+ return `${this.org}/${this.repo}@${this.versionRange}/${this.component}`;
41
+ }
42
+
43
+ toString(): string {
44
+ return this.path;
45
+ }
46
+
47
+ static fromString(path: string | null, v?: string | null): ComponentPath {
48
+ if (!path) throw new CCCError("No path specified");
49
+
50
+ const versionRange =
51
+ v ??
52
+ path
53
+ .match(/@[^\/]+/)
54
+ ?.toString()
55
+ .replace("@", "") ??
56
+ "unknown";
57
+
58
+ if (!versionRange) throw new CCCError("No version specified");
59
+
60
+ const [component, repo, org] = path
61
+ .replace(/@[^\/]+/, "")
62
+ .split("/")
63
+ .reverse();
64
+
65
+ return new ComponentPath({ org, repo, component, versionRange });
66
+ }
67
+ }
68
+
69
+ export type DetailType = {
70
+ component: ComponentPath;
71
+ source?: string;
72
+ cause?: string;
73
+ };
74
+
75
+ export function isValidComponentPathObject(
76
+ value: unknown
77
+ ): value is ComponentPathType {
78
+ if (typeof value === "object" && value !== null) {
79
+ return "org" in value && "repo" in value && "component" in value;
80
+ }
81
+
82
+ return false;
83
+ }
@@ -0,0 +1,157 @@
1
+ import Delegate from "ftdomdelegate/main";
2
+
3
+ import {
4
+ sanitise,
5
+ assignIfUndefined,
6
+ } from "@financial-times/o-tracking/src/javascript/utils";
7
+ import { Logger } from "./logger";
8
+ import { getTrace } from "./get-trace";
9
+
10
+ const eventPropertiesToCollect = ["ctrlKey", "altKey", "shiftKey", "metaKey"];
11
+
12
+ class Tracking {
13
+ cccId: string;
14
+ cccName: string;
15
+ subtype: string;
16
+ teamName: string;
17
+ shadowRoot: ShadowRoot | null;
18
+ category: string;
19
+ elements: string | string[];
20
+ isInitialised: boolean;
21
+ log: Logger;
22
+
23
+ constructor({
24
+ id = "00000000-0000-0000-0000-000000000000",
25
+ name = "ccc-component",
26
+ subtype = "interactive",
27
+ teamName = "djd",
28
+ shadowRoot = null,
29
+ category = "cta",
30
+ elements = 'a, button, input, [role="button"]',
31
+ logger,
32
+ }: {
33
+ id?: string;
34
+ name: string;
35
+ subtype: string;
36
+ teamName?: string;
37
+ shadowRoot: ShadowRoot | null;
38
+ category?: string;
39
+ elements?: string | string[];
40
+ logger: Logger;
41
+ }) {
42
+ this.cccId = id;
43
+ this.cccName = name;
44
+ this.subtype = subtype;
45
+ this.teamName = teamName;
46
+ this.shadowRoot = shadowRoot;
47
+ this.category = category;
48
+ this.elements = elements;
49
+ this.isInitialised = false;
50
+ this.log = logger ?? new Logger();
51
+ }
52
+
53
+ // Get properties for the event (as opposed to properties of the clicked element)
54
+ getEventProperties(event: any) {
55
+ const eventProperties: { [k: string]: any } = {};
56
+ for (const property of eventPropertiesToCollect) {
57
+ if (event[property]) {
58
+ try {
59
+ eventProperties[property] = sanitise(event[property]);
60
+ } catch (e) {
61
+ this.log.info(e);
62
+ }
63
+ }
64
+ }
65
+ return eventProperties;
66
+ }
67
+
68
+ // Controller for handling click events
69
+ handleClickEvent(
70
+ eventData: { action: string; category: string },
71
+ root: Element
72
+ ) {
73
+ return (clickEvent: Event, clickElement: HTMLElement) => {
74
+ const context: any = this.getEventProperties(clickEvent);
75
+ const { trace, customContext } = getTrace(clickElement, root);
76
+ context.custom =
77
+ clickElement.dataset && clickElement.dataset.custom
78
+ ? JSON.parse(clickElement.dataset.custom)
79
+ : null;
80
+ context.domPathTokens = trace;
81
+ context.component = {
82
+ id: this.cccId,
83
+ name: this.cccName,
84
+ type: "custom-code-component",
85
+ subtype: this.subtype,
86
+ };
87
+ context.teamName = this.teamName;
88
+ context.url = document.URL;
89
+
90
+ assignIfUndefined(customContext, context);
91
+
92
+ context.method = "ftCustomAnalytics";
93
+ eventData = { ...eventData, ...context };
94
+
95
+ // send spoor event
96
+ document.body.dispatchEvent(
97
+ new CustomEvent("oTracking.event", {
98
+ detail: eventData,
99
+ bubbles: true,
100
+ composed: true,
101
+ })
102
+ );
103
+ };
104
+ }
105
+
106
+ sendSpoorEvent(triggerAction: any, extraDetail: any) {
107
+ const eventData = {
108
+ category: "component",
109
+ action: "act",
110
+ component: {
111
+ id: this.cccId,
112
+ name: this.cccName,
113
+ type: "custom-code-component",
114
+ subtype: this.subtype,
115
+ },
116
+ teamName: this.teamName,
117
+ trigger_action: triggerAction,
118
+ custom: extraDetail,
119
+ method: "ftCustomAnalytics",
120
+ };
121
+
122
+ // send spoor event
123
+ document.body.dispatchEvent(
124
+ new CustomEvent("oTracking.event", {
125
+ detail: eventData,
126
+ bubbles: true,
127
+ composed: true,
128
+ })
129
+ );
130
+ }
131
+
132
+ init(id: string) {
133
+ if (!this.isInitialised) {
134
+ this.isInitialised = true;
135
+ this.cccId = id ? id : this.cccId;
136
+
137
+ const eventData = {
138
+ action: "click",
139
+ category: this.category,
140
+ };
141
+
142
+ const root = this.shadowRoot?.querySelector("[data-component-root]");
143
+
144
+ if (root) {
145
+ const shadowDelegate = new Delegate(root);
146
+ shadowDelegate.on(
147
+ "click",
148
+ this.elements,
149
+ this.handleClickEvent(eventData, root),
150
+ true
151
+ );
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ export default Tracking;
package/src/util.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Used to convert camelCase to kebab-case
3
+ * @param str
4
+ * @returns
5
+ */
6
+ export const kebabize = (str: string) =>
7
+ str.replace(
8
+ /[A-Z]+(?![a-z])|[A-Z]/g,
9
+ ($, ofs) => (ofs ? "-" : "") + $.toLowerCase()
10
+ );
11
+
12
+ /**
13
+ * Checks if the provided host is part of the allowed test environments.
14
+ *
15
+ * @param host - The hostname to check.
16
+ * @returns True if the hostname is a safe test environment, false otherwise.
17
+ */
18
+ export function isSafeTestEnv(host: string | undefined) {
19
+ return isAllowed([
20
+ 'localhost',
21
+ 'local.ft.com',
22
+ /^.*\.apps\.in\.ft\.com$/,
23
+ ], host);
24
+ }
25
+
26
+ /**
27
+ * Checks if the current window location hostname is considered a local environment.
28
+ *
29
+ * @returns True if the hostname is local, false otherwise.
30
+ */
31
+ export function isLocalEnv() {
32
+ return isAllowed([
33
+ 'localhost',
34
+ 'local.ft.com',
35
+ /^.*\.in\.ft\.com$/,
36
+ ], window.location.hostname);
37
+ }
38
+
39
+ /**
40
+ * Checks if the current window location host is one of the Spark environments.
41
+ *
42
+ * @returns True if the host is spark.ft.com or spark-staging.ft.com, false otherwise.
43
+ */
44
+ export function isSparkEnv() {
45
+ return isAllowed([
46
+ 'spark.ft.com',
47
+ 'spark-staging.ft.com',
48
+ ], window.location.host);
49
+ }
50
+
51
+ /**
52
+ * Checks if the given hostname is in the provided allowlist.
53
+ *
54
+ * @param allowlist - A list of allowed hostnames or RegExp matchers.
55
+ * @param hostname - The hostname to validate.
56
+ * @returns True if the hostname matches any item in the allowlist, false otherwise.
57
+ */
58
+ export function isAllowed(allowlist: (string|RegExp)[], hostname: string | undefined) {
59
+ if (!hostname) return false;
60
+ return allowlist.some(item => {
61
+ if (typeof item === 'string') {
62
+ return item === hostname;
63
+ }
64
+ return item.test(hostname);
65
+ });
66
+ }