@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/README.md +61 -0
- package/dist/custom-code-component.js +591 -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/webcomponent/src/tracking.d.ts +32 -0
- 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 +56 -0
- package/src/custom-code-component.ts +310 -0
- package/src/environment.ts +77 -0
- package/src/errors.ts +73 -0
- package/src/events.ts +22 -0
- package/src/get-trace.ts +144 -0
- package/src/index.ts +8 -0
- package/src/logger.ts +71 -0
- package/src/path.ts +83 -0
- package/src/tracking.ts +157 -0
- package/src/util.ts +66 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assigns a test URL based on the provided testEnv value.
|
|
3
|
+
*
|
|
4
|
+
* @param testEnv - A string indicating test environment setting
|
|
5
|
+
* @returns A `URL` object if valid and safe, or `undefined` if invalid.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assignTestURL(testEnv: string | null): URL | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Checks whether the given test URL can establish a successful WebSocket connection,
|
|
10
|
+
* which indicates a live test environment (e.g., Vite HMR is active).
|
|
11
|
+
*
|
|
12
|
+
* @param testUrl - The test environment URL to check.
|
|
13
|
+
* @returns A promise that resolves to true if WebSocket connection is open, otherwise false.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useComponentTestEnv(testUrl?: URL): Promise<boolean>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is the base CCC component error class. These are raised when the component being loaded errors
|
|
3
|
+
*/
|
|
4
|
+
import { ComponentPath, DetailType } from "./path";
|
|
5
|
+
export declare class CCCError extends Error {
|
|
6
|
+
component: ComponentPath | null;
|
|
7
|
+
source?: string;
|
|
8
|
+
constructor(message: string | null, detail?: DetailType);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* This class is used to raise errors that occur during the import of the CCC component app
|
|
12
|
+
*/
|
|
13
|
+
export declare class CCCImportError extends CCCError {
|
|
14
|
+
constructor(message: string, detail: DetailType);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* This class is used to raise errors that occur during the rendering of the CCC component app
|
|
18
|
+
*/
|
|
19
|
+
export declare class CCCRenderError extends CCCError {
|
|
20
|
+
constructor(message: string, detail: DetailType);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* This class is used to raise errors that occur as a result of the CCC component app timing out
|
|
24
|
+
*/
|
|
25
|
+
export declare class CCCTimeoutError extends CCCError {
|
|
26
|
+
constructor(detail: DetailType);
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ComponentPath } from "./path";
|
|
2
|
+
export declare class CCCConnectedEvent extends Event {
|
|
3
|
+
static eventType: string;
|
|
4
|
+
component: ComponentPath;
|
|
5
|
+
source?: string;
|
|
6
|
+
constructor(detail: {
|
|
7
|
+
component: ComponentPath;
|
|
8
|
+
source?: string;
|
|
9
|
+
}, opts?: EventInit);
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const init: () => void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const LogLevel: Readonly<{
|
|
2
|
+
DEBUG: 0;
|
|
3
|
+
INFO: 1;
|
|
4
|
+
WARN: 2;
|
|
5
|
+
ERROR: 3;
|
|
6
|
+
TEST: 4;
|
|
7
|
+
DEFAULT: 2;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function convertStringLogLevel(value: string | null): 0 | 2 | 1 | 3 | 4;
|
|
10
|
+
export declare class Logger {
|
|
11
|
+
level: number;
|
|
12
|
+
constructor({ level }?: {
|
|
13
|
+
level: number;
|
|
14
|
+
});
|
|
15
|
+
debug(...args: any[]): void;
|
|
16
|
+
log: (...args: any[]) => void;
|
|
17
|
+
info(...args: any[]): void;
|
|
18
|
+
warn(...args: any[]): void;
|
|
19
|
+
error(...args: any[]): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ComponentPathType = {
|
|
2
|
+
org: string;
|
|
3
|
+
repo: string;
|
|
4
|
+
component: string;
|
|
5
|
+
versionRange: string;
|
|
6
|
+
};
|
|
7
|
+
export declare class ComponentPath {
|
|
8
|
+
org: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
component: string;
|
|
11
|
+
versionRange: string;
|
|
12
|
+
constructor(path: ComponentPathType | string);
|
|
13
|
+
set path(path: ComponentPathType | string);
|
|
14
|
+
get path(): string;
|
|
15
|
+
toString(): string;
|
|
16
|
+
static fromString(path: string | null, v?: string | null): ComponentPath;
|
|
17
|
+
}
|
|
18
|
+
export type DetailType = {
|
|
19
|
+
component: ComponentPath;
|
|
20
|
+
source?: string;
|
|
21
|
+
cause?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function isValidComponentPathObject(value: unknown): value is ComponentPathType;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Logger } from "./logger";
|
|
2
|
+
declare class Tracking {
|
|
3
|
+
cccId: string;
|
|
4
|
+
cccName: string;
|
|
5
|
+
subtype: string;
|
|
6
|
+
teamName: string;
|
|
7
|
+
shadowRoot: ShadowRoot | null;
|
|
8
|
+
category: string;
|
|
9
|
+
elements: string | string[];
|
|
10
|
+
isInitialised: boolean;
|
|
11
|
+
log: Logger;
|
|
12
|
+
constructor({ id, name, subtype, teamName, shadowRoot, category, elements, logger, }: {
|
|
13
|
+
id?: string;
|
|
14
|
+
name: string;
|
|
15
|
+
subtype: string;
|
|
16
|
+
teamName?: string;
|
|
17
|
+
shadowRoot: ShadowRoot | null;
|
|
18
|
+
category?: string;
|
|
19
|
+
elements?: string | string[];
|
|
20
|
+
logger: Logger;
|
|
21
|
+
});
|
|
22
|
+
getEventProperties(event: any): {
|
|
23
|
+
[k: string]: any;
|
|
24
|
+
};
|
|
25
|
+
handleClickEvent(eventData: {
|
|
26
|
+
action: string;
|
|
27
|
+
category: string;
|
|
28
|
+
}, root: Element): (clickEvent: Event, clickElement: HTMLElement) => void;
|
|
29
|
+
sendSpoorEvent(triggerAction: any, extraDetail: any): void;
|
|
30
|
+
init(id: string): void;
|
|
31
|
+
}
|
|
32
|
+
export default Tracking;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Used to convert camelCase to kebab-case
|
|
3
|
+
* @param str
|
|
4
|
+
* @returns
|
|
5
|
+
*/
|
|
6
|
+
export declare const kebabize: (str: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Checks if the provided host is part of the allowed test environments.
|
|
9
|
+
*
|
|
10
|
+
* @param host - The hostname to check.
|
|
11
|
+
* @returns True if the hostname is a safe test environment, false otherwise.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isSafeTestEnv(host: string | undefined): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Checks if the current window location hostname is considered a local environment.
|
|
16
|
+
*
|
|
17
|
+
* @returns True if the hostname is local, false otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isLocalEnv(): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Checks if the current window location host is one of the Spark environments.
|
|
22
|
+
*
|
|
23
|
+
* @returns True if the host is spark.ft.com or spark-staging.ft.com, false otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSparkEnv(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Checks if the given hostname is in the provided allowlist.
|
|
28
|
+
*
|
|
29
|
+
* @param allowlist - A list of allowed hostnames or RegExp matchers.
|
|
30
|
+
* @param hostname - The hostname to validate.
|
|
31
|
+
* @returns True if the hostname matches any item in the allowlist, false otherwise.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isAllowed(allowlist: (string | RegExp)[], hostname: string | undefined): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ReactRenderer } from "../../ccc-sdk/src/renderers/react/ReactRenderer";
|
|
2
|
+
export declare const renderer: ReactRenderer;
|
|
3
|
+
declare const _default: (shadowRoot: ShadowRoot, attrs: any, ssr?: boolean) => {
|
|
4
|
+
unmount: (root: any) => void;
|
|
5
|
+
onmessage: {
|
|
6
|
+
(...data: any[]): void;
|
|
7
|
+
(message?: any, ...optionalParams: any[]): void;
|
|
8
|
+
};
|
|
9
|
+
ready: Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export default _default;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@financial-times/custom-code-component",
|
|
3
|
+
"version": "0.0.1-THIS-IS-UPDATED-BY-CI",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/custom-code-component.js",
|
|
8
|
+
"types": "./dist/custom-code-component.d.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "vite",
|
|
13
|
+
"build": "vite build",
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@datastream/core": "^0.0.36",
|
|
20
|
+
"@financial-times/content-tree": "github:financial-times/content-tree",
|
|
21
|
+
"@open-wc/testing-helpers": "^3.0.1",
|
|
22
|
+
"@testing-library/jest-dom": "^6.4.8",
|
|
23
|
+
"@testing-library/react": "^16.0.0",
|
|
24
|
+
"@vitejs/plugin-react": "^4.0.4",
|
|
25
|
+
"@vitest/browser": "^3.0.6",
|
|
26
|
+
"eslint": "^8.47.0",
|
|
27
|
+
"eslint-plugin-react": "^7.33.1",
|
|
28
|
+
"jsdom": "^24.1.1",
|
|
29
|
+
"local-pkg": "^1.0.0",
|
|
30
|
+
"msw": "^2.7.3",
|
|
31
|
+
"playwright": "^1.50.1",
|
|
32
|
+
"typescript": "^5.2.2",
|
|
33
|
+
"vite": "^4.4.9",
|
|
34
|
+
"vite-plugin-dts": "^3.6.0",
|
|
35
|
+
"vitest": "^3.0.6"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@webcomponents/scoped-custom-element-registry": "^0.0.9",
|
|
39
|
+
"ftdomdelegate": "^5.0.0",
|
|
40
|
+
"react": "^18.2.0",
|
|
41
|
+
"react-dom": "^18.2.0",
|
|
42
|
+
"ws": "^8.18.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@financial-times/o-tracking": "4.x"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"src"
|
|
50
|
+
],
|
|
51
|
+
"overrides": {
|
|
52
|
+
"vitest": {
|
|
53
|
+
"happy-dom": "latest"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file
|
|
3
|
+
* Main component definition for custom-code-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ContentTree } from "@financial-times/content-tree";
|
|
7
|
+
import { BaseRenderer } from "../../ccc-sdk/src/renderers/BaseRenderer";
|
|
8
|
+
import Tracking from "./tracking";
|
|
9
|
+
import { convertStringLogLevel, Logger } from "./logger";
|
|
10
|
+
import {
|
|
11
|
+
CCCError,
|
|
12
|
+
CCCImportError,
|
|
13
|
+
CCCRenderError,
|
|
14
|
+
CCCTimeoutError,
|
|
15
|
+
} from "./errors";
|
|
16
|
+
import { CCCConnectedEvent } from "./events";
|
|
17
|
+
import { ComponentPath } from "./path";
|
|
18
|
+
import { kebabize } from "./util";
|
|
19
|
+
import { assignTestURL, useComponentTestEnv } from "./environment";
|
|
20
|
+
|
|
21
|
+
export class FTCustomCodeComponent extends HTMLElement {
|
|
22
|
+
app?: typeof BaseRenderer.prototype.render;
|
|
23
|
+
mode: "closed" | "open" = "open";
|
|
24
|
+
RESERVED_ATTRS = new Set([
|
|
25
|
+
"iframe",
|
|
26
|
+
"path",
|
|
27
|
+
"version",
|
|
28
|
+
"data-component-props",
|
|
29
|
+
"data-asset-type",
|
|
30
|
+
"shadow-open",
|
|
31
|
+
"env",
|
|
32
|
+
"load-timeout",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
source?: string;
|
|
36
|
+
tracking?: Tracking;
|
|
37
|
+
lightRoot: ChildNode[];
|
|
38
|
+
component: ComponentPath;
|
|
39
|
+
|
|
40
|
+
log: Logger;
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
|
|
45
|
+
this.log = new Logger({
|
|
46
|
+
level: convertStringLogLevel(this.getAttribute("log")),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const path = this.getAttribute("path");
|
|
50
|
+
const versionRange = this.getAttribute("version");
|
|
51
|
+
this.component = ComponentPath.fromString(path, versionRange);
|
|
52
|
+
|
|
53
|
+
// Backup the light root to restore in case of error
|
|
54
|
+
this.lightRoot = Array.from(this.childNodes);
|
|
55
|
+
|
|
56
|
+
const supportsDeclarative =
|
|
57
|
+
HTMLElement.prototype.hasOwnProperty("attachInternals");
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const internals = supportsDeclarative && this.attachInternals();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
this.log.error(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async connectedCallback() {
|
|
67
|
+
try {
|
|
68
|
+
this.app = await this.load();
|
|
69
|
+
await this.mount();
|
|
70
|
+
await this.initTracking();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (e instanceof Error) {
|
|
73
|
+
requestAnimationFrame(() => {
|
|
74
|
+
this.emitError(e);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.unmount(e as Error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
emitError(error: Error) {
|
|
83
|
+
let errorEvent;
|
|
84
|
+
|
|
85
|
+
if (error instanceof CCCError && error.name?.startsWith("CCC")) {
|
|
86
|
+
errorEvent = error.name.replace(/^CCC/, "ccc:");
|
|
87
|
+
} else {
|
|
88
|
+
errorEvent = `ccc:${error?.name ?? "UnknownError"}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!errorEvent) {
|
|
92
|
+
return this.log.debug(error);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.dispatchEvent(
|
|
96
|
+
new ErrorEvent(errorEvent, {
|
|
97
|
+
bubbles: true,
|
|
98
|
+
cancelable: false,
|
|
99
|
+
composed: true,
|
|
100
|
+
error,
|
|
101
|
+
message: error.message,
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
disconnectedCallback() {
|
|
107
|
+
const path = this.getAttribute("path");
|
|
108
|
+
this.log.info(`<custom-code-component:${path}> disconnected`);
|
|
109
|
+
if (typeof this.onunmount === "function") this.onunmount();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
channel = new MessageChannel();
|
|
113
|
+
|
|
114
|
+
onmessage() {}
|
|
115
|
+
onunmount(root?: any) {}
|
|
116
|
+
async onready(app: Promise<void>) {
|
|
117
|
+
try {
|
|
118
|
+
await app;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof Error) this.emitError(e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
postMessage(event: any) {
|
|
125
|
+
this.channel.port1.postMessage(event);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async mount(prerendered?: ShadowRoot | null) {
|
|
129
|
+
try {
|
|
130
|
+
this.mode =
|
|
131
|
+
this.getAttribute("shadow-open") == "false" ? "closed" : "open";
|
|
132
|
+
|
|
133
|
+
this.dispatchEvent(
|
|
134
|
+
new CCCConnectedEvent({
|
|
135
|
+
component: this.component,
|
|
136
|
+
source: this.source,
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
this.dataset.cccReady = "true";
|
|
141
|
+
delete this.dataset.cccError;
|
|
142
|
+
|
|
143
|
+
if (!this.app) {
|
|
144
|
+
throw new Error("CCC mounted without App");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const ssr = this.shadowRoot !== null;
|
|
148
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: this.mode });
|
|
149
|
+
|
|
150
|
+
const data = JSON.parse(this.getAttribute("data-component-props")!);
|
|
151
|
+
|
|
152
|
+
const extraProps = Object.fromEntries(
|
|
153
|
+
[...this.attributes]
|
|
154
|
+
.filter((attribute) => !this.RESERVED_ATTRS.has(attribute.name))
|
|
155
|
+
.map((attribute) => [attribute.name, attribute.value])
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Create tracking instance
|
|
159
|
+
this.tracking = new Tracking({
|
|
160
|
+
name: this.component.toString(),
|
|
161
|
+
subtype: "interactive",
|
|
162
|
+
teamName: "djd",
|
|
163
|
+
shadowRoot: this.shadowRoot,
|
|
164
|
+
logger: this.log,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const { unmount, onmessage, ready } =
|
|
168
|
+
this.app(
|
|
169
|
+
shadow,
|
|
170
|
+
{
|
|
171
|
+
...extraProps,
|
|
172
|
+
data,
|
|
173
|
+
port: this.channel.port2,
|
|
174
|
+
tracking: this.tracking,
|
|
175
|
+
prerendered: !!prerendered,
|
|
176
|
+
children: this.children,
|
|
177
|
+
},
|
|
178
|
+
ssr
|
|
179
|
+
) || {};
|
|
180
|
+
|
|
181
|
+
if (unmount) this.onunmount = unmount;
|
|
182
|
+
if (onmessage) this.onmessage = onmessage;
|
|
183
|
+
if (ready) this.onready(ready);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.log.info(
|
|
186
|
+
`<custom-code-component> uncaught error during mount from ${this.component.toString()}`
|
|
187
|
+
);
|
|
188
|
+
this.log.error(err);
|
|
189
|
+
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Called in top-level error handler
|
|
195
|
+
// Replace shadow root with light DOM children on error
|
|
196
|
+
unmount(e: Error) {
|
|
197
|
+
this.onunmount();
|
|
198
|
+
this.shadowRoot?.replaceChildren(...this.lightRoot);
|
|
199
|
+
|
|
200
|
+
if (!this.dataset.cccError)
|
|
201
|
+
this.dataset.cccError = kebabize(e.name.replace("CCC", ""));
|
|
202
|
+
delete this.dataset.cccReady;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async load() {
|
|
206
|
+
const path = this.getAttribute("path");
|
|
207
|
+
const componentVersionRange = this.getAttribute("version");
|
|
208
|
+
const timeout = Number(this.getAttribute("load-timeout") || 10000);
|
|
209
|
+
const testEnv = this.getAttribute("testEnv")
|
|
210
|
+
const testUrl = assignTestURL(testEnv)
|
|
211
|
+
const isTestEnv = await useComponentTestEnv(testUrl)
|
|
212
|
+
|
|
213
|
+
const id = this.getAttribute("id");
|
|
214
|
+
// id querystring necessary to multiple allow components with the same source (same name and version number) to appear on the page correctly
|
|
215
|
+
this.source = isTestEnv
|
|
216
|
+
? `${testUrl?.origin}/src/${this.component.component}/index.jsx?id=${id}`
|
|
217
|
+
: `https://www.ft.com/__component/${this.component.org}/${this.component.repo}${
|
|
218
|
+
componentVersionRange ? `@${componentVersionRange}` : "@latest"
|
|
219
|
+
}/${this.component.component}/${this.component.component}.js?id=${id}`;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
return await new Promise<typeof BaseRenderer.prototype.render>(
|
|
223
|
+
(resolve, reject) => {
|
|
224
|
+
const to = setTimeout(() => {
|
|
225
|
+
this.log.error("CCC import timeout error");
|
|
226
|
+
reject(
|
|
227
|
+
new CCCTimeoutError({
|
|
228
|
+
component: this.component,
|
|
229
|
+
source: this.source,
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
}, Number(timeout));
|
|
233
|
+
|
|
234
|
+
if (this.source) {
|
|
235
|
+
import(/* webpackIgnore: true */ this.source /* @vite-ignore */)
|
|
236
|
+
.then(({ default: componentRenderFunction }) => {
|
|
237
|
+
if (componentRenderFunction) {
|
|
238
|
+
clearTimeout(to);
|
|
239
|
+
resolve(componentRenderFunction);
|
|
240
|
+
} else
|
|
241
|
+
throw new CCCImportError(
|
|
242
|
+
"No component renderer default export found",
|
|
243
|
+
{
|
|
244
|
+
component: this.component,
|
|
245
|
+
source: this.source,
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
})
|
|
249
|
+
.catch((e) => {
|
|
250
|
+
clearTimeout(to);
|
|
251
|
+
this.log.error(e);
|
|
252
|
+
if (e instanceof Error && !(e instanceof CCCImportError)) {
|
|
253
|
+
reject(
|
|
254
|
+
new CCCImportError(e.message, {
|
|
255
|
+
component: this.component,
|
|
256
|
+
source: this.source,
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
reject(e);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
clearTimeout(to);
|
|
265
|
+
throw new CCCImportError(`Unable to mount ${path}`, {
|
|
266
|
+
component: this.component,
|
|
267
|
+
source: this.source,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
this.log.error(
|
|
274
|
+
`<custom-code-component> error during import from ${path}@${componentVersionRange}`
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
initTracking = async () => {
|
|
282
|
+
try {
|
|
283
|
+
this.tracking?.init(this.id);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
const path = this.getAttribute("path");
|
|
286
|
+
const componentVersionRange = this.getAttribute("version");
|
|
287
|
+
this.log.info(
|
|
288
|
+
`Error initialising tracking on <custom-code-component> ${path}@${componentVersionRange}`
|
|
289
|
+
);
|
|
290
|
+
this.log.error(e);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface CustomCodeComponent extends ContentTree.Node {
|
|
296
|
+
type: "CustomCodeComponent";
|
|
297
|
+
path: string;
|
|
298
|
+
versionRange: string;
|
|
299
|
+
altText: string;
|
|
300
|
+
lastModified: string;
|
|
301
|
+
fallbackImage?: ContentTree.Image;
|
|
302
|
+
displayFallbackText: boolean;
|
|
303
|
+
layout: "in-line" | "mid-grid" | "full-grid" | "full-bleed";
|
|
304
|
+
/* prettier-ignore */
|
|
305
|
+
attributes: {
|
|
306
|
+
[key: string]: string | boolean | undefined;
|
|
307
|
+
} | { children?: CustomCodeComponent | Array<CustomCodeComponent> };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type { FTCustomCodeComponent as CCCHTMLElement };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isLocalEnv, isSafeTestEnv, isSparkEnv } from "./util"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Assigns a test URL based on the provided testEnv value.
|
|
6
|
+
*
|
|
7
|
+
* @param testEnv - A string indicating test environment setting
|
|
8
|
+
* @returns A `URL` object if valid and safe, or `undefined` if invalid.
|
|
9
|
+
*/
|
|
10
|
+
export function assignTestURL(testEnv: string | null): URL | undefined {
|
|
11
|
+
if (!testEnv) {
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
let testUrl
|
|
15
|
+
const defaultTestUrl = new URL('http://localhost:5173')
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
if (typeof testEnv === 'string') {
|
|
19
|
+
if (testEnv === 'true' && isLocalEnv()) {
|
|
20
|
+
testUrl = defaultTestUrl
|
|
21
|
+
} else {
|
|
22
|
+
const hasProtocol = testEnv.startsWith('http://') || testEnv.startsWith('https://')
|
|
23
|
+
testUrl = hasProtocol ? new URL(testEnv) : undefined
|
|
24
|
+
|
|
25
|
+
// Prevent script injection
|
|
26
|
+
if (testUrl && !isSafeTestEnv(testUrl?.hostname)) {
|
|
27
|
+
throw new Error("Unsafe testing host override");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} else if (isSparkEnv()) {
|
|
31
|
+
testUrl = defaultTestUrl;
|
|
32
|
+
}
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return testUrl
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return testUrl
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks whether the given test URL can establish a successful WebSocket connection,
|
|
42
|
+
* which indicates a live test environment (e.g., Vite HMR is active).
|
|
43
|
+
*
|
|
44
|
+
* @param testUrl - The test environment URL to check.
|
|
45
|
+
* @returns A promise that resolves to true if WebSocket connection is open, otherwise false.
|
|
46
|
+
*/
|
|
47
|
+
export async function useComponentTestEnv(testUrl?: URL): Promise<boolean> {
|
|
48
|
+
if (!testUrl) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function checkWebSocketOpen(host: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
return new Promise<boolean>((res) => {
|
|
55
|
+
const socket = new WebSocket(`ws://${host}`, "vite-hmr");
|
|
56
|
+
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
res(socket.readyState === WebSocket.OPEN);
|
|
59
|
+
socket.close();
|
|
60
|
+
}, 50);
|
|
61
|
+
|
|
62
|
+
socket.addEventListener("error", () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
socket.close();
|
|
65
|
+
res(false);
|
|
66
|
+
}, { once: true });
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("WebSocket creation failed:", err);
|
|
70
|
+
return Promise.resolve(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isWebSocketOpen = await checkWebSocketOpen(testUrl?.host);
|
|
75
|
+
|
|
76
|
+
return isWebSocketOpen
|
|
77
|
+
}
|