@financial-times/custom-code-component 1.11.1 → 2.0.1-alpha.10
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/dist/index.js +598 -0
- package/dist/index.js.map +1 -0
- package/dist/webcomponent/src/custom-code-component.d.ts +55 -0
- package/dist/webcomponent/src/environment.d.ts +15 -0
- package/dist/webcomponent/src/errors.d.ts +27 -0
- package/dist/webcomponent/src/events.d.ts +10 -0
- package/dist/webcomponent/src/get-trace.d.ts +8 -0
- package/dist/webcomponent/src/index.d.ts +1 -0
- package/dist/webcomponent/src/logger.d.ts +20 -0
- package/dist/webcomponent/src/path.d.ts +23 -0
- package/dist/{tracking.d.ts → webcomponent/src/tracking.d.ts} +12 -4
- package/dist/webcomponent/src/util.d.ts +33 -0
- package/dist/webcomponent/test/environment.test.d.ts +1 -0
- package/dist/webcomponent/test/error-handling.test.d.ts +8 -0
- package/dist/webcomponent/test/example.d.ts +11 -0
- package/dist/webcomponent/test/generate-readable-stream.d.ts +8 -0
- package/dist/webcomponent/test/path.test.d.ts +5 -0
- package/dist/webcomponent/test/ssr.test.d.ts +4 -0
- package/dist/webcomponent/test/utils.test.d.ts +1 -0
- package/dist/webcomponent/vite.config.d.ts +2 -0
- package/dist/webcomponent/vitest.config.d.ts +2 -0
- package/package.json +25 -17
- package/src/custom-code-component.ts +247 -167
- package/src/environment.ts +77 -0
- package/src/errors.ts +73 -0
- package/src/events.ts +22 -0
- package/src/{get-trace.js → get-trace.ts} +23 -14
- package/src/index.ts +8 -0
- package/src/logger.ts +71 -0
- package/src/path.ts +83 -0
- package/src/tracking.ts +20 -11
- package/src/util.ts +66 -0
- package/dist/custom-code-component.d.ts +0 -21
- package/dist/custom-code-component.js +0 -380
- package/src/custom-code-component.xsd +0 -67
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
import {
|
|
3
3
|
sanitise,
|
|
4
4
|
assignIfUndefined,
|
|
5
|
-
} from "@financial-times/o-tracking/src/javascript/utils
|
|
5
|
+
} from "@financial-times/o-tracking/src/javascript/utils";
|
|
6
6
|
|
|
7
|
+
type PropMap = { [k: string]: any };
|
|
7
8
|
// For a given container element, get the number of elements that match the
|
|
8
9
|
// original element (siblings); and the index of the original element (position).
|
|
9
|
-
const getSiblingsAndPosition = (
|
|
10
|
-
|
|
10
|
+
const getSiblingsAndPosition = (
|
|
11
|
+
el: Element | null,
|
|
12
|
+
originalEl: Element,
|
|
13
|
+
selector: string
|
|
14
|
+
) => {
|
|
15
|
+
const siblings = Array.from(el?.querySelectorAll(selector) ?? []);
|
|
11
16
|
const position = siblings.findIndex((item) => item === originalEl);
|
|
12
17
|
if (position === -1) {
|
|
13
18
|
return;
|
|
@@ -27,8 +32,8 @@ const elementPropertiesToCollect = [
|
|
|
27
32
|
"role",
|
|
28
33
|
];
|
|
29
34
|
// Get all (sanitised) properties of a given element.
|
|
30
|
-
const getAllElementProperties = (element) => {
|
|
31
|
-
const properties = {};
|
|
35
|
+
const getAllElementProperties = (element: any) => {
|
|
36
|
+
const properties: PropMap = {};
|
|
32
37
|
for (const property of elementPropertiesToCollect) {
|
|
33
38
|
const value =
|
|
34
39
|
element[property] ||
|
|
@@ -46,7 +51,7 @@ const getAllElementProperties = (element) => {
|
|
|
46
51
|
return properties;
|
|
47
52
|
};
|
|
48
53
|
|
|
49
|
-
const parseRawValue = (rawValue) => {
|
|
54
|
+
const parseRawValue = (rawValue: string) => {
|
|
50
55
|
try {
|
|
51
56
|
const parsedValue = JSON.parse(rawValue);
|
|
52
57
|
const type = Object.prototype.toString.call(parsedValue);
|
|
@@ -58,14 +63,14 @@ const parseRawValue = (rawValue) => {
|
|
|
58
63
|
}
|
|
59
64
|
};
|
|
60
65
|
|
|
61
|
-
const getAttributeValue = (rawValue) => {
|
|
66
|
+
const getAttributeValue = (rawValue: string) => {
|
|
62
67
|
const [isJSON, value] = parseRawValue(rawValue);
|
|
63
68
|
|
|
64
69
|
return isJSON ? value : rawValue;
|
|
65
70
|
};
|
|
66
71
|
|
|
67
72
|
// Get some properties of a given element.
|
|
68
|
-
const getDomPathProps = (attrs, props) => {
|
|
73
|
+
const getDomPathProps = (attrs: Attr[], props: PropMap) => {
|
|
69
74
|
// Collect any attribute that matches given strings.
|
|
70
75
|
attrs
|
|
71
76
|
.filter((attribute) =>
|
|
@@ -79,8 +84,12 @@ const getDomPathProps = (attrs, props) => {
|
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
// Get only the custom data-trackable-context-? properties of a given element
|
|
82
|
-
const getContextProps = (
|
|
83
|
-
|
|
87
|
+
const getContextProps = (
|
|
88
|
+
attrs: Attr[],
|
|
89
|
+
props: PropMap,
|
|
90
|
+
isOriginalEl: boolean
|
|
91
|
+
) => {
|
|
92
|
+
const customProps: { [k: string]: any } = {};
|
|
84
93
|
|
|
85
94
|
// for the original element collect properties like className, nodeName
|
|
86
95
|
if (isOriginalEl) {
|
|
@@ -102,11 +111,11 @@ const getContextProps = (attrs, props, isOriginalEl) => {
|
|
|
102
111
|
return customProps;
|
|
103
112
|
};
|
|
104
113
|
|
|
105
|
-
export function getTrace(el, rootEl) {
|
|
114
|
+
export function getTrace(el: Element, rootEl: Element) {
|
|
106
115
|
const originalEl = el;
|
|
107
|
-
const selector = originalEl
|
|
116
|
+
const selector = originalEl?.getAttribute("data-trackable")
|
|
108
117
|
? `[data-trackable="${originalEl.getAttribute("data-trackable")}"]`
|
|
109
|
-
: originalEl
|
|
118
|
+
: originalEl?.nodeName;
|
|
110
119
|
const trace = [];
|
|
111
120
|
const customContext = {};
|
|
112
121
|
while (el && el !== rootEl) {
|
|
@@ -129,7 +138,7 @@ export function getTrace(el, rootEl) {
|
|
|
129
138
|
|
|
130
139
|
assignIfUndefined(contextProps, customContext);
|
|
131
140
|
|
|
132
|
-
el = el.parentNode;
|
|
141
|
+
el = el.parentNode as Element;
|
|
133
142
|
}
|
|
134
143
|
return { trace, customContext };
|
|
135
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
|
+
}
|
package/src/tracking.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import Delegate from "ftdomdelegate";
|
|
2
|
-
|
|
1
|
+
import Delegate from "ftdomdelegate/main";
|
|
2
|
+
|
|
3
3
|
import {
|
|
4
4
|
sanitise,
|
|
5
5
|
assignIfUndefined,
|
|
6
|
-
} from "@financial-times/o-tracking/src/javascript/utils
|
|
6
|
+
} from "@financial-times/o-tracking/src/javascript/utils";
|
|
7
|
+
import { Logger } from "./logger";
|
|
8
|
+
import { getTrace } from "./get-trace";
|
|
7
9
|
|
|
8
10
|
const eventPropertiesToCollect = ["ctrlKey", "altKey", "shiftKey", "metaKey"];
|
|
9
11
|
|
|
@@ -16,6 +18,7 @@ class Tracking {
|
|
|
16
18
|
category: string;
|
|
17
19
|
elements: string | string[];
|
|
18
20
|
isInitialised: boolean;
|
|
21
|
+
log: Logger;
|
|
19
22
|
|
|
20
23
|
constructor({
|
|
21
24
|
id = "00000000-0000-0000-0000-000000000000",
|
|
@@ -25,6 +28,7 @@ class Tracking {
|
|
|
25
28
|
shadowRoot = null,
|
|
26
29
|
category = "cta",
|
|
27
30
|
elements = 'a, button, input, [role="button"]',
|
|
31
|
+
logger,
|
|
28
32
|
}: {
|
|
29
33
|
id?: string;
|
|
30
34
|
name: string;
|
|
@@ -33,6 +37,7 @@ class Tracking {
|
|
|
33
37
|
shadowRoot: ShadowRoot | null;
|
|
34
38
|
category?: string;
|
|
35
39
|
elements?: string | string[];
|
|
40
|
+
logger: Logger;
|
|
36
41
|
}) {
|
|
37
42
|
this.cccId = id;
|
|
38
43
|
this.cccName = name;
|
|
@@ -42,17 +47,18 @@ class Tracking {
|
|
|
42
47
|
this.category = category;
|
|
43
48
|
this.elements = elements;
|
|
44
49
|
this.isInitialised = false;
|
|
50
|
+
this.log = logger ?? new Logger();
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// Get properties for the event (as opposed to properties of the clicked element)
|
|
48
|
-
getEventProperties(event) {
|
|
49
|
-
const eventProperties = {};
|
|
54
|
+
getEventProperties(event: any) {
|
|
55
|
+
const eventProperties: { [k: string]: any } = {};
|
|
50
56
|
for (const property of eventPropertiesToCollect) {
|
|
51
57
|
if (event[property]) {
|
|
52
58
|
try {
|
|
53
59
|
eventProperties[property] = sanitise(event[property]);
|
|
54
60
|
} catch (e) {
|
|
55
|
-
|
|
61
|
+
this.log.info(e);
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
}
|
|
@@ -60,8 +66,11 @@ class Tracking {
|
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
// Controller for handling click events
|
|
63
|
-
handleClickEvent(
|
|
64
|
-
|
|
69
|
+
handleClickEvent(
|
|
70
|
+
eventData: { action: string; category: string },
|
|
71
|
+
root: Element
|
|
72
|
+
) {
|
|
73
|
+
return (clickEvent: Event, clickElement: HTMLElement) => {
|
|
65
74
|
const context: any = this.getEventProperties(clickEvent);
|
|
66
75
|
const { trace, customContext } = getTrace(clickElement, root);
|
|
67
76
|
context.custom =
|
|
@@ -94,7 +103,7 @@ class Tracking {
|
|
|
94
103
|
};
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
sendSpoorEvent(triggerAction, extraDetail) {
|
|
106
|
+
sendSpoorEvent(triggerAction: any, extraDetail: any) {
|
|
98
107
|
const eventData = {
|
|
99
108
|
category: "component",
|
|
100
109
|
action: "act",
|
|
@@ -120,7 +129,7 @@ class Tracking {
|
|
|
120
129
|
);
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
init(id) {
|
|
132
|
+
init(id: string) {
|
|
124
133
|
if (!this.isInitialised) {
|
|
125
134
|
this.isInitialised = true;
|
|
126
135
|
this.cccId = id ? id : this.cccId;
|
|
@@ -132,7 +141,7 @@ class Tracking {
|
|
|
132
141
|
|
|
133
142
|
const root = this.shadowRoot?.querySelector("[data-component-root]");
|
|
134
143
|
|
|
135
|
-
if (
|
|
144
|
+
if (root) {
|
|
136
145
|
const shadowDelegate = new Delegate(root);
|
|
137
146
|
shadowDelegate.on(
|
|
138
147
|
"click",
|
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
|
+
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file
|
|
3
|
-
* Main component definition for custom-code-component
|
|
4
|
-
*/
|
|
5
|
-
import { ContentTree } from "@financial-times/content-tree";
|
|
6
|
-
export declare const init: () => void;
|
|
7
|
-
export interface CustomCodeComponent extends ContentTree.Node {
|
|
8
|
-
type: "CustomCodeComponent";
|
|
9
|
-
path: string;
|
|
10
|
-
versionRange: string;
|
|
11
|
-
altText: string;
|
|
12
|
-
lastModified: string;
|
|
13
|
-
fallbackImage?: ContentTree.Image;
|
|
14
|
-
displayFallbackText: boolean;
|
|
15
|
-
layout: "in-line" | "mid-grid" | "full-grid" | "full-bleed";
|
|
16
|
-
attributes: {
|
|
17
|
-
[key: string]: string | boolean | undefined;
|
|
18
|
-
} | {
|
|
19
|
-
children?: CustomCodeComponent | Array<CustomCodeComponent>;
|
|
20
|
-
};
|
|
21
|
-
}
|